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:
474
frontend/src/pages/admin/AdminGroupsPage.tsx
Normal file
474
frontend/src/pages/admin/AdminGroupsPage.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Table, Button, Modal, Input, Checkbox, Tag, App, Spin, Empty } from "antd";
|
||||
import { Plus, Pencil, Trash2, UserPlus, Crown, ShieldCheck } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { isSuperAdmin } from "../../constants/task";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import {
|
||||
fetchGroups,
|
||||
fetchGroupDetail,
|
||||
fetchPhaseOptions,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
addGroupMember,
|
||||
removeGroupMember,
|
||||
setGroupLeader,
|
||||
fetchMemberCandidates,
|
||||
type BusinessGroup,
|
||||
type GroupDetail,
|
||||
type PhaseOption,
|
||||
type MemberCandidate,
|
||||
} from "../../services/groupApi";
|
||||
|
||||
/**
|
||||
* 业务分组管理 —— 组决定成员能看到哪一段生命周期的数据。
|
||||
*
|
||||
* 页面自带体验层门禁(isSuperAdmin),但真正的拦截在后端路由依赖
|
||||
* (仅 require_roles(SUPER_ADMIN))—— 这里只是别让普通用户看到空壳。
|
||||
*
|
||||
* ⚠️ 为什么只有超管能进:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组,
|
||||
* 若允许主管管理分组,他把自己移出组就能恢复全厂视野 —— 那是提权路径。
|
||||
*/
|
||||
export default function AdminGroupsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [groups, setGroups] = useState<BusinessGroup[]>([]);
|
||||
const [phaseOptions, setPhaseOptions] = useState<PhaseOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<GroupDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 编辑弹窗
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<BusinessGroup | null>(null); // null = 新建
|
||||
const [form, setForm] = useState({ name: "", parent_id: null as number | null, description: "", phases: [] as string[] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 加成员
|
||||
const [candidates, setCandidates] = useState<MemberCandidate[]>([]);
|
||||
const [picked, setPicked] = useState<string | undefined>(undefined);
|
||||
const [pickedLeader, setPickedLeader] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [gs, opts] = await Promise.all([fetchGroups(), fetchPhaseOptions()]);
|
||||
setGroups(gs);
|
||||
setPhaseOptions(opts);
|
||||
setSelectedId((prev) => prev ?? gs.find((g) => g.parent_id === null)?.id ?? gs[0]?.id ?? null);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "加载分组失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setDetail(await fetchGroupDetail(id));
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "加载分组详情失败"));
|
||||
setDetail(null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => { if (selectedId != null) loadDetail(selectedId); }, [selectedId, loadDetail]);
|
||||
|
||||
// 大组列表(下拉用)
|
||||
const topGroups = useMemo(() => groups.filter((g) => g.parent_id === null), [groups]);
|
||||
|
||||
// ============================================================
|
||||
// 编辑
|
||||
// ============================================================
|
||||
function openCreate(parentId: number | null = null) {
|
||||
setEditing(null);
|
||||
setForm({ name: "", parent_id: parentId, description: "", phases: [] });
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(g: BusinessGroup) {
|
||||
setEditing(g);
|
||||
setForm({
|
||||
name: g.name,
|
||||
parent_id: g.parent_id,
|
||||
description: g.description ?? "",
|
||||
phases: g.phases,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim()) { message.warning("请填写分组名称"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await updateGroup(editing.id, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
phases: form.phases,
|
||||
});
|
||||
message.success("已保存");
|
||||
} else {
|
||||
const created = await createGroup({
|
||||
name: form.name.trim(),
|
||||
parent_id: form.parent_id,
|
||||
description: form.description.trim() || null,
|
||||
phases: form.phases,
|
||||
});
|
||||
setSelectedId(created.id);
|
||||
message.success("已创建");
|
||||
}
|
||||
setEditOpen(false);
|
||||
await load();
|
||||
if (selectedId != null) await loadDetail(selectedId);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(g: BusinessGroup) {
|
||||
const next = !g.is_active;
|
||||
// ⚠️ 停用 = 该组所有人立即退回未分组状态,是一次批量权限变更,必须二次确认
|
||||
const tip = next
|
||||
? `启用「${g.name}」后,其 ${g.member_count} 名成员将按该组的范围查看数据。`
|
||||
: `停用「${g.name}」后,其 ${g.member_count} 名成员将【立即】失去该组带来的数据范围(等同于未分组)。确认停用?`;
|
||||
modal.confirm({
|
||||
title: next ? "确认启用分组" : "确认停用分组",
|
||||
content: tip,
|
||||
okText: next ? "启用" : "停用",
|
||||
okButtonProps: { danger: !next },
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await updateGroup(g.id, { is_active: next });
|
||||
message.success(next ? "已启用" : "已停用");
|
||||
await load();
|
||||
if (selectedId === g.id) await loadDetail(g.id);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "操作失败"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(g: BusinessGroup) {
|
||||
modal.confirm({
|
||||
title: `删除分组「${g.name}」?`,
|
||||
content: "仅空分组可删除。若组内还有成员,请先移除成员,或改为「停用」。",
|
||||
okText: "删除", okButtonProps: { danger: true }, cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteGroup(g.id);
|
||||
message.success("已删除");
|
||||
if (selectedId === g.id) setSelectedId(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "删除失败"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 成员
|
||||
// ============================================================
|
||||
async function openAddMember() {
|
||||
if (selectedId == null) return;
|
||||
try {
|
||||
setCandidates(await fetchMemberCandidates());
|
||||
setPicked(undefined);
|
||||
setPickedLeader(false);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "加载人员失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddMember() {
|
||||
if (selectedId == null || !picked) { message.warning("请选择人员"); return; }
|
||||
setAdding(true);
|
||||
try {
|
||||
await addGroupMember(selectedId, picked, pickedLeader);
|
||||
message.success("已添加");
|
||||
setPicked(undefined);
|
||||
setPickedLeader(false);
|
||||
setCandidates([]);
|
||||
await Promise.all([loadDetail(selectedId), load()]);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "添加失败"));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveMember(userId: string) {
|
||||
if (selectedId == null) return;
|
||||
try {
|
||||
await removeGroupMember(selectedId, userId);
|
||||
message.success("已移除");
|
||||
await Promise.all([loadDetail(selectedId), load()]);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "移除失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleLeader(userId: string, next: boolean) {
|
||||
if (selectedId == null) return;
|
||||
try {
|
||||
await setGroupLeader(selectedId, userId, next);
|
||||
message.success(next ? "已设为组长" : "已取消组长");
|
||||
await loadDetail(selectedId);
|
||||
} catch (err) {
|
||||
message.error(extractErrorMessage(err, "操作失败"));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染
|
||||
// ============================================================
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groupColumns = [
|
||||
{
|
||||
title: "分组",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (v: string, g: BusinessGroup) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{g.parent_id !== null && <span className="text-gray-300">└</span>}
|
||||
<span className="font-medium text-gray-800">{v}</span>
|
||||
{g.parent_id === null && <Tag color="blue">大组</Tag>}
|
||||
{!g.is_active && <Tag>已停用</Tag>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "可见范围",
|
||||
key: "scope",
|
||||
render: (_: unknown, g: BusinessGroup) =>
|
||||
g.phase_labels.length
|
||||
? g.phase_labels.map((l) => <Tag key={l} color="cyan">{l}</Tag>)
|
||||
: <span className="text-xs text-orange-500">未配置(看不到数据)</span>,
|
||||
},
|
||||
{ title: "成员", dataIndex: "member_count", key: "member_count", width: 60 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">业务分组</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{detail && (
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-1 text-sm font-medium text-gray-700">可见范围</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{detail.phase_labels.length
|
||||
? detail.phase_labels.map((l) => <Tag key={l} color="cyan" className="text-sm">{l}</Tag>)
|
||||
: <span className="text-sm text-orange-500">未配置 —— 该组成员看不到任何数据</span>}
|
||||
{detail.phases.length === 0 && detail.parent_id !== null && (
|
||||
<span className="text-xs text-gray-400">(继承自 {detail.parent_name})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="flex justify-center py-6"><Spin /></div>
|
||||
) : detail.members.length === 0 ? (
|
||||
<Empty description="该分组还没有成员" image={Empty.PRESENTED_IMAGE_SIMPLE} className="py-6" />
|
||||
) : (
|
||||
<Table
|
||||
rowKey="user_id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={detail.members}
|
||||
columns={[
|
||||
{ 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) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作", key: "action", width: 70,
|
||||
render: (_: unknown, m) => (
|
||||
<Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}>
|
||||
移除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 添加成员面板 */}
|
||||
{candidates.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<select
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={picked ?? ""}
|
||||
onChange={(e) => setPicked(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">选择人员…</option>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.username} value={c.username}>{c.full_name}({c.username})</option>
|
||||
))}
|
||||
</select>
|
||||
<Checkbox checked={pickedLeader} onChange={(e) => setPickedLeader(e.target.checked)}>设为组长</Checkbox>
|
||||
<Button type="primary" size="small" loading={adding} onClick={handleAddMember}>添加</Button>
|
||||
<Button size="small" onClick={() => setCandidates([])}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建 / 编辑弹窗 */}
|
||||
<Modal
|
||||
title={editing ? `编辑分组 · ${editing.name}` : (form.parent_id ? "新建小组" : "新建大组")}
|
||||
open={editOpen}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={520}
|
||||
>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-600">分组名称</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="如 生产大组 / 维修大组 / 生产小组"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
{form.parent_id !== null && (
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||
上级:{topGroups.find((g) => g.id === form.parent_id)?.name ?? form.parent_id}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-600">
|
||||
可见范围
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">
|
||||
{form.parent_id !== null ? "留空则继承上级大组" : "决定成员能看到哪些数据"}
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-1.5 rounded-lg border border-gray-200 p-3">
|
||||
{phaseOptions.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
checked={form.phases.includes(opt.value)}
|
||||
onChange={(e) => setForm({
|
||||
...form,
|
||||
phases: e.target.checked
|
||||
? [...form.phases, opt.value]
|
||||
: form.phases.filter((p) => p !== opt.value),
|
||||
})}
|
||||
>
|
||||
{opt.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-600">备注(选填)</label>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="这个组负责什么"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user