/**
* login.ts —— 登录态 store(Pinia setup 风格;README §3.2 状态最小化)。
*
* 职责边界:
* - 持有**响应式视图**(token/角色/权限码/菜单树),供组件 `useLogin()` 读取渲染菜单/按钮;
* - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
* - 登录/登出动作封装在此(调 api/auth/login.ts 两步 → 写 token-store → 同步视图 + scope)。
*
* 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
* ① 验账密成功 → applyStep1():暂存一次性票据 + 作用域列表 + 用户名(**不落 token**);
* 单条作用域:登录页自动 applyLogin(②);多条:进作用域选择页由用户挑选。
* ② 选定作用域签发 → applyLogin(TokenPair):写 token + 同步 roles/permissions/menuTree + 设 scope。
* pendingTicket/pendingMemberships 为 ①→② 之间的**内存过渡态**(票据 5min 有效、单次消费;
* 不落 localStorage——泄露的 ticket 可被换 token,落盘徒增风险面;刷新即丢,需重走①)。
*
* 典型使用(组件内):
*
* const login = useLogin();
* v-perm="'academic.fee.refund'" 指令读 login.hasPerm;
* {{ login.realName }}
*/
import { logout as apiLogout, type LoginStep1, type MembershipView, type TokenPair } from '@/api/auth/login';
import { fetchMe } from '@/api/auth/me';
import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
import { useScope } from '@/store/scope';
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;
/** 当前用户真实姓名(未登录为 '';① 验账密成功后即有值) */
realName: Ref;
/** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
roles: Ref;
/** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据,README §3.1) */
permissions: Ref>;
/** 可见导航菜单树(登录② 响应下发;layout 壳侧边菜单渲染。刷新后未恢复前为 [],菜单降级全隐藏) */
menuTree: Ref;
/** 两步登录① 暂存:一次性票据(null = 未处于 ①→② 过渡态) */
pendingTicket: Ref;
/** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
pendingMemberships: Ref;
/** 响应式视图是否已从后端恢复(② 签发或 /auth/me 成功);刷新后为 false,守卫据此触发 restoreMe 一次 */
restored: Ref;
/** 是否已登录(有 access token 且未过期;守卫链第一步) */
isLoggedIn: ComputedRef;
/** 判断是否持有指定权限码(v-perm 指令用) */
hasPerm: (perm: string) => boolean;
/** 登录① 成功后的过渡态落盘(暂存票据 + 作用域列表 + 用户名;不写 token) */
applyStep1: (data: LoginStep1) => void;
/** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
applyLogin: (data: TokenPair) => void;
/**
* 凭有效 access 恢复响应式视图(GET /auth/me;菜单刷新恢复用,T6.5+)。
*
* 语义:页面刷新后 token 三件套仍在 token-store,但 roles/permissions/menuTree 是 ② 下发的内存态
*(不落盘)——调 /auth/me 按已验签 claims 回读用户 + 当前作用域 + 按角色重新解析菜单/权限。
* - 成功:同步 realName/roles/permissions/menuTree + 设 scope(institutionId>0 时);
* - 失败(网络/3001 用户被删/5002):返回 false,视图保持现状(菜单全隐、scope 未就绪)——
* 3001(用户被删)调用方应据返回的 code 决定登出;本方法只报恢复成败,不代跳。
* - access 过期:http 拦截器自动 refresh 重放,恢复失败才返回 false。
*/
restoreMe: () => Promise;
/** 记住上次作用域(applyLogin 时调用;写 localStorage,下次登录作用域选择页默认选中) */
rememberScope: (institutionId: number, campusId: number) => void;
/** 读取上次作用域(作用域选择页初始化默认选中;无记录返回 null) */
lastScope: () => { institutionId: number; campusId: number } | null;
/** 执行登出(调后端吊销 → 清本地态) */
doLogout: () => Promise;
/** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
hydrate: () => void;
}
/**
* 作用域偏好存储键(记住"上次进入的机构/校区",下次登录的作用域选择页默认选中该项)。
* 非凭证(不涉 token/票据),可安全持久化到 localStorage;doLogout 不清除(偏好跨登录保留)。
*/
export const LAST_SCOPE_KEY = 'ysw:last-scope';
// /auth/me 恢复的 in-flight 单例(并发导航只发一次;模块级,store 实例唯一,语义等价)。
let restoreInflight: Promise | undefined;
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——menuTree 是响应式视图非凭证,
// 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
const menuTree = ref([]);
// 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
const pendingTicket = ref(null);
const pendingMemberships = ref([]);
// 视图恢复标记:applyLogin(② 签发)或 restoreMe(/auth/me)成功后为 true;
// hydrate(刷新后 token 在但视图空)/ doLogout 后置 false——守卫据此触发一次恢复。
const restored = ref(false);
/** 已登录判定:有 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.fee.refund`)。
*
* 出参:
* - boolean:未登录恒 false(fail-closed);登录态按 permissions 集合精确匹配。
*/
function hasPerm(perm: string): boolean {
return isLoggedIn.value && permissions.value.has(perm);
}
/**
* 登录① 成功过渡态落盘(暂存票据 + 作用域列表 + 用户名;**不写 token**)。
*
* 入参:
* - data LoginStep1:api/auth/login.ts ① 返回的 data(code=200 时)。
*
* 出参:无。
* 异常场景:
* - data 缺 loginTicket:视为后端契约违约,过渡态不落盘(pendingTicket 保持 null)。
*/
function applyStep1(data: LoginStep1): void {
if (!data.loginTicket) return;
pendingTicket.value = data.loginTicket;
pendingMemberships.value = data.memberships ?? [];
realName.value = data.user?.realName ?? '';
}
/**
* 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步 + scope 设置)。
*
* 入参:
* - data TokenPair:api/auth/login.ts ② 返回的 data(code=200 时)。
*
* 出参:无。
* 异常场景:
* - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
* - institutionId ≤ 0(平台级超管):scope.set 拒绝(机构必选),作用域保持未就绪——
* 平台级角色的作用域语义为独立待办,本处不特殊处理(对齐现状)。
*/
function applyLogin(data: TokenPair): void {
if (!data.accessToken) return;
const scope = useScope();
setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
accessToken.value = data.accessToken;
expiresAt.value = data.expiresAt;
realName.value = data.user?.realName ?? realName.value;
roles.value = data.roles ?? [];
permissions.value = new Set(data.permissions ?? []);
menuTree.value = data.menuTree ?? [];
// 选定作用域写入 scope store(守卫链第四步据此放行;institutionId>0 才合法)
if (data.institutionId > 0) {
scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
}
// ② 完成:清过渡态(票据已消费,留着反成泄露面);视图已完整 → 恢复标记置位
pendingTicket.value = null;
pendingMemberships.value = [];
restored.value = true;
// 记住本次作用域(机构>0 才记;campus 0 = 机构级也记,作为下次默认)
if (data.institutionId > 0) rememberScope(data.institutionId, data.campusId ?? 0);
}
/**
* 记住上次作用域(写 localStorage;下次登录的作用域选择页据此默认选中)。
*
* 入参:institutionId(>0 才记)/ campusId(0 = 机构级)。
* 出参:无。
* 异常场景:localStorage 不可用(隐私模式禁用):静默跳过,不影响登录流程。
*/
function rememberScope(institutionId: number, campusId: number): void {
if (!institutionId || institutionId <= 0) return;
try {
localStorage.setItem(LAST_SCOPE_KEY, JSON.stringify({ institutionId, campusId: campusId ?? 0 }));
} catch {
/* 隐私模式等 storage 禁用:忽略(偏好丢失不阻断业务) */
}
}
/**
* 读取上次作用域(作用域选择页初始化用)。
*
* 入参:无。
* 出参:{ institutionId, campusId } | null(无记录 / 解析失败 = null,调用方回退首项)。
* 异常场景:JSON 解析失败 / 结构不符:返回 null(fail-open 回退默认首项)。
*/
function lastScope(): { institutionId: number; campusId: number } | null {
try {
const raw = localStorage.getItem(LAST_SCOPE_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as { institutionId?: number; campusId?: number };
if (typeof p.institutionId === 'number' && p.institutionId > 0) {
return { institutionId: p.institutionId, campusId: typeof p.campusId === 'number' ? p.campusId : 0 };
}
return null;
} catch {
return null;
}
}
/**
* 凭有效 access 恢复响应式视图(GET /auth/me,菜单刷新恢复用)。
*
* 入参:无(作用域/角色来自已验签 JWT claims,后端按 claims 回读 + 重新解析菜单/权限)。
*
* 出参:
* - Promise:true = 恢复成功(roles/permissions/menuTree/scope 已同步);
* false = 无 token / 恢复失败(视图保持现状:菜单全隐、scope 未就绪)。
* 异常场景:
* - code=3001(token 有效但用户被删,罕见):按登出处理(强制重新登录)并返回 false;
* - access 过期:http 拦截器自动 refresh 重放,仍失败返回 false;
* - 网络层故障:返回 false(调用方不阻塞——守卫继续走作用域选择页降级)。
*
* 并发纪律:同一时刻仅一个 in-flight 请求(守卫并发导航复用同一 Promise)。
*/
async function restoreMe(): Promise {
if (!accessToken.value) return false;
if (restoreInflight) return restoreInflight;
restoreInflight = (async () => {
try {
const res = await fetchMe();
if (res.code === 3001) {
// token 有效但用户被删(罕见):会话作废,强制重新登录
void doLogout();
return false;
}
if (res.code !== 200 || !res.data) return false;
const d = res.data;
realName.value = d.realName || realName.value;
roles.value = d.roles ?? [];
permissions.value = new Set(d.permissions ?? []);
menuTree.value = d.menuTree ?? [];
if (d.institutionId > 0) {
useScope().set({ institutionId: d.institutionId, campusId: d.campusId ?? 0 });
}
restored.value = true;
return true;
} catch {
return false;
} finally {
restoreInflight = undefined;
}
})();
return restoreInflight;
}
/**
* 执行登出:调后端吊销 → 清本地态(无论后端是否成功,本地态必清——登出不允许卡住)。
*
* 入参:无。
*
* 出参:
* - Promise:恒 resolve(失败场景静默,网络错误不阻塞登出)。
*/
async function doLogout(): Promise {
const refresh = getRefreshToken();
try {
if (refresh) await apiLogout(refresh);
} catch {
// 网络故障/后端错误:不阻塞本地登出(jti 黑名单到 access 30min 后自然失效)
}
const scope = useScope();
scope.reset();
clearTokens();
accessToken.value = null;
expiresAt.value = null;
realName.value = '';
roles.value = [];
permissions.value = new Set();
menuTree.value = [];
pendingTicket.value = null;
pendingMemberships.value = [];
restored.value = false;
}
/**
* 从 token-store 同步本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。
*
* 入参:无。
*
* 出参:无。
* 异常场景:
* - 无 token(未登录):视图置空,守卫链引导到登录页。
* - 有 token(刷新后):roles/permissions/menuTree 是 ② 下发的内存态(刷新即丢),
* 此处仅恢复 token 三件套;完整视图恢复由守卫调 restoreMe()(/auth/me)完成。
*/
function hydrate(): void {
accessToken.value = getAccessToken();
expiresAt.value = getExpiresAt();
pendingTicket.value = null;
pendingMemberships.value = [];
// 刷新后视图未恢复(roles/menuTree/scope 全空):置 false,守卫首次导航触发 restoreMe
restored.value = false;
if (!accessToken.value) {
realName.value = '';
roles.value = [];
permissions.value = new Set();
menuTree.value = [];
}
}
return {
accessToken,
expiresAt,
realName,
roles,
permissions,
menuTree,
pendingTicket,
pendingMemberships,
restored,
isLoggedIn,
hasPerm,
applyStep1,
applyLogin,
rememberScope,
lastScope,
restoreMe,
doLogout,
hydrate,
};
});