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:
@@ -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 ?? '' },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user