import { useState, useEffect, useCallback, useMemo } from "react"; import { Table, Button, Modal, Input, Checkbox, Tag, App, Spin, Empty, Select } 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 GroupMember, type PhaseOption, type MemberCandidate, } from "../../services/groupApi"; /** * 业务分组 —— 全员可见,操作按角色分层。 * * 超管 → 所有组 + 全部操作(建组/改组/配范围/删组/管成员) * 主管/组长 → 只看到自己所属的组,能管**本组成员** * 普通成员 → 只看到自己所属的组,纯只读 * * ⚠️ 为什么主管不能改组范围:被分进组的 SUPERVISOR 会从「全厂」降级为只看本组, * 若他能改范围,把自己那组改成「生产+售后」就恢复全厂视野了。 * 「能管成员」安全(组长给自己加组会被唯一约束挡住),「能配范围」是提权入口。 * * 能否操作由服务端下发的 can_manage_group / can_manage_members 决定 —— * 组长身份是按组算的,前端凭 role 推不出来。 */ export default function AdminGroupsPage() { const { message, modal } = App.useApp(); const { user } = useAuth(); const [groups, setGroups] = useState([]); const [phaseOptions, setPhaseOptions] = useState([]); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); // 编辑弹窗 const [editOpen, setEditOpen] = useState(false); const [editing, setEditing] = useState(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([]); const [picked, setPicked] = useState(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, "操作失败")); } } // ============================================================ // 渲染 // ============================================================ // 建组 / 改组 / 配范围 / 删组 —— 仅超管(服务端同样硬校验,这里只是不给按钮) const canCreateGroup = isSuperAdmin(user?.role); if (loading) { return (
); } const groupColumns = [ { title: "分组", dataIndex: "name", key: "name", render: (v: string, g: BusinessGroup) => (
{g.parent_id !== null && └} {v} {g.parent_id === null && 大组} {!g.is_active && 已停用}
), }, { title: "可见范围", key: "scope", render: (_: unknown, g: BusinessGroup) => g.phase_labels.length ? g.phase_labels.map((l) => {l}) : 未配置(看不到数据), }, { title: "成员", dataIndex: "member_count", key: "member_count", width: 60 }, ]; return (

业务分组

{canCreateGroup ? "组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。" : "你所属的业务分组。分组决定你能看到哪一段生命周期的数据。"}

{canCreateGroup && ( )}
{groups.length === 0 ? (

你还没有被分配到任何业务分组

请联系管理员将你加入对应分组;未分组时看不到业务数据。

) : (
}} rowClassName={(g) => (g.id === selectedId ? "bg-blue-50 cursor-pointer" : "cursor-pointer")} onRow={(g) => ({ onClick: () => setSelectedId(g.id) })} />

点击一行查看详情与成员。小组未单独配置范围时,自动继承所属大组。

)} {detail && (

{detail.name}

{detail.parent_name && 属于 {detail.parent_name}} {!detail.is_active && 已停用} {detail.is_my_leader && 我是组长} {detail.can_manage_group && detail.parent_id === null && ( )}
{/* 改组名/范围/启停/删除 —— 仅超管(服务端同样硬校验) */} {detail.can_manage_group && (
)}
可见范围
{detail.phase_labels.length ? detail.phase_labels.map((l) => {l}) : 未配置 —— 该组成员看不到任何数据} {detail.phases.length === 0 && detail.parent_id !== null && ( (继承自 {detail.parent_name}) )}
成员 ({detail.members.length})
{detail.can_manage_members && ( )}
{detailLoading ? (
) : detail.members.length === 0 ? ( ) : (
{v} }, { title: "组长", dataIndex: "is_leader", key: "is_leader", width: 100, // 无权限时退化成纯展示(灰底文字),不给可点的样子 render: (v: boolean, m) => detail.can_manage_members ? ( ) : ( {v ? "组长" : "—"} ), }, ...(detail.can_manage_members ? [{ title: "操作", key: "action", width: 70, render: (_: unknown, m: GroupMember) => ( ), }] : []), ]} /> )} {/* 添加成员面板 —— 用 antd Select 而非原生 select: LICA 有近 20 人,原生下拉会整屏铺开且不能搜,又长又难选。 */} {candidates.length > 0 && (
setForm({ ...form, name: e.target.value })} placeholder="如 生产大组 / 维修大组 / 生产小组" maxLength={50} />
{form.parent_id !== null && (
上级:{topGroups.find((g) => g.id === form.parent_id)?.name ?? form.parent_id}
)}
{phaseOptions.map((opt) => ( setForm({ ...form, phases: e.target.checked ? [...form.phases, opt.value] : form.phases.filter((p) => p !== opt.value), })} > {opt.label} ))}
setForm({ ...form, description: e.target.value })} placeholder="这个组负责什么" maxLength={200} />
); }