feat(douyu): 精英宝典导入支持按多行账户名筛选
对齐 Cookie 自定义 CK 的粘贴导入方式,按账户名精确匹配并保持粘贴顺序。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user