任务量早就过了单页能装下的规模(库里 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 再按主键删。
834 lines
31 KiB
TypeScript
834 lines
31 KiB
TypeScript
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>
|
||
);
|
||
}
|