feat(分组): 搜索选择改为方块面板 + 组长设置改为明确按钮(支持多组长)

修正理解偏差:上一轮把「一行一个」理解成了成员列表,实际指的是**搜索姓名
时的下拉** —— antd Select 展开后一人一行、还带账号,又长又占地方。

1) 搜索选择:去掉 Select,改为内联的「搜索框 + 方块多选网格」
   · 候选人全铺成方块(auto-fill 网格),点方块选中/取消,**无滚动条**
   · 方块只显示中文名,账号挪到 title 悬停可见
   · 搜索在前端过滤(LICA 十几人,一次拉全量更跟手)
   · 顶部实时显示「已选 N 人」;按钮文案带数量「添加选中的 N 人」
   · 无匹配时给空状态,不会是一片空白

2) 组长设置:从「点方块切换」这种隐晦交互,改为**方块内常驻的明确按钮**
   · 未设组长 → 灰色「⚙ 设为组长」;已是组长 → 金色「👑 组长」
   · 点击即切换,一眼看懂「他是不是组长、怎么改」
   · 每个方块各管各的 —— **天然支持多个组长**

   数据层本来就支持多组长(is_leader 是成员行上的独立字段);实测同一组
   设两个组长并存,且两人的 /auth/me 都返回 is_leader=true。

顺带清理早先测试留下的数据污染:
   · 生产大组的可见范围曾被「验证改范围权限」的测试改成「生产+售后」,
     已改回只有「生产制造」
   · 删除了残留的「权限测试组」
This commit is contained in:
2026-09-21 17:50:58 +08:00
parent 670436f68c
commit 0bc9366e85

View File

