手动往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 → 组件”映射表:
后端(FastAPI): /articles → list_articles() 返回JSON 前端(Vue Router): / → HomeView组件 渲染页面 /favorites → FavoritesView组件 /login → LoginView组件📌路由思想完全一样,只是渲染端不同——后端渲染数据给接口,前端渲染组件给用户。
📦 Pinia——全局状态仓库
你有没有发现,收藏计数、登录用户这类状态在App.vue里越传越累?props传三层就叫prop drilling(钻透),传五层就是灾难。Pinia提供一个“全局抽屉”:任何组件都能取用登录用户、令牌,不用一层层传。
不用Pinia:App → NavBar → UserMenu → 头像组件(token传了四层) 用Pinia:任何组件直接useAuthStore()取token💡对照后端你已熟悉的模式:这就是“跨层共享的数据要集中管理”——settings单例、db session注入,同一个思想的前端版。
二、第0步:安装与项目结构改造
npminstallvue-router pinia新建目录与文件(views/放“页面级组件”,stores/放“全局状态”):
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:
import{computed,ref,watch}from'vue'import{defineStore}from'pinia'importapifrom'../lib/api'exportconstuseAuthStore=defineStore('auth',()=>{// 初始化从localStorage读——刷新页面登录态不丢consttoken=ref(localStorage.getItem('token')||'')constusername=ref(localStorage.getItem('username')||'')constisLoggedIn=computed(()=>Boolean(token.value))// token一变就写回localStorage(持久化)watch(token,(t)=>{if(t){localStorage.setItem('token',t)}else{localStorage.removeItem('token')}})asyncfunctionlogin(name,password){// ⚠️ 登录接口用OAuth2PasswordRequestForm,接收的是表单编码constform=newURLSearchParams({username:name,password})const{data}=awaitapi.post('/auth/login',form)token.value=data.access_token username.value=name}functionlogout(){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:
import{createRouter,createWebHistory}from'vue-router'import{useAuthStore}from'../stores/auth'constroutes=[{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:'/'},]constrouter=createRouter({history:createWebHistory(),routes})// 路由守卫:前端版门禁(login_required的镜像)router.beforeEach((to)=>{constauth=useAuthStore()if(to.meta.requiresAuth&&!auth.isLoggedIn){return{path:'/login',query:{redirect:to.fullPath}}}})exportdefaultrouter📌 三个关键点
| # | 关键点 | 说明 |
|---|---|---|
| ① | meta.requiresAuth | 路由的“元信息”标记——声明这条路由需要登录,守卫读它做判断 |
| ② | 守卫 | 每次跳转前执行——没登录访问/favorites→ 踢到/login并带上?redirect=/favorites(登录后原路返回) |
| ③ | () => import(...)懒加载 | 路由级代码分割——访问到哪个页面才下载哪个页面的代码,首屏不用一次拉全站 |
🚨 重要认知
📌前端守卫是“体验”不是“安全边界”——绕过它直接调API照样能拿到数据(如果后端没锁)。真正的安全永远在后端(
get_current_user)。
前端守卫管“让用户看到什么”,后端门禁管“让请求拿到什么”。
五、第3步:三页面落地
🚀 src/main.js(挂载插件)
import{createApp}from'vue'import{createPinia}from'pinia'importrouterfrom'./router'importAppfrom'./App.vue'createApp(App).use(createPinia())// 全局状态.use(router)// 路由.mount('#app')🔑 LoginView.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)
<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返回当前用户收藏的文章。后端补上这个接口:
# app/routers/favorites.py 追加fromsqlalchemyimportselect@router.get("")deflist_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()))returnsession.scalars(stmt).all()💡
favorites.c.article_id的.c是访问Table对象列的语法——关联表在这里正式被查询用到。
前端照三段式模板拉数据:
<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>六、验收清单
1. 未登录点“我的收藏” → 自动踢到/login?redirect=/favorites2. 登录成功 → 自动跳回/favorites(redirect兑现)3. 刷新页面 → 登录态还在(Pinia + localStorage持久化)4. 点“退出” → 导航栏变回“登录”,访问/favorites再次被踢5. 收藏页列出收藏过的文章(真接口数据)6. 随便敲一个不存在的路径 → 兜底回首页7. 全程无报错后提交Gitgitadd.gitcommit-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仓库地址】