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(对照组正常)
511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|