20. 状态管理与用户信息
前端手册状态管理大约 7 分钟约 2057 字
一句话定位
8 个 pinia store 分管主题、路由、标签页、缓存、用户信息与快捷入口;本篇讲清每个 store 的 state/action,以及获取用户信息、权限、字典、常量的正确姿势。
代码位置:src/stores/
结构图
main.ts createApp → app.use(pinia)(stores/index.ts 导出实例)
userInfo ─────── setUserInfos() → /api/sysAuth/userInfo(含 apis 权限点)
├────── setConstList() → /api/sysConst/list
├────── setDictList() → /api/sysDictType/allDictList
└────── setUserTables() → /api/sysRole/userRoleTableList
│
├─ authApiList ──► v-auth / auth() 按钮权限
├─ dictList ──► GSysDict 字典渲染
└─ constList ──► GSysDict isConst
themeConfig ──► 布局四态、主色、暗色、组件尺寸(Local 持久化)
routesList ──► 左侧菜单数据源
tagsViewRoutes ──► 标签页
keepAliveNames ──► keep-alive include
requestOldRoutes ──► 后端原始菜单树(快捷入口候选池来源)
quickEntry ──► 工作台「我的常用」(/api/sysUserMenu/*)一、逐个 store
1. stores/index.ts —— pinia 实例
import { createPinia } from 'pinia';
const pinia = createPinia();
export default pinia;router/index.ts、backEnd.ts 这些非组件环境要用 store 时,必须显式传入:useThemeConfig(pinia)。在组件里直接 useThemeConfig() 即可。
2. userInfo —— 用户信息 / 权限 / 字典 / 常量
| state | 类型 | 说明 |
|---|---|---|
userInfos | UserInfos | id / account / realName / phone / email / avatar / orgId / orgName / posId / posName / roles / authApiList / tenantId / accountType / jobNum / points ... |
constList | any[] | 常量列表({ code, data: { result: [...] } }) |
dictList | Record<string, DictItem[]> | 字典(键为字典编码) |
userTableList | any[] | 用户表格字段列表 |
| action | 说明 |
|---|---|
setUserInfos() | 优先读 Session.get('userInfo'),否则调 /api/sysAuth/userInfo;顺带设置水印、回写 Local.themeConfig |
setConstList() | 常量列表,落 Session |
setDictList() | 全量字典(含国际化处理) |
setUserTables() | 用户表格列集合 |
getConstDataByTypeCode(code) | 按常量类名取数据 |
getConstItemNameByType(typeCode, itemCode) | 按常量类名 + 编码取名称 |
codeToName(code, type) | 常量编码 → 名称(未加载/不存在返回 '',不抛异常) |
getDictDataByCode(dictTypeCode) | 按字典编码取字典项数组 |
getDictItemByValue(dictTypeCode, value) | 按值取字典项(找不到返回 {}) |
getDictItemByLabel(dictTypeCode, label) | 按名称取字典项 |
3. themeConfig —— 布局与主题
state.themeConfig 是一个大的配置对象,关键字段:
| 字段 | 默认 | 说明 |
|---|---|---|
primary | #248067 | 主色 |
isIsDark | false | 暗色模式 |
isRequestRoutes | true | 后端控制路由开关 |
globalComponentSize | - | small / default / large,映射 vxe 的 mini / small / medium |
globalI18n | - | 当前语言(同时写入 Accept-Language 请求头) |
globalTitle | - | 浏览器标题后缀 |
isCollapse / isUniqueOpened / isFixedHeader | - | 菜单折叠 / 手风琴 / 固定 Header |
isLockScreen / lockScreenTime | false / 300 | 自动锁屏 |
isWatermark / watermarkText | - | 水印(由 userInfo 写入) |
tablePageSequence | - | vxe 分页序号(注入 proxyConfig.seq) |
idleTimeout | - | 空闲超时(秒),App.vue 里 initIdleTimeout |
配套 action:setThemeConfig({ themeConfig })。配置持久化在 Local.get('themeConfig'),App.vue 的 onMounted 恢复。
4. routesList —— 菜单数据
| state / action | 说明 |
|---|---|
routesList | 已处理成多级嵌套的路由(左侧菜单、横向菜单数据源) |
currentRoute | 当前路由对象(守卫里 setCurrentRoute) |
isColumnsMenuHover / isColumnsNavHover | 分栏布局 hover 态 |
setRoutesList(data) / setCurrentRoute(route) / setColumnsMenuHover(bool) / setColumnsNavHover(bool) | - |
5. tagsViewRoutes —— 标签页
| state / action | 说明 |
|---|---|
tagsViewRoutes | 一维化后的路由(菜单搜索、tagsView 用) |
isTagsViewCurrenFull | 当前页全屏状态(存 Session) |
setTagsViewRoutes(data) / setCurrenFullscreen(bool) | - |
6. keepAliveNames —— 路由缓存
| state / action | 说明 |
|---|---|
keepAliveNames | 开启 TagsView 时的缓存 name 列表 |
cachedViews | 关闭 TagsView 时的缓存列表 |
setCacheKeepAlive(data) / addCachedView(view) / delCachedView(view) / delOthersCachedViews(view) / delAllCachedViews() | - |
缓存生效条件:路由 meta.isKeepAlive 且父级也是 isKeepAlive,且页面组件有 name。
7. requestOldRoutes —— 后端原始菜单树
| state / action | 说明 |
|---|---|
requestOldRoutes | 未处理 component 的原始路由,快捷入口的候选池来源 |
setRequestOldRoutes(routes) | - |
8. quickEntry —— 工作台快捷入口
| 成员 | 说明 |
|---|---|
state entries: MenuOutput[] / loaded: boolean | 已收藏菜单 |
getter pool: QuickEntryPool | 由 requestOldRoutes 拍平成「目录 → 叶子菜单」候选分组(跳过 isHide、type==3、status!=1、纯外链) |
getter entryIds: Set<number> | 已收藏 id 集合 |
action load() | SysUserMenuApi.apiSysUserMenuUserMenuListGet() |
action save(list) | 全量覆盖保存:apiSysUserMenuAddPost({ menuIdList }) |
action findPath(path) | 按路由 path 查候选池里的菜单 |
action isPickedPath(path) | 是否已收藏 |
action toggleByPath(path) | 切换收藏,返回 'added' | 'removed' | 'notFound' |
const quick = useQuickEntry();
await quick.load();
const r = await quick.toggleByPath('/system/config'); // 'added' | 'removed' | 'notFound'
if (r === 'notFound') ElMessage.warning('该页面不可收藏');⚠️ toggleByPath 内部会先 load():保存接口是全删全插,不拿最新列表会把别处新增的入口覆盖掉。
二、正确获取用户信息 / 权限 / 字典 / 常量
推荐:src/utils/useInfo.ts(函数式,每次现取)
import { userId, userName, userAccount, tenantId, orgId, orgName, posId, posName,
isAdmin, isSupperAdmin, isTenantAdmin, isNormalUser, isMember,
hasRoleCode, hasPrivilege, userEmail, userPhone, createTime, jobNum, points, roles } from '/@/utils/useInfo';
if (isAdmin()) { /* accountType 999(超管) 或 888(租户管理员) */ }
if (hasRoleCode('audit')) { /* 有某角色编码 */ }
if (hasPrivilege('sysConfig/add')) { /* 有某接口权限 */ }
const tid = tenantId();权限:src/utils/authFunction.ts
import { auth, auths, authAll } from '/@/utils/authFunction';
auth('sysConfig/update'); // 单个
auths(['a', 'b']); // 满足其一
authAll(['a', 'b']); // 全部满足字典 / 常量
const userStore = useUserInfo();
const items = userStore.getDictDataByCode('sys_status'); // 字典项数组
const label = userStore.getDictItemByValue('sys_status', '1')?.label;
const name = userStore.codeToName('1', 'CardTypeEnum'); // 常量:编码 → 名称或直接在模板里用 GSysDict(推荐):
<g-sys-dict v-model="row.status" code="sys_status" />
<g-sys-dict v-model="row.cardType" code="CardTypeEnum" is-const render-as="select" />⚠️ 最大的坑:不要在模块顶层解构 store
// ❌ 错误:userInfos 是整体重新赋值的(this.userInfos = await ...),
// 解构拿到的是赋值前的旧对象引用,登录完成后所有取值都会读到空对象
const { userInfos } = useUserInfo();
export const isAdmin = () => userInfos.accountType === 999; // 永远是 undefined
// ✅ 正确:每次调用时现取(useInfo.ts 就是这么写的)
const userInfoStore = useUserInfo();
const userInfos = () => userInfoStore.userInfos ?? {};
export const isAdmin = () => userInfos()?.accountType === 999;同理,不要在模块顶层执行 const { themeConfig } = storeToRefs(useThemeConfig()) 之外的地方缓存 store 字段;在函数体内取总是安全的。
其它注意点:
- 组件里用
storeToRefs()保持响应式:const { themeConfig } = storeToRefs(useThemeConfig())。 - 非组件环境(router、utils)要传 pinia:
useUserInfo(pinia)。 - 权限是登录时一次性拉全的(
authApiList),运行时变更需重新登录。
三、mittBus 事件总线
代码位置:src/utils/mitt.ts(export default emitter)+ src/types/mitt.d.ts(MittType)
import mittBus from '/@/utils/mitt';
// 订阅
mittBus.on('openSettingsDrawer', onOpenSettingsDrawer);
// 发布
mittBus.emit('openSettingsDrawer');
// 退订(必须用同一个函数引用!)
mittBus.off('openSettingsDrawer', onOpenSettingsDrawer);已定义的事件(types/mitt.d.ts):
| 事件 | 载荷 | 用途 |
|---|---|---|
openSettingsDrawer | - | 打开布局配置抽屉(App.vue 订阅) |
restoreDefault | - | 分栏布局鼠标移出,恢复默认菜单 |
setSendColumnsChildren | MittMenu | 分栏布局把菜单数据传入 navMenu |
setSendClassicChildren | MittMenu | 经典布局切割菜单 |
getBreadcrumbIndexSetFilterRoutes | - | 面包屑索引过滤路由 |
layoutMobileResize | LayoutMobileResize | 窗口变化适配移动端 |
openOrCloseSortable | - | TagsView 拖拽开关 |
openShareTagsView | - | TagsView 共用开关 |
onTagsViewRefreshRouterView | T | 刷新当前路由视图 |
onCurrentContextmenuClick | T | tagsView 右键菜单项点击 |
submitRefreshFk | T | 外键刷新 |
signalRReconnected / signalRStartFailed | T | SignalR 重连/失败,订阅方补拉数据 |
App.vue 里的注释值得抄:
// 打开布局配置抽屉(具名函数:mittBus.off 按引用比对,匿名函数解绑不掉)
const onOpenSettingsDrawer = () => { settingsRef.value?.openDrawer(); };
mittBus.on('openSettingsDrawer', onOpenSettingsDrawer);
// ...
onUnmounted(() => { mittBus.off('openSettingsDrawer', onOpenSettingsDrawer); });四、完整示例:一个页面里同时用上 store
<script lang="ts" setup name="demoPage">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '/@/stores/userInfo';
import { useThemeConfig } from '/@/stores/themeConfig';
import { useQuickEntry } from '/@/stores/quickEntry';
import { auth } from '/@/utils/authFunction';
import { isAdmin, tenantId } from '/@/utils/useInfo';
import mittBus from '/@/utils/mitt';
const userStore = useUserInfo();
const themeStore = useThemeConfig();
const { themeConfig } = storeToRefs(themeStore); // 保持响应式
const quick = useQuickEntry();
// 组件尺寸(跟随全局配置)
const size = computed(() => themeConfig.value.globalComponentSize);
// 字典:模板里直接用 GSysDict,脚本里这样取
const statusOptions = computed(() => userStore.getDictDataByCode('sys_status'));
// 权限
const canEdit = computed(() => auth('sysDemo/update') || isAdmin());
// 快捷入口
const toggleFav = async () => {
const r = await quick.toggleByPath('/system/demo');
console.log(r); // 'added' | 'removed' | 'notFound'
};
// 打开布局配置抽屉(走事件总线,不直接引组件)
mittBus.emit('openSettingsDrawer');
</script>⚠️ 注意事项 / 常见坑
- 顶层解构 = 拿到旧引用(见上文),这是本项目最易踩的坑。
- 非组件环境要传 pinia 实例:
useUserInfo(pinia)。 userInfos可能为空:useInfo.ts里所有函数都带?.并兜底,业务代码也要判空。- 字典未加载完就渲染:字典在
initBackEndControlRoutes()里加载,页面onMounted时通常已就绪;若做异步懒加载页面,用GSysDict的@data-loaded兜底。 codeToName查不到返回空串,不要拿它的结果做严格比较。quickEntry.save()是全量覆盖,务必先load()。- mitt 退订必须同名函数引用,匿名箭头函数解不掉,会内存泄漏。
themeConfig改完要持久化:直接改 store 字段不会自动写Local,需要的话手动Local.set('themeConfig', themeConfig.value)。
小结
userInfo是权限与字典的唯一源头,登录时一次性拉全。- 取用户信息用
utils/useInfo.ts的函数,判权限用auth/auths/authAll,字典渲染用GSysDict。 - 永远不要在模块顶层解构 store。
- 跨组件通信(尤其是布局相关)走
mittBus,退订用同名函数引用。