Files
track-LICA/frontend/src/components/layout/AdminLayout.tsx
duxingchen 3c1c5d6fb5 feat(分组权限): 分组管理接口 + 管理页 + 端到端验收
后端 endpoints/groups.py(**仅 SUPER_ADMIN**):
· 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的
  生产/测试小组都不用再配)
· 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT)
· **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然
  看不到数据了;强制「先移人再删组」多一步,但出错时是可见的
· 停用组的语义写死在接口文档:成员**立即**退回未分组状态

为什么只有超管能管分组(不是偏好,是必须):
  被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组,
  他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。

前端:
· AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认
· AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼
  提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈
· AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它)
· constants/task.ts 新增 isSuperAdmin,不手写 === 比较

端到端验收(实测):先造 1 生产 + 1 售后产品,然后
  生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来
  维修组员 → 1 条,全 AFTER_SALES
  超管     → 2 条,全量
  任务列表 total 与 returned 一致(验证 count/select 双过滤)
  扫码跨组仍 200(符合「能看、不能操作」的既定决策)
  停用维修大组 → 成员立即退回未分组
  上述测试数据已还原
2026-09-21 17:10:33 +08:00

204 lines
7.1 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, ShieldCheck } 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/groups",
icon: ShieldCheck,
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();
// 数据范围徽标 —— 让用户看得见「我为什么只看到这些」。
// 空范围(未分组)必须显眼:列表全空却没有任何提示,用户会以为系统坏了,
// 这是最难排查的一类反馈。
const scope = user?.scope;
const scopeBadge = !scope
? null
: scope.is_empty
? {
text: "未分组 · 暂无数据权限",
cls: "bg-orange-50 text-orange-600",
tip: "你未被分入任何业务组,列表会是空的。请联系管理员把你分配到对应分组。",
}
: scope.is_unrestricted
? { text: "全厂", cls: "bg-gray-100 text-gray-500", tip: "可查看全部数据" }
: {
text: `${scope.groups.join(" / ") || "已分组"} · ${scope.phase_labels.join(" / ")}`,
cls: "bg-blue-50 text-blue-600",
tip: `可见数据范围:${scope.phase_labels.join("、")}`,
};
// 认证加载中
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() {
// 必须 awaitlogout() 要先完成审计上报再清 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">
{scopeBadge && (
<span
className={`rounded px-2 py-0.5 text-xs ${scopeBadge.cls}`}
title={scopeBadge.tip}
>
{scopeBadge.text}
</span>
)}
{scope?.is_leader && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-600" title="你是某个业务组的组长">
</span>
)}
<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>
);
}