Files
live-hub-py/web/frontend/src/pages/LoginTasksPage.tsx
T
yml2213 a0db26def3 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 再按主键删。
2026-10-03 10:37:52 +08:00

834 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import {
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form,
} from 'antd';
import { message } from '../utils/antdMessage';
import {
PlayCircleOutlined, StopOutlined, FilterOutlined, ReloadOutlined, DeleteOutlined,
SettingOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons';
import { accountApi, loginApi, type AccountItem, type LoginTaskItem } from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const STATUS_COLORS: Record<string, string> = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
account_cancelled: 'error',
password_wrong: 'error',
account_unverified: 'warning',
account_verified: 'success',
account_auth_unknown: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
pending: '等待中',
running: '登录中',
success: '成功',
failed: '失败',
error: '异常',
account_cancelled: '账号已注销',
password_wrong: '账号密码错误',
account_unverified: '账号未认证',
account_verified: '账号已认证',
account_auth_unknown: '认证状态未知',
};
type BatchMode = 'login' | 'check';
const ACCOUNT_PAGE_SIZE = 50;
interface SelectGroupOption {
label: string;
options: { value: number; label: string }[];
}
export default function LoginTasksPage() {
const [accountOptions, setAccountOptions] = useState<AccountItem[]>([]);
const [accountCache, setAccountCache] = useState<Record<number, AccountItem>>({});
const [accountPage, setAccountPage] = useState(1);
const [accountTotal, setAccountTotal] = useState(0);
const [accountSearch, setAccountSearch] = useState('');
const [accountsLoading, setAccountsLoading] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [concurrency, setConcurrency] = useState(() => {
const v = localStorage.getItem('login_concurrency');
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
});
const [maxTotalTime, setMaxTotalTime] = useState(() => {
const v = localStorage.getItem('login_max_total_time');
return v !== null ? Math.max(0, Number(v) || 0) : 300;
});
const [maxLoginRetries, setMaxLoginRetries] = useState(() => {
const v = localStorage.getItem('login_max_login_retries');
return v !== null ? Math.max(0, Number(v) || 0) : 0;
});
const [apiStrategy, setApiStrategy] = useState(() => {
return localStorage.getItem('login_api_strategy') || 'wgapi';
});
const [settingsOpen, setSettingsOpen] = useState(false);
const tasksLoadingRef = useRef(false);
const accountPageRequestRef = useRef(0);
const tagSelectionRequestRef = useRef(0);
const [tagAccountIds, setTagAccountIds] = useState<Record<string, number[]>>({});
// 值变化时自动持久化
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
useEffect(() => { localStorage.setItem('login_max_total_time', String(maxTotalTime)); }, [maxTotalTime]);
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();
const { token } = theme.useToken();
const canBatch = can('login:batch');
const rememberAccounts = useCallback((items: AccountItem[]) => {
if (items.length === 0) return;
setAccountCache((prev) => {
const next = { ...prev };
for (const item of items) next[item.id] = item;
return next;
});
}, []);
const loadAccountsPage = useCallback(async (page: number, search: string, append = false) => {
const requestId = accountPageRequestRef.current + 1;
accountPageRequestRef.current = requestId;
setAccountsLoading(true);
try {
const data = await accountApi.listPaged({
page,
page_size: ACCOUNT_PAGE_SIZE,
search,
include_sensitive: false,
});
if (accountPageRequestRef.current !== requestId) return;
rememberAccounts(data.items);
setAccountOptions((prev) => {
const map = new Map((append ? prev : []).map((item) => [item.id, item]));
for (const item of data.items) map.set(item.id, item);
return Array.from(map.values());
});
setAccountPage(data.page);
setAccountTotal(data.total);
} catch (e: unknown) {
if (accountPageRequestRef.current === requestId) {
message.error(getErrorMessage(e));
}
} finally {
if (accountPageRequestRef.current === requestId) {
setAccountsLoading(false);
}
}
}, [rememberAccounts]);
const loadTags = useCallback(async () => {
try {
const tags = await accountApi.listTags();
setAllTags(tags.sort());
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}, []);
const loadAllAccountsByTag = useCallback(async (tag: string) => {
const items: AccountItem[] = [];
for (let page = 1; ; page += 1) {
const data = await accountApi.listPaged({
page,
page_size: 200,
tag,
include_sensitive: false,
});
items.push(...data.items);
if (items.length >= data.total) break;
}
rememberAccounts(items);
return items;
}, [rememberAccounts]);
// 标签选择变化时,同步更新选中的账号
const handleTagChange = useCallback(async (tags: string[]) => {
const requestId = tagSelectionRequestRef.current + 1;
tagSelectionRequestRef.current = requestId;
const previousTags = selectedTags;
setSelectedTags(tags);
const addedTags = tags.filter((tag) => !previousTags.includes(tag));
const removedTags = previousTags.filter((tag) => !tags.includes(tag));
try {
const addedResults = await Promise.all(
addedTags.map(async (tag) => [tag, (await loadAllAccountsByTag(tag)).map((account) => account.id)] as const),
);
if (tagSelectionRequestRef.current !== requestId) return;
setTagAccountIds((prev) => {
const next = { ...prev };
const removedIds = new Set(removedTags.flatMap((tag) => prev[tag] || []));
for (const tag of removedTags) delete next[tag];
for (const [tag, ids] of addedResults) next[tag] = ids;
const selectedByTags = new Set(Object.values(next).flat());
setSelectedIds((current) => [
...new Set([
...current.filter((id) => !removedIds.has(id) || selectedByTags.has(id)),
...selectedByTags,
]),
]);
return next;
});
} catch (e: unknown) {
if (tagSelectionRequestRef.current === requestId) {
message.error(getErrorMessage(e));
}
}
}, [loadAllAccountsByTag, selectedTags]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try {
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, taskPage, taskPageSize, taskStatus, selectedAllMatching]);
const hasActiveTasks = useMemo(
() => tasks.some((task) => ['pending', 'running'].includes(task.status)),
[tasks],
);
// 刷新/切页后 batchId 内存态丢失:从任务列表推导仍在运行的批次,
// 保证停止按钮与日志订阅在页面恢复后依然可用。
const activeBatchIdFromTasks = useMemo(
() => tasks.find((task) => ['pending', 'running'].includes(task.status))?.batch_id || null,
[tasks],
);
const effectiveBatchId = batchId || activeBatchIdFromTasks;
// 活跃批次存在但未订阅时自动重连实时日志(配合后端 WS 仅在批次结束后清理注册表)。
const wsSubscribedBatchRef = useRef<string | null>(null);
useEffect(() => {
if (!effectiveBatchId || wsConnected) return;
if (wsSubscribedBatchRef.current === effectiveBatchId) return;
wsSubscribedBatchRef.current = effectiveBatchId;
connectLogs(`/api/login/ws/login/${effectiveBatchId}`, {
onClose: () => { setBatchId(null); setStarting(false); },
onResult: () => { setBatchId(null); setStarting(false); },
onError: () => { setBatchId(null); setStarting(false); },
});
}, [effectiveBatchId, wsConnected, connectLogs]);
useEffect(() => {
void loadTags();
void loadTasks();
}, [loadTags, loadTasks]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadAccountsPage(1, accountSearch);
}, 250);
return () => window.clearTimeout(timer);
}, [accountSearch, loadAccountsPage]);
useEffect(() => {
const intervalMs = hasActiveTasks || wsConnected || starting || batchId ? 3000 : 15000;
const timer = setInterval(loadTasks, intervalMs);
return () => clearInterval(timer);
}, [batchId, hasActiveTasks, loadTasks, starting, wsConnected]);
// 共享的批量任务启动逻辑
const startBatch = async (accountIds: number[], mode: BatchMode = 'login') => {
if (accountIds.length === 0) {
message.warning('请选择账号');
return;
}
setStarting(true);
setLoading(true);
try {
const result = await loginApi.createBatch({
account_ids: accountIds,
concurrency,
max_login_retries: maxLoginRetries,
max_total_time: maxTotalTime,
api_strategy: apiStrategy,
mode,
});
setBatchId(result.batch_id);
wsSubscribedBatchRef.current = result.batch_id;
message.success(`已创建${mode === 'check' ? '检测' : '登录'}任务,共 ${result.count} 个账号`);
connectLogs(`/api/login/ws/login/${result.batch_id}`, {
onClose: () => { setBatchId(null); setStarting(false); },
onResult: () => { setBatchId(null); setStarting(false); },
onError: () => { setBatchId(null); setStarting(false); },
});
} catch (e: unknown) {
message.error(getErrorMessage(e));
setStarting(false);
} finally {
setLoading(false);
}
};
const handleBatchLogin = () => startBatch(selectedIds, 'login');
const handleBatchCheck = () => startBatch(selectedIds, 'check');
// 重试当前批次所有失败的任务
const handleRetryFailed = () => {
const failedIds = tasks
.filter((t) => ['failed', 'error'].includes(t.status))
.map((t) => t.account_id);
if (failedIds.length === 0) {
message.info('没有失败的任务');
return;
}
startBatch(failedIds, 'login');
};
// 重试单个失败任务
const handleRetryOne = (taskId: number) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
startBatch([task.account_id], 'login');
};
const handleStop = async () => {
if (effectiveBatchId) {
try {
await loginApi.stop(effectiveBatchId);
message.success('已发送停止信号');
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
}
};
const handleDeleteTask = async (taskId: number) => {
try {
await loginApi.deleteTask(taskId);
message.success('已删除');
loadTasks();
setSelectedRowKeys((prev) => prev.filter((k) => k !== taskId));
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (!selectedAllMatching && selectedRowKeys.length === 0) {
message.warning('请选择要删除的任务');
return;
}
try {
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) => [
'account_cancelled',
'password_wrong',
'account_unverified',
'account_verified',
'account_auth_unknown',
].includes(t.status)).length;
const selectableAccounts = useMemo(() => {
const map = new Map<number, AccountItem>();
accountOptions.forEach((account) => map.set(account.id, account));
selectedIds.forEach((id) => {
const account = accountCache[id];
if (account) map.set(account.id, account);
});
return Array.from(map.values());
}, [accountCache, accountOptions, selectedIds]);
const accountSelectOptions = useMemo(() => {
const grouped: Record<string, { value: number; label: string }[]> = {};
const noTag: { value: number; label: string }[] = [];
selectableAccounts.forEach((account) => {
const option = { value: account.id, label: account.username };
const tag = (account.tag || '').trim();
if (tag) {
if (!grouped[tag]) grouped[tag] = [];
grouped[tag].push(option);
} else {
noTag.push(option);
}
});
const result: SelectGroupOption[] = [];
Object.keys(grouped).sort().forEach((tag) => {
result.push({ label: tag, options: grouped[tag] });
});
if (noTag.length > 0) result.push({ label: '未分组', options: noTag });
return result;
}, [selectableAccounts]);
const selectedTagCount = useMemo(
() => new Set(Object.values(tagAccountIds).flat()).size,
[tagAccountIds],
);
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '账号', dataIndex: 'account_username' },
{
title: '状态',
dataIndex: 'status',
render: (status: string) => <Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>,
},
{ title: '消息', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'created_at', width: 180, render: (val: string) => formatTime(val) },
{
title: '操作',
width: 120,
render: (_: unknown, record: LoginTaskItem) => (
<Space size={4}>
{['failed', 'error'].includes(record.status) && !wsConnected && (
<Button
type="link"
size="small"
icon={<ReloadOutlined />}
onClick={() => handleRetryOne(record.id)}
>
重试
</Button>
)}
<Popconfirm title="确定删除此任务?" onConfirm={() => handleDeleteTask(record.id)} okText="删除" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 标题 + 筛选栏 */}
<div style={{ flexShrink: 0, paddingBottom: 8, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<h2 style={{ marginTop: 0, marginBottom: 6 }}>登录任务</h2>
{canBatch && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{allTags.length > 0 && (
<Select
mode="multiple"
style={{ minWidth: 200, maxWidth: 300 }}
placeholder="按标签筛选"
value={selectedTags}
onChange={handleTagChange}
options={allTags.map((t) => ({ value: t, label: t }))}
maxTagCount="responsive"
allowClear
size="small"
suffixIcon={<FilterOutlined />}
/>
)}
<Select
mode="multiple"
style={{ minWidth: 280, flex: 1, maxWidth: 500 }}
placeholder="选择账号"
value={selectedIds}
onChange={setSelectedIds}
options={accountSelectOptions}
maxTagCount="responsive"
showSearch
size="small"
loading={accountsLoading}
filterOption={false}
onSearch={setAccountSearch}
onPopupScroll={(event) => {
const target = event.currentTarget;
const reachedBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 24;
if (reachedBottom && accountOptions.length < accountTotal && !accountsLoading) {
void loadAccountsPage(accountPage + 1, accountSearch, true);
}
}}
dropdownRender={(menu) => (
<>
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center' }}>
<Button
size="small"
type="link"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSelectedIds((prev) => [...new Set([...prev, ...accountOptions.map((account) => account.id)])]);
setSelectedTags([]);
setTagAccountIds({});
tagSelectionRequestRef.current += 1;
}}
>
全选已加载 ({accountOptions.length})
</Button>
<Button
size="small"
type="link"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSelectedIds([]);
setSelectedTags([]);
setTagAccountIds({});
tagSelectionRequestRef.current += 1;
}}
>
清空
</Button>
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>
已加载 {accountOptions.length}/{accountTotal}
</span>
{accountOptions.length < accountTotal && (
<Button
size="small"
type="link"
loading={accountsLoading}
onMouseDown={(event) => event.preventDefault()}
onClick={() => void loadAccountsPage(accountPage + 1, accountSearch, true)}
>
加载更多
</Button>
)}
</div>
{menu}
</>
)}
/>
{selectedTags.length > 0 && (
<span style={{ color: token.colorTextSecondary, fontSize: 12, whiteSpace: 'nowrap' }}>
标签选中 {selectedTagCount} 个
</span>
)}
<Tooltip title={`登录设置(接口 ${apiStrategy},并发 ${concurrency},超时 ${maxTotalTime || '∞'}s,重试 ${maxLoginRetries || '∞'})`}>
<Button
icon={<SettingOutlined />}
onClick={() => setSettingsOpen(true)}
size="small"
/>
</Tooltip>
<Button
icon={<SafetyCertificateOutlined />}
loading={loading}
onClick={handleBatchCheck}
disabled={selectedIds.length === 0 || starting}
size="small"
>
检测账号
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={loading}
onClick={handleBatchLogin}
disabled={selectedIds.length === 0 || starting}
size="small"
>
开始登录
</Button>
{effectiveBatchId && (
<Button danger icon={<StopOutlined />} onClick={handleStop} size="small">
停止
</Button>
)}
</div>
)}
</div>
{/* 概览 + 任务列表区域 */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', paddingTop: 6 }}>
{/* 概览 */}
<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 }} />
{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
type="link"
size="small"
icon={<ReloadOutlined />}
onClick={handleRetryFailed}
loading={loading}
disabled={starting}
>
重试失败 ({failedCount})
</Button>
)}
</div>
{/* 任务列表 - 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"
sticky
rowSelection={{
selectedRowKeys,
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>
</div>
{/* 实时日志 - 底部可折叠 */}
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
collapsible
spinWhenEmpty
style={{ marginTop: 4 }}
/>
<Modal
title="登录设置"
open={settingsOpen}
onCancel={() => setSettingsOpen(false)}
footer={[
<Button
key="reset"
onClick={() => {
setConcurrency(3);
setMaxTotalTime(300);
setMaxLoginRetries(0);
setApiStrategy('wgapi');
}}
>
恢复默认
</Button>,
<Button key="ok" type="primary" onClick={() => setSettingsOpen(false)}>
完成
</Button>,
]}
>
<Form layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
label="登录接口"
tooltip="选择登录使用的接口版本。wgapi 为新版(推荐),iframe 为旧版备选。"
>
<Select
value={apiStrategy}
onChange={setApiStrategy}
style={{ width: 200 }}
options={[
{ value: 'wgapi', label: 'wgapi(新版,推荐)' },
{ value: 'iframe', label: 'iframe(旧版备选)' },
]}
/>
</Form.Item>
<Form.Item
label="并发数"
tooltip="同时登录的账号数。1 为顺序执行,最多 10。"
>
<Space.Compact>
<InputNumber
min={1}
max={10}
value={concurrency}
onChange={(v) => setConcurrency(v || 1)}
style={{ width: 120 }}
/>
<Button disabled>个</Button>
</Space.Compact>
</Form.Item>
<Form.Item
label="单账号超时"
tooltip="单个账号登录的总时长上限,超时则放弃。0 表示不限。"
>
<Space.Compact>
<InputNumber
min={0}
max={3600}
step={30}
value={maxTotalTime}
onChange={(v) => setMaxTotalTime(v ?? 0)}
style={{ width: 120 }}
placeholder="0=不限"
/>
<Button disabled>秒</Button>
</Space.Compact>
</Form.Item>
<Form.Item
label="登录整体重试次数"
tooltip="单个账号最多重试多少轮(每轮换新代理)。0 表示使用后端默认上限 20 轮。"
>
<Space.Compact>
<InputNumber
min={0}
max={50}
value={maxLoginRetries}
onChange={(v) => setMaxLoginRetries(v ?? 0)}
style={{ width: 120 }}
placeholder="0=无限"
/>
<Button disabled>次</Button>
</Space.Compact>
</Form.Item>
</Form>
</Modal>
</div>
);
}