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 (
GroupCreate,
GroupDetailOut,
GroupMemberAdd,
GroupMemberOut,
GroupMembersAdd,
GroupMembersAddResult,
GroupMemberUpdate,
GroupOut,
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)
async def add_member(
async def add_members(
group_id: int,
payload: GroupMemberAdd,
payload: GroupMembersAdd,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
"""**批量**把成员加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。
权限:超管 / 部门主管 / **本组组长**。组长给自己加组会被唯一约束挡住
(他本来就在组里),所以这里不构成提权。
语义:
· 已在组内的静默跳过(返回在 skipped 里),不报错 ——
多选时难免选中已在组里的人,为此整批失败体验很差。
· 只要有一个是超管就整批拒绝并指名道姓,避免"部分成功"这种难解释的状态。
"""
await _get_group_or_404(db, 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, "只有超级管理员、主管或本组组长可以添加成员"
)
# 去重 + 去空白,保持用户选择的顺序
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(
status.HTTP_400_BAD_REQUEST,
"超级管理员不受业务分组约束(数据范围恒为全厂),无需加入分组",
f"以下账号是超级管理员业务分组对其不生效:{''.join(supers)}",
)
exists = await db.scalar(
select(BusinessGroupMember.id).where(
already = set((await db.execute(
select(BusinessGroupMember.user_id).where(
BusinessGroupMember.group_id == group_id,
BusinessGroupMember.user_id == payload.user_id,
BusinessGroupMember.user_id.in_(tuple(wanted)),
)
)
if exists:
raise HTTPException(status.HTTP_409_CONFLICT, "该成员已在此分组中")
)).scalars().all())
member = BusinessGroupMember(
group_id=group_id, user_id=payload.user_id, is_leader=payload.is_leader,
)
db.add(member)
to_add = [u for u in wanted if u not in already]
for u in to_add:
db.add(BusinessGroupMember(
group_id=group_id, user_id=u, is_leader=payload.is_leader,
))
await db.commit()
from app.services.mom_cache import get_display_names
name_map = get_display_names([payload.user_id])
return GroupMemberOut(
user_id=payload.user_id,
display_name=name_map.get(payload.user_id) or payload.user_id,
is_leader=payload.is_leader,
all_names = get_display_names(wanted)
skipped = [u for u in wanted if u in already]
return GroupMembersAddResult(
added=[
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):
user_id: str = Field(..., min_length=1, max_length=64, description="MOM 账号")
class GroupMembersAdd(BaseModel):
"""批量加成员 —— 界面是多选下拉,一次提交一批"""
user_ids: list[str] = Field(
..., min_length=1, max_length=200, description="要加入的 MOM 账号列表",
)
is_leader: bool = False
class GroupMembersAddResult(BaseModel):
added: list["GroupMemberOut"] = [] # 本次真正加进去的
skipped: list[str] = [] # 已在组内、被跳过的账号
skipped_names: list[str] = [] # 上面那些账号的中文名,便于前端提示
class GroupMemberUpdate(BaseModel):
is_leader: bool

View File

@ -1,5 +1,5 @@
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 { useAuth } from "../../contexts/AuthContext";
import { isSuperAdmin } from "../../constants/task";
@ -11,13 +11,12 @@ import {
createGroup,
updateGroup,
deleteGroup,
addGroupMember,
addGroupMembers,
removeGroupMember,
setGroupLeader,
fetchMemberCandidates,
type BusinessGroup,
type GroupDetail,
type GroupMember,
type PhaseOption,
type MemberCandidate,
} from "../../services/groupApi";
@ -55,7 +54,7 @@ export default function AdminGroupsPage() {
// 加成员
const [candidates, setCandidates] = useState<MemberCandidate[]>([]);
const [picked, setPicked] = useState<string | undefined>(undefined);
const [picked, setPicked] = useState<string[]>([]); // 多选
const [pickedLeader, setPickedLeader] = useState(false);
const [adding, setAdding] = useState(false);
@ -193,7 +192,7 @@ export default function AdminGroupsPage() {
if (selectedId == null) return;
try {
setCandidates(await fetchMemberCandidates());
setPicked(undefined);
setPicked([]);
setPickedLeader(false);
} catch (err) {
message.error(extractErrorMessage(err, "加载人员失败"));
@ -201,12 +200,22 @@ export default function AdminGroupsPage() {
}
async function handleAddMember() {
if (selectedId == null || !picked) { message.warning("请选择人员"); return; }
if (selectedId == null || picked.length === 0) { message.warning("请选择人员"); return; }
setAdding(true);
try {
await addGroupMember(selectedId, picked, pickedLeader);
message.success("已添加");
setPicked(undefined);
const res = await addGroupMembers(selectedId, picked, pickedLeader);
const n = res.added.length;
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);
setCandidates([]);
await Promise.all([loadDetail(selectedId), load()]);
@ -376,40 +385,38 @@ export default function AdminGroupsPage() {
) : 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); }}
/* 标签云而非表格:成员多时一人一行要滚很久,标签能一行放好几个。
有权限时:点标签正文切换组长、点 × 移除;无权限时纯展示。 */
<div className="flex flex-wrap gap-2 rounded-lg border border-gray-100 bg-gray-50/60 p-3">
{detail.members.map((m) => (
<Tag
key={m.user_id}
color={m.is_leader ? "gold" : undefined}
closable={detail.can_manage_members}
onClose={(e) => { e.preventDefault(); handleRemoveMember(m.user_id); }}
className="!m-0 !px-2.5 !py-1 !text-sm"
>
<Tooltip
title={
detail.can_manage_members
? (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 ? "组长" : "设为组长"}
</button>
) : (
<span className={v ? "inline-flex items-center gap-1 text-xs text-amber-600" : "text-xs text-gray-300"}>
<Crown size={12} />{v ? "组长" : "—"}
{m.is_leader && <Crown size={12} className="mr-1 inline align-[-1px]" />}
{m.display_name}
<span className="ml-1.5 font-mono text-xs opacity-50">{m.user_id}</span>
</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>
),
}] : []),
]}
/>
</Tooltip>
</Tag>
))}
</div>
)}
{/* 添加成员面板 —— 用 antd Select 而非原生 select
@ -417,14 +424,16 @@ export default function AdminGroupsPage() {
{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="搜索姓名或账号…"
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}`,

View File

@ -91,13 +91,25 @@ export async function deleteGroup(groupId: number): Promise<void> {
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,
userId: string,
userIds: string[],
isLeader = false
): Promise<GroupMember> {
const { data } = await api.post<GroupMember>(`/groups/${groupId}/members`, {
user_id: userId,
): Promise<GroupMembersAddResult> {
const { data } = await api.post<GroupMembersAddResult>(`/groups/${groupId}/members`, {
user_ids: userIds,
is_leader: isLeader,
});
return data;