| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169 |
- /**
- * 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;
- * <template>{{ login.realName }}</template>
- * <tabbar-menu :tab-tree="login.tabTree" />
- */
- 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<string | null>;
- /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
- expiresAt: Ref<number | null>;
- /** 当前用户真实姓名(未登录为 '';② 下发 user.realName) */
- realName: Ref<string>;
- /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
- roles: Ref<string[]>;
- /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据) */
- permissions: Ref<Set<string>>;
- /** 可见底部导航树(② 响应 menuTree 下发;tabbar-menu 渲染。刷新后未恢复前为 [],tab 降级全隐藏) */
- tabTree: Ref<MenuNode[]>;
- /** 是否已登录(有 access token 且未过期;守卫链第一步) */
- isLoggedIn: ComputedRef<boolean>;
- /** 判断是否持有指定权限码(v-perm 指令用) */
- hasPerm: (perm: string) => boolean;
- /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图) */
- applyLogin: (data: TokenPair) => void;
- /** 执行登出(调后端吊销 → 清本地态) */
- doLogout: () => Promise<void>;
- /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
- hydrate: () => void;
- }
- export const useLogin = defineStore('login', (): LoginStore => {
- const accessToken = ref<string | null>(null);
- const expiresAt = ref<number | null>(null);
- const realName = ref('');
- const roles = ref<string[]>([]);
- const permissions = ref(new Set<string>());
- // 可见底部导航树(仅内存态,不落 localStorage——tabTree 是响应式视图非凭证,
- // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
- const tabTree = ref<MenuNode[]>([]);
- /** 已登录判定:有 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<void>:恒 resolve(失败场景静默,网络错误不阻塞登出)。
- */
- async function doLogout(): Promise<void> {
- 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,
- };
- });
|