2026-09-11 21:57:38 +08:00
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
|
|
import { defineStore } from 'pinia';
|
2026-10-11 11:49:32 +08:00
|
|
|
|
import {
|
|
|
|
|
|
ROLE_LABELS,
|
2026-10-31 09:40:23 +08:00
|
|
|
|
PERMISSION_META,
|
2026-10-11 11:49:32 +08:00
|
|
|
|
STORAGE_KEYS,
|
|
|
|
|
|
hasPermission,
|
|
|
|
|
|
type LoginRequest,
|
|
|
|
|
|
type LoginResponse,
|
|
|
|
|
|
type PermissionKey,
|
|
|
|
|
|
type SessionUser,
|
|
|
|
|
|
} from '@classhelper/shared';
|
|
|
|
|
|
import { authApi, permissionApi } from '@/api';
|
2026-10-31 09:40:23 +08:00
|
|
|
|
import type { MenuAction } from '@/config/navigation';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 会话主体。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 学生**不是账号**(只是 `Student` 名单记录,不能登录),所以 Web 管理端里
|
|
|
|
|
|
* 只可能是 `ADMIN` / `TEACHER`;`CLASS_DEVICE` 是教室机器上的 ClassHelper 班级端,
|
|
|
|
|
|
* 它走桌面客户端,不会出现在这里 —— 类型仍然带上它,是为了与后端契约一致。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function readStoredUser(): SessionUser | null {
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const raw = localStorage.getItem(STORAGE_KEYS.user);
|
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
|
try {
|
2026-10-06 19:41:18 +08:00
|
|
|
|
return JSON.parse(raw) as SessionUser;
|
2026-09-11 21:57:38 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
|
|
|
|
const token = ref<string | null>(localStorage.getItem(STORAGE_KEYS.token));
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const user = ref<SessionUser | null>(readStoredUser());
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
|
2026-10-11 11:49:32 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 当前账号的**生效权限**(`GET /api/permissions/me`)。
|
|
|
|
|
|
*
|
2026-10-18 11:53:37 +08:00
|
|
|
|
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md 的现行工程约定)——
|
2026-10-11 11:49:32 +08:00
|
|
|
|
* 真正的拦截始终在服务端。页面刷新后这里是空的,由 `AdminLayout` 挂载时调
|
|
|
|
|
|
* {@link ensurePermissions} 补上。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const permissions = ref<PermissionKey[]>([]);
|
2026-10-17 03:06:46 +08:00
|
|
|
|
/** 生效权限是否来自显式分组;没有显式分组时使用可编辑的角色默认组。 */
|
2026-10-11 11:49:32 +08:00
|
|
|
|
const permissionsFromGroups = ref(false);
|
|
|
|
|
|
const permissionsLoaded = ref(false);
|
2026-10-31 09:40:23 +08:00
|
|
|
|
const classScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
|
|
|
|
|
|
const manageScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
|
|
|
|
|
|
const actionScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
|
|
|
|
|
|
const allScopeKeys = ref<PermissionKey[]>([]);
|
|
|
|
|
|
function canForClass(key: PermissionKey, classId: string | null | undefined): boolean {
|
|
|
|
|
|
return (
|
|
|
|
|
|
can(key) &&
|
|
|
|
|
|
(classId ? (classScopes.value[key] ?? []).includes(classId) : allScopeKeys.value.includes(key))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
function canManageClass(key: PermissionKey, classId: string | null | undefined): boolean {
|
|
|
|
|
|
return (
|
|
|
|
|
|
can(key) &&
|
|
|
|
|
|
(classId
|
|
|
|
|
|
? (manageScopes.value[key] ?? classScopes.value[key] ?? []).includes(classId)
|
|
|
|
|
|
: allScopeKeys.value.includes(key))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
function canShowMenu(permission: PermissionKey | undefined, actions?: readonly MenuAction[]): boolean {
|
|
|
|
|
|
if (permission && !can(permission)) return false;
|
|
|
|
|
|
if (!actions) return true;
|
|
|
|
|
|
return actions.some((action) => {
|
|
|
|
|
|
const key = typeof action === 'string' ? action : action.key;
|
|
|
|
|
|
if (!can(key)) return false;
|
|
|
|
|
|
if (
|
|
|
|
|
|
(typeof action !== 'string' && action.scope === 'permission') ||
|
|
|
|
|
|
PERMISSION_META[key].kind !== 'class'
|
|
|
|
|
|
)
|
|
|
|
|
|
return true;
|
|
|
|
|
|
const ids =
|
|
|
|
|
|
typeof action !== 'string' && action.scope === 'manage'
|
|
|
|
|
|
? (manageScopes.value[key] ?? [])
|
|
|
|
|
|
: (actionScopes.value[key] ?? []);
|
|
|
|
|
|
// 全校名单维护还允许新建未分班学生。
|
|
|
|
|
|
if (
|
|
|
|
|
|
key === 'roster:manage' &&
|
|
|
|
|
|
allScopeKeys.value.includes(key) &&
|
|
|
|
|
|
(!permission || allScopeKeys.value.includes(permission))
|
|
|
|
|
|
)
|
|
|
|
|
|
return true;
|
|
|
|
|
|
return ids.some(
|
|
|
|
|
|
(id) =>
|
|
|
|
|
|
!permission ||
|
|
|
|
|
|
PERMISSION_META[permission].kind !== 'class' ||
|
|
|
|
|
|
(classScopes.value[permission] ?? []).includes(id),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-10-11 11:49:32 +08:00
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const isAuthenticated = computed(() => Boolean(token.value));
|
|
|
|
|
|
const role = computed(() => user.value?.role ?? null);
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const isAdmin = computed(() => role.value === 'ADMIN');
|
2026-09-11 21:57:38 +08:00
|
|
|
|
const displayName = computed(() => user.value?.name ?? '未登录');
|
2026-10-06 19:41:18 +08:00
|
|
|
|
const roleLabel = computed(() => (role.value ? ROLE_LABELS[role.value] : '未登录'));
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
2026-10-11 11:49:32 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 是否有某个生效权限。
|
|
|
|
|
|
*
|
2026-10-31 09:40:23 +08:00
|
|
|
|
* 页面直接使用服务端生效集合;管理员全集也由服务端返回。
|
2026-10-11 11:49:32 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function can(key: PermissionKey): boolean {
|
|
|
|
|
|
return hasPermission(permissions.value, key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 拉取生效权限(幂等,`force` 用于权限改完之后刷新)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 失败**不抛出**:拿不到权限时 `can()` 保持 false(即"少显示几个入口"),
|
|
|
|
|
|
* 不要因此卡住路由或让整页渲染不出来;错误提示由 http.ts 的响应拦截器负责。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function ensurePermissions(force = false): Promise<void> {
|
|
|
|
|
|
if (!token.value) return;
|
|
|
|
|
|
if (permissionsLoaded.value && !force) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const mine = await permissionApi.me();
|
|
|
|
|
|
permissions.value = mine.permissions;
|
2026-10-31 09:40:23 +08:00
|
|
|
|
classScopes.value = mine.classScopes ?? {};
|
|
|
|
|
|
manageScopes.value = mine.manageScopes ?? {};
|
|
|
|
|
|
actionScopes.value = mine.actionScopes ?? {};
|
|
|
|
|
|
allScopeKeys.value = mine.allScopeKeys ?? [];
|
2026-10-11 11:49:32 +08:00
|
|
|
|
permissionsFromGroups.value = mine.fromGroups;
|
|
|
|
|
|
permissionsLoaded.value = true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 见上方注释:静默失败,保持"未加载"(下次调用还会重试)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */
|
|
|
|
|
|
function resetPermissions(): void {
|
|
|
|
|
|
permissions.value = [];
|
2026-10-31 09:40:23 +08:00
|
|
|
|
classScopes.value = {};
|
|
|
|
|
|
manageScopes.value = {};
|
|
|
|
|
|
actionScopes.value = {};
|
|
|
|
|
|
allScopeKeys.value = [];
|
2026-10-11 11:49:32 +08:00
|
|
|
|
permissionsFromGroups.value = false;
|
|
|
|
|
|
permissionsLoaded.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
function persist(): void {
|
|
|
|
|
|
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
|
|
|
|
|
|
else localStorage.removeItem(STORAGE_KEYS.token);
|
|
|
|
|
|
if (user.value) localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(user.value));
|
|
|
|
|
|
else localStorage.removeItem(STORAGE_KEYS.user);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setSession(payload: LoginResponse): void {
|
2026-10-11 11:49:32 +08:00
|
|
|
|
resetPermissions();
|
2026-09-11 21:57:38 +08:00
|
|
|
|
token.value = payload.token;
|
2026-10-06 19:41:18 +08:00
|
|
|
|
user.value = { ...payload.user };
|
2026-09-11 21:57:38 +08:00
|
|
|
|
persist();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function login(payload: LoginRequest): Promise<void> {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSession(await authApi.login(payload));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchProfile(): Promise<void> {
|
|
|
|
|
|
if (!token.value) return;
|
|
|
|
|
|
user.value = await authApi.me();
|
|
|
|
|
|
persist();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function logout(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (token.value) await authApi.logout();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 退出登录失败不阻塞本地清理
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clear(): void {
|
|
|
|
|
|
token.value = null;
|
|
|
|
|
|
user.value = null;
|
2026-10-11 11:49:32 +08:00
|
|
|
|
resetPermissions();
|
2026-09-11 21:57:38 +08:00
|
|
|
|
persist();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
token,
|
|
|
|
|
|
user,
|
|
|
|
|
|
loading,
|
2026-10-11 11:49:32 +08:00
|
|
|
|
permissions,
|
|
|
|
|
|
permissionsFromGroups,
|
|
|
|
|
|
permissionsLoaded,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
isAuthenticated,
|
2026-10-06 19:41:18 +08:00
|
|
|
|
isAdmin,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
role,
|
|
|
|
|
|
displayName,
|
|
|
|
|
|
roleLabel,
|
2026-10-11 11:49:32 +08:00
|
|
|
|
can,
|
2026-10-31 09:40:23 +08:00
|
|
|
|
canForClass,
|
|
|
|
|
|
canManageClass,
|
|
|
|
|
|
canShowMenu,
|
2026-10-11 11:49:32 +08:00
|
|
|
|
ensurePermissions,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
login,
|
|
|
|
|
|
logout,
|
|
|
|
|
|
fetchProfile,
|
|
|
|
|
|
clear,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|