import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode, } from "react"; import { login as loginApi, getMe, logout as logoutApi } from "../services/authApi"; // ============================================================ // 类型 // ============================================================ /** * 业务分组数据范围 —— 来自 /auth/me。 * * ⚠️ 登录接口(/auth/login)**不查库**、不返回 scope,所以登录后要先静默 * 调一次 getMe() 才拿得到;否则页头的范围徽标会空一下。 */ export interface DataScopeInfo { is_unrestricted: boolean; // 全厂(超管 / 未分组主管) is_empty: boolean; // 未分组 —— 看不到任何数据 phases: string[]; phase_labels: string[]; // 中文标签,服务端下发 groups: string[]; is_leader: boolean; reason: string; } export interface UserInfo { id: string; username: string; display_name: string; role: string; scope?: DataScopeInfo; } interface AuthState { user: UserInfo | null; token: string | null; loading: boolean; } interface AuthContextValue extends AuthState { login: (username: string, password: string) => Promise; /** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */ logout: () => Promise; isAuthenticated: boolean; } // ============================================================ // Token 存储 key // ============================================================ const ACCESS_TOKEN_KEY = "track_admin_token"; const REFRESH_TOKEN_KEY = "track_admin_refresh_token"; const USER_KEY = "track_admin_user"; // ============================================================ // Context // ============================================================ const AuthContext = createContext(null); export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用"); return ctx; } // ============================================================ // Provider // ============================================================ export function AuthProvider({ children }: { children: ReactNode }) { const [state, setState] = useState({ user: null, token: null, loading: true, }); // 初始化:从 localStorage 恢复双 Token useEffect(() => { const savedToken = localStorage.getItem(ACCESS_TOKEN_KEY); const savedUser = localStorage.getItem(USER_KEY); if (savedToken && savedUser) { try { const user = JSON.parse(savedUser) as UserInfo; setState({ user, token: savedToken, loading: false }); // 后台静默验证 token 是否仍有效 getMe(savedToken) .then((fresh) => { setState((prev) => ({ ...prev, user: fresh })); localStorage.setItem(USER_KEY, JSON.stringify(fresh)); }) .catch((err) => { // 验证失败不踢出用户 — 真正的过期由业务 API 401 拦截器 // 通过 Refresh Token 无感刷新,彻底失败才跳转登录 console.error( "[Auth] Token 后台验证失败(保留本地登录态,依赖拦截器刷新):", err?.message ?? err, ); }); } catch { logoutInternal(); } } else { setState((prev) => ({ ...prev, loading: false })); } }, []); function logoutInternal() { localStorage.removeItem(ACCESS_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(USER_KEY); setState({ user: null, token: null, loading: false }); } const login = useCallback(async (username: string, password: string) => { const result = await loginApi(username, password); const accessToken = result.access_token; const refreshToken = result.refresh_token; let user: UserInfo = result.user; // 存储双 Token localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); // 补拉一次 /auth/me 拿数据范围(scope)。 // ⚠️ 登录接口不查库、不返回 scope,直接用 result.user 的话页头范围徽标会 // 空一下,用户会以为分组没生效。多这一次请求换状态一致,值得。 // 失败不阻断登录:拿不到 scope 只是徽标不显示,不影响使用。 try { user = { ...user, ...(await getMe(accessToken)) }; } catch { /* 忽略:保持登录,只是暂时没有范围信息 */ } localStorage.setItem(USER_KEY, JSON.stringify(user)); setState({ user, token: accessToken, loading: false }); }, []); const logout = useCallback(async () => { // ⚠️ 必须【先 await 上报、再清 token】。两边顺序反了或不等,退出就留不下痕: // 1) axios 的请求拦截器是在微任务里执行的,它去 localStorage 读 token 时, // 同步的 logoutInternal() 早已把 token 清掉 → 请求不带 Authorization // → 后端只能记成「未认证」,退出归因不到人; // 2) 调用方点完退出还会立刻 navigate 到登录页,进一步压缩执行窗口。 // 所以这里(async) + 调用方(await) 两处都得改,只改一处等于没改。 // 失败绝不影响退出:JWT 无状态,服务端本就不需要它成功。 try { await logoutApi(); } catch { /* 静默:断网/超时也照退不误 */ } logoutInternal(); }, []); const ctxValue = useMemo( () => ({ ...state, login, logout, isAuthenticated: !!state.token && !!state.user, }), [state, login, logout], ); return ( {children} ); }