@ -1,6 +1,6 @@
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 { Table, Button, Modal, Input, Checkbox, Tag, App, Spin, Empty } from "antd";
import { Plus, Pencil, Trash2, UserPlus, Crown, ShieldCheck, Search, Check } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import { isSuperAdmin } from "../../constants/task";
import { extractErrorMessage } from "../../utils/errorMessage";
@ -56,6 +56,7 @@ export default function AdminGroupsPage() {
const [candidates, setCandidates] = useState<MemberCandidate[]>([]);
const [picked, setPicked] = useState<string[]>([]); // 多选
const [pickedLeader, setPickedLeader] = useState(false);
const [candidateKeyword, setCandidateKeyword] = useState("");
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
@ -91,6 +92,21 @@ export default function AdminGroupsPage() {
// 大组列表(下拉用)
const topGroups = useMemo(() => groups.filter((g) => g.parent_id === null), [groups]);
// 候选人按关键字过滤 —— 前端过滤即可LICA 才十几人,一次拉全量更跟手
const visibleCandidates = useMemo(() => {
const kw = candidateKeyword.trim().toLowerCase();
if (!kw) return candidates;
return candidates.filter(
(c) => c.full_name.toLowerCase().includes(kw) || c.username.toLowerCase().includes(kw)
);
}, [candidates, candidateKeyword]);
function togglePick(username: string) {
setPicked((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
);
}
// ============================================================
// 编辑
// ============================================================
@ -194,6 +210,7 @@ export default function AdminGroupsPage() {
setCandidates(await fetchMemberCandidates());
setPicked([]);
setPickedLeader(false);
setCandidateKeyword("");
} catch (err) {
message.error(extractErrorMessage(err, "加载人员失败"));
}
@ -392,30 +409,45 @@ export default function AdminGroupsPage() {
{detail.members.map((m) => (
<div
key={m.user_id}
title={
detail.can_manage_members
? `${m.display_name}${m.user_id})· ${m.is_leader ? "点击取消组长" : "点击设为组长"}`
: `${m.display_name}${m.user_id}`
}
onClick={() => {
if (detail.can_manage_members) handleToggleLeader(m.user_id, !m.is_leader);
}}
className={`group relative flex h-11 select-none items-center justify-center rounded-lg border px-2 text-sm transition-colors ${
title={`${m.display_name}${m.user_id}`}
className={`group relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-2 transition-colors ${
m.is_leader
? "border-amber-300 bg-amber-50 font-medium text-amber-800"
: "border-gray-200 bg-white text-gray-700"
} ${
detail.can_manage_members
? "cursor-pointer hover:border-blue-300 hover:bg-blue-50"
: ""
}`}
? "border-amber-300 bg-amber-50"
: "border-gray-200 bg-white"
} ${detail.can_manage_members ? "hover:border-blue-300" : ""}`}
>
{m.is_leader && (
<Crown size={12} className="mr-1 shrink-0 text-amber-500" />
)}
<span className="truncate">{m.display_name}</span>
<span
className={`truncate text-sm ${m.is_leader ? "font-medium text-amber-800" : "text-gray-700"}`}
>
{m.display_name}
</span>
{/* 移除:默认藏起来,悬停该方块才出现,避免误点 */}
{/* 组长:一个**始终可见**的明确按钮,而不是"点方块"这种隐晦交互。
一个人是不是组长、怎么改,一眼就能看到。
每个方块各管各的 —— 因此**天然支持多个组长**。 */}
{detail.can_manage_members ? (
<button
onClick={() => handleToggleLeader(m.user_id, !m.is_leader)}
title={m.is_leader ? "取消组长" : "设为组长"}
className={`mt-0.5 flex items-center gap-0.5 rounded px-1 text-[11px] transition-colors ${
m.is_leader
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: "text-gray-400 hover:bg-amber-50 hover:text-amber-600"
}`}
>
<Crown size={11} />
{m.is_leader ? "组长" : "设为组长"}
</button>
) : (
m.is_leader && (
<span className="mt-0.5 flex items-center gap-0.5 text-[11px] text-amber-600">
<Crown size={11} />
</span>
)
)}
{/* 移出:悬停才出现,避免误点 */}
{detail.can_manage_members && (
<button
title="移出分组"
@ -430,34 +462,84 @@ export default function AdminGroupsPage() {
</div>
)}
{/* 添加成员面板 —— 用 antd Select 而非原生 select
LICA 有近 20 人,原生下拉会整屏铺开且不能搜,又长又难选。 */}
{/* 添加成员面板 —— 不用下拉:候选人全铺成方块,点选即可,无滚动条。
LICA 十几人,一次全展示比"展开下拉再翻找"快得多。 */}
{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
mode="multiple"
className="min-w-[300px] flex-1"
placeholder="搜索姓名或账号,可多选…"
showSearch
allowClear
autoFocus
value={picked}
onChange={(v) => setPicked(v)}
optionFilterProp="label"
maxTagCount="responsive"
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 className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="mb-2.5 flex items-center gap-3">
<Input
className="max-w-xs"
size="small"
placeholder="搜索姓名或账号…"
prefix={<Search size={14} className="text-gray-400" />}
value={candidateKeyword}
onChange={(e) => setCandidateKeyword(e.target.value)}
allowClear
autoFocus
/>
<span className="text-xs text-gray-500">
<span className="font-medium text-blue-600">{picked.length}</span>
{candidateKeyword && ` · 匹配 ${visibleCandidates.length}`}
</span>
</div>
{visibleCandidates.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-400"></div>
) : (
<div className="grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(88px,1fr))]">
{visibleCandidates.map((c) => {
const on = picked.includes(c.username);
return (
<button
key={c.username}
type="button"
title={`${c.full_name}${c.username}`}
onClick={() => togglePick(c.username)}
className={`relative flex h-11 items-center justify-center rounded-lg border px-1 text-sm transition-colors ${
on
? "border-blue-500 bg-blue-500 font-medium text-white"
: "border-gray-200 bg-white text-gray-700 hover:border-blue-300 hover:bg-blue-100"
}`}
>
<span className="truncate">{c.full_name}</span>
{on && (
<Check
size={12}
className="absolute right-1 top-1 text-white"
/>
)}
</button>
);
})}
</div>
)}
<div className="mt-3 flex items-center gap-3">
<Checkbox
checked={pickedLeader}
onChange={(e) => setPickedLeader(e.target.checked)}
>
</Checkbox>
<div className="flex gap-2">
<Button
type="primary"
size="small"
loading={adding}
disabled={picked.length === 0}
onClick={handleAddMember}
>
{picked.length}
</Button>
<Button size="small" onClick={() => { setCandidates([]); setCandidateKeyword(""); }}>
</Button>
</div>
{picked.length > 0 && (
<Button type="link" size="small" onClick={() => setPicked([])}>
</Button>
)}
</div>
</div>
)}