15 KiB
15 KiB
状态管理
**本文引用的文件** - [auth.js](file://fms-vue/src/stores/auth.js) - [app.js](file://fms-vue/src/stores/app.js) - [permissions.js](file://fms-vue/src/stores/permissions.js) - [main.js](file://fms-vue/src/main.js) - [router/index.js](file://fms-vue/src/router/index.js) - [api.js](file://fms-vue/src/services/api.js) - [theme/index.js](file://fms-vue/src/theme/index.js) - [stores.spec.js](file://fms-vue/tests/stores/stores.spec.js) - [permissions.spec.js](file://fms-vue/tests/permissions.spec.js)目录
简介
本文件为 FMS 前端(Vue + Pinia)的状态管理文档,聚焦以下目标:
- 基于 Pinia 的 store 创建、模块化与数据持久化策略
- 用户认证状态:登录态、用户信息、JWT 令牌处理
- 应用全局状态:主题、语言偏好、UI 状态(侧边栏、页签等)
- 权限状态:菜单权限、按钮/功能点权限控制逻辑
- 状态更新最佳实践与性能优化
- 状态调试与测试方法
项目结构
FMS 前端采用按领域划分的 store 模块:
- stores/auth.js:认证与会话、用户回显信息
- stores/app.js:应用级 UI 状态(主题、语言、页签、路由刷新计数)
- stores/permissions.js:权限数据加载与访问/功能点判断
- router/index.js:路由守卫统一触发权限加载与鉴权
- services/api.js:后端接口封装(登录、数据加载等)
- theme/index.js:主题切换与主色变量写入 DOM
- main.js:Pinia 初始化、插件启用、主题同步初始化
graph TB
A["main.js<br/>创建 Pinia、启用持久化插件"] --> B["stores/app.js<br/>应用状态与主题同步"]
A --> C["stores/auth.js<br/>认证与会话持久化"]
A --> D["stores/permissions.js<br/>权限数据与判断"]
E["router/index.js<br/>beforeEach 守卫"] --> C
E --> D
D --> F["services/api.js<br/>loadDataApi 等"]
B --> G["theme/index.js<br/>applyDarkMode / applyPrimaryColor"]
图表来源
- main.js:14-22
- app.js:6-102
- auth.js:4-48
- permissions.js:13-195
- router/index.js:109-133
- api.js:16-24
- theme/index.js:6-15
章节来源
核心组件
- 认证 Store(auth)
- 维护会话凭证(token、user)与跨会话用户回显信息(orgId、account)
- 通过 pinia-plugin-persistedstate 将会话写 sessionStorage,用户回显写 localStorage
- 提供 setSession、clearSession、saveLastLogin 等方法
- 应用 Store(app)
- 管理暗色模式、主色、侧边栏折叠、语言、已访问页签、路由刷新计数
- 通过 watch 将状态同步到 DOM(主题类名与 CSS 变量)
- 仅持久化主题与语言相关字段
- 权限 Store(permissions)
- 拉取模块、用户授权、功能点数据,并缓存结果
- 支持管理员提权(g3soft)直接可见全部启用模块
- 提供 canAccess(菜单/页面访问)、canPower(功能点/按钮)判断
- 监听认证主体变化自动清理缓存,避免脏数据
章节来源
架构总览
下图展示了从路由守卫到权限加载、再到视图渲染的整体流程。
sequenceDiagram
participant U as "用户"
participant R as "router/index.js"
participant A as "stores/auth.js"
participant P as "stores/permissions.js"
participant S as "services/api.js"
participant V as "视图组件"
U->>R : 访问受保护路由
R->>A : 检查 isAuthenticated
alt 未登录
R-->>U : 重定向到 /login?redirect=...
else 已登录
R->>P : await load()
P->>S : loadDataApi(...) 多次并发
S-->>P : 返回模块/授权/功能点数据
P-->>R : 完成加载
R->>V : 允许进入并渲染
end
图表来源
详细组件分析
认证 Store(auth.js)
- 状态设计
- loginInfo:包含 token 与 user,用于当前会话鉴权
- userInfo:包含 orgId 与 account,用于登录页回显,退出登录不清除
- isAuthenticated:计算属性,基于 token 是否存在
- 关键方法
- setSession:兼容多种字段形态(token/access_token、user/user_info),设置 orgId
- clearSession:清空会话凭证,保留 userInfo
- saveLastLogin:保存机构与账号,供下次打开登录页回显
- 持久化策略
- 使用 pinia-plugin-persistedstate
- 会话凭证(loginInfo)持久化到 sessionStorage,键名为 fms-login
- 用户回显(userInfo)持久化到 localStorage,键名为 fms-user
- 安全与体验
- 会话随标签页关闭失效;回显数据跨会话保留,提升用户体验
flowchart TD
Start(["调用 setSession"]) --> Normalize["规范化字段<br/>token/access_token, user/user_info"]
Normalize --> SetState["写入 loginInfo 与 userInfo.orgId"]
SetState --> PersistSS["持久化到 sessionStorage(fms-login)"]
SetState --> PersistLS["持久化到 localStorage(fms-user)"]
PersistSS --> End(["完成"])
PersistLS --> End
图表来源
章节来源
应用 Store(app.js)
- 状态设计
- darkMode、primaryColor:主题相关
- sidebarCollapsed:侧边栏折叠状态
- locale:语言偏好(占位)
- visitedTabs:已访问页签列表(工作台固定不可关闭)
- routeRefreshKeys:路由刷新计数器,key 为 fullPath
- 计算属性
- cachedViews:根据 visitedTabs 生成 keep-alive 的 include 列表(去重)
- 动作
- 主题切换:setDark/toggleDark/setPrimary
- 侧边栏:toggleSidebar/setSidebar
- 语言:toggleLocale
- 页签管理:addVisitedTab/removeVisitedTab/closeOthers/closeLeft/closeRight/closeAll
- 路由刷新:refreshRoute/getRouteRefreshKey/pruneOrphanRefreshKeys
- 持久化
- 仅持久化 darkMode、primaryColor、locale
- 主题同步
- initThemeSync:在 Pinia 实例激活后建立 watch,将状态同步到 DOM(class 与 CSS 变量)
classDiagram
class AppStore {
+boolean darkMode
+string primaryColor
+boolean sidebarCollapsed
+string locale
+Object[] visitedTabs
+Object~number~ routeRefreshKeys
+cachedViews() string[]
+setDark(value) void
+toggleDark() void
+setPrimary(color) void
+toggleSidebar() void
+setSidebar(collapsed) void
+toggleLocale() void
+addVisitedTab(tab) void
+removeVisitedTab(key) void
+closeOthers(key) void
+closeLeft(key) void
+closeRight(key) void
+closeAll() void
+pruneOrphanRefreshKeys() void
+getRouteRefreshKey(key) number
+refreshRoute(key) void
}
图表来源
章节来源
权限 Store(permissions.js)
- 数据模型
- modules:可用模块列表
- grantedPageIds:用户授权的页面 ID 集合
- moduleCodes:用户可访问的模块编码集合
- powerCodes:用户可用的功能点编码集合(形如 moduleId.powerCode)
- isG3soft:是否为管理员提权账号
- 加载逻辑
- 管理员提权:仅加载启用模块,跳过授权表查询
- 普通用户:并发加载 s_module、b_user_module、b_user_power、s_module_power,并组装权限集合
- 主体去重:同一 orgId:userId 重复调用不重复请求,并发共享同一 Promise
- 权限判断
- canAccess(moduleCode):判断是否可访问某模块(含父 page 类型模块)
- canPower(scopeCode, powerCode):判断是否拥有某功能点(含父 page 类型模块)
- 清理机制
- 监听 authStore 的变化(orgId、userId、account、token),在主体变更时清理缓存
- clear 方法区分“进行中请求属于其他主体”的情况,作废旧请求,避免脏数据
flowchart TD
LStart(["调用 load()"]) --> CheckAdmin{"是否 g3soft?"}
CheckAdmin --> |是| LoadModules["加载启用模块(s_module)"]
CheckAdmin --> |否| ValidateUser{"校验 userId"}
ValidateUser --> |无效| ThrowErr["抛出错误"]
ValidateUser --> |有效| CheckDedup{"是否已加载该主体?"}
CheckDedup --> |是| ReturnCached["返回缓存"]
CheckDedup --> |否| ParallelLoad["并发加载 4 个接口"]
ParallelLoad --> Assemble["组装 modules/grantedPageIds/powerCodes"]
Assemble --> MarkLoaded["记录 loadedPrincipalKey"]
MarkLoaded --> LEnd(["完成"])
ThrowErr --> LEnd
ReturnCached --> LEnd
图表来源
章节来源
路由守卫与状态联动
- 登录态检查:未登录访问受保护路由跳转 /login,带 redirect 参数
- 权限加载:在 beforeEach 中统一调用 permissionStore.load(),确保首屏与业务页挂载前已完成
- 访问控制:若路由 meta.moduleId 存在且无权限,则跳转 /dashboard
sequenceDiagram
participant Router as "router/index.js"
participant Auth as "stores/auth.js"
participant Perm as "stores/permissions.js"
Router->>Auth : 读取 isAuthenticated
alt 未登录
Router-->>Router : 重定向到 /login?redirect=...
else 已登录
Router->>Perm : await load()
Perm-->>Router : 完成
Router->>Router : 校验 canAccess(moduleId)
Router-->>Router : 放行或跳转 /dashboard
end
图表来源
章节来源
依赖关系分析
- main.js
- 创建 Pinia 并启用 pinia-plugin-persistedstate
- 在挂载前调用 initThemeSync,确保主题同步
- app.js
- 依赖 theme/index.js 的 applyDarkMode、applyPrimaryColor
- permissions.js
- 依赖 services/api.js 的 loadDataApi
- 依赖 auth.js 获取当前用户信息
- router/index.js
- 依赖 auth.js 与 permissions.js 进行鉴权与权限加载
graph LR
M["main.js"] --> A["app.js"]
M --> Au["auth.js"]
M --> P["permissions.js"]
A --> T["theme/index.js"]
P --> API["services/api.js"]
P --> Au
R["router/index.js"] --> Au
R --> P
图表来源
章节来源
性能考虑
- 并发与去重
- 权限加载对同一主体去重,并发调用共享 Promise,减少重复网络请求
- 管理员提权路径简化请求数量
- 内存与状态清理
- 关闭页签时清理 routeRefreshKeys,避免无限增长
- 权限 store 在主体变更时清理缓存,防止脏数据
- 首屏体验
- 路由守卫在 mount 前完成权限加载,避免菜单“跳出”
- 主题同步在挂载前执行,避免首屏闪烁
- 持久化粒度
- 仅持久化必要状态(主题、语言、用户回显),降低存储体积与读写开销
[本节为通用性能建议,无需特定文件引用]
故障排查指南
- 登录后菜单空白
- 检查权限 store 是否在路由守卫中被正确 await
- 确认主体变化时 clear 不会作废正在进行的请求(同主体)
- 主题未生效
- 确认 initThemeSync 已在 Pinia 实例激活后调用
- 检查 applyDarkMode/applyPrimaryColor 是否正确操作 DOM
- 登录态异常
- 检查 sessionStorage/localStorage 中的键名与内容
- 确认 clearSession 仅清除会话凭证,不影响用户回显
- 权限判断不准确
- 确认 canAccess/canPower 的 scopeCode 与 powerCode 格式正确
- 检查模块树解析(resolvePageId)是否能找到父 page 类型模块
章节来源
结论
FMS 前端通过 Pinia 实现了清晰、可维护的状态管理:
- 认证、应用、权限三大 store 职责明确,耦合度低
- 通过路由守卫集中管理鉴权与权限加载,保证首屏体验
- 使用 pinia-plugin-persistedstate 实现细粒度的持久化策略
- 完善的测试覆盖验证了关键行为与边界条件
[本节为总结性内容,无需特定文件引用]
附录
状态更新最佳实践
- 单一数据源:所有状态变更通过 store actions 进行,组件内不直接修改状态
- 副作用隔离:主题同步、DOM 操作集中在 app store 的初始化与 watch 中
- 幂等与去重:权限加载对同一主体去重,避免重复请求
- 最小持久化:仅持久化必要状态,减少存储压力
- 错误处理:权限加载失败时静默处理,避免阻断路由导航
[本节为通用实践建议,无需特定文件引用]
状态调试与测试方法
- 单元测试
- 使用 vitest 与 createTestPinia 创建独立 Pinia 实例
- 模拟 API 响应,验证权限加载与判断逻辑
- 验证持久化键名与内容(sessionStorage/localStorage)
- 断言要点
- 认证:setSession/clearSession 的行为与持久化键
- 应用:主题切换、页签管理、刷新计数
- 权限:主体去重、管理员提权、并发请求复用
- 调试技巧
- 在浏览器控制台查看 Pinia state(通过 devtools)
- 观察路由守卫日志与 NProgress 进度条,定位加载时机
章节来源