login.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /**
  2. * login.ts —— 登录态 store(Pinia setup 风格;编码规范"状态最小化")。
  3. *
  4. * 职责边界:
  5. * - 持有**响应式视图**(token/角色/权限码/tabTree),供组件 `useLogin()` 读取渲染 tab/按钮;
  6. * - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
  7. * - 登录② 凭证落盘封装在此(调 api/auth/login.ts 两步的② TokenPair → 写 token-store → 同步视图)。
  8. * 三端共用一份(对齐"底层用户统一")。
  9. *
  10. * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
  11. * ① 验账密(POST /login)→ loginTicket + memberships[](**不签 token**);
  12. * ② 选定作用域(POST /issue)→ TokenPair(access+refresh+roles+menuTree+permissions)→ applyLogin 落盘。
  13. * 移动端**无**独立作用域选择页:登录页在① 成功后自动取 memberships[0] 立即 ②,故本 store 只需
  14. * applyLogin(落 ② 的 TokenPair);不保留 ① 过渡态(票据一次性,① 未 ② 前刷新即丢,需重走①)。
  15. *
  16. * 典型使用(组件内):
  17. *
  18. * const login = useLogin();
  19. * v-perm="'academic.checkin.view'" 指令读 login.hasPerm;
  20. * <template>{{ login.realName }}</template>
  21. * <tabbar-menu :tab-tree="login.tabTree" />
  22. */
  23. import { logout as apiLogout, type TokenPair } from '../api/auth/login';
  24. import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '../identity/token-store';
  25. import type { ComputedRef, Ref } from 'vue';
  26. import type { MenuNode } from '../types/api';
  27. /**
  28. * setup store 的返回面类型(defineStore 的 setup 函数返回值;字段为 ref 本体)。
  29. * 注:经 `useLogin()` 消费时 Pinia **自动解包**(组件里 login.accessToken 是 string | null),
  30. * 本接口仅约束 setup 函数返回,消费方以 useLogin() 推断类型为准。
  31. */
  32. export interface LoginStore {
  33. /** access token(响应式视图;物理值以 token-store 为准) */
  34. accessToken: Ref<string | null>;
  35. /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
  36. expiresAt: Ref<number | null>;
  37. /** 当前用户真实姓名(未登录为 '';② 下发 user.realName) */
  38. realName: Ref<string>;
  39. /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
  40. roles: Ref<string[]>;
  41. /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据) */
  42. permissions: Ref<Set<string>>;
  43. /** 可见底部导航树(② 响应 menuTree 下发;tabbar-menu 渲染。刷新后未恢复前为 [],tab 降级全隐藏) */
  44. tabTree: Ref<MenuNode[]>;
  45. /** 是否已登录(有 access token 且未过期;守卫链第一步) */
  46. isLoggedIn: ComputedRef<boolean>;
  47. /** 判断是否持有指定权限码(v-perm 指令用) */
  48. hasPerm: (perm: string) => boolean;
  49. /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图) */
  50. applyLogin: (data: TokenPair) => void;
  51. /** 执行登出(调后端吊销 → 清本地态) */
  52. doLogout: () => Promise<void>;
  53. /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
  54. hydrate: () => void;
  55. }
  56. export const useLogin = defineStore('login', (): LoginStore => {
  57. const accessToken = ref<string | null>(null);
  58. const expiresAt = ref<number | null>(null);
  59. const realName = ref('');
  60. const roles = ref<string[]>([]);
  61. const permissions = ref(new Set<string>());
  62. // 可见底部导航树(仅内存态,不落 localStorage——tabTree 是响应式视图非凭证,
  63. // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
  64. const tabTree = ref<MenuNode[]>([]);
  65. /** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
  66. const isLoggedIn = computed(() => {
  67. if (!accessToken.value) return false;
  68. const exp = expiresAt.value;
  69. return exp === null || Date.now() < exp - 30_000;
  70. });
  71. /**
  72. * 判断是否持有指定权限码。
  73. *
  74. * 入参:
  75. * - perm string:`域.资源.动作` 三段式权限码(如 `academic.checkin.view`)。
  76. *
  77. * 出参:
  78. * - boolean:未登录恒 false(fail-closed);登录态按 permissions 集合精确匹配。
  79. */
  80. function hasPerm(perm: string): boolean {
  81. return isLoggedIn.value && permissions.value.has(perm);
  82. }
  83. /**
  84. * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步)。
  85. *
  86. * 入参:
  87. * - data TokenPair:api/auth/login.ts ②(issue)返回的 data(code=200 时)。
  88. *
  89. * 出参:无。
  90. * 异常场景:
  91. * - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
  92. */
  93. function applyLogin(data: TokenPair): void {
  94. if (!data.accessToken) return;
  95. setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
  96. accessToken.value = data.accessToken;
  97. expiresAt.value = data.expiresAt;
  98. realName.value = data.user?.realName ?? '';
  99. roles.value = data.roles ?? [];
  100. permissions.value = new Set(data.permissions ?? []);
  101. // 后端 ② 下发 menuTree;移动端作底部 Tabbar 消费(store 字段名 tabTree,契约字段名 menuTree)
  102. tabTree.value = data.menuTree ?? [];
  103. }
  104. /**
  105. * 执行登出:调后端吊销 → 清本地态(无论后端是否成功,本地态必清——登出不允许卡住)。
  106. *
  107. * 入参:无。
  108. *
  109. * 出参:
  110. * - Promise<void>:恒 resolve(失败场景静默,网络错误不阻塞登出)。
  111. */
  112. async function doLogout(): Promise<void> {
  113. const refresh = getRefreshToken();
  114. try {
  115. if (refresh) await apiLogout(refresh);
  116. } catch {
  117. // 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效)
  118. }
  119. clearTokens();
  120. accessToken.value = null;
  121. expiresAt.value = null;
  122. realName.value = '';
  123. roles.value = [];
  124. permissions.value = new Set();
  125. tabTree.value = [];
  126. }
  127. /**
  128. * 从 token-store 同步一次本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。
  129. *
  130. * 入参:无。
  131. *
  132. * 出参:无。
  133. * 异常场景:
  134. * - 无 token(未登录):视图置空,守卫链引导到登录页。
  135. */
  136. function hydrate(): void {
  137. accessToken.value = getAccessToken();
  138. expiresAt.value = getExpiresAt();
  139. // 刷新后 roles/permissions 未知(后端未提供"凭 access 取用户中心资料"端点前),
  140. // 首次请求 401 触发 refresh 流程;完整恢复依赖后续 /api/auth/me 端点(规划项)。
  141. if (!accessToken.value) {
  142. realName.value = '';
  143. roles.value = [];
  144. permissions.value = new Set();
  145. tabTree.value = [];
  146. }
  147. }
  148. return {
  149. accessToken,
  150. expiresAt,
  151. realName,
  152. roles,
  153. permissions,
  154. tabTree,
  155. isLoggedIn,
  156. hasPerm,
  157. applyLogin,
  158. doLogout,
  159. hydrate,
  160. };
  161. });