feat(douyu): 支持工作台分页与一键导入

This commit is contained in:
yml2213
2026-08-07 23:06:08 +08:00
parent 5d8fdf8620
commit c77e8f4641
3 changed files with 128 additions and 38 deletions
+31
View File
@@ -211,6 +211,37 @@ def list_task_accounts(
return result return result
@router.get("/accounts/ids")
def list_task_account_ids(
search: str = Query(""),
tag: 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),
))
account_ids = [
account_id
for account_id, in query.enable_eagerloads(False).order_by(Account.id.desc()).with_entities(Account.id).all()
]
return {"account_ids": account_ids, "total": len(account_ids)}
@router.get("/config", response_model=DouyuConfigOut) @router.get("/config", response_model=DouyuConfigOut)
def get_config( def get_config(
db: Session = Depends(get_db), db: Session = Depends(get_db),
+3 -1
View File
@@ -16,8 +16,10 @@ export const douyuApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'), taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/douyu/task-types'),
listAccounts: (params?: { search?: string; tag?: string; ids?: string }) => listAccounts: (params?: { search?: string; tag?: string; ids?: string }) =>
api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }), api.get<DouyuTaskAccountItem[], DouyuTaskAccountItem[]>('/douyu/accounts', { params }),
listAccountsPaged: (params: PageParams & { ids?: string }) => listAccountsPaged: (params: PageParams & { ids?: string; tag?: string }) =>
api.get<PaginatedResponse<DouyuTaskAccountItem>, PaginatedResponse<DouyuTaskAccountItem>>('/douyu/accounts', { params }), api.get<PaginatedResponse<DouyuTaskAccountItem>, PaginatedResponse<DouyuTaskAccountItem>>('/douyu/accounts', { params }),
listAccountIds: (params?: { search?: string; tag?: string }) =>
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>('/douyu/accounts/ids', { params }),
getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'), getConfig: () => api.get<DouyuConfig, DouyuConfig>('/douyu/config'),
updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data), updateConfig: (data: Partial<DouyuConfig>) => api.put<DouyuConfig, DouyuConfig>('/douyu/config', data),
listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'), listGoods: () => api.get<DouyuGoodsItem[], DouyuGoodsItem[]>('/douyu/goods'),
+94 -37
View File
@@ -348,6 +348,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const v = Number(localStorage.getItem('douyu_task_account_page_size')); const v = Number(localStorage.getItem('douyu_task_account_page_size'));
return [10, 20, 50, 100].includes(v) ? v : 20; return [10, 20, 50, 100].includes(v) ? v : 20;
}); });
const [accountPage, setAccountPage] = useState(1);
const [accountTotal, setAccountTotal] = useState(0);
const tasksLoadingRef = useRef(false); const tasksLoadingRef = useRef(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
@@ -357,6 +359,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const [importTags, setImportTags] = useState<string[]>([]); const [importTags, setImportTags] = useState<string[]>([]);
const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]); const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]);
const [importLoading, setImportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false);
const [importAllLoading, setImportAllLoading] = useState(false);
const [importPage, setImportPage] = useState(1);
const [importTotal, setImportTotal] = useState(0);
const importPageSize = 20;
// 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换) // 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换)
const [qrTaskIds, setQrTaskIds] = useState<number[]>([]); const [qrTaskIds, setQrTaskIds] = useState<number[]>([]);
@@ -526,7 +532,14 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
setLoading(true); setLoading(true);
try { try {
const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([ const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([
workbenchIds.length > 0 ? douyuApi.listAccounts({ ids: workbenchIds.join(',') }) : Promise.resolve([]), workbenchIds.length > 0
? douyuApi.listAccountsPaged({
ids: workbenchIds.join(','),
search: accountSearch.trim() || undefined,
page: accountPage,
page_size: accountPageSize,
})
: Promise.resolve({ items: [] as DouyuTaskAccountItem[], total: 0, page: accountPage, page_size: accountPageSize }),
isPeaceHandbook isPeaceHandbook
? douyuApi.listPeaceGoods() ? douyuApi.listPeaceGoods()
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()), : (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
@@ -535,12 +548,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
douyuApi.taskTypes(), douyuApi.taskTypes(),
]); ]);
if (accResult.status === 'fulfilled') { if (accResult.status === 'fulfilled') {
setAccounts(accResult.value); setAccounts(accResult.value.items);
// 账号被删除等场景下清理已失效的工作台 ID,避免请求残留空 ID setAccountTotal(accResult.value.total);
if (workbenchIds.length > 0) { if (accountPage > 1 && accResult.value.items.length === 0) {
const validIds = accResult.value.map((a) => a.id); setAccountPage(Math.max(1, Math.ceil(accResult.value.total / accountPageSize)));
const next = workbenchIds.filter((id) => validIds.includes(id));
if (next.length !== workbenchIds.length) setWorkbenchIds(next);
} }
} }
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value); if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
@@ -575,7 +586,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]); }, [accountPage, accountPageSize, accountSearch, canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return; if (tasksLoadingRef.current) return;
@@ -920,19 +931,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
setImportLoading(true); setImportLoading(true);
try { try {
// 候选池 = 账号库按搜索/标签过滤,剔除已导入工作台的账号 // 候选池 = 账号库按搜索/标签过滤,剔除已导入工作台的账号
const result = await douyuApi.listAccounts({ search: importSearch, tag: importTag || undefined }); const result = await douyuApi.listAccountsPaged({
setImportPool(result.filter((a) => !workbenchIds.includes(a.id))); search: importSearch.trim() || undefined,
tag: importTag || undefined,
page: importPage,
page_size: importPageSize,
});
setImportPool(result.items.filter((a) => !workbenchIds.includes(a.id)));
setImportTotal(result.total);
} finally { } finally {
setImportLoading(false); setImportLoading(false);
} }
}, [importSearch, importTag, workbenchIds]); }, [importPage, importSearch, importTag, workbenchIds]);
useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]); useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]);
useEffect(() => { useEffect(() => {
if (!importOpen) return; if (!importOpen) return;
accountApi.listTags().then(setImportTags).catch(() => {}); accountApi.listTags().then(setImportTags).catch(() => {});
}, [importOpen]); }, [importOpen]);
// 切换搜索/标签时清空已选,避免计数与可见行不一致 // 切换搜索/标签时清空已选,避免计数与可见行不一致
useEffect(() => { setImportSelectedIds([]); }, [importSearch, importTag]); useEffect(() => {
setImportPage(1);
setImportSelectedIds([]);
}, [importSearch, importTag]);
useEffect(() => { setImportSelectedIds([]); }, [importPage]);
const importAccounts = (ids: number[]) => { const importAccounts = (ids: number[]) => {
const toImport = importPool.filter((a) => ids.includes(a.id)); const toImport = importPool.filter((a) => ids.includes(a.id));
@@ -946,6 +967,29 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
message.success(`已导入 ${toImport.length} 个账号`); message.success(`已导入 ${toImport.length} 个账号`);
}; };
const importAllAccounts = async () => {
setImportAllLoading(true);
try {
const result = await douyuApi.listAccountIds({
search: importSearch.trim() || undefined,
tag: importTag || undefined,
});
const imported = result.account_ids.filter((id) => !workbenchIds.includes(id));
if (imported.length === 0) {
message.info('当前筛选条件下没有可新增的账号');
return;
}
setWorkbenchIds((prev) => [...new Set([...prev, ...imported])]);
setImportSelectedIds([]);
setImportOpen(false);
message.success(`已一键导入 ${imported.length} 个账号`);
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setImportAllLoading(false);
}
};
const removeSelected = () => { const removeSelected = () => {
const sel = new Set(selectedIds); const sel = new Set(selectedIds);
setWorkbenchIds((prev) => prev.filter((id) => !sel.has(id))); setWorkbenchIds((prev) => prev.filter((id) => !sel.has(id)));
@@ -1427,18 +1471,6 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}, },
]; ];
const filteredAccounts = useMemo(() => {
if (!accountSearch.trim()) return accounts;
const kw = accountSearch.toLowerCase();
return accounts.filter((a) =>
(a.nickname || '').toLowerCase().includes(kw)
|| (a.username || '').toLowerCase().includes(kw)
|| (a.uid || '').toLowerCase().includes(kw)
|| (a.game_name || '').toLowerCase().includes(kw)
|| (a.esports_game_name || '').toLowerCase().includes(kw),
);
}, [accounts, accountSearch]);
const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null); const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null);
useEffect(() => { useEffect(() => {
if (configOpen && config) setConfigFormValues({ ...config }); if (configOpen && config) setConfigFormValues({ ...config });
@@ -1700,7 +1732,11 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<Space> <Space>
<Input <Input
size="small" placeholder="搜索账号/昵称/游戏名" prefix={<SearchOutlined />} size="small" placeholder="搜索账号/昵称/游戏名" prefix={<SearchOutlined />}
value={accountSearch} onChange={(e) => setAccountSearch(e.target.value)} value={accountSearch}
onChange={(e) => {
setAccountSearch(e.target.value);
setAccountPage(1);
}}
style={{ width: 200 }} allowClear style={{ width: 200 }} allowClear
/> />
</Space> </Space>
@@ -1723,15 +1759,18 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
onChange: (keys) => setSelectedIds(keys.map(Number)), onChange: (keys) => setSelectedIds(keys.map(Number)),
}} }}
columns={accountColumns} columns={accountColumns}
dataSource={filteredAccounts} dataSource={accounts}
pagination={{ pagination={{
current: accountPage,
pageSize: accountPageSize, pageSize: accountPageSize,
total: accountTotal,
showSizeChanger: true, showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100], pageSizeOptions: [10, 20, 50, 100],
size: 'small', size: 'small',
showLessItems: true, showLessItems: true,
showTotal: (total) => `${total}`, showTotal: (total) => `${total}`,
onChange: (_page, size) => { onChange: (page, size) => {
setAccountPage(size === accountPageSize ? page : 1);
if (size !== accountPageSize) { if (size !== accountPageSize) {
setAccountPageSize(size); setAccountPageSize(size);
localStorage.setItem('douyu_task_account_page_size', String(size)); localStorage.setItem('douyu_task_account_page_size', String(size));
@@ -1834,7 +1873,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}>
<Card <Card
size="small" title="账号" size="small" title="账号"
extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>} extra={<Tag color="blue"> {selectedIds.length}/{accountTotal}</Tag>}
style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
> >
@@ -1863,7 +1902,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</Card> </Card>
<Card <Card
size="small" title="账号" size="small" title="账号"
extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>} extra={<Tag color="blue"> {selectedIds.length}/{accountTotal}</Tag>}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
> >
@@ -1906,30 +1945,40 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
footer={null} footer={null}
width={700} width={700}
> >
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}> <div style={{ marginBottom: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<Space> <Space wrap>
<Input <Input
placeholder="搜索账号/昵称" prefix={<SearchOutlined />} placeholder="搜索账号/昵称" prefix={<SearchOutlined />}
value={importSearch} onChange={(e) => setImportSearch(e.target.value)} value={importSearch}
onChange={(e) => {
setImportSearch(e.target.value);
setImportPage(1);
}}
style={{ width: 220 }} style={{ width: 220 }}
/> />
<Select <Select
placeholder="按标签筛选" allowClear showSearch placeholder="按标签筛选" allowClear showSearch
value={importTag || undefined} value={importTag || undefined}
onChange={(value?: string) => setImportTag(value || '')} onChange={(value?: string) => {
setImportTag(value || '');
setImportPage(1);
}}
style={{ width: 160 }} style={{ width: 160 }}
options={importTags.map((t) => ({ value: t, label: t }))} options={importTags.map((t) => ({ value: t, label: t }))}
/> />
</Space> </Space>
<Space> <Space wrap style={{ width: '100%', justifyContent: 'flex-start' }}>
<Button onClick={() => importAccounts(importPool.map((a) => a.id))}> <Button onClick={() => importAccounts(importPool.map((a) => a.id))}>
({importPool.length}) ({importPool.length})
</Button>
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
</Button> </Button>
<Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}> <Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
({importSelectedIds.length}) ({importSelectedIds.length})
</Button> </Button>
</Space> </Space>
</Space> </div>
<Table <Table
rowKey="id" size="small" loading={importLoading} rowKey="id" size="small" loading={importLoading}
rowSelection={{ selectedRowKeys: importSelectedIds, onChange: (keys) => setImportSelectedIds(keys.map(Number)) }} rowSelection={{ selectedRowKeys: importSelectedIds, onChange: (keys) => setImportSelectedIds(keys.map(Number)) }}
@@ -1949,7 +1998,15 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
render: (v) => v ?? '-', render: (v) => v ?? '-',
}, },
]} ]}
pagination={{ pageSize: 10 }} pagination={{
current: importPage,
pageSize: importPageSize,
total: importTotal,
size: 'small',
showLessItems: true,
showTotal: (total) => `${total}`,
onChange: setImportPage,
}}
locale={{ emptyText: '没有可导入的账号' }} locale={{ emptyText: '没有可导入的账号' }}
/> />
</Modal> </Modal>