feat(douyu): 精英宝典导入支持按多行账户名筛选

对齐 Cookie 自定义 CK 的粘贴导入方式,按账户名精确匹配并保持粘贴顺序。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-29 15:02:53 +08:00
co-authored by Cursor
parent 1ac6a13a2c
commit 4806d4bbf1
4 changed files with 238 additions and 59 deletions
+84
View File
@@ -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
+84 -46
View File
@@ -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)}
+1 -1
View File
@@ -19,7 +19,7 @@ export const douyuApi = {
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
listAccountsPaged: (params: PageParams & { ids?: string; tag?: string }) =>
api.get<PaginatedResponse<DouyuTaskAccountItem>, PaginatedResponse<DouyuTaskAccountItem>>('/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<DouyuConfig, DouyuConfig>('/douyu/config'),
getRechargeChannel: () => api.get<DouyuRechargeChannel, DouyuRechargeChannel>('/douyu/recharge-channel'),
@@ -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<string>();
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<string[]>([]);
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 })
<Modal
title="导入账号到操作台"
open={importOpen}
onCancel={() => setImportOpen(false)}
onCancel={closeImportModal}
footer={null}
width={700}
width={720}
>
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }} size={8}>
<Input.TextArea
value={importNamesText}
onChange={(e) => setImportNamesText(e.target.value)}
placeholder="从 Excel 复制斗鱼账户名后粘贴到这里,每行一个"
autoSize={{ minRows: 3, maxRows: 6 }}
/>
<Space wrap>
<Button type="primary" icon={<FilterOutlined />} onClick={applyImportNames}>
按账号名筛选
</Button>
<Button onClick={clearImportNames} disabled={!importNamesText && importAccountNames.length === 0}>
清空
</Button>
{importAccountNames.length > 0 && (
<Text type="secondary">已载入 {importAccountNames.length} 个账户名,匹配结果共 {importTotal} 条</Text>
)}
</Space>
</Space>
<div style={{ marginBottom: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input
@@ -1937,7 +1994,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
导入本页 ({importPool.length})
</Button>
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
一键导入全部
{(importAccountNames.length > 0 || importNamesText.trim()) ? '导入全部匹配' : '一键导入全部'}
</Button>
<Button type="primary" onClick={() => void importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
导入选中 ({importSelectedIds.length})