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 = { 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 = { 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([]); const [accountCache, setAccountCache] = useState>({}); const [accountPage, setAccountPage] = useState(1); const [accountTotal, setAccountTotal] = useState(0); const [accountSearch, setAccountSearch] = useState(''); const [accountsLoading, setAccountsLoading] = useState(false); const [allTags, setAllTags] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); const [batchId, setBatchId] = useState(null); const [selectedTags, setSelectedTags] = useState([]); 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>({}); // 值变化时自动持久化 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([]); 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(''); 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(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(); 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 = {}; 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) => {STATUS_LABELS[status] || status}, }, { title: '消息', dataIndex: 'message', ellipsis: true }, { title: '时间', dataIndex: 'created_at', width: 180, render: (val: string) => formatTime(val) }, { title: '操作', width: 120, render: (_: unknown, record: LoginTaskItem) => ( {['failed', 'error'].includes(record.status) && !wsConnected && ( )} handleDeleteTask(record.id)} okText="删除" cancelText="取消"> 已加载 {accountOptions.length}/{accountTotal} {accountOptions.length < accountTotal && ( )} {menu} )} /> {selectedTags.length > 0 && ( 标签选中 {selectedTagCount} 个 )} {effectiveBatchId && ( )} )} {/* 概览 + 任务列表区域 */}
{/* 概览 */}
共 {taskTotal} 个任务 成功 {successCount} 失败 {failedCount} {checkedCount > 0 && 检测 {checkedCount}} {effectiveBatchId && 批次: {effectiveBatchId}} setConcurrency(v || 1)} style={{ width: 120 }} /> setMaxTotalTime(v ?? 0)} style={{ width: 120 }} placeholder="0=不限" /> setMaxLoginRetries(v ?? 0)} style={{ width: 120 }} placeholder="0=无限" />
); }