feat(login): 任务列表支持分页、跨页全选与一键清空

任务量早就过了单页能装下的规模(库里 1.4 万条,单批就 177 条),但接口
硬编码 limit(200)、前端 pagination={false},于是「全选」其实只能选到前
200 条,失败任务清不干净。

- 接口加 page/page_size/status:不传 page 时仍返回裸数组(Cookie 页等
  老调用方依赖这个契约,不能改);传了才返回 {items,total},total 供跨页全选。
- 删除支持 all_matching:按当前筛选条件删全部,不必先翻完所有页去勾。
- 前端:分页控件(20/50/100/200/500,记住选择)、状态筛选、表头
  「选择全部 N 条(含其他页)」、「清空失败」一键操作。

两条护栏:
- **pending/running 一律不删** —— 删掉会让批次线程找不到记录;要停批次
  走 /stop/{batch_id}。
- 不给任何条件就拒绝删除,绝不默认全删。

顺带:带 join 的 query 不能 bulk delete(SQLAlchemy 会拒绝),改成先查
id 再按主键删。
This commit is contained in:
yml2213
2026-10-03 10:37:52 +08:00
parent 5995f16c39
commit a0db26def3
5 changed files with 550 additions and 34 deletions
+15
View File
@@ -10,13 +10,28 @@ interface CreateBatchParams {
mode?: 'login' | 'check';
}
export interface TaskPage {
items: LoginTaskItem[];
total: number;
page: number;
page_size: number;
}
export const loginApi = {
createBatch: (params: CreateBatchParams) =>
api.post<BatchLoginResult, BatchLoginResult>('/login/batch', params),
// 不传 page 时后端返回裸数组(老调用方契约),传了才返回 {items,total}。
listTasks: (batch_id?: string) =>
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: batch_id ? { batch_id } : {} }),
listTasksPaged: (params: { page: number; page_size: number; batch_id?: string; status?: string }) =>
api.get<TaskPage, TaskPage>('/login/tasks', { params: { ...params, batch_id: params.batch_id ?? '' } }),
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/login/tasks/summary'),
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
// 跨页全选用:按当前筛选条件删除全部(后端会跳过在途任务)。
deleteTasksMatching: (params: { batch_id?: string; status?: string }) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/login/tasks', {
params: { all_matching: true, batch_id: params.batch_id ?? '', status: params.status ?? '' },
}),
};
+148 -16
View File
@@ -89,6 +89,16 @@ export default function LoginTasksPage() {
useEffect(() => { localStorage.setItem('login_max_login_retries', String(maxLoginRetries)); }, [maxLoginRetries]);
useEffect(() => { localStorage.setItem('login_api_strategy', apiStrategy); }, [apiStrategy]);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
// 任务列表分页:库里上万条,单页装不下;total 同时用于「全选所有结果」。
const [taskPage, setTaskPage] = useState(1);
const [taskPageSize, setTaskPageSize] = useState(() => {
const v = localStorage.getItem('login_task_page_size');
return v ? Math.max(1, Math.min(500, Number(v) || 50)) : 50;
});
const [taskStatus, setTaskStatus] = useState<string>('');
const [taskTotal, setTaskTotal] = useState(0);
useEffect(() => { localStorage.setItem('login_task_page_size', String(taskPageSize)); }, [taskPageSize]);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions();
@@ -199,14 +209,23 @@ export default function LoginTasksPage() {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try {
const data = await loginApi.listTasks(batchId || undefined);
setTasks(data);
const data = await loginApi.listTasksPaged({
page: taskPage,
page_size: taskPageSize,
batch_id: batchId || undefined,
status: taskStatus || undefined,
});
setTasks(data.items);
setTaskTotal(data.total);
// 翻页/筛选后清掉失效的勾选,避免「选中了看不见的行」。
const visible = new Set(data.items.map((t) => t.id));
setSelectedRowKeys((prev) => (selectedAllMatching ? prev : prev.filter((k) => visible.has(k))));
} catch {
// 忽略轮询失败,下一次定时刷新会继续尝试。
} finally {
tasksLoadingRef.current = false;
}
}, [batchId]);
}, [batchId, taskPage, taskPageSize, taskStatus, selectedAllMatching]);
const hasActiveTasks = useMemo(
() => tasks.some((task) => ['pending', 'running'].includes(task.status)),
@@ -331,20 +350,54 @@ export default function LoginTasksPage() {
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
if (!selectedAllMatching && selectedRowKeys.length === 0) {
message.warning('请选择要删除的任务');
return;
}
try {
await loginApi.deleteTasks(selectedRowKeys);
message.success(`已删除 ${selectedRowKeys.length} 个任务`);
const res = selectedAllMatching
? await loginApi.deleteTasksMatching({
batch_id: batchId || undefined,
status: taskStatus || undefined,
})
: await loginApi.deleteTasks(selectedRowKeys);
message.success(res.message || `已删除 ${res.deleted} 个任务`);
setSelectedRowKeys([]);
setSelectedAllMatching(false);
setTaskPage(1);
loadTasks();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
// 一键清空当前筛选下的失败任务(跨页,不用先翻完所有页去勾)
const handleClearFailed = async () => {
try {
const res = await loginApi.deleteTasksMatching({
batch_id: batchId || undefined,
status: 'failed',
});
message.success(res.message || `已删除 ${res.deleted} 个任务`);
setSelectedRowKeys([]);
setSelectedAllMatching(false);
setTaskPage(1);
loadTasks();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleSelectAllMatching = () => {
setSelectedAllMatching(true);
setSelectedRowKeys([]);
};
const clearSelection = () => {
setSelectedAllMatching(false);
setSelectedRowKeys([]);
};
const successCount = tasks.filter((t) => t.status === 'success').length;
const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length;
const checkedCount = tasks.filter((t) => [
@@ -556,19 +609,64 @@ export default function LoginTasksPage() {
{/* 概览 + 任务列表区域 */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', paddingTop: 6 }}>
{/* 概览 */}
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, color: token.colorTextSecondary, padding: '4px 0' }}>
<span>共 <b>{tasks.length}</b> 个任务</span>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 12, fontSize: 13, color: token.colorTextSecondary, padding: '4px 0', flexWrap: 'wrap' }}>
<span>共 <b>{taskTotal}</b> 个任务</span>
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
{checkedCount > 0 && <span>检测 <b>{checkedCount}</b></span>}
{effectiveBatchId && <span>批次: <b>{effectiveBatchId}</b></span>}
<Select
size="small"
style={{ width: 130 }}
value={taskStatus}
onChange={(v) => { setTaskStatus(v); setTaskPage(1); clearSelection(); }}
options={[
{ value: '', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'pending', label: '等待中' },
{ value: 'running', label: '进行中' },
]}
/>
<div style={{ flex: 1 }} />
{selectedRowKeys.length > 0 && (
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 个任务?`} onConfirm={handleDeleteSelected} okText="删除" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
删除选中 ({selectedRowKeys.length})
</Button>
</Popconfirm>
{canBatch && (
<>
{(selectedRowKeys.length > 0 || selectedAllMatching) && (
<Popconfirm
title={
selectedAllMatching
? `确定删除筛选条件下的全部 ${taskTotal} 个任务?(进行中的会跳过)`
: `确定删除选中的 ${selectedRowKeys.length} 个任务?`
}
onConfirm={handleDeleteSelected}
okText="删除"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{selectedAllMatching
? `删除全部 (${taskTotal})`
: `删除选中 (${selectedRowKeys.length})`}
</Button>
</Popconfirm>
)}
{selectedAllMatching && (
<Button type="link" size="small" onClick={clearSelection}>
取消全选
</Button>
)}
{failedCount > 0 && (
<Popconfirm
title={`确定清空当前筛选下的失败任务?共 ${taskStatus === 'failed' ? taskTotal : failedCount} 个(进行中的会跳过)`}
onConfirm={handleClearFailed}
okText="清空"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
清空失败
</Button>
</Popconfirm>
)}
</>
)}
{!wsConnected && failedCount > 0 && (
<Button
@@ -586,16 +684,50 @@ export default function LoginTasksPage() {
{/* 任务列表 - flex:1 占满剩余空间,内部滚动 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{selectedAllMatching && (
<div style={{ padding: '4px 8px', fontSize: 12, background: token.colorInfoBg, color: token.colorInfoText, borderRadius: 4, marginBottom: 6 }}>
已选中当前筛选条件下的全部 <b>{taskTotal}</b> 条(含未显示的分页)。
<Button type="link" size="small" onClick={clearSelection} style={{ padding: '0 4px' }}>
取消
</Button>
</div>
)}
<Table
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
pagination={false}
sticky
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
onChange: (keys) => {
setSelectedRowKeys(keys as number[]);
setSelectedAllMatching(false);
},
// 「全选所有结果」:跨页,按当前筛选条件选中全部(不只是本页)。
selections: [
Table.SELECTION_ALL,
Table.SELECTION_INVERT,
{
key: 'all-matching',
text: `选择全部 ${taskTotal} 条(含其他页)`,
onSelect: () => handleSelectAllMatching(),
},
Table.SELECTION_NONE,
],
}}
pagination={{
current: taskPage,
pageSize: taskPageSize,
total: taskTotal,
size: 'small',
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200', '500'],
showTotal: (t) => `共 ${t} 条`,
onChange: (page, size) => {
setTaskPage(page);
setTaskPageSize(size);
},
}}
/>
</div>