feat(分组): 搜索选择改为方块面板 + 组长设置改为明确按钮(支持多组长)
修正理解偏差:上一轮把「一行一个」理解成了成员列表,实际指的是**搜索姓名
时的下拉** —— antd Select 展开后一人一行、还带账号,又长又占地方。
1) 搜索选择:去掉 Select,改为内联的「搜索框 + 方块多选网格」
· 候选人全铺成方块(auto-fill 网格),点方块选中/取消,**无滚动条**
· 方块只显示中文名,账号挪到 title 悬停可见
· 搜索在前端过滤(LICA 十几人,一次拉全量更跟手)
· 顶部实时显示「已选 N 人」;按钮文案带数量「添加选中的 N 人」
· 无匹配时给空状态,不会是一片空白
2) 组长设置:从「点方块切换」这种隐晦交互,改为**方块内常驻的明确按钮**
· 未设组长 → 灰色「⚙ 设为组长」;已是组长 → 金色「👑 组长」
· 点击即切换,一眼看懂「他是不是组长、怎么改」
· 每个方块各管各的 —— **天然支持多个组长**
数据层本来就支持多组长(is_leader 是成员行上的独立字段);实测同一组
设两个组长并存,且两人的 /auth/me 都返回 is_leader=true。
顺带清理早先测试留下的数据污染:
· 生产大组的可见范围曾被「验证改范围权限」的测试改成「生产+售后」,
已改回只有「生产制造」
· 删除了残留的「权限测试组」
This commit is contained in:
@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user