feat(分组): 成员改为标签云展示 + 添加成员支持多选批量

1) 成员不再一人一行
   原先用 antd Table:成员一多就要滚很久,右侧还大片留白。
   改为标签云(flex-wrap),一行能放好几个。
   交互一并简化:
     · 点标签正文 → 切换组长(悬停有提示)
     · 点 ×     → 移出分组
     · 无权限时两者都不出现,退化成纯展示
   组长用金色标签 + 皇冠图标,一眼可辨。

2) 添加成员支持多选
   Select 加 mode="multiple" + maxTagCount="responsive",可搜可多选、批量提交。
   后端 POST /groups/{id}/members 由单个 user_id 改为 user_ids 数组,
   返回 {added, skipped, skipped_names}:
     · 已在组内的**静默跳过**而非整批 409 —— 多选时难免选中已在组里的人,
       为此让整批失败体验很差;前端会把跳过的名单说清楚,
       否则用户会疑惑「明明选了 5 个,怎么只多了 3 个」
     · 批量里混入超管则**整批拒绝并指名道姓**,不制造「部分成功」这种
       难以解释的中间状态

实测(后端):
  批量加 3 人           → added 3
  重复提交(2 旧 1 新) → added 1 / skipped 2,整批仍成功
  混入超管              → 400「以下账号是超级管理员,业务分组对其不生效:sunxia」
  空列表                → 422
  测试成员已清理
This commit is contained in:
2026-09-21 17:44:06 +08:00
parent 5095756571
commit 9598c3fb9f
4 changed files with 121 additions and 72 deletions

View File

