|
@@ -20,6 +20,7 @@
|
|
|
* <template>{{ login.realName }}</template>
|
|
* <template>{{ login.realName }}</template>
|
|
|
*/
|
|
*/
|
|
|
import { logout as apiLogout, type LoginStep1, type MembershipView, type TokenPair } from '@/api/auth/login';
|
|
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 { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '@/identity/token-store';
|
|
|
import { useScope } from '@/store/scope';
|
|
import { useScope } from '@/store/scope';
|
|
|
import type { ComputedRef, Ref } from 'vue';
|
|
import type { ComputedRef, Ref } from 'vue';
|
|
@@ -47,6 +48,8 @@ export interface LoginStore {
|
|
|
pendingTicket: Ref<string | null>;
|
|
pendingTicket: Ref<string | null>;
|
|
|
/** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
|
|
/** 两步登录① 暂存:可选作用域列表(作用域选择页渲染依据) */
|
|
|
pendingMemberships: Ref<MembershipView[]>;
|
|
pendingMemberships: Ref<MembershipView[]>;
|
|
|
|
|
+ /** 响应式视图是否已从后端恢复(② 签发或 /auth/me 成功);刷新后为 false,守卫据此触发 restoreMe 一次 */
|
|
|
|
|
+ restored: Ref<boolean>;
|
|
|
/** 是否已登录(有 access token 且未过期;守卫链第一步) */
|
|
/** 是否已登录(有 access token 且未过期;守卫链第一步) */
|
|
|
isLoggedIn: ComputedRef<boolean>;
|
|
isLoggedIn: ComputedRef<boolean>;
|
|
|
/** 判断是否持有指定权限码(v-perm 指令用) */
|
|
/** 判断是否持有指定权限码(v-perm 指令用) */
|
|
@@ -55,12 +58,26 @@ export interface LoginStore {
|
|
|
applyStep1: (data: LoginStep1) => void;
|
|
applyStep1: (data: LoginStep1) => void;
|
|
|
/** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
|
|
/** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图 + scope) */
|
|
|
applyLogin: (data: TokenPair) => void;
|
|
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<boolean>;
|
|
|
/** 执行登出(调后端吊销 → 清本地态) */
|
|
/** 执行登出(调后端吊销 → 清本地态) */
|
|
|
doLogout: () => Promise<void>;
|
|
doLogout: () => Promise<void>;
|
|
|
/** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
|
|
/** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
|
|
|
hydrate: () => void;
|
|
hydrate: () => void;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// /auth/me 恢复的 in-flight 单例(并发导航只发一次;模块级,store 实例唯一,语义等价)。
|
|
|
|
|
+let restoreInflight: Promise<boolean> | undefined;
|
|
|
|
|
+
|
|
|
export const useLogin = defineStore('login', (): LoginStore => {
|
|
export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
const accessToken = ref<string | null>(null);
|
|
const accessToken = ref<string | null>(null);
|
|
|
const expiresAt = ref<number | null>(null);
|
|
const expiresAt = ref<number | null>(null);
|
|
@@ -73,6 +90,9 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
// 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
|
|
// 两步登录① 过渡态(内存态,不落盘——见文件头状态机说明)
|
|
|
const pendingTicket = ref<string | null>(null);
|
|
const pendingTicket = ref<string | null>(null);
|
|
|
const pendingMemberships = ref<MembershipView[]>([]);
|
|
const pendingMemberships = ref<MembershipView[]>([]);
|
|
|
|
|
+ // 视图恢复标记:applyLogin(② 签发)或 restoreMe(/auth/me)成功后为 true;
|
|
|
|
|
+ // hydrate(刷新后 token 在但视图空)/ doLogout 后置 false——守卫据此触发一次恢复。
|
|
|
|
|
+ const restored = ref(false);
|
|
|
|
|
|
|
|
/** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
|
|
/** 已登录判定:有 access token 且未过期(提前 30s 视为过期,避免临界请求撞上 1002) */
|
|
|
const isLoggedIn = computed(() => {
|
|
const isLoggedIn = computed(() => {
|
|
@@ -137,9 +157,56 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
if (data.institutionId > 0) {
|
|
if (data.institutionId > 0) {
|
|
|
scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
|
|
scope.set({ institutionId: data.institutionId, campusId: data.campusId ?? 0 });
|
|
|
}
|
|
}
|
|
|
- // ② 完成:清过渡态(票据已消费,留着反成泄露面)
|
|
|
|
|
|
|
+ // ② 完成:清过渡态(票据已消费,留着反成泄露面);视图已完整 → 恢复标记置位
|
|
|
pendingTicket.value = null;
|
|
pendingTicket.value = null;
|
|
|
pendingMemberships.value = [];
|
|
pendingMemberships.value = [];
|
|
|
|
|
+ restored.value = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 凭有效 access 恢复响应式视图(GET /auth/me,菜单刷新恢复用)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 入参:无(作用域/角色来自已验签 JWT claims,后端按 claims 回读 + 重新解析菜单/权限)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 出参:
|
|
|
|
|
+ * - Promise<boolean>:true = 恢复成功(roles/permissions/menuTree/scope 已同步);
|
|
|
|
|
+ * false = 无 token / 恢复失败(视图保持现状:菜单全隐、scope 未就绪)。
|
|
|
|
|
+ * 异常场景:
|
|
|
|
|
+ * - code=3001(token 有效但用户被删,罕见):按登出处理(强制重新登录)并返回 false;
|
|
|
|
|
+ * - access 过期:http 拦截器自动 refresh 重放,仍失败返回 false;
|
|
|
|
|
+ * - 网络层故障:返回 false(调用方不阻塞——守卫继续走作用域选择页降级)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 并发纪律:同一时刻仅一个 in-flight 请求(守卫并发导航复用同一 Promise)。
|
|
|
|
|
+ */
|
|
|
|
|
+ async function restoreMe(): Promise<boolean> {
|
|
|
|
|
+ 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;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -168,6 +235,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
menuTree.value = [];
|
|
menuTree.value = [];
|
|
|
pendingTicket.value = null;
|
|
pendingTicket.value = null;
|
|
|
pendingMemberships.value = [];
|
|
pendingMemberships.value = [];
|
|
|
|
|
+ restored.value = false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -178,16 +246,16 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
* 出参:无。
|
|
* 出参:无。
|
|
|
* 异常场景:
|
|
* 异常场景:
|
|
|
* - 无 token(未登录):视图置空,守卫链引导到登录页。
|
|
* - 无 token(未登录):视图置空,守卫链引导到登录页。
|
|
|
- * - 有 token(刷新后):roles/permissions/menuTree 未知(② 过渡态不落盘,刷新即丢),
|
|
|
|
|
- * 完整恢复依赖后续 /api/auth/me 端点(规划项);过渡态恒置空。
|
|
|
|
|
|
|
+ * - 有 token(刷新后):roles/permissions/menuTree 是 ② 下发的内存态(刷新即丢),
|
|
|
|
|
+ * 此处仅恢复 token 三件套;完整视图恢复由守卫调 restoreMe()(/auth/me)完成。
|
|
|
*/
|
|
*/
|
|
|
function hydrate(): void {
|
|
function hydrate(): void {
|
|
|
accessToken.value = getAccessToken();
|
|
accessToken.value = getAccessToken();
|
|
|
expiresAt.value = getExpiresAt();
|
|
expiresAt.value = getExpiresAt();
|
|
|
pendingTicket.value = null;
|
|
pendingTicket.value = null;
|
|
|
pendingMemberships.value = [];
|
|
pendingMemberships.value = [];
|
|
|
- // 刷新后 roles/permissions 未知(后端未提供"凭 access 取用户中心资料"端点前),
|
|
|
|
|
- // 首次请求 401 触发 refresh 流程;完整恢复依赖后续 /api/auth/me 端点(规划项)。
|
|
|
|
|
|
|
+ // 刷新后视图未恢复(roles/menuTree/scope 全空):置 false,守卫首次导航触发 restoreMe
|
|
|
|
|
+ restored.value = false;
|
|
|
if (!accessToken.value) {
|
|
if (!accessToken.value) {
|
|
|
realName.value = '';
|
|
realName.value = '';
|
|
|
roles.value = [];
|
|
roles.value = [];
|
|
@@ -205,10 +273,12 @@ export const useLogin = defineStore('login', (): LoginStore => {
|
|
|
menuTree,
|
|
menuTree,
|
|
|
pendingTicket,
|
|
pendingTicket,
|
|
|
pendingMemberships,
|
|
pendingMemberships,
|
|
|
|
|
+ restored,
|
|
|
isLoggedIn,
|
|
isLoggedIn,
|
|
|
hasPerm,
|
|
hasPerm,
|
|
|
applyStep1,
|
|
applyStep1,
|
|
|
applyLogin,
|
|
applyLogin,
|
|
|
|
|
+ restoreMe,
|
|
|
doLogout,
|
|
doLogout,
|
|
|
hydrate,
|
|
hydrate,
|
|
|
};
|
|
};
|