feat(权限): 业务分组对全员开放(操作分层)+ 操作审计仅超管可见
需求:业务分组开放给所有人,主管可操作、其余人只读;操作审计前端不显示。
⚠️ 这里有个必须收窄的边界:按数据范围规则,被分进组的 SUPERVISOR 会从
「全厂」降级为只看本组。若允许他改可见范围,他把自己那组改成「生产+售后」
就恢复全厂视野;若允许建组,他新建一个组再把自己塞进去,同样绕过。
**「能管成员」与「能配范围」必须分开** —— 前者安全(组长给自己加组会被
唯一约束挡住、移出自己只是失去权限),后者是提权入口。
因此分层如下:
| 谁 | 看 | 改 |
|------------------|--------------|------------------------------------|
| SUPER_ADMIN | 所有组 | 全部(建/改/删组、配范围、管成员) |
| 主管 / 本组组长 | 自己所属的组 | **仅本组成员**(加人/移人/设组长) |
| 普通成员 | 自己所属的组 | 无(只读) |
后端(groups.py):
· 去掉路由级的 require_roles(SUPER_ADMIN),改为逐端点校验
· list_groups / get_group:非超管只返回自己所属的组;看别组详情 403
(连「这个组存在但你没份」都不暴露,避免被拿来推测组织架构)
· create/update/delete:_require_super —— 这三个 + 配范围是提权入口
· add/update/remove member:_can_manage_members(超管 / 主管 / 本组组长)
· GroupOut 新增 can_manage_members / can_manage_group / is_my_leader
—— 由服务端算好下发,前端不按 role 自行推导(组长身份是按组算的)
前端:
· AdminGroupsPage 去掉整页超管门禁,改为按能力显示按钮:
无权限时「组长」列退化成纯展示而非可点按钮
没有任何组时给「你还没有被分配到任何业务分组」的引导提示
· AdminLayout 的 MENU 支持 superOnly 标记,「操作审计」只对超管显示
(后端 /audit/* 本就挂了 require_admin,这里只是不给入口,
否则普通用户点进去只会看到一堆 403)
实测(四类账号):
建组/改范围:仅超管通过,其余全 403
管成员:超管/主管/本组组长通过;非本组 403;跨组组长 403
可见性:主管未分组看到 0 个组、维修组长只看到维修大组、生产组员只看到生产小组
详情页能力标记正确下发;越权查别组详情 403
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
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";
|
||||
import { isSuperAdmin } from "../../constants/task";
|
||||
|
||||
const MENU = [
|
||||
{
|
||||
@ -50,6 +51,10 @@ const MENU = [
|
||||
path: "/admin/audit",
|
||||
icon: ScrollText,
|
||||
description: "谁在何时操作了什么 · 含失败与被拒请求",
|
||||
// 仅超级管理员可见 —— 审计里有全员的 IP、操作明细与失败记录,
|
||||
// 对普通成员展示既无必要也多一层信息暴露面。
|
||||
// 后端 /audit/* 本来就挂了 require_admin,这里只是不给入口。
|
||||
superOnly: true,
|
||||
},
|
||||
{
|
||||
title: "管理层大屏",
|
||||
@ -64,6 +69,11 @@ export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout, isAuthenticated, loading } = useAuth();
|
||||
|
||||
// 菜单按角色过滤:标了 superOnly 的(操作审计)只对超管显示。
|
||||
// 真正的拦截在后端(/audit/* 挂了 require_admin),这里只是不给入口,
|
||||
// 否则普通用户点进去只会看到一堆 403。
|
||||
const visibleMenu = MENU.filter((m) => !m.superOnly || isSuperAdmin(user?.role));
|
||||
|
||||
// 数据范围徽标 —— 让用户看得见「我为什么只看到这些」。
|
||||
// 空范围(未分组)必须显眼:列表全空却没有任何提示,用户会以为系统坏了,
|
||||
// 这是最难排查的一类反馈。
|
||||
@ -115,7 +125,7 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 px-3 py-4">
|
||||
{MENU.map((item) => {
|
||||
{visibleMenu.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<NavLink
|
||||
@ -160,7 +170,7 @@ export default function AdminLayout() {
|
||||
<span>管理端</span>
|
||||
<span>/</span>
|
||||
<span className="font-medium text-gray-600">
|
||||
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
|
||||
{visibleMenu.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -17,18 +17,24 @@ import {
|
||||
fetchMemberCandidates,
|
||||
type BusinessGroup,
|
||||
type GroupDetail,
|
||||
type GroupMember,
|
||||
type PhaseOption,
|
||||
type MemberCandidate,
|
||||
} from "../../services/groupApi";
|
||||
|
||||
/**
|
||||
* 业务分组管理 —— 组决定成员能看到哪一段生命周期的数据。
|
||||
* 业务分组 —— 全员可见,操作按角色分层。
|
||||
*
|
||||
* 页面自带体验层门禁(isSuperAdmin),但真正的拦截在后端路由依赖
|
||||
* (仅 require_roles(SUPER_ADMIN))—— 这里只是别让普通用户看到空壳。
|
||||
* 超管 → 所有组 + 全部操作(建组/改组/配范围/删组/管成员)
|
||||
* 主管/组长 → 只看到自己所属的组,能管**本组成员**
|
||||
* 普通成员 → 只看到自己所属的组,纯只读
|
||||
*
|
||||
* ⚠️ 为什么只有超管能进:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组,
|
||||
* 若允许主管管理分组,他把自己移出组就能恢复全厂视野 —— 那是提权路径。
|
||||
* ⚠️ 为什么主管不能改组范围:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组,
|
||||
* 若他能改范围,把自己那组改成「生产+售后」就恢复全厂视野了。
|
||||
* 「能管成员」安全(组长给自己加组会被唯一约束挡住),「能配范围」是提权入口。
|
||||
*
|
||||
* 能否操作由服务端下发的 can_manage_group / can_manage_members 决定 ——
|
||||
* 组长身份是按组算的,前端凭 role 推不出来。
|
||||
*/
|
||||
export default function AdminGroupsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
@ -236,14 +242,8 @@ export default function AdminGroupsPage() {
|
||||
// ============================================================
|
||||
// 渲染
|
||||
// ============================================================
|
||||
if (!isSuperAdmin(user?.role)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-20 shadow-sm">
|
||||
<ShieldCheck className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">仅超级管理员可管理业务分组</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 建组 / 改组 / 配范围 / 删组 —— 仅超管(服务端同样硬校验,这里只是不给按钮)
|
||||
const canCreateGroup = isSuperAdmin(user?.role);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -284,29 +284,43 @@ export default function AdminGroupsPage() {
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">业务分组</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。
|
||||
{canCreateGroup
|
||||
? "组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。"
|
||||
: "你所属的业务分组。分组决定你能看到哪一段生命周期的数据。"}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<Plus size={16} />} onClick={() => openCreate(null)}>
|
||||
新建大组
|
||||
</Button>
|
||||
{canCreateGroup && (
|
||||
<Button type="primary" icon={<Plus size={16} />} onClick={() => openCreate(null)}>
|
||||
新建大组
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={groupColumns}
|
||||
dataSource={groups}
|
||||
size="small"
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="还没有分组" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
rowClassName={(g) => (g.id === selectedId ? "bg-blue-50 cursor-pointer" : "cursor-pointer")}
|
||||
onRow={(g) => ({ onClick: () => setSelectedId(g.id) })}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-gray-400">
|
||||
点击一行查看详情与成员。小组未单独配置范围时,自动继承所属大组。
|
||||
</p>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl bg-white py-16 shadow-sm">
|
||||
<ShieldCheck className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">你还没有被分配到任何业务分组</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
请联系管理员将你加入对应分组;未分组时看不到业务数据。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={groupColumns}
|
||||
dataSource={groups}
|
||||
size="small"
|
||||
pagination={false}
|
||||
locale={{ emptyText: <Empty description="还没有分组" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
rowClassName={(g) => (g.id === selectedId ? "bg-blue-50 cursor-pointer" : "cursor-pointer")}
|
||||
onRow={(g) => ({ onClick: () => setSelectedId(g.id) })}
|
||||
/>
|
||||
<p className="mt-3 text-xs text-gray-400">
|
||||
点击一行查看详情与成员。小组未单独配置范围时,自动继承所属大组。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
@ -315,19 +329,23 @@ export default function AdminGroupsPage() {
|
||||
<h3 className="text-lg font-semibold text-gray-800">{detail.name}</h3>
|
||||
{detail.parent_name && <Tag>属于 {detail.parent_name}</Tag>}
|
||||
{!detail.is_active && <Tag color="default">已停用</Tag>}
|
||||
{detail.parent_id === null && (
|
||||
{detail.is_my_leader && <Tag color="gold">我是组长</Tag>}
|
||||
{detail.can_manage_group && detail.parent_id === null && (
|
||||
<Button size="small" icon={<Plus size={14} />} onClick={() => openCreate(detail.id)}>
|
||||
新建小组
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<Pencil size={14} />} onClick={() => openEdit(detail)}>编辑</Button>
|
||||
<Button size="small" onClick={() => handleToggleActive(detail)}>
|
||||
{detail.is_active ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 size={14} />} onClick={() => handleDelete(detail)}>删除</Button>
|
||||
</div>
|
||||
{/* 改组名/范围/启停/删除 —— 仅超管(服务端同样硬校验) */}
|
||||
{detail.can_manage_group && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" icon={<Pencil size={14} />} onClick={() => openEdit(detail)}>编辑</Button>
|
||||
<Button size="small" onClick={() => handleToggleActive(detail)}>
|
||||
{detail.is_active ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 size={14} />} onClick={() => handleDelete(detail)}>删除</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
@ -346,9 +364,11 @@ export default function AdminGroupsPage() {
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
成员 <span className="text-gray-400">({detail.members.length})</span>
|
||||
</div>
|
||||
<Button size="small" icon={<UserPlus size={14} />} onClick={openAddMember}>
|
||||
添加成员
|
||||
</Button>
|
||||
{detail.can_manage_members && (
|
||||
<Button size="small" icon={<UserPlus size={14} />} onClick={openAddMember}>
|
||||
添加成员
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
@ -365,24 +385,29 @@ export default function AdminGroupsPage() {
|
||||
{ title: "姓名", dataIndex: "display_name", key: "display_name" },
|
||||
{ title: "账号", dataIndex: "user_id", key: "user_id", render: (v: string) => <span className="font-mono text-xs text-gray-500">{v}</span> },
|
||||
{
|
||||
title: "组长", dataIndex: "is_leader", key: "is_leader", width: 90,
|
||||
render: (v: boolean, m) => (
|
||||
title: "组长", dataIndex: "is_leader", key: "is_leader", width: 100,
|
||||
// 无权限时退化成纯展示(灰底文字),不给可点的样子
|
||||
render: (v: boolean, m) => detail.can_manage_members ? (
|
||||
<button
|
||||
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs ${v ? "bg-amber-50 text-amber-600" : "text-gray-400 hover:text-amber-500"}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleLeader(m.user_id, !v); }}
|
||||
>
|
||||
<Crown size={12} />{v ? "组长" : "设为组长"}
|
||||
</button>
|
||||
) : (
|
||||
<span className={v ? "inline-flex items-center gap-1 text-xs text-amber-600" : "text-xs text-gray-300"}>
|
||||
<Crown size={12} />{v ? "组长" : "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
...(detail.can_manage_members ? [{
|
||||
title: "操作", key: "action", width: 70,
|
||||
render: (_: unknown, m) => (
|
||||
render: (_: unknown, m: GroupMember) => (
|
||||
<Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}>
|
||||
移除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
}] : []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -23,6 +23,10 @@ export interface BusinessGroup {
|
||||
effective_phases: string[]; // 实际生效范围(自己没配则取父组的)
|
||||
phase_labels: string[]; // 生效范围的中文标签
|
||||
member_count: number;
|
||||
// ---- 当前用户对这个组能做什么(服务端算好的,别按 role 自己推导)----
|
||||
can_manage_members: boolean; // 能加/移成员、设组长
|
||||
can_manage_group: boolean; // 能改组名/范围/启停/删除 —— 仅超管
|
||||
is_my_leader: boolean; // 我是这个组的组长
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
|
||||
Reference in New Issue
Block a user