diff --git a/backend/app/api/v1/endpoints/groups.py b/backend/app/api/v1/endpoints/groups.py index 1e77ef5..f4613a1 100644 --- a/backend/app/api/v1/endpoints/groups.py +++ b/backend/app/api/v1/endpoints/groups.py @@ -231,9 +231,11 @@ async def list_phase_options(current_user: dict = Depends(get_current_user)): @router.get("/member-candidates", response_model=list[MemberCandidate]) -def list_member_candidates( +async def list_member_candidates( keyword: str = Query("", description="按姓名/账号模糊搜索"), limit: int = Query(500, ge=1, le=1000), + group_id: int | None = Query(None, description="传了则把已在该组的成员排除掉"), + db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT)。 @@ -244,10 +246,25 @@ def list_member_candidates( · 若再给他打组长标记,会产生「组长却不受组范围限制」的矛盾状态。 所以既不显示在候选里,也不允许加入(add_member 会再挡一道)。 + ⚠️ 传 `group_id` 时会**排除已在该组的成员** —— 否则会出现「这个人明明已经在 + 成员名单里,选人时却还能再选一次」的矛盾,用户会以为系统没生效。 + + ⚠️ MOM 与 Track 是**两个独立的库**,没法 JOIN,所以先在本库查出该组的成员 + 集合,再在内存里排掉。LICA 只有十几人,这个做法完全够用。 + 注意这里**不复用 users.py 的端点函数**:那个函数与 FastAPI 的 Query 默认值 耦合,直接调用拿到的是 Query 对象而非值。所以照抄同一条 SQL 的写法, 但部门条件取自同一处 settings.ORG_DEPARTMENT,口径不会漂移。 """ + # 先取本组成员(Track 库) + exclude: set[str] = set() + if group_id is not None: + rows = await db.execute( + select(BusinessGroupMember.user_id) + .where(BusinessGroupMember.group_id == group_id) + ) + exclude = {r[0] for r in rows.all()} + db = MomSessionLocal() try: base_sql = """ @@ -266,13 +283,15 @@ def list_member_candidates( from sqlalchemy import text rows = db.execute(text(sql_text), params).fetchall() - return [ - MemberCandidate( - username=row.username.split("/")[-1] if "/" in row.username else row.username, - full_name=row.full_name or row.username, - ) - for row in rows - ] + out: list[MemberCandidate] = [] + for row in rows: + acct = row.username.split("/")[-1] if "/" in row.username else row.username + if acct in exclude: # 已在组内 —— 不再作为候选 + continue + out.append(MemberCandidate( + username=acct, full_name=row.full_name or row.username, + )) + return out except Exception as e: raise HTTPException( status.HTTP_502_BAD_GATEWAY, f"MOM 用户查询失败: {str(e)}" diff --git a/frontend/src/pages/admin/AdminGroupsPage.tsx b/frontend/src/pages/admin/AdminGroupsPage.tsx index b8ec84d..296fb3f 100644 --- a/frontend/src/pages/admin/AdminGroupsPage.tsx +++ b/frontend/src/pages/admin/AdminGroupsPage.tsx @@ -207,7 +207,8 @@ export default function AdminGroupsPage() { async function openAddMember() { if (selectedId == null) return; try { - setCandidates(await fetchMemberCandidates()); + // 传 selectedId:后端会把已在本组的人排除掉,避免"已经加了还能再选" + setCandidates(await fetchMemberCandidates(selectedId)); setPicked([]); setPickedLeader(false); setCandidateKeyword(""); @@ -234,7 +235,9 @@ export default function AdminGroupsPage() { } setPicked([]); setPickedLeader(false); - setCandidates([]); + // 面板保持打开并重拉候选 —— 刚加进去的人会从方块里消失, + // 这样既能连续加人,也不会出现"刚加完还能再选他一次" + setCandidates(await fetchMemberCandidates(selectedId)); await Promise.all([loadDetail(selectedId), load()]); } catch (err) { message.error(extractErrorMessage(err, "添加失败")); diff --git a/frontend/src/services/groupApi.ts b/frontend/src/services/groupApi.ts index aa1d769..bc170bc 100644 --- a/frontend/src/services/groupApi.ts +++ b/frontend/src/services/groupApi.ts @@ -131,10 +131,21 @@ export async function setGroupLeader( return data; } -/** 候选人下拉 —— 数据源是 MOM 的 LICA 部门人员 */ -export async function fetchMemberCandidates(keyword?: string): Promise { +/** + * 候选人列表 —— 数据源是 MOM 的 LICA 部门人员。 + * + * ⚠️ 传 groupId 时后端会**排除已在该组的人** —— 否则会出现 + * 「他明明已经在成员名单里,选人时却还能再选一次」的矛盾。 + */ +export async function fetchMemberCandidates( + groupId?: number, + keyword?: string +): Promise { const { data } = await api.get("/groups/member-candidates", { - params: keyword ? { keyword } : {}, + params: { + ...(groupId != null ? { group_id: groupId } : {}), + ...(keyword ? { keyword } : {}), + }, }); return data; }