手动往localStorage塞token的临时方案今天退役。登录页正式登场------Vue Router管页面跳转、Pinia管全局状态 ,路由守卫实现"没登录访问受保护页面自动踢回登录页"。前端基础设施,到此齐装满员。
🎯 本篇产出 :三页面SPA(首页 / 我的收藏 / 登录)+ 登录态全局管理 + 前端门禁。含代码约150行。阶段二收官篇。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | 给SPA加上路由与全局状态管理 |
| 代码行数 | ~150行(含注释) |
| 依赖 | vue-router + pinia |
| 核心功能 | 三页面路由 + 登录态持久化 + 前端门禁 |
| 跑起来的命令 | npm run dev → 浏览器访问各页面 |
| 核心知识点 | 路由表、meta.requiresAuth、Pinia store、路由守卫 |
| 做完你能得到 | 一套完整的SPA前端基础设施 |
一、两个新成员:为什么SPA需要它们
🗺️ Vue Router------前端的路由表
你已经在FastAPI里写过路由:URL映射到函数。SPA只有一个HTML壳,地址栏的变化全靠JS处理------Vue Router就是前端的"URL → 组件"映射表:
text
后端(FastAPI): /articles → list_articles() 返回JSON
前端(Vue Router): / → HomeView组件 渲染页面
/favorites → FavoritesView组件
/login → LoginView组件
📌 路由思想完全一样,只是渲染端不同------后端渲染数据给接口,前端渲染组件给用户。
📦 Pinia------全局状态仓库
你有没有发现,收藏计数、登录用户这类状态在App.vue里越传越累?props传三层就叫prop drilling(钻透 ),传五层就是灾难。Pinia提供一个"全局抽屉":任何组件都能取用登录用户、令牌,不用一层层传。
text
不用Pinia:App → NavBar → UserMenu → 头像组件(token传了四层)
用Pinia:任何组件直接useAuthStore()取token
💡 对照后端你已熟悉的模式 :这就是"跨层共享的数据要集中管理"------settings单例、db session注入,同一个思想的前端版。
二、第0步:安装与项目结构改造
bash
npm install vue-router pinia
新建目录与文件(views/放"页面级组件",stores/放"全局状态"):
text
src/
├── main.js # 入口:挂载router与pinia
├── router/index.js # 路由表 + 守卫
├── stores/auth.js # 登录态全局仓库
├── views/
│ ├── HomeView.vue # 首页(文章列表------从App.vue搬过来)
│ ├── LoginView.vue # 登录页(新)
│ └── FavoritesView.vue # 我的收藏(新,受保护页面)
└── components/ # 组件保持不变
🎯 App.vue从此变成"壳" :只剩导航栏 +
<router-view />(当前路由的组件在这里渲染)------页面各自成文件,这正是"组件即函数"的放大版。
三、第1步:Pinia------auth全局仓库
src/stores/auth.js:
javascript
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import api from '../lib/api'
export const useAuthStore = defineStore('auth', () => {
// 初始化从localStorage读------刷新页面登录态不丢
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const isLoggedIn = computed(() => Boolean(token.value))
// token一变就写回localStorage(持久化)
watch(token, (t) => {
if (t) {
localStorage.setItem('token', t)
} else {
localStorage.removeItem('token')
}
})
async function login(name, password) {
// ⚠️ 登录接口用OAuth2PasswordRequestForm,接收的是表单编码
const form = new URLSearchParams({ username: name, password })
const { data } = await api.post('/auth/login', form)
token.value = data.access_token
username.value = name
}
function logout() {
token.value = ''
username.value = ''
localStorage.removeItem('username')
}
return { token, username, isLoggedIn, login, logout }
})
📌 三段式设计
| # | 结构 | 作用 |
|---|---|---|
| ① | ref存状态 | token、username |
| ② | computed派生 | isLoggedIn |
| ③ | action改状态 | login、logout |
💡
watch(token, ...)做持久化 ------令牌一变自动写localStorage,手动setItem从此消失。
🚨 登录请求的编码坑
📌 登录接口用的是
OAuth2PasswordRequestForm,接收表单编码(application/x-www-form-urlencoded),不是JSON。 前端必须用URLSearchParams构造请求体,否则后端会报422。
四、第2步:Router------路由表 + 守卫
src/router/index.js:
javascript
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes = [
{ path: '/', component: () => import('../views/HomeView.vue') },
// lazy import:路由级代码分割------访问到才加载,首屏更快
{
path: '/favorites',
component: () => import('../views/FavoritesView.vue'),
meta: { requiresAuth: true }, // 标记:这条路由需要登录
},
{ path: '/login', component: () => import('../views/LoginView.vue') },
// 兜底:没匹配到的路径一律回首页
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
const router = createRouter({ history: createWebHistory(), routes })
// 路由守卫:前端版门禁(login_required的镜像)
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default router
📌 三个关键点
| # | 关键点 | 说明 |
|---|---|---|
| ① | meta.requiresAuth |
路由的"元信息"标记------声明这条路由需要登录,守卫读它做判断 |
| ② | 守卫 | 每次跳转前执行------没登录访问/favorites → 踢到/login并带上?redirect=/favorites(登录后原路返回) |
| ③ | () => import(...)懒加载 |
路由级代码分割------访问到哪个页面才下载哪个页面的代码,首屏不用一次拉全站 |
🚨 重要认知
📌 前端守卫是"体验"不是"安全边界" ------绕过它直接调API照样能拿到数据(如果后端没锁)。真正的安全永远在后端(
get_current_user)。
前端守卫管"让用户看到什么",后端门禁管"让请求拿到什么"。
五、第3步:三页面落地
🚀 src/main.js(挂载插件)
javascript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
createApp(App)
.use(createPinia()) // 全局状态
.use(router) // 路由
.mount('#app')
🔑 LoginView.vue(登录页核心)
vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const username = ref('')
const password = ref('')
const errorMsg = ref('')
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
async function submit() {
errorMsg.value = ''
try {
await auth.login(username.value, password.value)
// 登录成功:跳回被拦截时记下的页面,没有就回首页
router.push(route.query.redirect || '/')
} catch (e) {
errorMsg.value = '登录失败:' + (e.response?.data?.detail || '请重试')
}
}
</script>
<template>
<form class="login" @submit.prevent="submit">
<h1>登录早报站</h1>
<input v-model="username" placeholder="用户名" />
<input v-model="password" type="password" placeholder="密码" />
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
<button type="submit">登 录</button>
</form>
</template>
🐚 App.vue变壳(补齐script setup + template)
vue
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import { useAuthStore } from './stores/auth'
const auth = useAuthStore()
</script>
<template>
<nav>
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/favorites">我的收藏</RouterLink>
<template v-if="auth.isLoggedIn">
<span>👤 {{ auth.username }}</span>
<a href="#" @click.prevent="auth.logout()">退出</a>
</template>
<RouterLink v-else to="/login">登录</RouterLink>
</nav>
<main>
<RouterView /> <!-- 当前路由的组件渲染在这里 -->
</main>
</template>
⚠️ 注意 :
RouterLink和RouterView在<script setup>里显式import了,才能被模板识别(Vue Router 4的约定,不再自动全局注册)。
🆕 FavoritesView.vue(受保护页面)
需要后端配合------GET /favorites返回当前用户收藏的文章。后端补上这个接口:
python
# app/routers/favorites.py 追加
from sqlalchemy import select
@router.get("")
def list_favorites(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
):
"""返回当前用户收藏的文章列表"""
stmt = (
select(Article)
.join(favorites, favorites.c.article_id == Article.id)
.where(favorites.c.user_id == user.id)
.order_by(Article.id.desc())
)
return session.scalars(stmt).all()
💡
favorites.c.article_id的.c是访问Table对象列的语法------关联表在这里正式被查询用到。
前端照三段式模板拉数据:
vue
<script setup>
import { onMounted, ref } from 'vue'
import api from '../lib/api'
import ArticleCard from '../components/ArticleCard.vue'
const articles = ref([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
const { data } = await api.get('/favorites')
articles.value = data
} catch (e) {
error.value = '加载失败:' + (e.response?.data?.detail || e.message)
} finally {
loading.value = false
}
})
</script>
<template>
<h1>我的收藏</h1>
<p v-if="loading">加载中...</p>
<p v-else-if="error" class="error">{{ error }}</p>
<p v-else-if="articles.length === 0">还没有收藏任何文章。</p>
<ArticleCard v-for="a in articles" :key="a.id" v-bind="a" />
</template>
六、验收清单
bash
1. 未登录点"我的收藏" → 自动踢到/login?redirect=/favorites
2. 登录成功 → 自动跳回/favorites(redirect兑现)
3. 刷新页面 → 登录态还在(Pinia + localStorage持久化)
4. 点"退出" → 导航栏变回"登录",访问/favorites再次被踢
5. 收藏页列出收藏过的文章(真接口数据)
6. 随便敲一个不存在的路径 → 兜底回首页
7. 全程无报错后提交Git
bash
git add .
git commit -m "Router + Pinia:SPA路由、全局登录态、前端门禁"
七、常见报错:这6个,Router/Pinia的标配(重点!)
① [Vue Router warn]: No match found for location with path "/xxx"
🔍 原因:路由表里没有这个路径。
✅ 解法 :加catch-all兜底(第4节的/:pathMatch(.*)*);或检查路径拼写。
📌 地址栏是用户唯一能直接输入的界面------永远给它一个兜底。
② Failed to resolve async component
🔍 原因 :懒加载的import路径写错,或views/文件名与路径不一致(大小写敏感)。
✅ 解法:核对文件路径。
⚠️ lazy import的字符串是运行时才解析------写错不会在启动时报,只在访问时炸。
③ 模板里用auth报Property "auth" is not defined
🔍 原因 :Pinia store不会自动注入模板------要像其他变量一样在script setup里声明。
✅ 解法 :const auth = useAuthStore()后再用。
💡 想解构取值必须
storeToRefs(auth)------直接解构会丢响应性 (Pinia版的.value之痛)。
④ 刷新后登录态丢了
🔍 原因:store初始化时没从localStorage读token(或watch持久化没写)。
✅ 解法:对照第3节初始化两行 + watch写法。
📌 持久化三件套:初始化读、变更时写、清除时删。
⑤ 路由守卫无限循环 / 一直跳登录页
🔍 原因 :守卫把登录页也拦了(login路由带了requiresAuth),或重定向目标仍是受保护页。
✅ 解法:
login路由不带meta.requiresAuth;- 守卫里跳转前确认目标不是自己。
⑥ 登录成功却停在登录页
🔍 原因 :route.query.redirect读错拼写,或登录后跳转被守卫再次拦截。
✅ 解法:
router.push(route.query.redirect || '/');- F12 → Network看登录请求是否真的200。
八、阶段 · 收官自查 ✅
从零到能用的现代前端:
| 主题 | 装进工具箱的能力 |
|---|---|
| 前端世界观 | 声明式渲染 + Node/Vite翻译表 |
| Vue3起步 | SFC、ref响应式、props、v-for |
| 组件通信 | emit、具名插槽、v-model原理、单向数据流 |
| 前后端分离 | axios、CORS、拦截器、防抖请求 |
| Router + Pinia | SPA路由、全局登录态、前端门禁 |
✅ 出门条
- 你的早报站前端能登录
- 能收藏
- 能搜索
- 有路由有守卫
- 与后端(阶段一)合在一起,一个"前后端分离的真产品"呼之欲出
🎯 后续,把这两个半场正式合成一个完整产品。
九、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | 404页面:把catch-all从redirect换成专属NotFoundView组件(带"回首页"链接) | ⭐⭐ | 更友好的用户体验 |
| 2 | 登录页完善:提交按钮加loading态、错误信息标红、密码框回车可提交 | ⭐⭐ | 表单体验 |
| 3 | 角色守卫 :路由meta加role(admin专属页),守卫里校验auth.role |
⭐⭐⭐ | 权限分级 |
| 4(选做) | 退出确认:退出前弹确认 | ⭐⭐ | 组件内dialog或window.confirm------思考哪个体验好 |
📦 配套代码
完整前端与后端GET /favorites已上传GitHub(python_daily/):【gitee仓库地址】