diff --git a/backend/app/api/v1/endpoints/groups.py b/backend/app/api/v1/endpoints/groups.py index f059e5f..1e77ef5 100644 --- a/backend/app/api/v1/endpoints/groups.py +++ b/backend/app/api/v1/endpoints/groups.py @@ -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], ) diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py index 3ef8c78..9b091df 100644 --- a/backend/app/schemas/group.py +++ b/backend/app/schemas/group.py @@ -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 diff --git a/frontend/src/pages/admin/AdminGroupsPage.tsx b/frontend/src/pages/admin/AdminGroupsPage.tsx index 90c91ab..2a8cb29 100644 --- a/frontend/src/pages/admin/AdminGroupsPage.tsx +++ b/frontend/src/pages/admin/AdminGroupsPage.tsx @@ -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([]); - const [picked, setPicked] = useState(undefined); + const [picked, setPicked] = useState([]); // 多选 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 ? ( ) : ( - {v} }, - { - title: "组长", dataIndex: "is_leader", key: "is_leader", width: 100, - // 无权限时退化成纯展示(灰底文字),不给可点的样子 - render: (v: boolean, m) => detail.can_manage_members ? ( - - ) : ( - - {v ? "组长" : "—"} + {m.is_leader && } + {m.display_name} + {m.user_id} - ), - }, - ...(detail.can_manage_members ? [{ - title: "操作", key: "action", width: 70, - render: (_: unknown, m: GroupMember) => ( - - ), - }] : []), - ]} - /> + + + ))} + )} {/* 添加成员面板 —— 用 antd Select 而非原生 select: @@ -417,14 +424,16 @@ export default function AdminGroupsPage() { {candidates.length > 0 && (