import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { Table, Button, Select, message, Tag, Space, Spin, InputNumber, Tooltip, Popconfirm, theme, } from 'antd'; import { PlayCircleOutlined, StopOutlined, FilterOutlined, ThunderboltOutlined, ReloadOutlined, DownOutlined, UpOutlined, DeleteOutlined } from '@ant-design/icons'; import { accountApi, loginApi } from '../api/modules'; import { getUser, hasPerm } from '../store/auth'; import { formatTime } from '../utils/time'; const STATUS_COLORS: Record = { pending: 'default', running: 'processing', success: 'success', failed: 'error', error: 'error', }; const STATUS_LABELS: Record = { pending: '等待中', running: '登录中', success: '成功', failed: '失败', error: '异常', }; 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 [logs, setLogs] = useState<{ level: string; message: string }[]>([]); const [wsConnected, setWsConnected] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [concurrency, setConcurrency] = useState(3); const [logVisible, setLogVisible] = useState(true); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const wsRef = useRef(null); const logEndRef = useRef(null); const user = getUser(); const { token } = theme.useToken(); const canBatch = hasPerm(user, '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 = async () => { try { const data = await accountApi.list(); setAccounts(data); } catch (e: any) { message.error(e.message); } }; // 标签选择变化时,同步更新选中的账号 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 = async () => { try { const data = await loginApi.listTasks(batchId || undefined); setTasks(data); } catch {} }; useEffect(() => { Promise.all([loadAccounts(), loadTasks()]); }, []); // 日志自动滚动到底部 useEffect(() => { if (logVisible && logEndRef.current) { logEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [logs, logVisible]); useEffect(() => { const timer = setInterval(loadTasks, 3000); return () => clearInterval(timer); }, [batchId]); // 共享的批量登录启动逻辑 const startBatch = async (accountIds: number[]) => { if (accountIds.length === 0) { message.warning('请选择账号'); return; } setLoading(true); setLogs([]); try { const result = await loginApi.createBatch(accountIds, 5, concurrency); setBatchId(result.batch_id); message.success(`已创建登录任务,共 ${result.count} 个账号`); // 连接 WebSocket const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/login/ws/login/${result.batch_id}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; setWsConnected(true); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.level === 'heartbeat') return; if (msg.level === 'result') return; setLogs((prev) => [...prev, msg]); }; ws.onclose = () => { wsRef.current = null; setWsConnected(false); setBatchId(null); }; ws.onerror = () => { setWsConnected(false); }; } catch (e: any) { message.error(e.message); } 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: any) { message.error(e.message); } } }; const handleDeleteTask = async (taskId: number) => { try { await loginApi.deleteTask(taskId); message.success('已删除'); loadTasks(); setSelectedRowKeys((prev) => prev.filter((k) => k !== taskId)); } catch (e: any) { message.error(e.message); } }; const handleDeleteSelected = async () => { if (selectedRowKeys.length === 0) { message.warning('请选择要删除的任务'); return; } try { await loginApi.deleteTasks(selectedRowKeys); message.success(`已删除 ${selectedRowKeys.length} 个任务`); setSelectedRowKeys([]); loadTasks(); } catch (e: any) { message.error(e.message); } }; 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: (_: any, record: any) => ( {['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[]), }} /> {/* 实时日志 - 底部可折叠 */}
setLogVisible((v) => !v)} > 实时日志 {logVisible ? : } {logs.length > 0 && {logs.length} 条} {wsConnected && 连接中}
{logVisible && (
{logs.length === 0 ? ( ) : ( logs.map((log, i) => (
{log.message}
)) )}
)}
); }