/** * login.ts —— 登录态 store(Pinia setup 风格;编码规范"状态最小化")。 * * 职责边界: * - 持有**响应式视图**(token/角色/权限码/tabTree),供组件 `useLogin()` 读取渲染 tab/按钮; * - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage); * - 登录② 凭证落盘封装在此(调 api/auth/login.ts 两步的② TokenPair → 写 token-store → 同步视图)。 * 三端共用一份(对齐"底层用户统一")。 * * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06): * ① 验账密(POST /login)→ loginTicket + memberships[](**不签 token**); * ② 选定作用域(POST /issue)→ TokenPair(access+refresh+roles+menuTree+permissions)→ applyLogin 落盘。 * 移动端**无**独立作用域选择页:登录页在① 成功后自动取 memberships[0] 立即 ②,故本 store 只需 * applyLogin(落 ② 的 TokenPair);不保留 ① 过渡态(票据一次性,① 未 ② 前刷新即丢,需重走①)。 * * 典型使用(组件内): * * const login = useLogin(); * v-perm="'academic.checkin.view'" 指令读 login.hasPerm; * * */ import { logout as apiLogout, type TokenPair } from '../api/auth/login'; import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '../identity/token-store'; import type { ComputedRef, Ref } from 'vue'; import type { MenuNode } from '../types/api'; /** * setup store 的返回面类型(defineStore 的 setup 函数返回值;字段为 ref 本体)。 * 注:经 `useLogin()` 消费时 Pinia **自动解包**(组件里 login.accessToken 是 string | null), * 本接口仅约束 setup 函数返回,消费方以 useLogin() 推断类型为准。 */ export interface LoginStore { /** access token(响应式视图;物理值以 token-store 为准) */ accessToken: Ref; /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */ expiresAt: Ref; /** 当前用户真实姓名(未登录为 '';② 下发 user.realName) */ realName: Ref; /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */ roles: Ref; /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据) */ permissions: Ref>; /** 可见底部导航树(② 响应 menuTree 下发;tabbar-menu 渲染。刷新后未恢复前为 [],tab 降级全隐藏) */ tabTree: Ref; /** 是否已登录(有 access token 且未过期;守卫链第一步) */ isLoggedIn: ComputedRef; /** 判断是否持有指定权限码(v-perm 指令用) */ hasPerm: (perm: string) => boolean; /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图) */ applyLogin: (data: TokenPair) => void; /** 执行登出(调后端吊销 → 清本地态) */ doLogout: () => Promise; /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */ hydrate: () => void; } export const useLogin = defineStore('login', (): LoginStore => { const accessToken = ref(null); const expiresAt = ref(null); const realName = ref(''); const roles = ref([]); const permissions = ref(new Set()); // 可见底部导航树(仅内存态,不落 localStorage——tabTree 是响应式视图非凭证, // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项)) const tabTree = ref([]); /** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */ const isLoggedIn = computed(() => { if (!accessToken.value) return false; const exp = expiresAt.value; return exp === null || Date.now() < exp - 30_000; }); /** * 判断是否持有指定权限码。 * * 入参: * - perm string:`域.资源.动作` 三段式权限码(如 `academic.checkin.view`)。 * * 出参: * - boolean:未登录恒 false(fail-closed);登录态按 permissions 集合精确匹配。 */ function hasPerm(perm: string): boolean { return isLoggedIn.value && permissions.value.has(perm); } /** * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步)。 * * 入参: * - data TokenPair:api/auth/login.ts ②(issue)返回的 data(code=200 时)。 * * 出参:无。 * 异常场景: * - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。 */ function applyLogin(data: TokenPair): void { if (!data.accessToken) return; setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt }); accessToken.value = data.accessToken; expiresAt.value = data.expiresAt; realName.value = data.user?.realName ?? ''; roles.value = data.roles ?? []; permissions.value = new Set(data.permissions ?? []); // 后端 ② 下发 menuTree;移动端作底部 Tabbar 消费(store 字段名 tabTree,契约字段名 menuTree) tabTree.value = data.menuTree ?? []; } /** * 执行登出:调后端吊销 → 清本地态(无论后端是否成功,本地态必清——登出不允许卡住)。 * * 入参:无。 * * 出参: * - Promise:恒 resolve(失败场景静默,网络错误不阻塞登出)。 */ async function doLogout(): Promise { const refresh = getRefreshToken(); try { if (refresh) await apiLogout(refresh); } catch { // 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效) } clearTokens(); accessToken.value = null; expiresAt.value = null; realName.value = ''; roles.value = []; permissions.value = new Set(); tabTree.value = []; } /** * 从 token-store 同步一次本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。 * * 入参:无。 * * 出参:无。 * 异常场景: * - 无 token(未登录):视图置空,守卫链引导到登录页。 */ function hydrate(): void { accessToken.value = getAccessToken(); expiresAt.value = getExpiresAt(); // 刷新后 roles/permissions 未知(后端未提供"凭 access 取用户中心资料"端点前), // 首次请求 401 触发 refresh 流程;完整恢复依赖后续 /api/auth/me 端点(规划项)。 if (!accessToken.value) { realName.value = ''; roles.value = []; permissions.value = new Set(); tabTree.value = []; } } return { accessToken, expiresAt, realName, roles, permissions, tabTree, isLoggedIn, hasPerm, applyLogin, doLogout, hydrate, }; });