AI驱动Web_Vue第二部分_企业后台_Pinia_动态路由_RBAC详解

O泡李华 5

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 守卫写法

差异,

优先参考:

当前官方文档