diff --git a/tests/test_douyu_account_name_filter.py b/tests/test_douyu_account_name_filter.py new file mode 100644 index 0000000..8af88f0 --- /dev/null +++ b/tests/test_douyu_account_name_filter.py @@ -0,0 +1,84 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import Account, LoginTask, User +from web.backend.routers.douyu import ( + _parse_account_names, + list_task_account_ids, + list_task_accounts, +) + + +class TestDouyuAccountNameFilter: + def setup_method(self): + self.engine = create_engine("sqlite://") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + self.user = User(username="admin", password_hash="hash", role="super_admin") + self.session.add(self.user) + self.session.commit() + + self.accounts = {} + for name in ("account-a", "account-b", "account-c"): + account = Account( + username=name, + password="password", + email=f"{name}@example.com", + email_password="mail-password", + ) + self.session.add(account) + self.session.flush() + self.session.add( + LoginTask( + batch_id=f"login-{name}", + account_id=account.id, + created_by=self.user.id, + status="success", + cookie="acf_uid=1", + ) + ) + self.accounts[name] = account + self.session.commit() + + def teardown_method(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def test_parse_account_names_trims_and_dedupes(self): + assert _parse_account_names( + "account-c\n\naccount-a\r\naccount-c\n account-b " + ) == [ + "account-c", + "account-a", + "account-b", + ] + + def test_list_task_accounts_follows_paste_order(self): + result = list_task_accounts( + search="", + tag="", + ids="", + account_names="account-c\naccount-a", + page=1, + page_size=50, + db=self.session, + current=self.user, + ) + assert result["total"] == 2 + assert [item.username for item in result["items"]] == ["account-c", "account-a"] + + def test_list_task_account_ids_follows_paste_order(self): + result = list_task_account_ids( + search="", + tag="", + account_names="account-c\nmissing-name\naccount-a", + db=self.session, + current=self.user, + ) + assert result["account_ids"] == [ + self.accounts["account-c"].id, + self.accounts["account-a"].id, + ] + assert result["total"] == 2 diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 8232930..42a1273 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -17,7 +17,7 @@ from fastapi import ( WebSocketDisconnect, ) from loguru import logger -from sqlalchemy import func, or_, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session, joinedload from core.douyu import ( @@ -243,6 +243,67 @@ def _require_batch_owner(db: Session, current: User, batch_id: str) -> None: raise HTTPException(status_code=403, detail="无权操作该斗鱼任务批次") +def _parse_account_names(raw_names: str) -> list[str]: + """解析前端粘贴的 Excel 账号名,每行一个并去重。""" + 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 _apply_task_account_filters( + query, + *, + search: str = "", + tag: str = "", + account_names: str = "", + ids: str = "", +): + """按工作台导入弹窗的搜索、标签、多行账号名筛选任务账号。""" + id_list = [int(item) for item in ids.split(",") if item.strip().isdigit()] + if id_list: + query = query.filter(Account.id.in_(id_list)) + tag_text = (tag or "").strip() + if tag_text: + query = query.filter(Account.tag == tag_text) + selected_names = _parse_account_names(account_names) + if selected_names: + query = query.filter(Account.username.in_(selected_names)) + search_text = (search or "").strip() + if search_text: + pattern = f"%{search_text}%" + query = query.filter( + or_( + Account.username.ilike(pattern), + Account.uid.ilike(pattern), + Account.nickname.ilike(pattern), + Account.tag.ilike(pattern), + Account.game_name.ilike(pattern), + Account.esports_game_name.ilike(pattern), + Account.xpd_game_name.ilike(pattern), + ) + ) + return query, selected_names + + +def _order_task_accounts(query, selected_names: list[str]): + """自定义账号名按粘贴顺序排序,未指定时保持最新账号优先。""" + if selected_names: + input_order = case( + {name: index for index, name in enumerate(selected_names)}, + value=Account.username, + else_=len(selected_names), + ) + return query.order_by(input_order, Account.id.desc()) + return query.order_by(Account.id.desc()) + + def _account_out(account: Account) -> DouyuTaskAccountOut: return DouyuTaskAccountOut( id=account.id, @@ -324,6 +385,7 @@ def list_task_accounts( search: str = Query(""), tag: str = Query(""), ids: str = Query(""), + account_names: str = Query(""), page: int | None = Query(None, ge=1), page_size: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), @@ -331,33 +393,20 @@ def list_task_accounts( ): """查看可执行斗鱼任务的账号(必须有成功 Cookie)。 - tag 按账号标签精确筛选;ids 为逗号分隔的账号 ID,用于工作台按已导入账号过滤;为空时返回全部。 + tag 按账号标签精确筛选;ids 为逗号分隔的账号 ID,用于工作台按已导入账号过滤; + account_names 为多行斗鱼账户名(每行一个),按粘贴顺序精确匹配;为空时返回全部。 """ - query = _visible_task_accounts_query(db, current) - id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] - if id_list: - query = query.filter(Account.id.in_(id_list)) - tag_text = (tag or "").strip() - if tag_text: - query = query.filter(Account.tag == tag_text) - search_text = (search or "").strip() - if search_text: - pattern = f"%{search_text}%" - query = query.filter( - or_( - Account.username.ilike(pattern), - Account.uid.ilike(pattern), - Account.nickname.ilike(pattern), - Account.tag.ilike(pattern), - Account.game_name.ilike(pattern), - Account.esports_game_name.ilike(pattern), - Account.xpd_game_name.ilike(pattern), - ) - ) + query, selected_names = _apply_task_account_filters( + _visible_task_accounts_query(db, current), + search=search, + tag=tag, + account_names=account_names, + ids=ids, + ) total = None if page is not None: total = query.order_by(None).count() - query = query.order_by(Account.id.desc()) + query = _order_task_accounts(query, selected_names) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) else: @@ -377,34 +426,23 @@ def list_task_accounts( def list_task_account_ids( search: str = Query(""), tag: str = Query(""), + account_names: str = Query(""), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """返回当前用户可导入工作台的全部账号 ID,不受列表分页限制。""" - query = _visible_task_accounts_query(db, current) - tag_text = (tag or "").strip() - if tag_text: - query = query.filter(Account.tag == tag_text) - search_text = (search or "").strip() - if search_text: - pattern = f"%{search_text}%" - query = query.filter( - or_( - Account.username.ilike(pattern), - Account.uid.ilike(pattern), - Account.nickname.ilike(pattern), - Account.tag.ilike(pattern), - Account.game_name.ilike(pattern), - Account.esports_game_name.ilike(pattern), - Account.xpd_game_name.ilike(pattern), - ) - ) + query, selected_names = _apply_task_account_filters( + _visible_task_accounts_query(db, current), + search=search, + tag=tag, + account_names=account_names, + ) account_ids = [ account_id - for (account_id,) in query.enable_eagerloads(False) - .order_by(Account.id.desc()) - .with_entities(Account.id) - .all() + for (account_id,) in _order_task_accounts( + query.enable_eagerloads(False).with_entities(Account.id), + selected_names, + ).all() ] return {"account_ids": account_ids, "total": len(account_ids)} diff --git a/web/frontend/src/api/douyu.ts b/web/frontend/src/api/douyu.ts index 33617d3..e20e48d 100644 --- a/web/frontend/src/api/douyu.ts +++ b/web/frontend/src/api/douyu.ts @@ -19,7 +19,7 @@ export const douyuApi = { api.get('/douyu/accounts', { params }), listAccountsPaged: (params: PageParams & { ids?: string; tag?: string }) => api.get, PaginatedResponse>('/douyu/accounts', { params }), - listAccountIds: (params?: { search?: string; tag?: string }) => + listAccountIds: (params?: { search?: string; tag?: string; account_names?: string }) => api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>('/douyu/accounts/ids', { params }), getConfig: () => api.get('/douyu/config'), getRechargeChannel: () => api.get('/douyu/recharge-channel'), diff --git a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx index d5ff41d..8de9727 100644 --- a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx +++ b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { TableProps } from 'antd'; import { - CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, GiftOutlined, + CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, FilterOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, } from '@ant-design/icons'; @@ -61,6 +61,16 @@ import { const { Text } = Typography; +function parseAccountNames(text: string): string[] { + const seen = new Set(); + return text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !seen.has(name) && seen.add(name)); +} + export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) { const isPeaceHandbook = handbook === 'peace'; const isEsportsHandbook = handbook === 'esports'; @@ -110,6 +120,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const [importAllLoading, setImportAllLoading] = useState(false); const [importPage, setImportPage] = useState(1); const [importTotal, setImportTotal] = useState(0); + const [importNamesText, setImportNamesText] = useState(''); + const [importAccountNames, setImportAccountNames] = useState([]); const importPageSize = 20; // 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换) @@ -783,10 +795,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) const loadImportPool = useCallback(async () => { setImportLoading(true); try { - // 候选池 = 账号库按搜索/标签过滤,剔除已导入工作台的账号 + // 候选池 = 账号库按搜索/标签/粘贴账户名过滤,剔除已导入工作台的账号 const result = await douyuApi.listAccountsPaged({ search: importSearch.trim() || undefined, tag: importTag || undefined, + account_names: importAccountNames.length > 0 ? importAccountNames.join('\n') : undefined, page: importPage, page_size: importPageSize, }); @@ -795,17 +808,17 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) } finally { setImportLoading(false); } - }, [importPage, importSearch, importTag, workbenchIds]); + }, [importAccountNames, importPage, importSearch, importTag, workbenchIds]); useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]); useEffect(() => { if (!importOpen) return; accountApi.listTags().then(setImportTags).catch(() => {}); }, [importOpen]); - // 切换搜索/标签时清空已选,避免计数与可见行不一致 + // 切换搜索/标签/自定义账户名时清空已选,避免计数与可见行不一致 useEffect(() => { setImportPage(1); setImportSelectedIds([]); - }, [importSearch, importTag]); + }, [importSearch, importTag, importAccountNames]); useEffect(() => { setImportSelectedIds([]); }, [importPage]); const saveWorkbenchIds = async (nextIds: number[]) => { @@ -828,25 +841,50 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) return [...prev, ...toImport.filter((a) => !existing.has(a.id))]; }); setImportSelectedIds([]); - setImportOpen(false); + closeImportModal(); message.success(`已导入 ${toImport.length} 个账号`); }; + const closeImportModal = () => { + setImportOpen(false); + setImportNamesText(''); + setImportAccountNames([]); + setImportSelectedIds([]); + }; + + const applyImportNames = () => { + const names = parseAccountNames(importNamesText); + if (names.length === 0) { + message.warning('请先粘贴斗鱼账户名,每行一个'); + return; + } + setImportAccountNames(names); + message.success(`已载入 ${names.length} 个账户名,正在筛选账号`); + }; + + const clearImportNames = () => { + setImportNamesText(''); + setImportAccountNames([]); + }; + const importAllAccounts = async () => { + const names = importAccountNames.length > 0 + ? importAccountNames + : parseAccountNames(importNamesText); setImportAllLoading(true); try { const result = await douyuApi.listAccountIds({ search: importSearch.trim() || undefined, tag: importTag || undefined, + account_names: names.length > 0 ? names.join('\n') : undefined, }); const imported = result.account_ids.filter((id) => !workbenchIds.includes(id)); if (imported.length === 0) { - message.info('当前筛选条件下没有可新增的账号'); + message.info(names.length > 0 ? '没有匹配到可新增的账号' : '当前筛选条件下没有可新增的账号'); return; } await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]); - setImportSelectedIds([]); - setImportOpen(false); + closeImportModal(); message.success(`已一键导入 ${imported.length} 个账号`); } catch (error) { message.error(getErrorMessage(error)); @@ -1906,10 +1944,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) setImportOpen(false)} + onCancel={closeImportModal} footer={null} - width={700} + width={720} > + + setImportNamesText(e.target.value)} + placeholder="从 Excel 复制斗鱼账户名后粘贴到这里,每行一个" + autoSize={{ minRows: 3, maxRows: 6 }} + /> + + + + {importAccountNames.length > 0 && ( + 已载入 {importAccountNames.length} 个账户名,匹配结果共 {importTotal} 条 + )} + +