From 281769b4402921fcf320590bb85e506a86fa21a3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 7 Oct 2026 09:13:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(accounts):=20=E8=B4=A6=E5=8F=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=8C=89=E5=BD=92=E5=B1=9E=E7=AD=9B=E9=80=89=EF=BC=8C?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=86=E9=85=8D=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=85=A8=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来只有 assigned_only(已分配),筛不出「未分配」,也看不到账号归谁。 新增 assign_status/assigned_user_id 可与标签、CK、搜索等叠加; BatchAssign 支持 all_matching,「筛出未分配 → 一键分给某人」不必先勾选。 --- tests/test_account_assign_filter.py | 184 +++++++++++++++++++++++ web/backend/routers/accounts.py | 47 +++++- web/backend/schemas.py | 20 ++- web/frontend/src/api/accounts.ts | 6 + web/frontend/src/api/types.ts | 9 ++ web/frontend/src/pages/AccountsPage.tsx | 185 +++++++++++++++++++++++- 6 files changed, 436 insertions(+), 15 deletions(-) create mode 100644 tests/test_account_assign_filter.py diff --git a/tests/test_account_assign_filter.py b/tests/test_account_assign_filter.py new file mode 100644 index 0000000..9d547d1 --- /dev/null +++ b/tests/test_account_assign_filter.py @@ -0,0 +1,184 @@ +"""账号管理的「归属筛选 + 快速分配」。 + +背景:账号列表原先只能用 `assigned_only` 表达「已分配」,**没有「未分配」**, +也看不到、筛不出「这个账号归谁」。于是「把一批还没主的账号分给客服」只能 +靠人肉翻页勾选。 + +本文件守住两件事: + +1. `GET /accounts` 的归属筛选(unassigned / assigned / 指定客服)能生效, + 并且与标签等条件**同时**生效(综合筛选,不是互相覆盖); +2. `POST /accounts/batch-assign` 支持 `all_matching` —— 按当前筛选解析目标, + 「筛出未分配 → 一键分给某人」不必先勾选。显式 `account_ids` 的旧路径不变。 +""" + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, User +from web.backend.routers.accounts import batch_assign_accounts, list_accounts +from web.backend.schemas import BatchAssign + + +class _Base: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.admin = User(username="af-admin", password_hash="h", role="super_admin") + self.support = User(username="af-kf", password_hash="h", role="support") + self.other = User(username="af-kf2", password_hash="h", role="support") + self.session.add_all([self.admin, self.support, self.other]) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def make_account(self, name: str, **kwargs) -> Account: + account = Account( + username=name, + password="p", + email=f"{name}@e.com", + email_password="ep", + # 默认持有 CK —— 分配接口只接受「还拿着 CK」的账号 + cookie=kwargs.pop("cookie", "acf_uid=1"), + **kwargs, + ) + self.session.add(account) + self.session.commit() + return account + + def names(self, **kwargs) -> list[str]: + """按归属条件查列表,返回用户名(沿用路由真实调用方式)。 + + 未显式传的参数保持默认值;`Query(...)` 默认对象由 `_query_str` / + `isinstance` 兜住,这正是路由函数能被直接调用的原因。 + """ + params = { + "assigned_only": False, + "tag": "", + "has_cookie": False, + "search": "", + "page": 1, + "page_size": 50, + "include_sensitive": False, + "db": self.session, + "current": self.admin, + } + params.update(kwargs) + result = list_accounts(**params) + assert isinstance(result, dict) + return [item.username for item in result["items"]] + + +class TestAssignFilter(_Base): + def test_unassigned_only(self): + self.make_account("free-1") + self.make_account("owned-1", assigned_to=self.support.id) + + assert self.names(assign_status="unassigned") == ["free-1"] + + def test_assigned_only(self): + self.make_account("free-2") + self.make_account("owned-2", assigned_to=self.support.id) + + assert self.names(assign_status="assigned") == ["owned-2"] + + def test_filter_by_specific_support(self): + self.make_account("a-of-kf", assigned_to=self.support.id) + self.make_account("a-of-kf2", assigned_to=self.other.id) + self.make_account("a-free") + + assert self.names(assigned_user_id=self.other.id) == ["a-of-kf2"] + + def test_combines_with_tag(self): + """综合筛选:归属 + 标签要**同时**生效,而不是后一个覆盖前一个。""" + self.make_account("tag-a-free", tag="10.1-测试") + self.make_account("tag-b-free", tag="10.2-测试") + self.make_account("tag-a-owned", tag="10.1-测试", assigned_to=self.support.id) + + assert self.names(assign_status="unassigned", tag="10.1-测试") == ["tag-a-free"] + + +class TestAllMatchingAssign(_Base): + def test_assigns_every_matching_unassigned(self): + free = [self.make_account(f"mf-{i}") for i in range(3)] + # 已归别人:这一批里**不能**被动到 + taken = self.make_account("mf-taken", assigned_to=self.other.id) + + result = batch_assign_accounts( + BatchAssign( + all_matching=True, + assign_status="unassigned", + assigned_to=self.support.id, + ), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 3 + for account in free: + self.session.refresh(account) + assert account.assigned_to == self.support.id + self.session.refresh(taken) + assert taken.assigned_to == self.other.id + + def test_all_matching_respects_tag(self): + self.make_account("mt-a", tag="A") + self.make_account("mt-b", tag="B") + + result = batch_assign_accounts( + BatchAssign(all_matching=True, tag="A", assigned_to=self.support.id), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 1 + assert self.names(assigned_user_id=self.support.id) == ["mt-a"] + + def test_unassign_all_matching(self): + """留空目标 = 取消分配,同样支持按筛选全选。""" + a = self.make_account("mu-a", assigned_to=self.support.id) + b = self.make_account("mu-b", assigned_to=self.support.id) + + result = batch_assign_accounts( + BatchAssign(all_matching=True, assign_status="assigned", assigned_to=None), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 2 + for account in (a, b): + self.session.refresh(account) + assert account.assigned_to is None + + def test_explicit_ids_still_work(self): + """旧路径(显式勾选)不能被 all_matching 改动带坏。""" + a = self.make_account("me-a") + b = self.make_account("me-b") + + result = batch_assign_accounts( + BatchAssign(account_ids=[a.id], assigned_to=self.support.id), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 1 + self.session.refresh(a) + self.session.refresh(b) + assert a.assigned_to == self.support.id + assert b.assigned_to is None + + def test_all_matching_without_match_is_rejected(self): + import pytest + from fastapi import HTTPException + + self.make_account("mn-a", assigned_to=self.other.id) + with pytest.raises(HTTPException) as exc: + batch_assign_accounts( + BatchAssign( + all_matching=True, + assign_status="unassigned", + assigned_to=self.support.id, + ), + db=self.session, + current=self.admin, + ) + assert exc.value.status_code == 400 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index c6d33ad..9b862f7 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -97,6 +97,8 @@ def _filter_accounts_query( current: User, *, assigned_only: bool = False, + assign_status: str = "", + assigned_user_id: int | None = None, tag: str | None = None, has_cookie: bool = False, search: str = "", @@ -117,6 +119,16 @@ def _filter_accounts_query( query = query.filter(Account.id.in_(cookie_account_ids_query(db))) if assigned_only and user_has_permission(current, "account:view_all"): query = query.filter(Account.assigned_to.isnot(None)) + # 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、 + # 搜索…)是**同时生效**的,这正是「综合筛选」的用法。 + assign_value = _query_str(assign_status) + if assign_value == "unassigned": + query = query.filter(Account.assigned_to.is_(None)) + elif assign_value == "assigned": + query = query.filter(Account.assigned_to.isnot(None)) + # isinstance 兜住「测试直接调路由函数、Query 默认值未解析」的情况。 + if isinstance(assigned_user_id, int) and not isinstance(assigned_user_id, bool): + query = query.filter(Account.assigned_to == assigned_user_id) tag_value = _query_str(tag) if tag_value: query = query.filter(Account.tag == tag_value) @@ -219,6 +231,8 @@ def _selected_account_ids( _visible_accounts_query(db, current), current, assigned_only=req.assigned_only, + assign_status=req.assign_status, + assigned_user_id=req.assigned_user_id, tag=req.tag, has_cookie=req.has_cookie, search=req.search, @@ -366,6 +380,10 @@ def _require_account_tag_permission(current: User) -> None: @router.get("") def list_accounts( assigned_only: bool = Query(False), + assign_status: str = Query( + "", description="归属筛选:unassigned=未分配 / assigned=已分配;空=全部" + ), + assigned_user_id: int | None = Query(None, description="只看某个客服名下的账号"), tag: str = Query(None), has_cookie: bool = Query(False), search: str = Query(""), @@ -406,6 +424,8 @@ def list_accounts( _visible_accounts_query(db, current), current, assigned_only=assigned_only, + assign_status=assign_status, + assigned_user_id=assigned_user_id, tag=tag, has_cookie=has_cookie, search=search, @@ -773,8 +793,14 @@ def batch_assign_accounts( db: Session = Depends(get_db), current: User = Depends(require_permission("account:assign")), ): - """批量分配/取消分配账号给客服。""" - if not req.account_ids: + """批量分配/取消分配账号给客服。 + + `all_matching=True` 时按当前筛选条件解析目标(与批量打标签同一套语义)—— + 「筛出未分配 → 一键分给某人」不必先手动勾选,这正是账号管理页 + 「归属筛选 + 快速分配」的组合用法。 + """ + ids = _selected_account_ids(db, current, req) + if not ids: raise HTTPException(status_code=400, detail="请选择账号") # 验证目标用户 @@ -790,7 +816,7 @@ def batch_assign_accounts( invalid_ids = ( db.query(Account.id) .filter( - Account.id.in_(req.account_ids), + Account.id.in_(ids), Account.id.notin_(cookie_ids_query), ) .all() @@ -809,13 +835,13 @@ def batch_assign_accounts( ) # 先取出账号对象(批量时间线要逐个写事件,光有 count 不够) - target_accounts = db.query(Account).filter(Account.id.in_(req.account_ids)).all() + target_accounts = db.query(Account).filter(Account.id.in_(ids)).all() count = ( db.query(Account) - .filter(Account.id.in_(req.account_ids)) + .filter(Account.id.in_(ids)) .update({Account.assigned_to: req.assigned_to}, synchronize_session=False) ) - db.query(DouyuTask).filter(DouyuTask.account_id.in_(req.account_ids)).update( + db.query(DouyuTask).filter(DouyuTask.account_id.in_(ids)).update( {DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False ) target_user = ( @@ -825,13 +851,18 @@ def batch_assign_accounts( ) target_name = target_user.username if target_user else "" action_word = "分配" if req.assigned_to else "取消分配" + scope = "当前筛选下" if req.all_matching else "选中的" title = f"批量分配:{target_name}" if target_name else "批量取消分配" record_audit( db, current, action="account:assign", target=f"批量{action_word}{count}个账号", - detail={"assigned_to": req.assigned_to, "assigned_username": target_name}, + detail={ + "assigned_to": req.assigned_to, + "assigned_username": target_name, + "scope": "all_matching" if req.all_matching else "selected", + }, ) # 每个账号的时间线各留一条 —— 「这个账号什么时候被分配给谁」是账号级事实。 emit_bulk( @@ -856,7 +887,7 @@ def batch_assign_accounts( db.commit() action = "分配" if req.assigned_to else "取消分配" return { - "message": f"已批量{action} {count} 个账号", + "message": f"已批量{action}{scope} {count} 个账号", "success": True, "count": count, } diff --git a/web/backend/schemas.py b/web/backend/schemas.py index aa9ef47..10af0ce 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -123,11 +123,6 @@ class AccountAssign(BaseModel): assigned_to: int | None = None -class BatchAssign(BaseModel): - account_ids: list[int] - assigned_to: int | None = None # None=取消分配 - - class AccountBulkSelection(BaseModel): """批量操作选择范围:显式 ID 或当前筛选条件下全部账号。""" @@ -136,6 +131,10 @@ class AccountBulkSelection(BaseModel): search: str = "" tag: str = "" assigned_only: bool = False + #: 归属筛选:""=全部 / unassigned=未分配 / assigned=已分配 + assign_status: str = "" + #: 只看某个客服名下的账号(与 assign_status 可叠加) + assigned_user_id: int | None = None has_cookie: bool = False status: str = "" ck_status: str = "" @@ -150,6 +149,17 @@ class AccountBulkSelection(BaseModel): role_bind_status: str = "" +class BatchAssign(AccountBulkSelection): + """批量分配 / 取消分配。 + + ⚠️ `assigned_to` 是**目标**客服(None=取消分配),与父类的 + `assigned_user_id`(筛选「谁名下的账号」)是两件事,别混。 + `all_matching=True` 时按父类的筛选条件解析目标账号。 + """ + + assigned_to: int | None = None + + class AccountBulkTag(AccountBulkSelection): tag_value: str = "" diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index 1fb1096..935f63c 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -8,6 +8,7 @@ import type { AccountOverview, AssignmentsSummary, BasicSummary, + BatchAssignRequest, MessageCountResponse, MessageDeletedResponse, MessageResponse, @@ -21,6 +22,8 @@ export const accountApi = { listPaged: ( params: PageParams & { assigned_only?: boolean; + assign_status?: string; + assigned_user_id?: number; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; @@ -47,6 +50,9 @@ export const accountApi = { api.put(`/accounts/${id}/assign`, { assigned_to }), batchAssign: (account_ids: number[], assigned_to: number | null) => api.post('/accounts/batch-assign', { account_ids, assigned_to }), + // 同一接口的选择集版本:可带筛选条件(all_matching),供「按筛选全选」分配 + batchAssignSelection: (data: BatchAssignRequest) => + api.post('/accounts/batch-assign', data), assignmentsSummary: () => api.get('/accounts/assignments/summary'), setRemark: (id: number, remark: string) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 022fb0a..2d2ef6d 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -30,6 +30,10 @@ export interface AccountBulkSelection { search?: string; tag?: string; assigned_only?: boolean; + /** 归属筛选:unassigned=未分配 / assigned=已分配;空=全部 */ + assign_status?: string; + /** 只看某个客服名下的账号 */ + assigned_user_id?: number; has_cookie?: boolean; status?: string; ck_status?: string; @@ -48,6 +52,11 @@ export interface AccountBulkTagRequest extends AccountBulkSelection { tag_value: string; } +/** 批量分配:`assigned_to` 是目标客服(null=取消分配),别与筛选用的 assigned_user_id 混 */ +export interface BatchAssignRequest extends AccountBulkSelection { + assigned_to?: number | null; +} + export interface PaginatedResponse { items: T[]; total: number; diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index e6b5187..cd2ad71 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -33,8 +33,9 @@ import { DownOutlined, UpOutlined, ClearOutlined, + UsergroupAddOutlined, } from '@ant-design/icons'; -import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary } from '../api/modules'; +import { accountApi, douyuApi, type AccountExchangeRecord, type AccountExchangeRecords, type AccountItem, type BasicSummary, type SupportUserItem } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime, formatTimeShort } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -183,6 +184,13 @@ export default function AccountsPage() { const [statusFilter, setStatusFilter] = useState(''); const [ckFilter, setCkFilter] = useState(''); const [bindFilter, setBindFilter] = useState(''); + // 归属筛选:'' | 'unassigned' | 'assigned' | 'user:' + // 单独一个 Select 而不是两个,是因为「未分配 / 已分配 / 具体某个客服」互斥。 + const [assignFilter, setAssignFilter] = useState(''); + const [assignUsers, setAssignUsers] = useState([]); + const [assignModalOpen, setAssignModalOpen] = useState(false); + const [assignTargetId, setAssignTargetId] = useState(undefined); + const [assigning, setAssigning] = useState(false); const [searchText, setSearchText] = useState(''); // 高价值资产与高级自定义筛选状态 @@ -250,7 +258,15 @@ export default function AccountsPage() { const canImport = can('account:import'); const canTag = can('account:tag') || canImport; const canDelete = can('account:delete'); - const canSelectRows = canTag || canDelete; + const canAssign = can('account:assign'); + // 只有分配权限的人也要能勾选账号(否则没法批量分配)。 + const canSelectRows = canTag || canDelete || canAssign; + // 一个 Select 的值拆成后端两个参数:归属状态 + 指定客服。 + const assignStatusParam = + assignFilter === 'unassigned' || assignFilter === 'assigned' ? assignFilter : ''; + const assignedUserIdParam = assignFilter.startsWith('user:') + ? Number(assignFilter.slice(5)) + : undefined; const loadAccounts = useCallback(async () => { setLoading(true); @@ -265,6 +281,8 @@ export default function AccountsPage() { if (statusFilter) params.status = statusFilter; if (ckFilter) params.ck_status = ckFilter; if (bindFilter) params.bind_status = bindFilter; + if (assignStatusParam) params.assign_status = assignStatusParam; + if (assignedUserIdParam !== undefined) params.assigned_user_id = assignedUserIdParam; if (highValueFilter) params.high_value_filter = highValueFilter; if (minPoints !== undefined) params.min_points = minPoints; if (maxPoints !== undefined) params.max_points = maxPoints; @@ -296,6 +314,8 @@ export default function AccountsPage() { statusFilter, ckFilter, bindFilter, + assignStatusParam, + assignedUserIdParam, highValueFilter, minPoints, maxPoints, @@ -326,6 +346,19 @@ export default function AccountsPage() { } }, []); + const loadAssignUsers = useCallback(async () => { + try { + const data = await accountApi.assignmentsSummary(); + setAssignUsers(data.support_users); + } catch { + // 客服列表拉不到不影响列表本身,只是分配时选不到人。 + } + }, []); + + useEffect(() => { + if (canAssign) loadAssignUsers(); + }, [canAssign, loadAssignUsers]); + useEffect(() => { loadAccounts(); }, [loadAccounts]); @@ -344,6 +377,8 @@ export default function AccountsPage() { status: statusFilter || undefined, ck_status: ckFilter || undefined, bind_status: bindFilter || undefined, + assign_status: assignStatusParam || undefined, + assigned_user_id: assignedUserIdParam, high_value_filter: highValueFilter || undefined, min_points: minPoints, max_points: maxPoints, @@ -360,6 +395,8 @@ export default function AccountsPage() { statusFilter, ckFilter, bindFilter, + assignStatusParam, + assignedUserIdParam, highValueFilter, minPoints, maxPoints, @@ -383,6 +420,8 @@ export default function AccountsPage() { statusFilter, ckFilter, bindFilter, + assignStatusParam, + assignedUserIdParam, highValueFilter, minPoints, maxPoints, @@ -442,6 +481,34 @@ export default function AccountsPage() { } }; + // 快速分配:`target` 为目标客服,显式传 null 才是取消分配(不做「留空即取消」, + // 免得点错一下就把一批账号的归属清掉)。支持「全选当前筛选」, + // 所以「筛出未分配 → 一键分给某人」不用先手动勾选。 + const handleBatchAssign = async (target: number | null) => { + if (selectedCount === 0) { + message.warning('请先选择账号,或先设置筛选条件后点「全选当前筛选」'); + return; + } + setAssigning(true); + try { + const result = await accountApi.batchAssignSelection({ + ...selectionPayload, + assigned_to: target, + }); + message.success(result.message); + setAssignModalOpen(false); + setAssignTargetId(undefined); + clearSelection(); + loadAccounts(); + loadSummary(); + if (canAssign) loadAssignUsers(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setAssigning(false); + } + }; + const handleSetTag = async (accountId: number, tag: string) => { try { await accountApi.setTag(accountId, tag); @@ -637,6 +704,35 @@ export default function AccountsPage() { ); }, }, + { + // 归属:看得见才管得着 —— 未分配的一眼可见,而且直接点就能分。 + title: '分配', + dataIndex: 'assigned_username', + width: 110, + render: (name: string | null, record: AccountItem) => { + if (name) { + return {name}; + } + return canAssign ? ( + + ) : ( + 未分配 + ); + }, + }, { title: '积分 / 鱼翅', dataIndex: 'points', @@ -931,6 +1027,19 @@ export default function AccountsPage() { }, ]; + // 归属筛选下拉:两个固定状态 + 各客服(客服列表拉不到时只剩固定项)。 + const assignFilterOptions = useMemo( + () => [ + { value: 'unassigned', label: '未分配' }, + { value: 'assigned', label: '已分配' }, + ...assignUsers.map((user) => ({ + value: `user:${user.id}`, + label: `客服:${user.username}(${user.assigned_count})`, + })), + ], + [assignUsers], + ); + return (
@@ -968,6 +1077,17 @@ export default function AccountsPage() { onChange={(val) => { setBindFilter(val || ''); setCurrentPage(1); }} options={BIND_OPTIONS} /> + setAssignTargetId(val ?? undefined)} + options={assignUsers.map((user) => ({ + value: user.id, + label: `${user.username}(现 ${user.assigned_count} 个)`, + }))} + /> + + 只有仍持有 CK 的账号能被分配;取消分配会清空这些账号的归属。 + + +