Files
track-LICA/frontend/src/pages/admin/AdminGroupsPage.tsx
duxingchen 5095756571 fix(分组): 把超管排除在分组之外 + 添加成员改用带搜索的下拉
1) 超管不进分组
   超管的数据范围是硬编码全厂(resolve_data_scope 规则 1),业务分组对他
   根本不生效。把他放进成员名单会造成两处矛盾:
     · 界面上「他在这个组里」暗示他受该组约束,但实际不受;
     · 若再给他打组长标记,会出现「组长却不受组范围限制」的怪状态。
   所以:
     · member-candidates 的 SQL 加 `COALESCE(role,'') <> 'SUPER_ADMIN'`
       (LICA 19 人 → 17 人)
     · add_member 再挡一道并给出明确原因,防止绕过界面直接调接口
     · _is_super_admin_account 在 MOM 查询失败时**保守当作超管拦下** ——
       误拦只是加不进去,误放会留下脏数据

2) 添加成员的下拉改用 antd Select
   原先是原生 <select>:LICA 近 20 人,展开会整屏铺开、且不能搜索,又长又难选。
   改为带 showSearch 的 Select(按姓名或账号过滤,optionFilterProp=label),
   并在未选人时禁用「添加」按钮,避免无意义报错。

实测:
  候选人数 19 → 17,超管已不在列表中
  直接 POST 加 sunxia / xingyouwu → 400 并给出原因
  普通成员 duwensheng 加入 → 201,移出 → 204(对照组正常)
2026-09-21 17:41:17 +08:00

511 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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, "操作失败"));
}
}
// ============================================================
// 渲染
// ============================================================
// 建组 / 改组 / 配范围 / 删组 —— 仅超管(服务端同样硬校验,这里只是不给按钮)
const canCreateGroup = isSuperAdmin(user?.role);
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">
{canCreateGroup
? "组决定成员能看到哪一段生命周期的数据。可在组内配置可见范围、分配成员。"
: "你所属的业务分组。分组决定你能看到哪一段生命周期的数据。"}
</p>
</div>
{canCreateGroup && (
<Button type="primary" icon={<Plus size={16} />} onClick={() => openCreate(null)}>
</Button>
)}
</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">
<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.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>
{/* 改组名/范围/启停/删除 —— 仅超管(服务端同样硬校验) */}
{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">
<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>
{detail.can_manage_members && (
<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: 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: GroupMember) => (
<Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}>
</Button>
),
}] : []),
]}
/>
)}
{/* 添加成员面板 —— 用 antd Select 而非原生 select
LICA 有近 20 人,原生下拉会整屏铺开且不能搜,又长又难选。 */}
{candidates.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<Select
className="min-w-[260px] flex-1"
placeholder="搜索姓名或账号…"
showSearch
allowClear
autoFocus
value={picked}
onChange={(v) => setPicked(v)}
optionFilterProp="label"
options={candidates.map((c) => ({
value: c.username,
label: `${c.full_name}${c.username}`,
}))}
/>
<Checkbox checked={pickedLeader} onChange={(e) => setPickedLeader(e.target.checked)}>
</Checkbox>
<div className="flex gap-2">
<Button type="primary" size="small" loading={adding} disabled={!picked} onClick={handleAddMember}>
</Button>
<Button size="small" onClick={() => setCandidates([])}></Button>
</div>
</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>
);
}