后端 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(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
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() {
|
||
// 必须 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">
|
||
{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>
|
||
);
|
||
}
|