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(符合「能看、不能操作」的既定决策)
  停用维修大组 → 成员立即退回未分组
  上述测试数据已还原
This commit is contained in:
2026-09-21 17:10:33 +08:00
parent cfcfca7269
commit 3c1c5d6fb5
9 changed files with 1218 additions and 3 deletions

View File

@ -1,5 +1,5 @@
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 { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText, ShieldCheck } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -39,6 +39,12 @@ const MENU = [
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
{
title: "业务分组",
path: "/admin/groups",
icon: ShieldCheck,
description: "生产组 / 维修组 · 决定成员可见的数据范围",
},
{
title: "操作审计",
path: "/admin/audit",
@ -58,6 +64,26 @@ export default function AdminLayout() {
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 (
@ -138,8 +164,21 @@ export default function AdminLayout() {
</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">