AI驱动Web_Vue第二部分_企业后台_Pinia_动态路由_RBAC详解
AI 驱动 Web - Vue 第二部分:企业后台、Pinia、动态路由与 RBAC 联调
本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:AI 驱动 Web - Vue(第二部分)
前置知识:Vue3 基础、Vite、Axios、Element Plus、Vue Router、SpringBoot、RESTful、RBAC
本章目标:把第一部分“能写 Vue 页面”继续升级为“能搭企业后台管理系统”,掌握 Pinia、Layout、动态菜单、动态路由、RBAC 权限、按钮权限、Axios 工程化封装、页面缓存、组件封装、错误处理、SpringBoot 完整联调与 AI 辅助企业前端开发。
推荐技术栈:Vue 3 + Vite + JavaScript + Vue Router + Pinia + Axios + Element Plus + SpringBoot。
一、这一阶段和上一阶段有什么区别
上一阶段主要解决:
Vue 是什么
怎么创建项目
怎么写组件
怎么调用 SpringBoot
怎么显示表格
怎么写 CRUD
这一阶段开始解决:
为什么登录后刷新页面用户信息没了?
多个页面怎么共享当前用户?
角色和权限放在哪里?
左侧菜单怎么根据后端权限动态生成?
用户没有某个权限时按钮怎么隐藏?
用户手工输入一个无权限 URL 怎么办?
Axios 为什么要统一封装?
401 为什么只应该处理一次?
后台 Layout 怎么搭?
菜单、路由、权限到底是什么关系?
大型项目如何拆目录?
如何把 RBAC 后端真正接到 Vue 前端?
二、企业后台整体架构
Vue
│
├─ Login
├─ Layout
│ ├─ Sidebar
│ ├─ Header
│ └─ Main
├─ Router
│ ├─ Public Routes
│ └─ Dynamic Routes
├─ Pinia
│ ├─ User Store
│ ├─ Permission Store
│ └─ App Store
├─ API
│ ├─ auth.js
│ ├─ user.js
│ ├─ role.js
│ └─ permission.js
├─ Axios
│ ├─ Base URL
│ ├─ Token
│ ├─ Timeout
│ ├─ Error
│ ├─ 401
│ └─ 403
└─ Element Plus
├─ Menu
├─ Table
├─ Form
├─ Dialog
└─ Pagination
三、Pinia 是什么
Pinia 是 Vue 的状态管理库。
适合保存:
当前用户
Token
角色
权限
动态菜单
侧边栏状态
主题
不适合把所有局部数据都塞进去。
局部状态例如:
一个弹窗是否打开
某个页面的搜索表单
某个按钮的 loading
应该留在组件内部。
四、安装 Pinia
npm install pinia
五、main.js 注册 Pinia
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"
import router from "./router"
const app =
createApp(
App
)
const pinia =
createPinia()
app.use(
pinia
)
app.use(
router
)
app.mount(
"#app"
)
六、defineStore
创建:
src/stores/user.js
Setup Store 示例:
import {
ref
} from "vue"
import {
defineStore
} from "pinia"
export const useUserStore =
defineStore(
"user",
() => {
const token =
ref("")
const user =
ref(null)
const roles =
ref([])
const permissions =
ref([])
return {
token,
user,
roles,
permissions
}
}
)
七、Pinia 核心理解
可以把 Pinia 理解为:
state
状态
getters
派生状态
actions
修改状态和执行业务
在 Setup Store 中:
ref/reactive
≈ state
computed
≈ getter
普通函数
≈ action
八、组件使用 Store
import {
useUserStore
} from "@/stores/user"
const userStore =
useUserStore()
读取:
console.log(
userStore.token
)
修改建议:
userStore.login(...)
或者通过 Store 中定义好的方法。
九、storeToRefs
如果要解构:
const {
token,
permissions
} =
userStore
可能会失去响应式连接。
推荐:
import {
storeToRefs
} from "pinia"
const {
token,
permissions
} =
storeToRefs(
userStore
)
十、企业后台至少三个 Store
建议:
userStore
负责用户、Token、角色、权限
permissionStore
负责菜单和动态路由
appStore
负责侧边栏、主题等 UI 状态
十一、Token 持久化
Pinia 默认是:
内存状态
页面刷新以后:
会重新初始化
因此 Token 常配合:
localStorage
学习阶段方案:
Pinia + localStorage
十二、token.js
创建:
src/utils/token.js
const TOKEN_KEY =
"rbac_token"
export const getToken =
() =>
localStorage.getItem(
TOKEN_KEY
)
export const setToken =
token =>
localStorage.setItem(
TOKEN_KEY,
token
)
export const removeToken =
() =>
localStorage.removeItem(
TOKEN_KEY
)
十三、为什么封装 token.js
不要整个项目到处:
localStorage.getItem(
"token"
)
因为以后如果切换:
Cookie
就需要改大量代码。
封装以后:
只改 token.js
十四、userStore 初始化 Token
const token =
ref(
getToken() || ""
)
十五、登录 Action
const login =
async form => {
const result =
await loginApi(
form
)
token.value =
result.data.token
setToken(
token.value
)
}
十六、登录后完整流程
POST /auth/login
↓
获得 Token
↓
保存 Token
↓
GET /auth/me
↓
获得用户、角色、权限
↓
GET /auth/menus
↓
获得菜单
↓
生成动态路由
↓
进入首页
十七、为什么刷新后调用 /auth/me
只持久化 Token。
其他:
用户信息
角色
权限
菜单
刷新后:
重新从后端获取
这样能减少:
长期使用旧权限
十八、getUserInfo
const getUserInfo =
async () => {
const result =
await getMe()
user.value =
result.data.user
roles.value =
result.data.roles || []
permissions.value =
result.data.permissions || []
return result.data
}
十九、退出
const logout =
async () => {
try {
await logoutApi()
} finally {
token.value =
""
user.value =
null
roles.value =
[]
permissions.value =
[]
removeToken()
}
}
二十、Layout 是什么
后台页面通常统一拥有:
Sidebar
Header
Main
所以不应该:
每个页面重复写
而是抽:
Layout
二十一、目录
src/layout
│
├─ Layout.vue
└─ components
├─ Sidebar.vue
├─ HeaderBar.vue
└─ AppMain.vue
二十二、Layout.vue
<script setup>
import Sidebar from "./components/Sidebar.vue"
import HeaderBar from "./components/HeaderBar.vue"
</script>
<template>
<div class="app-layout">
<Sidebar />
<div class="app-container">
<HeaderBar />
<main class="app-main">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
.app-layout {
display: flex;
min-height: 100vh;
}
.app-container {
flex: 1;
min-width: 0;
}
.app-main {
padding: 20px;
}
</style>
二十三、Sidebar
通常使用:
Element Plus el-menu
基础:
<el-menu
router
>
<el-menu-item
index="/dashboard"
>
首页
</el-menu-item>
<el-menu-item
index="/system/users"
>
用户管理
</el-menu-item>
</el-menu>
二十四、菜单、路由、权限的区别
菜单:
用户看到什么入口
路由:
URL 映射到哪个 Vue 页面
权限:
用户能不能执行某个功能
它们有关联,但不是一个东西。
二十五、一个有路由但没有菜单的例子
/system/users/10
用户详情页:
需要路由
但通常:
不放 Sidebar
二十六、按钮权限也不是菜单
例如:
user:delete
主要控制:
删除按钮
不是侧边栏菜单。
二十七、静态路由
通常不依赖权限:
/login
/403
/404
可以前端写死。
二十八、动态路由
登录后根据:
后端菜单 / 权限
运行时添加:
/system/users
/system/roles
二十九、router.addRoute()
Vue Router 支持:
router.addRoute(
route
)
用于运行时添加路由。
三十、为什么动态路由
普通用户没有:
用户管理权限
前端可以不注册:
/system/users
这样用户手工输入 URL 时:
前端也无法正常进入该业务页
但要记住:
动态路由不能替代后端权限
三十一、后端菜单数据
例如:
[
{
"routeName": "System",
"path": "/system",
"component": "Layout",
"title": "系统管理",
"children": [
{
"routeName": "SystemUser",
"path": "users",
"component": "system/user/index",
"title": "用户管理",
"permissionCode": "user:list"
}
]
}
]
三十二、菜单协议建议字段
id
parentId
routeName
path
component
title
icon
permissionCode
hidden
sort
children
三十三、为什么 routeName 必须唯一
动态路由:
name
如果重复:
可能发生覆盖或冲突
所以后端菜单:
routeName 唯一
三十四、component 不能直接随便执行
后端:
component = system/user/index
前端应该:
映射到 views 下合法组件
不要:
eval(component)
三十五、import.meta.glob
Vite 可通过:
const viewModules =
import.meta.glob(
"/src/views/**/*.vue"
)
建立允许的页面组件集合。
三十六、resolveView
const resolveView =
component => {
const key =
`/src/views/${component}.vue`
const loader =
viewModules[
key
]
if (
!loader
) {
throw new Error(
`组件不存在: ${component}`
)
}
return loader
}
三十七、Layout 特殊处理
if (
item.component
===
"Layout"
) {
route.component =
Layout
} else {
route.component =
resolveView(
item.component
)
}
三十八、递归菜单转路由
const transformRoute =
item => {
const route = {
path:
item.path,
name:
item.routeName,
meta: {
title:
item.title,
permission:
item.permissionCode
}
}
if (
item.component
===
"Layout"
) {
route.component =
Layout
} else {
route.component =
resolveView(
item.component
)
}
if (
item.children?.length
) {
route.children =
item.children.map(
transformRoute
)
}
return route
}
三十九、permissionStore
import {
ref
} from "vue"
import {
defineStore
} from "pinia"
export const usePermissionStore =
defineStore(
"permission",
() => {
const menus =
ref([])
const dynamicRoutes =
ref([])
const initialized =
ref(false)
const addedRouteNames =
ref([])
const reset =
() => {
menus.value =
[]
dynamicRoutes.value =
[]
initialized.value =
false
addedRouteNames.value =
[]
}
return {
menus,
dynamicRoutes,
initialized,
addedRouteNames,
reset
}
}
)
四十、为什么 initialized 很重要
导航守卫可能:
执行很多次
如果每次都:
GET /auth/menus
router.addRoute()
会重复初始化。
所以:
initialized=false
只初始化一次
四十一、页面刷新为什么动态路由会消失
因为运行时动态路由存在:
JavaScript 内存
浏览器刷新:
Vue 应用重新启动
所以:
必须重新 addRoute
四十二、导航守卫
当前推荐新代码使用:
守卫返回值
例如:
router.beforeEach(
async to => {
if (
!getToken()
&&
to.path !== "/login"
) {
return {
path:
"/login"
}
}
return true
}
)
四十三、旧教程的 next()
旧教程常:
router.beforeEach(
(
to,
from,
next
) => {
next()
}
)
现在建议你优先掌握:
return true
return false
return 路由地址
这种写法。
四十四、企业导航守卫流程
访问业务页面
↓
是不是公开路由
↓
有没有 Token
├─ 无 → /login
↓
用户信息是否加载
├─ 无 → /auth/me
↓
动态路由是否初始化
├─ 无 → /auth/menus
↓
addRoute
↓
重新进入目标 URL
↓
显示页面
四十五、守卫示例
router.beforeEach(
async to => {
const userStore =
useUserStore()
const permissionStore =
usePermissionStore()
if (
to.meta.public
) {
return true
}
if (
!userStore.token
) {
return {
path:
"/login",
query: {
redirect:
to.fullPath
}
}
}
if (
!permissionStore.initialized
) {
await userStore
.getUserInfo()
await permissionStore
.generateRoutes()
return {
path:
to.fullPath,
replace:
true
}
}
return true
}
)
四十六、为什么加载动态路由后要重新进入一次
当前第一次访问时:
目标路由还没注册
动态路由添加完成后:
重新匹配一次目标 URL
才能进入新路由。
四十七、redirect 参数
用户:
未登录访问 /system/users
跳:
/login?redirect=/system/users
登录成功后:
回到原页面
四十八、登录成功跳转
const redirect =
route.query.redirect
||
"/dashboard"
router.replace(
redirect
)
四十九、为什么 replace
避免:
登录完成后
浏览器后退
又回登录页
五十、退出后必须清动态路由
场景:
管理员登录
↓
注册管理员路由
↓
退出
↓
普通用户登录
如果旧路由不清:
管理员路由仍在内存
虽然 API 最终会 403,
但:
前端权限状态被污染
五十一、removeRoute
permissionStore
.addedRouteNames
.forEach(
name => {
if (
router.hasRoute(
name
)
) {
router.removeRoute(
name
)
}
}
)
五十二、完整退出流程
POST /auth/logout
↓
清 localStorage Token
↓
清 userStore
↓
removeRoute
↓
清 permissionStore
↓
跳 /login
五十三、按钮权限
页面有:
user:list
不代表有:
user:add
user:update
user:delete
所以按钮:
单独判断权限码
五十四、最简单按钮判断
<el-button
v-if="
userStore.permissions.includes(
'user:add'
)
"
>
新增
</el-button>
五十五、自定义 v-permission
页面多以后:
includes
重复很多。
可以封装:
<el-button
v-permission="'user:add'"
>
新增
</el-button>
五十六、permission directive
export default {
mounted(
el,
binding
) {
const required =
binding.value
const userStore =
useUserStore()
const permissions =
userStore.permissions
const hasSuper =
permissions.includes(
"*:*:*"
)
const allowed =
hasSuper
||
permissions.includes(
required
)
if (
!allowed
) {
el.parentNode
?.removeChild(
el
)
}
}
}
五十七、注册指令
main.js:
app.directive(
"permission",
permissionDirective
)
五十八、前端权限的底线
无论菜单隐藏、路由隐藏、按钮隐藏做得多好:
后端仍必须校验权限
因为用户可以直接:
Postman
curl
自写脚本
调用 API。
五十九、Axios 企业封装
request.js 应统一处理:
baseURL
timeout
Token
业务响应
HTTP 状态码
401
403
网络错误
六十、创建 Axios Instance
import axios
from "axios"
const service =
axios.create({
baseURL:
import.meta.env
.VITE_API_BASE_URL,
timeout:
15000
})
六十一、请求拦截器
service.interceptors.request.use(
config => {
const token =
getToken()
if (
token
) {
config.headers.Authorization =
`Bearer ${token}`
}
return config
},
error =>
Promise.reject(
error
)
)
六十二、为什么 request.js 直接从 token.js 获取 Token
如果 request.js 强依赖:
userStore
有时会出现:
Pinia 尚未激活
循环 import
所以:
Token 存取工具独立
结构更稳。
六十三、响应拦截器
service.interceptors.response.use(
response => {
return response.data
},
error => {
return Promise.reject(
error
)
}
)
六十四、统一业务 code
如果后端:
{
"code": "SUCCESS",
"message": "success",
"data": {}
}
可以:
if (
body.code
!==
"SUCCESS"
) {
ElMessage.error(
body.message
||
"操作失败"
)
return Promise.reject(
new Error(
body.message
)
)
}
六十五、HTTP Status 和业务 code
HTTP:
401
403
404
409
500
业务 code:
USER_EXISTS
ROLE_NOT_FOUND
TOKEN_INVALID
二者:
不要混成一个概念
六十六、401
表示:
未登录
Token 无效
Token 过期
前端通常:
清 Token
清 Store
清动态路由
跳登录
六十七、403
表示:
已经登录
但无权限
前端:
不要清 Token
只:
提示无权限
或:
跳 403 页面
六十八、多个 401 同时返回
一个页面可能同时请求:
用户信息
菜单
通知
Token 过期后:
三个请求都 401
如果每个都:
弹窗 + 跳登录
会很乱。
六十九、401 单次处理锁
let handlingUnauthorized =
false
第一次处理:
true
其他 401:
不重复执行
七十、handle401
const handle401 =
async () => {
if (
handlingUnauthorized
) {
return
}
handlingUnauthorized =
true
removeToken()
ElMessage.error(
"登录状态已失效"
)
await router.replace(
"/login"
)
handlingUnauthorized =
false
}
七十一、网络错误
如果:
error.response
不存在,
可能:
后端没启动
网络断开
代理错误
CORS
域名错误
七十二、Timeout
例如:
15 秒
超时后:
给用户明确提示
不要所有错误都:
系统异常
七十三、API 层
推荐:
src/api/auth.js
src/api/user.js
src/api/role.js
src/api/permission.js
页面不要:
到处直接 axios.get()
七十四、auth.js
import request
from "@/utils/request"
export const login =
data =>
request.post(
"/auth/login",
data
)
export const getMe =
() =>
request.get(
"/auth/me"
)
export const getMenus =
() =>
request.get(
"/auth/menus"
)
export const logout =
() =>
request.post(
"/auth/logout"
)
七十五、user.js
export const getUserPage =
params =>
request.get(
"/users",
{
params
}
)
export const createUser =
data =>
request.post(
"/users",
data
)
export const updateUser =
(
id,
data
) =>
request.put(
`/users/${id}`,
data
)
export const deleteUser =
id =>
request.delete(
`/users/${id}`
)
七十六、企业 CRUD 页面布局
常见:
查询区域
↓
工具栏
↓
Table
↓
Pagination
↓
Dialog
七十七、查询区域
Element Plus:
<el-form
:inline="true"
:model="query"
>
适合后台:
横向搜索栏
七十八、列表 loading
<el-table
v-loading="loading"
:data="users"
>
七十九、提交 loading
<el-button
type="primary"
:loading="submitting"
@click="submit"
>
保存
</el-button>
避免:
连续点击
八十、前端防重复不能代替后端幂等
前端 loading:
只是减少误操作
后端重要接口仍需:
唯一约束
事务
幂等设计
八十一、组件拆分
不要把:
列表
编辑
角色分配
权限分配
全放:
UserView.vue
可以拆:
UserView.vue
UserFormDialog.vue
UserRoleDialog.vue
RolePermissionDialog.vue
八十二、UserFormDialog
父:
<UserFormDialog
v-model="dialogVisible"
:user-id="currentUserId"
@success="loadUsers"
/>
八十三、自定义组件 v-model
子:
const props =
defineProps({
modelValue:
Boolean,
userId:
Number
})
const emit =
defineEmits([
"update:modelValue",
"success"
])
关闭:
emit(
"update:modelValue",
false
)
八十四、保存成功
emit(
"success"
)
父组件:
重新加载列表
八十五、为什么用 emit
子组件只负责:
告诉父组件“成功了”
不用知道:
父组件怎么刷新
减少耦合。
八十六、Element Plus Dialog
推荐:
Dialog
├─ Form
└─ Footer
├─ Cancel
└─ Submit
八十七、Dialog v-model
<el-dialog
v-model="visible"
title="用户信息"
>
八十八、表单 reset
每次新增:
必须重置旧表单
否则可能:
看到上一次编辑的数据
八十九、resetFields
formRef.value
?.resetFields()
需要:
el-form-item
正确设置 prop
九十、分页删除后空页
当前:
第 3 页
只有一条
删除后:
第 3 页变空
可:
if (
users.value.length
===
1
&&
query.pageNum > 1
) {
query.pageNum--
}
然后:
重新加载
九十一、KeepAlive
后台切换页面时,如果希望:
搜索条件
分页位置
不丢,
可以使用:
KeepAlive
九十二、AppMain
<router-view
v-slot="{ Component }"
>
<keep-alive>
<component
:is="Component"
/>
</keep-alive>
</router-view>
九十三、不要所有页面无脑缓存
因为:
内存增加
数据可能过期
可通过:
route.meta.keepAlive
控制。
九十四、onActivated
被 KeepAlive 缓存的页面重新激活:
onActivated(
() => {
// 可选刷新数据
}
)
九十五、onMounted vs onActivated
onMounted
第一次挂载
onActivated
缓存组件再次激活
九十六、Breadcrumb
后台:
系统管理 > 用户管理
可以通过:
route.matched
构建。
九十七、route meta 标题
meta: {
title:
"用户管理"
}
九十八、页面 title
守卫或 afterEach:
document.title =
`${to.meta.title || "后台"} - RBAC`
九十九、Sidebar 高亮
const route =
useRoute()
<el-menu
router
:default-active="route.path"
>
一百、侧边栏折叠
appStore:
const sidebarCollapsed =
ref(false)
const toggleSidebar =
() => {
sidebarCollapsed.value =
!sidebarCollapsed.value
}
一百零一、角色权限页面
后端:
GET /api/permissions/tree
GET /api/roles/{id}/permissions
PUT /api/roles/{id}/permissions
前端:
el-tree
最适合。
一百零二、el-tree
<el-tree
ref="treeRef"
:data="permissionTree"
show-checkbox
node-key="id"
:props="treeProps"
/>
一百零三、treeProps
const treeProps = {
label:
"permissionName",
children:
"children"
}
一百零四、回显角色已有权限
treeRef.value
.setCheckedKeys(
permissionIds
)
一百零五、获取勾选权限
const permissionIds =
treeRef.value
.getCheckedKeys()
一百零六、半选节点
树权限可能:
父菜单半选
要和后端约定:
是否提交 halfCheckedKeys
不要:
随便猜
一百零七、用户分配角色
可以:
el-checkbox-group
或者:
el-select multiple
一百零八、后端 API 完整对接
认证:
POST /api/auth/login
GET /api/auth/me
GET /api/auth/menus
POST /api/auth/logout
用户:
GET /api/users
GET /api/users/{id}
POST /api/users
PUT /api/users/{id}
DELETE /api/users/{id}
GET /api/users/{id}/roles
PUT /api/users/{id}/roles
角色:
GET /api/roles
GET /api/roles/{id}
POST /api/roles
PUT /api/roles/{id}
DELETE /api/roles/{id}
GET /api/roles/{id}/permissions
PUT /api/roles/{id}/permissions
权限:
GET /api/permissions/tree
一百零九、登录完整链路
LoginView
↓
userStore.login()
↓
POST /auth/login
↓
Token
↓
setToken()
↓
userStore.getUserInfo()
↓
GET /auth/me
↓
Roles + Permissions
↓
permissionStore.generateRoutes()
↓
GET /auth/menus
↓
transformRoute
↓
router.addRoute()
↓
Dashboard
一百一十、刷新完整链路
浏览器刷新
↓
Pinia 重建
↓
从 localStorage 读取 Token
↓
动态路由为空
↓
beforeEach
↓
/auth/me
↓
/auth/menus
↓
重新 addRoute
↓
继续进入当前 URL
一百一十一、401 完整链路
API Request
↓
Token 过期
↓
SpringBoot 401
↓
Axios Response Interceptor
↓
只处理一次
↓
清 Token
↓
清 Store
↓
removeRoute
↓
Login
一百一十二、403 完整链路
用户已登录
↓
调用无权限 API
↓
SpringBoot 403
↓
Axios
↓
提示无权限
↓
保持登录
一百一十三、前后端权限两道门
前端:
页面门
菜单
路由
按钮
后端:
数据门
Token
权限码
Service 数据权限
真正安全:
以后端为准
一百一十四、水平越权
例如用户自己只能看:
自己的资料
却修改 URL:
/users/2
访问别人数据。
前端隐藏无效。
后端必须:
检查当前用户是否允许访问目标资源
一百一十五、垂直越权
普通用户调用:
管理员删除接口
RBAC 权限码:
主要解决这类功能权限
一百一十六、History 模式刷新 404
使用:
createWebHistory()
点击页面:
正常
但部署后刷新:
/system/users
可能:
Nginx 404
一百一十七、原因
浏览器直接请求服务器:
/system/users
服务器找:
真实文件
当然没有。
一百一十八、Nginx SPA fallback
通常需要:
location / {
try_files
$uri
$uri/
/index.html;
}
让:
未知前端路径
回:
index.html
再由 Vue Router 处理。
一百一十九、API 代理
location /api/ {
proxy_pass
http://127.0.0.1:8080/;
}
具体是否保留 /api 前缀:
取决于 proxy_pass 写法和后端接口设计
部署时要实际验证。
一百二十、典型生产架构
Browser
↓
Nginx
├─ /
│ → Vue dist
│
└─ /api
→ SpringBoot
一百二十一、Pinia 持久化插件
社区存在:
Pinia 持久化插件
但学习阶段不急着用。
先理解:
Pinia 是内存
localStorage 是持久存储
一百二十二、为什么不建议整个 Store 全持久化
权限和菜单:
可能已经改变
如果 localStorage 永久保留:
旧权限长期存在
所以:
Token 持久化
其他重新请求
更清楚。
一百二十三、Composable 简单了解
如果多个页面重复:
分页逻辑
查询重置
窗口尺寸
可以抽:
Composable
例如:
export const usePagination =
() => {
const pageNum =
ref(1)
const pageSize =
ref(10)
return {
pageNum,
pageSize
}
}
一百二十四、Pinia 和 Composable 区别
Pinia:
共享状态
Composable:
复用逻辑
不是一个概念。
一百二十五、AI 生成企业 Vue 项目最容易犯的错
Vue2 / Vue3 混用
Element UI / Element Plus 混用
Vuex / Pinia 混用
重复创建 Router
重复创建 Axios
动态路由无限循环
退出后没 removeRoute
Store 解构丢响应式
权限持久化过期
假造后端 API
一百二十六、给 AI 的固定项目上下文
以后可以直接告诉 AI:
前端技术栈:
Vue3 + Vite + JavaScript + Pinia + Vue Router + Axios + Element Plus
代码风格:
<script setup>
后端:
SpringBoot RESTful
认证:
Authorization: Bearer Token
权限:
RBAC permissionCode
统一响应:
{
code,
message,
data
}
要求:
沿用现有 request.js、stores 和 router。
不要使用 Vue2 API。
不要创建不存在的接口。
一百二十七、AI 修改动态路由提示词
当前项目使用 Vue3 + Vue Router + Pinia。
请使用导航守卫返回值风格,
不要以旧式 next() 作为主写法。
动态路由首次加载一次。
刷新页面后根据 Token 重新请求 /api/auth/me 和 /api/auth/menus。
退出登录时清 Store 并 removeRoute。
只修改 router、permissionStore 和必要相关文件。
一百二十八、AI 排错输入
至少提供:
Console 报错
Network Request URL
HTTP Method
Status Code
Response Body
当前 Vue 文件
后端接口映射
一百二十九、联调正确顺序
1. 后端 Postman 全部跑通
2. Login
3. Token Header
4. /auth/me
5. Layout
6. 静态菜单
7. /auth/menus
8. 动态路由
9. User CRUD
10. Role CRUD
11. Permission Tree
12. 按钮权限
13. 401 / 403
14. 退出清理
15. npm run build
16. Nginx
一百三十、为什么一步一步做
因为如果一次做:
Pinia
Router
Axios
权限
CRUD
Nginx
报错后:
无法判断是哪一层
一百三十一、常见错误:Pinia 报没有 active Pinia
可能:
useStore()
调用时:
Pinia 还没有 app.use(pinia)
或者在模块初始化阶段:
错误地过早使用 Store
一百三十二、常见错误:刷新后菜单没了
原因:
permissionStore 在内存
解决:
Token 恢复
↓
重新 /me
↓
重新 /menus
一百三十三、常见错误:所有路由都 404
检查:
router 是否注册
Layout 是否正确
addRoute 是否执行
route path 是否正确
component 是否解析成功
一百三十四、常见错误:动态路由无限跳
检查:
initialized
是不是永远:
false
或者:
每次守卫都重新 return to.fullPath
一百三十五、常见错误:管理员退出后普通用户还有旧菜单
检查:
removeRoute
permissionStore.reset
userStore.reset
一百三十六、常见错误:按钮不显示
检查三处:
数据库 permissionCode
SpringBoot permissionCode
Vue permissionCode
例如:
user:delete
必须完全一致。
一百三十七、常见错误:前端按钮显示但后端 403
说明:
前端权限数据可能旧了
或者:
后端权限配置已经变化
最终:
以后端 403 为准
一百三十八、常见错误:Store 解构后 UI 不更新
用:
storeToRefs
处理响应式 state/getters。
一百三十九、常见错误:401 弹三次
说明:
并发请求都触发统一处理
增加:
handlingUnauthorized
单次处理机制。
一百四十、常见错误:403 跳登录
不要:
403 清 Token
403 的含义:
已经认证
但没有权限
一百四十一、常见错误:History 点击正常刷新 404
优先:
Nginx try_files
一百四十二、常见错误:Dialog 打开是上次数据
在新增前:
reset form
resetFields
一百四十三、常见错误:删除当前页最后一条后空白
处理:
pageNum 回退
一百四十四、常见错误:el-tree 回显不正确
检查:
node-key
permissionIds
父子节点关系
半选策略
setCheckedKeys 调用时机
一百四十五、常见错误:component 动态 import 不成功
检查:
后端 component 字符串
views 实际路径
import.meta.glob key
文件名大小写
一百四十六、必须掌握 Pinia
createPinia
defineStore
Setup Store
state
getter
action
storeToRefs
一百四十七、必须掌握认证状态
Token
localStorage
/auth/me
logout
reset
一百四十八、必须掌握动态路由
constantRoutes
dynamicRoutes
route meta
router.beforeEach
addRoute
removeRoute
一百四十九、必须掌握权限
菜单权限
页面权限
按钮权限
API 权限
v-permission
一百五十、必须掌握 Axios 工程化
axios.create
baseURL
timeout
Request Interceptor
Response Interceptor
401
403
业务错误
网络错误
一百五十一、必须掌握企业后台组件
Layout
Sidebar
Header
Breadcrumb
Table
Form
Dialog
Tree
Pagination
KeepAlive
一百五十二、面试题 1:Pinia 是什么
答:
Pinia 是 Vue 的状态管理库。
适合保存多个组件和页面共享的状态,
例如当前用户、Token、角色和权限。
局部表单、弹窗等状态
不应该全部放进 Pinia。
一百五十三、面试题 2:为什么 Store 刷新丢失
答:
Pinia 默认保存在 JavaScript 内存。
浏览器刷新后 Vue 应用重新启动,
Store 也会重新初始化。
因此通常持久化 Token,
再通过 /auth/me 恢复用户和权限。
一百五十四、面试题 3:storeToRefs 有什么用
答:
用于将 Pinia Store 中的响应式 state/getters
安全地转换成 refs 后解构,
避免直接解构导致响应式连接丢失。
一百五十五、面试题 4:菜单、路由、权限区别
答:
菜单决定用户看到哪些导航入口。
路由决定某个 URL 渲染哪个 Vue 组件。
权限决定用户是否允许访问页面或执行操作。
三者有关联但不等价。
一百五十六、面试题 5:动态路由怎么实现
答:
登录后从后端获取当前用户菜单或路由配置,
前端转换为 Vue Router 的路由记录,
再通过 router.addRoute() 动态注册。
刷新后需要重新加载,
退出后需要清理动态路由。
一百五十七、面试题 6:为什么动态路由不能替代后端权限
答:
动态路由只能控制浏览器端页面入口。
用户可以绕过 Vue,
直接构造 HTTP 请求访问后端。
真正的数据安全必须由后端认证和授权保证。
一百五十八、面试题 7:为什么 401 和 403 不同处理
答:
401 表示身份未认证或 Token 已失效,
通常清登录状态并回登录页。
403 表示用户已经登录,
只是没有目标权限,
通常只提示无权限。
一百五十九、面试题 8:为什么 Axios 要封装
答:
统一处理 baseURL、Token、超时、
业务响应、HTTP 错误、401 和 403。
业务页面只关心接口方法和参数,
减少重复代码。
一百六十、面试题 9:History 模式为什么刷新 404
答:
页面内跳转由 Vue Router 处理。
浏览器刷新时会把当前 URL 直接发给 Web 服务器,
服务器找不到对应物理文件时会 404。
Nginx 通常需要通过 try_files
把未知前端路径回退到 index.html。
一百六十一、面试题 10:按钮权限是否安全
答:
按钮权限本身不是安全措施。
它只是让没有权限的用户看不到操作入口。
真正的安全控制必须由 SpringBoot 后端
再次校验 permissionCode。
一百六十二、面试题 11:Pinia 和 Composable 区别
答:
Pinia 主要解决跨组件共享状态。
Composable 主要解决 Composition API 逻辑复用。
Composable 不一定意味着所有调用者共享同一份全局状态。
一百六十三、面试题 12:为什么只持久化 Token
答:
角色、权限和菜单可能在后台发生变化。
如果把它们长期持久化在浏览器,
容易继续使用旧权限。
因此更常见的是持久化 Token,
页面刷新后重新从后端拉取当前用户信息。
一百六十四、完整知识树
Vue Enterprise
│
├─ Pinia
│ ├─ UserStore
│ ├─ PermissionStore
│ └─ AppStore
│
├─ Auth
│ ├─ Token
│ ├─ Me
│ ├─ Logout
│ ├─ 401
│ └─ 403
│
├─ Router
│ ├─ Static
│ ├─ Dynamic
│ ├─ Guard
│ ├─ Meta
│ ├─ addRoute
│ └─ removeRoute
│
├─ RBAC
│ ├─ Menu
│ ├─ Page
│ ├─ Button
│ └─ API
│
├─ Layout
│ ├─ Sidebar
│ ├─ Header
│ ├─ Breadcrumb
│ └─ Main
│
├─ Axios
│ ├─ Instance
│ ├─ Request
│ ├─ Response
│ └─ Error
│
├─ Element Plus
│ ├─ Table
│ ├─ Form
│ ├─ Dialog
│ ├─ Tree
│ └─ Pagination
│
└─ SpringBoot
├─ Auth
├─ RBAC
├─ RESTful
└─ MyBatis
一百六十五、最终完整项目目录
src
│
├─ api
│ ├─ auth.js
│ ├─ user.js
│ ├─ role.js
│ └─ permission.js
│
├─ assets
│
├─ components
│
├─ directives
│ └─ permission.js
│
├─ layout
│ ├─ Layout.vue
│ └─ components
│
├─ router
│ ├─ index.js
│ ├─ routes.js
│ └─ guard.js
│
├─ stores
│ ├─ user.js
│ ├─ permission.js
│ └─ app.js
│
├─ utils
│ ├─ request.js
│ ├─ token.js
│ └─ permission.js
│
├─ views
│ ├─ login
│ ├─ dashboard
│ ├─ system
│ │ ├─ user
│ │ ├─ role
│ │ └─ permission
│ └─ error
│
├─ App.vue
└─ main.js
一百六十六、完整开发顺序
1. 安装 Pinia
2. token.js
3. userStore
4. Axios 完善
5. Login 联调
6. /auth/me
7. Layout
8. Sidebar
9. permissionStore
10. /auth/menus
11. transformRoute
12. addRoute
13. User CRUD
14. Role CRUD
15. Permission Tree
16. UserRoleDialog
17. RolePermissionDialog
18. v-permission
19. 401 / 403
20. removeRoute
21. KeepAlive
22. npm run build
23. Nginx
一百六十七、本章总结
完成这一章后,你应该不再只是:
会写 Vue 页面
而是能够理解一个企业后台的完整结构:
Pinia
负责共享状态
Router
负责页面导航
Dynamic Route
负责权限页面加载
Element Plus
负责后台 UI
Axios
负责统一 HTTP
SpringBoot
负责真正认证授权和业务
最重要的状态恢复流程:
localStorage Token
↓
Vue Reload
↓
/auth/me
↓
Roles + Permissions
↓
/auth/menus
↓
addRoute
最重要的权限原则:
前端权限负责体验
后端权限负责安全
最重要的退出原则:
清 Token
清 Store
清动态路由
回登录页
最重要的调试顺序:
Console
↓
Network
↓
Router
↓
Store
↓
Axios
↓
SpringBoot
第三阶段课程表中的:
AI 驱动 Web - Vue
两个课程单元到这里完成。
下一篇正式进入:
Activiti 7 + BPMN 工作流详解
后面会继续学习:
工作流是什么
BPMN
流程定义
流程部署
流程实例
任务
审批
候选人
流程变量
历史记录
SpringBoot 集成 Activiti 7
官方参考
Pinia
https://pinia.vuejs.org/
Vue Router
https://router.vuejs.org/
Element Plus
https://element-plus.org/
前端生态更新较快。
新项目如果和旧教程出现:
API
配置
Node 版本
Router 守卫写法
差异,
优先参考:
当前官方文档