@ -38,8 +38,9 @@ from app.models.business_group import (
from app.schemas.group import ( from app.schemas.group import (
GroupCreate, GroupCreate,
GroupDetailOut, GroupDetailOut,
GroupMemberAdd,
GroupMemberOut, GroupMemberOut,
GroupMembersAdd,
GroupMembersAddResult,
GroupMemberUpdate, GroupMemberUpdate,
GroupOut, GroupOut,
GroupUpdate, GroupUpdate,
@ -563,18 +564,23 @@ async def delete_group(
# 成员管理 # 成员管理
# ============================================================ # ============================================================
@router.post("/{group_id}/members", response_model=GroupMemberOut, @router.post("/{group_id}/members", response_model=GroupMembersAddResult,
status_code=status.HTTP_201_CREATED) status_code=status.HTTP_201_CREATED)
async def add_member( async def add_members(
group_id: int, group_id: int,
payload: GroupMemberAdd, payload: GroupMembersAdd,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。 """**批量**把成员加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住 权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住
(他本来就在组里),所以这里不构成提权。 (他本来就在组里),所以这里不构成提权。
语义:
· 已在组内的静默跳过(返回在 skipped 里),不报错 ——
多选时难免选中已在组里的人,为此整批失败体验很差。
· 只要有一个是超管就整批拒绝并指名道姓,避免"部分成功"这种难解释的状态。
""" """
await _get_group_or_404(db, group_id) await _get_group_or_404(db, group_id)
if not await _can_manage_members(db, current_user, group_id): if not await _can_manage_members(db, current_user, group_id):
@ -582,35 +588,48 @@ async def add_member(
status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以添加成员" status.HTTP_403_FORBIDDEN, "只有超级管理员、主管或本组组长可以添加成员"
) )
# 去重 + 去空白,保持用户选择的顺序
wanted = list(dict.fromkeys(u.strip() for u in payload.user_ids if u and u.strip()))
if not wanted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "请至少选择一名成员")
# 超管不进分组:他的范围是硬编码全厂,分组对他不生效。 # 超管不进分组:他的范围是硬编码全厂,分组对他不生效。
# 放进来的话界面会暗示「他受这个组约束」,与事实不符。 # 放进来的话界面会暗示「他受这个组约束」,与事实不符。
if await _is_super_admin_account(payload.user_id): supers = [u for u in wanted if await _is_super_admin_account(u)]
if supers:
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,
"超级管理员不受业务分组约束(数据范围恒为全厂),无需加入分组", f"以下账号是超级管理员业务分组对其不生效:{''.join(supers)}",
) )
exists = await db.scalar( already = set((await db.execute(
select(BusinessGroupMember.id).where( select(BusinessGroupMember.user_id).where(
BusinessGroupMember.group_id == group_id, BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id == payload.user_id, BusinessGroupMember.user_id.in_(tuple(wanted)),
) )
) )).scalars().all())
if exists:
raise HTTPException(status.HTTP_409_CONFLICT, "该成员已在此分组中")
member = BusinessGroupMember( to_add = [u for u in wanted if u not in already]
group_id=group_id, user_id=payload.user_id, is_leader=payload.is_leader, for u in to_add:
) db.add(BusinessGroupMember(
db.add(member) group_id=group_id, user_id=u, is_leader=payload.is_leader,
))
await db.commit() await db.commit()
from app.services.mom_cache import get_display_names from app.services.mom_cache import get_display_names
name_map = get_display_names([payload.user_id]) all_names = get_display_names(wanted)
return GroupMemberOut( skipped = [u for u in wanted if u in already]
user_id=payload.user_id, return GroupMembersAddResult(
display_name=name_map.get(payload.user_id) or payload.user_id, added=[
is_leader=payload.is_leader, GroupMemberOut(
user_id=u,
display_name=all_names.get(u) or u,
is_leader=payload.is_leader,
)
for u in to_add
],
skipped=skipped,
skipped_names=[all_names.get(u) or u for u in skipped],
) )

View File

@ -55,11 +55,20 @@ class GroupUpdate(BaseModel):
) )
class GroupMemberAdd(BaseModel): class GroupMembersAdd(BaseModel):
user_id: str = Field(..., min_length=1, max_length=64, description="MOM 账号") """批量加成员 —— 界面是多选下拉,一次提交一批"""
user_ids: list[str] = Field(
..., min_length=1, max_length=200, description="要加入的 MOM 账号列表",
)
is_leader: bool = False is_leader: bool = False
class GroupMembersAddResult(BaseModel):
added: list["GroupMemberOut"] = [] # 本次真正加进去的
skipped: list[str] = [] # 已在组内、被跳过的账号
skipped_names: list[str] = [] # 上面那些账号的中文名,便于前端提示
class GroupMemberUpdate(BaseModel): class GroupMemberUpdate(BaseModel):
is_leader: bool is_leader: bool

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import { Table, Button, Modal, Input, Checkbox, Tag, App, Spin, Empty, Select } from "antd"; import { Table, Button, Modal, Input, Checkbox, Tag, App, Spin, Empty, Select, Tooltip } from "antd";
import { Plus, Pencil, Trash2, UserPlus, Crown, ShieldCheck } from "lucide-react"; import { Plus, Pencil, Trash2, UserPlus, Crown, ShieldCheck } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { isSuperAdmin } from "../../constants/task"; import { isSuperAdmin } from "../../constants/task";
@ -11,13 +11,12 @@ import {
createGroup, createGroup,
updateGroup, updateGroup,
deleteGroup, deleteGroup,
addGroupMember, addGroupMembers,
removeGroupMember, removeGroupMember,
setGroupLeader, setGroupLeader,
fetchMemberCandidates, fetchMemberCandidates,
type BusinessGroup, type BusinessGroup,
type GroupDetail, type GroupDetail,
type GroupMember,
type PhaseOption, type PhaseOption,
type MemberCandidate, type MemberCandidate,
} from "../../services/groupApi"; } from "../../services/groupApi";
@ -55,7 +54,7 @@ export default function AdminGroupsPage() {
// 加成员 // 加成员
const [candidates, setCandidates] = useState<MemberCandidate[]>([]); const [candidates, setCandidates] = useState<MemberCandidate[]>([]);
const [picked, setPicked] = useState<string | undefined>(undefined); const [picked, setPicked] = useState<string[]>([]); // 多选
const [pickedLeader, setPickedLeader] = useState(false); const [pickedLeader, setPickedLeader] = useState(false);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
@ -193,7 +192,7 @@ export default function AdminGroupsPage() {
if (selectedId == null) return; if (selectedId == null) return;
try { try {
setCandidates(await fetchMemberCandidates()); setCandidates(await fetchMemberCandidates());
setPicked(undefined); setPicked([]);
setPickedLeader(false); setPickedLeader(false);
} catch (err) { } catch (err) {
message.error(extractErrorMessage(err, "加载人员失败")); message.error(extractErrorMessage(err, "加载人员失败"));
@ -201,12 +200,22 @@ export default function AdminGroupsPage() {
} }
async function handleAddMember() { async function handleAddMember() {
if (selectedId == null || !picked) { message.warning("请选择人员"); return; } if (selectedId == null || picked.length === 0) { message.warning("请选择人员"); return; }
setAdding(true); setAdding(true);
try { try {
await addGroupMember(selectedId, picked, pickedLeader); const res = await addGroupMembers(selectedId, picked, pickedLeader);
message.success("已添加"); const n = res.added.length;
setPicked(undefined); const skipped = res.skipped_names.length;
// 已在组内的会被后端静默跳过 —— 必须把话说清楚,
// 否则用户会疑惑「明明选了 5 个,怎么只多了 3 个」
if (n && skipped) {
message.success(`已添加 ${n} 人;${res.skipped_names.join("、")} 已在组内,自动跳过`);
} else if (n) {
message.success(`已添加 ${n}`);
} else {
message.info("所选人员都已在组内,无需重复添加");
}
setPicked([]);
setPickedLeader(false); setPickedLeader(false);
setCandidates([]); setCandidates([]);
await Promise.all([loadDetail(selectedId), load()]); await Promise.all([loadDetail(selectedId), load()]);
@ -376,40 +385,38 @@ export default function AdminGroupsPage() {
) : detail.members.length === 0 ? ( ) : detail.members.length === 0 ? (
<Empty description="该分组还没有成员" image={Empty.PRESENTED_IMAGE_SIMPLE} className="py-6" /> <Empty description="该分组还没有成员" image={Empty.PRESENTED_IMAGE_SIMPLE} className="py-6" />
) : ( ) : (
<Table /* 标签云而非表格:成员多时一人一行要滚很久,标签能一行放好几个。
rowKey="user_id" 有权限时:点标签正文切换组长、点 × 移除;无权限时纯展示。 */
size="small" <div className="flex flex-wrap gap-2 rounded-lg border border-gray-100 bg-gray-50/60 p-3">
pagination={false} {detail.members.map((m) => (
dataSource={detail.members} <Tag
columns={[ key={m.user_id}
{ title: "姓名", dataIndex: "display_name", key: "display_name" }, color={m.is_leader ? "gold" : undefined}
{ title: "账号", dataIndex: "user_id", key: "user_id", render: (v: string) => <span className="font-mono text-xs text-gray-500">{v}</span> }, closable={detail.can_manage_members}
{ onClose={(e) => { e.preventDefault(); handleRemoveMember(m.user_id); }}
title: "组长", dataIndex: "is_leader", key: "is_leader", width: 100, className="!m-0 !px-2.5 !py-1 !text-sm"
// 无权限时退化成纯展示(灰底文字),不给可点的样子 >
render: (v: boolean, m) => detail.can_manage_members ? ( <Tooltip
<button title={
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"}`} detail.can_manage_members
onClick={(e) => { e.stopPropagation(); handleToggleLeader(m.user_id, !v); }} ? (m.is_leader ? "点击取消组长" : "点击设为组长")
: undefined
}
>
<span
className={`select-none ${detail.can_manage_members ? "cursor-pointer" : ""}`}
onClick={() => {
if (detail.can_manage_members) handleToggleLeader(m.user_id, !m.is_leader);
}}
> >
<Crown size={12} />{v ? "组长" : "设为组长"} {m.is_leader && <Crown size={12} className="mr-1 inline align-[-1px]" />}
</button> {m.display_name}
) : ( <span className="ml-1.5 font-mono text-xs opacity-50">{m.user_id}</span>
<span className={v ? "inline-flex items-center gap-1 text-xs text-amber-600" : "text-xs text-gray-300"}>
<Crown size={12} />{v ? "组长" : "—"}
</span> </span>
), </Tooltip>
}, </Tag>
...(detail.can_manage_members ? [{ ))}
title: "操作", key: "action", width: 70, </div>
render: (_: unknown, m: GroupMember) => (
<Button type="link" size="small" danger onClick={(e) => { e.stopPropagation(); handleRemoveMember(m.user_id); }}>
</Button>
),
}] : []),
]}
/>
)} )}
{/* 添加成员面板 —— 用 antd Select 而非原生 select {/* 添加成员面板 —— 用 antd Select 而非原生 select
@ -417,14 +424,16 @@ export default function AdminGroupsPage() {
{candidates.length > 0 && ( {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"> <div className="mt-3 flex flex-wrap items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<Select <Select
className="min-w-[260px] flex-1" mode="multiple"
placeholder="搜索姓名或账号…" className="min-w-[300px] flex-1"
placeholder="搜索姓名或账号,可多选…"
showSearch showSearch
allowClear allowClear
autoFocus autoFocus
value={picked} value={picked}
onChange={(v) => setPicked(v)} onChange={(v) => setPicked(v)}
optionFilterProp="label" optionFilterProp="label"
maxTagCount="responsive"
options={candidates.map((c) => ({ options={candidates.map((c) => ({
value: c.username, value: c.username,
label: `${c.full_name}${c.username}`, label: `${c.full_name}${c.username}`,

View File

@ -91,13 +91,25 @@ export async function deleteGroup(groupId: number): Promise<void> {
await api.delete(`/groups/${groupId}`); await api.delete(`/groups/${groupId}`);
} }
export async function addGroupMember( /** 批量加成员的结果 */
export interface GroupMembersAddResult {
added: GroupMember[]; // 本次真正加进去的
skipped: string[]; // 已在组内、被跳过的账号
skipped_names: string[]; // 上面那些账号的中文名
}
/**
* 批量添加成员。
* 已在组内的会被**静默跳过**(不报错)—— 多选时难免选中已在组里的人,
* 为此整批失败体验很差。
*/
export async function addGroupMembers(
groupId: number, groupId: number,
userId: string, userIds: string[],
isLeader = false isLeader = false
): Promise<GroupMember> { ): Promise<GroupMembersAddResult> {
const { data } = await api.post<GroupMember>(`/groups/${groupId}/members`, { const { data } = await api.post<GroupMembersAddResult>(`/groups/${groupId}/members`, {
user_id: userId, user_ids: userIds,
is_leader: isLeader, is_leader: isLeader,
}); });
return data; return data;