React 19 + Vite 6 + Router 7 + TanStack Query + Zustand;Semi 二次封装与 Remix 图标统一出口;顶部模块 + 左侧功能菜单布局、Cloud Blue 主题、登录页与 401/403/404/500 页面;请求层含 token 注入、单飞刷新与统一错误提示。
142 lines
3.7 KiB
TypeScript
142 lines
3.7 KiB
TypeScript
import { PERMISSION_CODES } from '@lingxi/shared';
|
|
import { create } from 'zustand';
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
roleName?: string;
|
|
}
|
|
|
|
export interface AuthSession {
|
|
accessToken: string | null;
|
|
refreshToken: string | null;
|
|
user: AuthUser | null;
|
|
permissions: string[];
|
|
}
|
|
|
|
export interface SetSessionInput {
|
|
accessToken: string;
|
|
refreshToken?: string | null;
|
|
user: AuthUser;
|
|
permissions?: readonly string[];
|
|
}
|
|
|
|
export interface AuthState extends AuthSession {
|
|
setSession: (input: SetSessionInput) => void;
|
|
setTokens: (tokens: { accessToken: string; refreshToken?: string | null }) => void;
|
|
clearSession: () => void;
|
|
signInDemo: () => void;
|
|
hasPermission: (code: string) => boolean;
|
|
}
|
|
|
|
const STORAGE_KEY = 'lingxi.auth.session';
|
|
|
|
const EMPTY_SESSION: AuthSession = {
|
|
accessToken: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
permissions: [],
|
|
};
|
|
|
|
export const DEMO_USER: AuthUser = {
|
|
id: '01J0DEMO000000000000000000',
|
|
name: '灵犀管理员',
|
|
email: 'admin@lingxi.local',
|
|
roleName: '平台管理员',
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function isAuthUser(value: unknown): value is AuthUser {
|
|
return (
|
|
isRecord(value) &&
|
|
typeof value.id === 'string' &&
|
|
typeof value.name === 'string' &&
|
|
typeof value.email === 'string'
|
|
);
|
|
}
|
|
|
|
function isAuthSession(value: unknown): value is AuthSession {
|
|
return (
|
|
isRecord(value) &&
|
|
(typeof value.accessToken === 'string' || value.accessToken === null) &&
|
|
(typeof value.refreshToken === 'string' || value.refreshToken === null) &&
|
|
(isAuthUser(value.user) || value.user === null) &&
|
|
Array.isArray(value.permissions) &&
|
|
value.permissions.every((permission) => typeof permission === 'string')
|
|
);
|
|
}
|
|
|
|
function readPersistedSession(): AuthSession {
|
|
if (typeof window === 'undefined') {
|
|
return EMPTY_SESSION;
|
|
}
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (raw === null) {
|
|
return EMPTY_SESSION;
|
|
}
|
|
const parsed: unknown = JSON.parse(raw);
|
|
return isAuthSession(parsed) ? parsed : EMPTY_SESSION;
|
|
} catch {
|
|
return EMPTY_SESSION;
|
|
}
|
|
}
|
|
|
|
function persistSession(session: AuthSession): void {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
try {
|
|
if (session.accessToken === null) {
|
|
window.localStorage.removeItem(STORAGE_KEY);
|
|
return;
|
|
}
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
|
} catch {
|
|
// 本地存储不可用(隐私模式等)时退化为内存会话,不影响登录流程
|
|
}
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()((set, get) => ({
|
|
...readPersistedSession(),
|
|
setSession: (input) => {
|
|
const session: AuthSession = {
|
|
accessToken: input.accessToken,
|
|
refreshToken: input.refreshToken ?? null,
|
|
user: input.user,
|
|
permissions: [...(input.permissions ?? PERMISSION_CODES)],
|
|
};
|
|
persistSession(session);
|
|
set(session);
|
|
},
|
|
setTokens: (tokens) => {
|
|
const current = get();
|
|
const session: AuthSession = {
|
|
...current,
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken ?? current.refreshToken,
|
|
};
|
|
persistSession(session);
|
|
set({ accessToken: session.accessToken, refreshToken: session.refreshToken });
|
|
},
|
|
clearSession: () => {
|
|
persistSession(EMPTY_SESSION);
|
|
set(EMPTY_SESSION);
|
|
},
|
|
signInDemo: () => {
|
|
const session: AuthSession = {
|
|
accessToken: 'demo-access-token',
|
|
refreshToken: 'demo-refresh-token',
|
|
user: DEMO_USER,
|
|
permissions: [...PERMISSION_CODES],
|
|
};
|
|
persistSession(session);
|
|
set(session);
|
|
},
|
|
hasPermission: (code) => get().permissions.includes(code),
|
|
}));
|