diff --git a/tests/test_account_name_batch_selection.py b/tests/test_account_name_batch_selection.py new file mode 100644 index 0000000..5b5aa33 --- /dev/null +++ b/tests/test_account_name_batch_selection.py @@ -0,0 +1,94 @@ +"""账号管理「按账号名批量选号」相关测试。""" + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, User +from web.backend.routers.accounts import ( + _parse_account_names, + batch_tag_selection, + list_account_ids, + list_accounts, +) +from web.backend.schemas import AccountBulkTag + + +class TestAccountNameBatchSelection: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.admin = User( + username="name-filter-admin", + password_hash="hash", + role="super_admin", + is_active=True, + ) + self.session.add(self.admin) + self.session.commit() + + self.accounts = {} + for name in ("name-filter-a", "name-filter-b", "name-filter-c"): + account = Account( + username=name, + password="password", + email=f"{name}@example.com", + email_password="mail-password", + cookie="acf_uid=1", + ) + self.session.add(account) + self.session.flush() + self.accounts[name] = account + self.session.commit() + + def teardown_method(self): + self.session.close() + + def test_parse_account_names_trims_and_dedupes(self): + assert _parse_account_names( + "name-filter-c\n\nname-filter-a\r\nname-filter-c\n name-filter-b " + ) == ["name-filter-c", "name-filter-a", "name-filter-b"] + + def test_list_accounts_filters_by_exact_names(self): + result = list_accounts( + account_names="name-filter-c\nname-filter-missing\nname-filter-a", + page=1, + page_size=20, + db=self.session, + current=self.admin, + ) + + assert result["total"] == 2 + assert {item.username for item in result["items"]} == { + "name-filter-a", + "name-filter-c", + } + + def test_list_account_ids_follows_paste_order(self): + result = list_account_ids( + account_names="name-filter-c\nname-filter-missing\nname-filter-a", + db=self.session, + current=self.admin, + ) + + assert result["account_ids"] == [ + self.accounts["name-filter-c"].id, + self.accounts["name-filter-a"].id, + ] + assert result["usernames"] == ["name-filter-c", "name-filter-a"] + assert result["total"] == 2 + + def test_batch_tag_selection_supports_account_names(self): + result = batch_tag_selection( + req=AccountBulkTag( + all_matching=True, + account_names="name-filter-a\nname-filter-b", + tag_value="按名单标签", + ), + db=self.session, + current=self.admin, + ) + + assert result["count"] == 2 + for name in ("name-filter-a", "name-filter-b"): + self.session.refresh(self.accounts[name]) + assert self.accounts[name].tag == "按名单标签" + self.session.refresh(self.accounts["name-filter-c"]) + assert self.accounts["name-filter-c"].tag == "" diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index eb49722..a6b73d1 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -98,6 +98,20 @@ def _query_bool(value) -> bool: return value is True +def _parse_account_names(raw_names: str) -> list[str]: + """解析粘贴的账号名:每行一个,去空白并按输入顺序去重。""" + names: list[str] = [] + seen: set[str] = set() + for value in ( + (raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") + ): + name = value.strip() + if name and name not in seen: + names.append(name) + seen.add(name) + return names + + def _filter_accounts_query( db: Session, query, @@ -109,6 +123,7 @@ def _filter_accounts_query( tag: str | None = None, has_cookie: bool = False, search: str = "", + account_names: str = "", status: str = "", ck_status: str = "", failure_kind: str = "", @@ -140,6 +155,9 @@ def _filter_accounts_query( tag_value = _query_str(tag) if tag_value: query = query.filter(Account.tag == tag_value) + selected_names = _parse_account_names(_query_str(account_names)) + if selected_names: + query = query.filter(Account.username.in_(selected_names)) status_value = _query_str(status) if status_value: query = query.filter(Account.status == status_value) @@ -248,6 +266,7 @@ def _selected_account_ids( tag=req.tag, has_cookie=req.has_cookie, search=req.search, + account_names=req.account_names, status=req.status, ck_status=req.ck_status, failure_kind=req.failure_kind, @@ -398,6 +417,9 @@ def list_accounts( ), assigned_user_id: int | None = Query(None, description="只看某个客服名下的账号"), tag: str = Query(None), + account_names: str = Query( + "", description="多行斗鱼账号名(每行一个),精确匹配并自动去重" + ), has_cookie: bool = Query(False), search: str = Query(""), status: str = Query("", description="按生命周期状态筛选;空=全部(不含归档)"), @@ -446,6 +468,7 @@ def list_accounts( tag=tag, has_cookie=has_cookie, search=search, + account_names=account_names, status=status, ck_status=ck_status, failure_kind=failure_kind, @@ -623,6 +646,79 @@ def list_accounts( return result +@router.get("/ids") +def list_account_ids( + assigned_only: bool = Query(False), + assign_status: str = Query(""), + assigned_user_id: int | None = Query(None), + tag: str = Query(None), + has_cookie: bool = Query(False), + search: str = Query(""), + account_names: str = Query( + "", description="多行斗鱼账号名(每行一个),精确匹配并自动去重" + ), + status: str = Query(""), + ck_status: str = Query(""), + failure_kind: str = Query(""), + bind_status: str = Query(""), + high_value_filter: str = Query(""), + min_points: int | None = Query(None), + max_points: int | None = Query(None), + min_gold: float | None = Query(None), + max_gold: float | None = Query(None), + elite_opened: bool | None = Query(None), + esports_opened: bool | None = Query(None), + role_bind_status: str = Query(""), + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """返回当前筛选条件下的全部账号 ID,并按粘贴的账号名顺序排序。 + + 前端「按账号名批量选号」用它一次性拿到所有匹配 ID,再写入表格的选择集, + 这样跨分页勾选和后续批量标签都不受当前页限制。 + """ + query = _filter_accounts_query( + db, + _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, + account_names=account_names, + status=status, + ck_status=ck_status, + failure_kind=failure_kind, + bind_status=bind_status, + high_value_filter=high_value_filter, + min_points=min_points, + max_points=max_points, + min_gold=min_gold, + max_gold=max_gold, + elite_opened=elite_opened, + esports_opened=esports_opened, + role_bind_status=role_bind_status, + ) + selected_names = _parse_account_names(_query_str(account_names)) + if selected_names: + input_order = case( + {name: index for index, name in enumerate(selected_names)}, + value=Account.username, + else_=len(selected_names), + ) + query = query.order_by(input_order, Account.id.desc()) + else: + query = query.order_by(Account.id.desc()) + rows = query.with_entities(Account.id, Account.username).all() + return { + "account_ids": [account_id for account_id, _ in rows], + "usernames": [username for _, username in rows], + "total": len(rows), + } + + @router.get("/summary") def accounts_summary( db: Session = Depends(get_db), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index aebbe8b..85e06be 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -130,6 +130,8 @@ class AccountBulkSelection(BaseModel): all_matching: bool = False search: str = "" tag: str = "" + #: 多行斗鱼账号名(每行一个),与其它筛选条件叠加 + account_names: str = "" assigned_only: bool = False #: 归属筛选:""=全部 / unassigned=未分配 / assigned=已分配 assign_status: str = "" diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index dae9410..a82be23 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -45,6 +45,34 @@ export const accountApi = { }, ) => api.get, PaginatedResponse>('/accounts', { params }), + // 按当前筛选条件返回全部账号 ID(不受分页限制),供「按账号名批量选号」使用 + listIds: ( + params?: { + search?: string; + assigned_only?: boolean; + assign_status?: string; + assigned_user_id?: number; + tag?: string; + has_cookie?: boolean; + status?: string; + ck_status?: string; + failure_kind?: string; + bind_status?: string; + high_value_filter?: string; + min_points?: number; + max_points?: number; + min_gold?: number; + max_gold?: number; + elite_opened?: boolean; + esports_opened?: boolean; + role_bind_status?: string; + account_names?: string; + }, + ) => + api.get< + { account_ids: number[]; usernames: string[]; total: number }, + { account_ids: number[]; usernames: string[]; total: number } + >('/accounts/ids', { params }), export: (data: AccountExportRequest) => api.post('/accounts/export', data, { responseType: 'blob' }), summary: () => api.get('/accounts/summary'), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 3c127cb..1d352da 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -29,6 +29,8 @@ export interface AccountBulkSelection { all_matching?: boolean; search?: string; tag?: string; + /** 多行斗鱼账号名(每行一个),与其它筛选条件叠加 */ + account_names?: string; assigned_only?: boolean; /** 归属筛选:unassigned=未分配 / assigned=已分配;空=全部 */ assign_status?: string; diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 3763996..17f933b 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -25,6 +25,8 @@ import { clearStoredFilters, } from './accounts/storage'; import { AccountToolbar } from './accounts/components/AccountToolbar'; +import { AccountNameBatchSelector } from './accounts/components/AccountNameBatchSelector'; +import type { AccountNameMatchInfo } from './accounts/components/AccountNameBatchSelector'; import { StatusCards } from './accounts/components/StatusCards'; import { FilterPanel } from './accounts/components/FilterPanel'; import { buildAccountColumns } from './accounts/components/AccountColumns'; @@ -35,6 +37,23 @@ import { ExchangeRecordsModal, } from './accounts/components/AccountModals'; +function parseAccountNames(raw: string): string[] { + const names: string[] = []; + const seen = new Set(); + raw + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .forEach((line) => { + const name = line.trim(); + if (name && !seen.has(name)) { + names.push(name); + seen.add(name); + } + }); + return names; +} + export default function AccountsPage() { const navigate = useNavigate(); const { modal } = App.useApp(); @@ -64,6 +83,12 @@ export default function AccountsPage() { const [searchText, setSearchText] = useState(() => initialFilters.searchText || ''); const [exporting, setExporting] = useState(false); + // 按账号名批量选号:粘贴名单 -> 匹配 ID -> 自动勾选 -> 批量改标签 + const [accountNameText, setAccountNameText] = useState(''); + const [accountNamesFilter, setAccountNamesFilter] = useState(''); + const [accountNameMatchInfo, setAccountNameMatchInfo] = useState(null); + const [accountNameMatching, setAccountNameMatching] = useState(false); + // 高价值资产与高级自定义筛选状态 const [highValueFilter, setHighValueFilter] = useState(() => initialFilters.highValueFilter || ''); const [minPoints, setMinPoints] = useState(() => initialFilters.minPoints); @@ -99,8 +124,12 @@ export default function AccountsPage() { setBindFilter(''); setAssignFilter(''); setSearchText(''); + setAccountNameText(''); + setAccountNamesFilter(''); + setAccountNameMatchInfo(null); resetAdvancedFiltersState(); setCurrentPage(1); + clearSelection(); clearStoredFilters(); message.success('已重置所有筛选条件'); }; @@ -172,6 +201,7 @@ export default function AccountsPage() { include_sensitive: canViewFull && sensitiveVisible, }; if (tagFilter) params.tag = tagFilter; + if (accountNamesFilter) params.account_names = accountNamesFilter; if (statusFilter) params.status = statusFilter; if (ckFilter) params.ck_status = ckFilter; if (failureKindFilter) params.failure_kind = failureKindFilter; @@ -206,6 +236,7 @@ export default function AccountsPage() { searchText, sensitiveVisible, tagFilter, + accountNamesFilter, statusFilter, ckFilter, failureKindFilter, @@ -271,6 +302,7 @@ export default function AccountsPage() { all_matching: selectedAllMatching, search: searchText.trim(), tag: tagFilter, + account_names: accountNamesFilter || undefined, status: statusFilter || undefined, ck_status: ckFilter || undefined, failure_kind: failureKindFilter || undefined, @@ -291,6 +323,7 @@ export default function AccountsPage() { selectedAllMatching, selectedRowKeys, tagFilter, + accountNamesFilter, statusFilter, ckFilter, failureKindFilter, @@ -468,6 +501,61 @@ export default function AccountsPage() { roleBindStatus, ]); + const handleApplyAccountNames = async () => { + const names = parseAccountNames(accountNameText); + if (names.length === 0) { + message.warning('请先粘贴斗鱼账号名,每行一个'); + return; + } + setAccountNameMatching(true); + try { + const result = await accountApi.listIds({ + search: searchText.trim() || undefined, + tag: tagFilter || undefined, + status: statusFilter || undefined, + ck_status: ckFilter || undefined, + failure_kind: failureKindFilter || undefined, + bind_status: bindFilter || undefined, + assign_status: assignStatusParam || undefined, + assigned_user_id: assignedUserIdParam, + high_value_filter: highValueFilter || undefined, + min_points: minPoints, + max_points: maxPoints, + min_gold: minGold, + max_gold: maxGold, + elite_opened: eliteOpened, + esports_opened: esportsOpened, + role_bind_status: roleBindStatus || undefined, + account_names: names.join('\n'), + }); + if (result.total === 0) { + message.warning('没有匹配到账号,请检查账号名或当前筛选条件'); + return; + } + setAccountNamesFilter(names.join('\n')); + setAccountNameMatchInfo({ submitted: names.length, matched: result.total }); + setSelectedRowKeys(result.account_ids); + setSelectedAllMatching(false); + setCurrentPage(1); + const unmatched = Math.max(0, names.length - result.total); + message.success( + `已匹配并勾选 ${result.total} 个账号${unmatched > 0 ? `,${unmatched} 个账号名未匹配` : ''}`, + ); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setAccountNameMatching(false); + } + }; + + const handleClearAccountNames = () => { + setAccountNameText(''); + setAccountNamesFilter(''); + setAccountNameMatchInfo(null); + clearSelection(); + setCurrentPage(1); + }; + const handleImport = async () => { if (!importText.trim()) { message.warning('请输入账号数据'); @@ -642,7 +730,8 @@ export default function AccountsPage() { ); const hasAnyFilter = Boolean( - tagFilter || + accountNamesFilter || + tagFilter || statusFilter || ckFilter || failureKindFilter || @@ -750,6 +839,21 @@ export default function AccountsPage() { setCurrentPage={setCurrentPage} /> + { + setBatchTagInput(''); + setBatchTagVisible(true); + }} + loading={accountNameMatching} + canTag={canTag} + selectedCount={selectedCount} + matchInfo={accountNameMatchInfo} + /> + ( - + + + + + + {matchInfo && ( + 0 ? 'warning' : 'secondary'}> + 已粘贴 {matchInfo.submitted} 个账号名,匹配到 {matchInfo.matched} 个账号 + {unmatched > 0 + ? `;${unmatched} 个未匹配或不在当前筛选范围内` + : ''} + + )} + + + + ); +}