import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode, } from "react"; import { login as loginApi, getMe, logout as logoutApi } from "../services/authApi"; // ============================================================ // 类型 // ============================================================ export interface UserInfo { id: string; username: string; display_name: string; role: string; } 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; const user: UserInfo = result.user; // 存储双 Token localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); 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} ); }