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:
@ -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],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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})`,
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user