Files
track/frontend/src/components/layout/AdminLayout.tsx
duxingchen df3f914eb1 fix(audit): 修复「退出登录」无日志(PC + 移动端)
现象:审计日志里 logout 记录数恒为 0,退出动作完全不可见。

根因有两处,缺一不可:

1. 移动端(App)根本没有发起过上报
   settings.vue 的 handleLogout 只做 removeStorageSync + reLaunch,
   一个请求都没发。而 uni.reLaunch 会销毁页面上下文、直接掐断未完成的
   uni.request —— 所以必须「先 await 上报、再清 token 与跳转」。

2. PC 端存在时序竞态
   axios 的请求拦截器在微任务里执行、现读 localStorage 取 token;
   而原实现同步清空 localStorage 并立刻 navigate,拦截器跑到时 token
   已经没了 → 请求不带 Authorization → 后端只能记成「未认证」。
   注:只改 AuthContext 不够,调用方 AdminLayout / ProfilePage 原来是
   `logout(); navigate(...)`,不等就跳转照样会掐断请求 —— 三处都得改。

改动:
- 移动端 handleLogout 改 async,await post("/auth/logout") 后再清 token
- AuthContext.logout 改 async(类型同步为 () => Promise<void>),
  先 await 上报再清 token;失败静默,绝不阻断退出
- AdminLayout / ProfilePage 两个调用点补 await
- 全部用 try-catch 兜住:断网/超时也照退不误(JWT 无状态,
  服务端本就不需要它成功)
2026-09-21 13:47:31 +08:00

165 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
{
title: "全局概览",
path: "/admin/dashboard",
icon: LayoutDashboard,
description: "生产数据总览 · 产品 + 任务统计",
},
{
title: "产品管理",
path: "/admin/products",
icon: Package,
description: "创建产品 · 生成二维码 · 打印标签",
},
{
title: "任务全景",
path: "/admin/tasks",
icon: GitBranch,
description: "身份证查任务树 · 裂变/返工可视化",
},
{
title: "人员看板",
path: "/admin/people",
icon: Users,
description: "按负责人查看在制品设备分布",
},
{
title: "效能分析",
path: "/admin/analytics",
icon: BarChart3,
description: "人员效能 / 设备流转 ECharts 可视化",
},
{
title: "WIP 分布矩阵",
path: "/admin/matrix",
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
{
title: "操作审计",
path: "/admin/audit",
icon: ScrollText,
description: "谁在何时操作了什么 · 含失败与被拒请求",
},
{
title: "管理层大屏",
path: "/admin/screen",
icon: Tv,
description: "全屏数据大盘 · 直通率 / 产量趋势 / 不良分布",
},
];
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout, isAuthenticated, loading } = useAuth();
// 认证加载中
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
);
}
// 未登录 → 跳转登录页
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
async function handleLogout() {
// 必须 await:logout() 要先完成审计上报再清 token,
// 提前 navigate 会把请求掐断,退出就留不下痕
await logout();
navigate("/admin/login", { replace: true });
}
return (
<div className="flex min-h-screen bg-gray-50">
{/* 侧边栏 */}
<aside className="fixed left-0 top-0 z-40 flex h-screen w-56 flex-col border-r border-gray-200 bg-white">
<div className="flex items-center gap-2 border-b border-gray-100 px-5 py-4">
<QrCode className="h-5 w-5 text-blue-600" />
<span className="text-sm font-bold text-gray-800">生产流转 · 管理端</span>
</div>
<nav className="flex-1 space-y-0.5 px-3 py-4">
{MENU.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<NavLink
key={item.path}
to={item.path}
className={`flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors ${
isActive ? "bg-blue-50 text-blue-700" : "text-gray-600 hover:bg-gray-100"
}`}
>
<item.icon className={`mt-0.5 h-4 w-4 shrink-0 ${isActive ? "text-blue-600" : "text-gray-400"}`} />
<div>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-gray-400">{item.description}</p>
</div>
</NavLink>
);
})}
</nav>
<div className="space-y-1 border-t border-gray-100 px-5 py-3">
<a
href="/scan"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-green-600 transition-colors hover:bg-green-50"
>
<Smartphone className="h-3.5 w-3.5" />
模拟手机扫码
</a>
<a
href="/"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-gray-400 transition-colors hover:text-gray-600"
>
<ArrowLeft className="h-3.5 w-3.5" />
移动端首页
</a>
</div>
</aside>
<main className="ml-56 flex-1">
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<div className="flex items-center gap-2 text-xs text-gray-400">
<LayoutDashboard className="h-3 w-3" />
<span>管理端</span>
<span>/</span>
<span className="font-medium text-gray-600">
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
</span>
</div>
{/* 用户信息 + 登出 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<User className="h-3.5 w-3.5" />
<span className="font-medium text-gray-700">
{user?.display_name ?? user?.username ?? "—"}
</span>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LogOut className="h-3 w-3" />
退出
</button>
</div>
</div>
<div className="p-6">
<Outlet />
</div>
</main>
</div>
);
}