diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx
index f4197c1..8bcbcc7 100644
--- a/frontend/src/components/layout/AdminLayout.tsx
+++ b/frontend/src/components/layout/AdminLayout.tsx
@@ -72,8 +72,10 @@ export default function AdminLayout() {
return ;
}
- function handleLogout() {
- logout();
+ async function handleLogout() {
+ // 必须 await:logout() 要先完成审计上报再清 token,
+ // 提前 navigate 会把请求掐断,退出就留不下痕
+ await logout();
navigate("/admin/login", { replace: true });
}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index aa4ef32..4e46b3c 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -28,7 +28,8 @@ interface AuthState {
interface AuthContextValue extends AuthState {
login: (username: string, password: string) => Promise;
- logout: () => void;
+ /** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */
+ logout: () => Promise;
isAuthenticated: boolean;
}
@@ -113,12 +114,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setState({ user, token: accessToken, loading: false });
}, []);
- const logout = useCallback(() => {
- // 先上报登出(审计留痕),再做本地清理 —— 顺序不能反:
- // api 的请求拦截器是从 localStorage 取 token 的,清掉之后就发不出这个请求了。
- // 刻意不 await、失败也不阻断:JWT 无状态,服务端本就不需要它成功,
- // 而用户点了「退出」必须退得掉,不能因为一次上报失败就卡住。
- logoutApi().catch(() => {});
+ const logout = useCallback(async () => {
+ // ⚠️ 必须【先 await 上报、再清 token】。两边顺序反了或不等,退出就留不下痕:
+ // 1) axios 的请求拦截器是在微任务里执行的,它去 localStorage 读 token 时,
+ // 同步的 logoutInternal() 早已把 token 清掉 → 请求不带 Authorization
+ // → 后端只能记成「未认证」,退出归因不到人;
+ // 2) 调用方点完退出还会立刻 navigate 到登录页,进一步压缩执行窗口。
+ // 所以这里(async) + 调用方(await) 两处都得改,只改一处等于没改。
+ // 失败绝不影响退出:JWT 无状态,服务端本就不需要它成功。
+ try {
+ await logoutApi();
+ } catch {
+ /* 静默:断网/超时也照退不误 */
+ }
logoutInternal();
}, []);
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
index dae3df8..a946fc3 100644
--- a/frontend/src/pages/ProfilePage.tsx
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -15,8 +15,10 @@ export default function ProfilePage() {
const displayName = user?.display_name || user?.username || "未知用户";
const avatarChar = displayName.charAt(0);
- function handleLogout() {
- logout();
+ async function handleLogout() {
+ // 必须 await:logout() 要先完成审计上报再清 token,
+ // 提前 navigate 会把请求掐断,退出就留不下痕
+ await logout();
navigate("/admin/login", { replace: true });
}
diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts
index fab47e8..90b86c6 100644
--- a/frontend/src/services/authApi.ts
+++ b/frontend/src/services/authApi.ts
@@ -60,9 +60,10 @@ export async function getMe(token: string): Promise {
* JWT 无状态,服务端不会(也无法)吊销令牌,本地清 token 就是登出。
* 但没有这个请求,前端的「退出」动作在审计里完全不可见,所以必须上报一次。
*
- * 刻意走 axios 实例(而非上面几个函数的裸 fetch):它的请求拦截器会从
- * localStorage 自动注入 Authorization,从而让后端记录到真实操作人。
- * ⚠️ 因此调用方必须在清除本地 token 【之前】调用本函数。
+ * ⚠️ 调用方必须 **await 本函数之后**才清 localStorage 与跳转:
+ * axios 的请求拦截器在微任务里执行、需要现读 localStorage 取 token。
+ * 若不等就同步清空并 navigate,请求会不带 Authorization(或直接被掐断),
+ * 后端只能记成「未认证」,退出归因不到人 —— 实测审计里 logout 记录为 0。
*/
export async function logout(): Promise {
await api.post("/auth/logout");
diff --git a/track-uniapp/src/pages/profile/settings.vue b/track-uniapp/src/pages/profile/settings.vue
index 5d9d9dc..64b981f 100644
--- a/track-uniapp/src/pages/profile/settings.vue
+++ b/track-uniapp/src/pages/profile/settings.vue
@@ -38,6 +38,7 @@