/**
* 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;
* {{ login.realName }}
*
*/
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,
};
});