import { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Button, Select, message, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, } from 'antd'; import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DeleteOutlined } 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', }; const STATUS_LABELS: Record = { pending: '等待中', running: '登录中', success: '成功', failed: '失败', error: '异常', }; interface SelectGroupOption { label: string; options: { value: number; label: string }[]; } export default function LoginTasksPage() { const [accounts, setAccounts] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [tasks, setTasks] = useState([]); const [loading, setLoading] = 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; }); // 值变化时自动持久化 useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { can } = usePermissions(); const { token } = theme.useToken(); const canBatch = can('login:batch'); // 从账号中提取所有标签 const allTags = useMemo(() => { const tags = [...new Set(accounts.map((a) => (a.tag || '').trim()).filter(Boolean))]; return tags.sort(); }, [accounts]); // 标签→账号ID映射 const tagAccountMap = useMemo(() => { const map: Record = {}; accounts.forEach((a) => { const tag = (a.tag || '').trim(); if (tag) { if (!map[tag]) map[tag] = []; map[tag].push(a.id); } }); return map; }, [accounts]); const loadAccounts = useCallback(async () => { try { const data = await accountApi.list(); setAccounts(data); } catch (e: unknown) { message.error(getErrorMessage(e)); } }, []); // 标签选择变化时,同步更新选中的账号 const handleTagChange = useCallback((tags: string[]) => { setSelectedTags(tags); setSelectedIds((prev) => { const prevTagSet = new Set(selectedTags); const newTagSet = new Set(tags); // 新增的标签 const addedTags = tags.filter((t) => !prevTagSet.has(t)); // 移除的标签 const removedTags = selectedTags.filter((t) => !newTagSet.has(t)); // 收集被移除标签下的所有账号ID const removedIds = new Set(removedTags.flatMap((t) => tagAccountMap[t] || [])); // 收集新增标签下的所有账号ID const addedIds = addedTags.flatMap((t) => tagAccountMap[t] || []); // 保留手动选择的账号(不属于任何已选标签的),移除被取消标签的账号,添加新选标签的账号 const manualIds = prev.filter((id) => { const acc = accounts.find((a) => a.id === id); if (!acc) return false; const accTag = (acc.tag || '').trim(); // 保留不属于当前任何已选标签、也不属于被移除标签的 return !prevTagSet.has(accTag) && !removedIds.has(id); }); return [...new Set([...manualIds, ...addedIds])]; }); }, [selectedTags, tagAccountMap, accounts]); const loadTasks = useCallback(async () => { try { const data = await loginApi.listTasks(batchId || undefined); setTasks(data); } catch { // 忽略轮询失败,下一次定时刷新会继续尝试。 } }, [batchId]); useEffect(() => { Promise.all([loadAccounts(), loadTasks()]); }, [loadAccounts, loadTasks]); useEffect(() => { const timer = setInterval(loadTasks, 3000); return () => clearInterval(timer); }, [loadTasks]); // 共享的批量登录启动逻辑 const startBatch = async (accountIds: number[]) => { if (accountIds.length === 0) { message.warning('请选择账号'); return; } setLoading(true); try { const result = await loginApi.createBatch({ account_ids: accountIds, concurrency, }); setBatchId(result.batch_id); message.success(`已创建登录任务,共 ${result.count} 个账号`); connectLogs(`/api/login/ws/login/${result.batch_id}`, { onClose: () => setBatchId(null), onResult: () => setBatchId(null), }); } catch (e: unknown) { message.error(getErrorMessage(e)); } finally { setLoading(false); } }; const handleBatchLogin = () => startBatch(selectedIds); // 重试当前批次所有失败的任务 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); }; // 重试单个失败任务 const handleRetryOne = (taskId: number) => { const task = tasks.find((t) => t.id === taskId); if (!task) return; startBatch([task.account_id]); }; const handleStop = async () => { if (batchId) { try { await loginApi.stop(batchId); 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 (selectedRowKeys.length === 0) { message.warning('请选择要删除的任务'); return; } try { await loginApi.deleteTasks(selectedRowKeys); message.success(`已删除 ${selectedRowKeys.length} 个任务`); setSelectedRowKeys([]); loadTasks(); } catch (e: unknown) { message.error(getErrorMessage(e)); } }; const successCount = tasks.filter((t) => t.status === 'success').length; const failedCount = tasks.filter((t) => ['failed', 'error'].includes(t.status)).length; 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="取消"> {menu} )} /> {selectedTags.length > 0 && ( 标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个 )} setConcurrency(v || 1)} style={{ width: 50 }} size="small" /> {batchId && ( )} )} {/* 概览 + 任务列表区域 */}
{/* 概览 */}
{tasks.length} 个任务 成功 {successCount} 失败 {failedCount} {batchId && 批次: {batchId}}
{selectedRowKeys.length > 0 && ( )} {!wsConnected && failedCount > 0 && ( )}
{/* 任务列表 - flex:1 占满剩余空间,内部滚动 */}
setSelectedRowKeys(keys as number[]), }} /> {/* 实时日志 - 底部可折叠 */} ); }