状态管理
状态管理
业务用途
前端需要一个全局可访问的地方存储用户登录态(Token)、用户信息、当前租户、权限码与角色。本平台使用 Pinia 3 在 frontend/src/store/user.ts 中定义了唯一的 useUserStore,并配合 localStorage 做持久化,保证刷新页面后登录态不丢失。状态管理贯穿登录、请求拦截、路由守卫、按钮权限指令四个环节。
涉及文件
frontend/src/store/user.ts-- Pinia Store(核心)frontend/src/utils/request.ts-- 请求拦截器读取 Token / 租户 ID;401 调用clearSessionfrontend/src/router/index.ts-- 守卫直接读localStorage做 JWT 过期检测frontend/src/views/login/Login.vue-- 登录成功后调用 setter 写入状态frontend/src/directives/permission.ts--v-permission/v-role指令读取权限 / 角色
实现机制
Store 定义
frontend/src/store/user.ts 采用 Pinia 的 Setup Store 写法(组合式 API 风格):
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { request } from '@/utils/request'
export const useUserStore = defineStore('user', () => {
// ===== State:从 localStorage 初始化,刷新不丢 =====
const token = ref<string>(localStorage.getItem('token') || '')
const userInfo = ref<any>(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const tenantId = ref<string>(localStorage.getItem('tenantId') || '1')
const permissions = ref<string[]>(JSON.parse(localStorage.getItem('permissions') || '[]'))
const roles = ref<string[]>(JSON.parse(localStorage.getItem('roles') || '[]'))
// ===== Actions =====
// ... setter / clearSession / logout / hasPermission / hasRole
return { token, userInfo, tenantId, permissions, roles,
setToken, setUserInfo, setTenantId, setPermissions, setRoles,
clearSession, logout, hasPermission, hasRole }
})State 设计
Store 暴露 5 个响应式状态,均在创建时从 localStorage 读取初始值:
| State | 类型 | localStorage 键 | 默认值 | 用途 |
|---|---|---|---|---|
token | string | token | '' | JWT Token |
userInfo | any | userInfo | null | 用户信息对象(id / username / nickname / tenantId 等) |
tenantId | string | tenantId | '1' | 当前租户 ID |
permissions | string[] | permissions | [] | 权限码数组(如 ['system:user:add', 'system:user:edit']) |
roles | string[] | roles | [] | 角色编码数组(如 ['admin', 'manager']) |
为什么用 ref 而不是 state 对象
Setup Store 用 ref() 定义状态,相比 Options Store 的 state 对象写法,更贴近组合式 API 风格,且可以直接在 Store 内使用 computed、watch 等组合式函数。两种写法功能等价。
Setter:同步写 localStorage
每个 setter 都同时更新响应式状态和 localStorage,保证二者始终一致:
const setToken = (val: string) => {
token.value = val
localStorage.setItem('token', val)
}
const setUserInfo = (val: any) => {
userInfo.value = val
localStorage.setItem('userInfo', JSON.stringify(val))
if (val?.id) {
localStorage.setItem('userId', String(val.id)) // 额外存一份 userId 便于读取
}
}
const setTenantId = (val: string) => {
tenantId.value = val
localStorage.setItem('tenantId', val)
}
const setPermissions = (val: string[]) => {
permissions.value = val
localStorage.setItem('permissions', JSON.stringify(val))
}
const setRoles = (val: string[]) => {
roles.value = val
localStorage.setItem('roles', JSON.stringify(val))
}为什么 setUserInfo 要额外存 userId
userInfo 是一个 JSON 对象,读取 id 需要 JSON.parse(localStorage.getItem('userInfo')).id。某些场景(如后端日志埋点)只需要 userId,单独存一份 userId 可以直接 localStorage.getItem('userId') 读取,避免反复 JSON 解析。
clearSession:清除登录态
clearSession 清空所有状态并移除对应的 localStorage 键,租户 ID 重置为默认值 '1':
const clearSession = () => {
// 清空响应式状态
token.value = ''
userInfo.value = null
permissions.value = []
roles.value = []
// 移除 localStorage
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
localStorage.removeItem('userId')
localStorage.removeItem('permissions')
localStorage.removeItem('roles')
localStorage.removeItem('tenantId')
localStorage.removeItem('accessibleMenuPaths')
// 租户 ID 重置为默认
tenantId.value = '1'
}clearSession 与路由守卫 clearLoginState 的关系
两者清理的 localStorage 键高度重合,但触发场景不同:
userStore.clearSession()-- 由请求层request.ts在收到 401 响应时调用,会同步清空 Pinia 响应式状态。clearLoginState()(在router/index.ts中)-- 由路由守卫在检测到 JWT 过期时调用,直接操作localStorage,不经过 Store(因为守卫执行时 Store 实例可能尚未创建)。
两者都移除了 accessibleMenuPaths,确保菜单白名单不会残留。详见 。
logout:登出
logout 先调用后端登出接口(通知后端使 Token 失效),无论成功与否都清除本地登录态:
const logout = async () => {
try {
await request.post('/auth/logout')
} catch (e) {
console.error('Logout API error:', e)
} finally {
clearSession() // 即使后端登出失败,也要清除本地状态
}
}finally 保证本地清理
即使用户登出时网络异常或后端报错,finally 块仍会执行 clearSession(),确保前端登录态被清除。否则用户会卡在「登出失败但本地仍有 Token」的尴尬状态。
权限与角色判断
hasPermission 和 hasRole 支持传入字符串或字符串数组(数组用 some 语义,满足其一即可):
const hasPermission = (perm: string | string[]) => {
if (!permissions.value || permissions.value.length === 0) return false
if (Array.isArray(perm)) {
return perm.some(p => permissions.value.includes(p))
}
return permissions.value.includes(perm)
}
const hasRole = (role: string | string[]) => {
if (!roles.value || roles.value.length === 0) return false
if (Array.isArray(role)) {
return role.some(r => roles.value.includes(r))
}
return roles.value.includes(role)
}这两个方法被 v-permission / v-role 指令调用:
<!-- 单个权限码 -->
<el-button v-permission="'system:user:add'">新增</el-button>
<!-- 多个权限码,满足其一即可 -->
<el-button v-permission="['system:user:edit', 'system:user:reset']">编辑</el-button>
<!-- 角色判断 -->
<el-button v-role="'admin'">超级操作</el-button>指令实现见 frontend/src/directives/permission.ts:
const checkPermission = (el: HTMLElement, binding: DirectiveBinding) => {
const { value } = binding
const userStore = useUserStore()
if (value) {
if (!userStore.hasPermission(value)) {
el.parentNode?.removeChild(el) // 无权限直接从 DOM 移除
}
}
}登录流程如何写入 Store
frontend/src/views/login/Login.vue 的 completeLogin 函数在登录成功后调用各 setter:
const completeLogin = (data: any) => {
userStore.setToken(data.token)
userStore.setUserInfo(data.userInfo)
userStore.setTenantId(String(data.userInfo?.tenantId || 1))
userStore.setPermissions(data.permissions || [])
userStore.setRoles(data.roles || [])
ElMessage.success('登录成功')
router.replace(resolveRedirectPath())
}后端 /auth/login 接口一次性返回 Token、用户信息、权限码、角色,前端写入 Store(同时持久化到 localStorage),然后跳转到 redirect 路径或首页。

Store 在请求层的应用
frontend/src/utils/request.ts 的请求拦截器从 Store 读取 Token 和租户 ID 注入请求头:
service.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
if (userStore.tenantId) {
config.headers['Tenant-Id'] = userStore.tenantId
}
return config
})401 响应时调用 clearSession:
const redirectToLogin = () => {
if (redirectingToLogin) return
redirectingToLogin = true
const userStore = useUserStore()
userStore.clearSession() // 清除 Store + localStorage
router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
setTimeout(() => { redirectingToLogin = false }, 500)
}状态流转全景图
登录页 Login.vue
│ completeLogin(data)
▼
useUserStore.setToken / setUserInfo / setTenantId / setPermissions / setRoles
│ 同时写 Pinia 响应式状态 + localStorage
▼
┌─────────────────────────────────────────────────┐
│ Pinia Store(内存) localStorage(持久) │
│ token: 'eyJ...' token: 'eyJ...' │
│ userInfo: {...} userInfo: '{...}' │
│ tenantId: '1' tenantId: '1' │
│ permissions: [...] permissions: '[...]' │
│ roles: [...] roles: '[...]' │
└─────────────────────────────────────────────────┘
│ │
├── 请求拦截器读取 ──▶ Authorization / Tenant-Id 请求头
├── v-permission 指令读取 ──▶ 按钮显示 / 隐藏
└── 路由守卫读 localStorage ──▶ JWT 过期检测 / 菜单白名单
│ 401 响应 / JWT 过期
▼
clearSession() / clearLoginState()
│ 清空 Pinia + localStorage
▼
跳转 /login?redirect=xxx操作步骤
在组件中使用 Store
<script setup lang="ts">
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 读取状态(响应式)
console.log(userStore.token)
console.log(userStore.userInfo?.nickname)
// 判断权限
const canEdit = userStore.hasPermission('system:user:edit')
// 判断角色
const isAdmin = userStore.hasRole('admin')
</script>登出
import { useUserStore } from '@/store/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const handleLogout = async () => {
await userStore.logout() // 调后端登出 + 清本地状态
router.push('/login')
}切换租户
const userStore = useUserStore()
const handleSwitchTenant = (tenantId: string) => {
userStore.setTenantId(tenantId)
// 切换租户后通常需要重新加载菜单与数据
location.reload()
}切换租户后需刷新
租户 ID 变更后,已加载的页面数据仍是旧租户的。setTenantId 只更新了状态,不自动刷新数据。通常切换租户后执行 location.reload() 整页刷新,让菜单、路由、页面数据全部按新租户重新加载。
常见问题
刷新页面后登录态丢失
Store 从 localStorage 初始化,如果登录态丢失说明 localStorage 被清空。排查:
- 浏览器是否开了「无痕模式」或「隐私模式」(关闭后 localStorage 清空)。
- 是否有其他代码误调了
clearSession()或localStorage.clear()。 - 检查
setToken等 setter 是否正确执行(登录成功后打断点确认)。
v-permission 指令不生效
- 确认
main.ts中调用了setupPermissionDirective(app)。 - 确认登录时
setPermissions写入了正确的权限码数组。 - 指令在
mounted/updated钩子检查,如果权限码是异步加载的,确保权限写入 Store 后元素重新渲染。
多标签页登录态不同步
localStorage 在同源多标签页间共享,但 Pinia 状态是各标签页独立的。如果 A 标签页登出,B 标签页的 Pinia Store 仍持有旧 Token,直到 B 标签页发起请求收到 401 才触发 clearSession。如需实时同步,可监听 storage 事件:
window.addEventListener('storage', (e) => {
if (e.key === 'token' && !e.newValue) {
// 其他标签页登出了,当前标签页也跳登录
router.push('/login')
}
})为什么没有用 pinia-plugin-persistedstate
本平台手动在 setter 中写 localStorage,而非用持久化插件。原因:
- 需要精细控制每个键的存储格式(如
userInfo要JSON.stringify,token直接存字符串)。 clearSession需要同时清除非 Store 管理的accessibleMenuPaths。- 手动写入更直观,便于排查问题。
下一步
- 了解公共组件如何使用权限与状态:
- 了解设计器如何编排业务流程:
