- WebSocket URL 根据页面协议自动选择 ws/wss - 使用 window.location.host 替代硬编码端口,适配内网穿透代理场景 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
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<string, string> = {
|
|
pending: 'default',
|
|
running: 'processing',
|
|
success: 'success',
|
|
failed: 'error',
|
|
error: 'error',
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
pending: '等待中',
|
|
running: '登录中',
|
|
success: '成功',
|
|
failed: '失败',
|
|
error: '异常',
|
|
};
|
|
|
|
export default function LoginTasksPage() {
|
|
const [accounts, setAccounts] = useState<any[]>([]);
|
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
const [tasks, setTasks] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [batchId, setBatchId] = useState<string | null>(null);
|
|
const [logs, setLogs] = useState<{ level: string; message: string }[]>([]);
|
|
const [wsConnected, setWsConnected] = useState(false);
|
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
const [concurrency, setConcurrency] = useState(3);
|
|
const [logVisible, setLogVisible] = useState(true);
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const logEndRef = useRef<HTMLDivElement | null>(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<string, number[]> = {};
|
|
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) => <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: (_: any, record: any) => (
|
|
<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={(() => {
|
|
const grouped: Record<string, { value: number; label: string }[]> = {};
|
|
const noTag: { value: number; label: string }[] = [];
|
|
accounts.forEach((a) => {
|
|
const tag = (a.tag || '').trim();
|
|
if (tag) {
|
|
if (!grouped[tag]) grouped[tag] = [];
|
|
grouped[tag].push({ value: a.id, label: a.username });
|
|
} else {
|
|
noTag.push({ value: a.id, label: a.username });
|
|
}
|
|
});
|
|
const result: any[] = [];
|
|
Object.keys(grouped).sort().forEach((tag) => {
|
|
result.push({ label: tag, options: grouped[tag] });
|
|
});
|
|
if (noTag.length > 0) {
|
|
result.push({ label: '未分组', options: noTag });
|
|
}
|
|
return result;
|
|
})()}
|
|
maxTagCount="responsive"
|
|
showSearch
|
|
size="small"
|
|
filterOption={(input, option) => {
|
|
if (!option) return false;
|
|
const label = (option as any).label as string || '';
|
|
return label.toLowerCase().includes(input.toLowerCase());
|
|
}}
|
|
dropdownRender={(menu) => (
|
|
<>
|
|
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
|
|
<Button size="small" type="link" onClick={() => { setSelectedIds(accounts.map((a) => a.id)); setSelectedTags([]); }}>
|
|
全选 ({accounts.length})
|
|
</Button>
|
|
<Button size="small" type="link" onClick={() => { setSelectedIds([]); setSelectedTags([]); }}>
|
|
清空
|
|
</Button>
|
|
</div>
|
|
{menu}
|
|
</>
|
|
)}
|
|
/>
|
|
{selectedTags.length > 0 && (
|
|
<span style={{ color: token.colorTextSecondary, fontSize: 12, whiteSpace: 'nowrap' }}>
|
|
标签选中 {accounts.filter((a) => selectedTags.includes((a.tag || '').trim())).length} 个
|
|
</span>
|
|
)}
|
|
<Tooltip title="同时登录的账号数,1为顺序执行">
|
|
<Space size={4}>
|
|
<ThunderboltOutlined style={{ color: token.colorTextSecondary }} />
|
|
<InputNumber
|
|
min={1}
|
|
max={10}
|
|
value={concurrency}
|
|
onChange={(v) => setConcurrency(v || 1)}
|
|
style={{ width: 50 }}
|
|
size="small"
|
|
/>
|
|
</Space>
|
|
</Tooltip>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlayCircleOutlined />}
|
|
loading={loading}
|
|
onClick={handleBatchLogin}
|
|
disabled={selectedIds.length === 0}
|
|
size="small"
|
|
>
|
|
开始登录
|
|
</Button>
|
|
{batchId && (
|
|
<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: 16, fontSize: 13, color: token.colorTextSecondary, padding: '4px 0' }}>
|
|
<span>共 <b>{tasks.length}</b> 个任务</span>
|
|
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
|
|
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
|
|
{batchId && <span>批次: <b>{batchId}</b></span>}
|
|
<div style={{ flex: 1 }} />
|
|
{selectedRowKeys.length > 0 && (
|
|
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 个任务?`} onConfirm={handleDeleteSelected} okText="删除" cancelText="取消">
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
删除选中 ({selectedRowKeys.length})
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
{!wsConnected && failedCount > 0 && (
|
|
<Button
|
|
type="link"
|
|
size="small"
|
|
icon={<ReloadOutlined />}
|
|
onClick={handleRetryFailed}
|
|
loading={loading}
|
|
>
|
|
重试失败 ({failedCount})
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 任务列表 - flex:1 占满剩余空间,内部滚动 */}
|
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={tasks}
|
|
rowKey="id"
|
|
size="small"
|
|
pagination={false}
|
|
sticky
|
|
rowSelection={{
|
|
selectedRowKeys,
|
|
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 实时日志 - 底部可折叠 */}
|
|
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, marginTop: 4 }}>
|
|
<div
|
|
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '4px 0', userSelect: 'none' }}
|
|
onClick={() => setLogVisible((v) => !v)}
|
|
>
|
|
<span style={{ fontWeight: 500, fontSize: 13 }}>实时日志</span>
|
|
{logVisible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />}
|
|
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} 条</span>}
|
|
{wsConnected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
|
</div>
|
|
{logVisible && (
|
|
<div
|
|
style={{
|
|
height: '20vh',
|
|
overflow: 'auto',
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
padding: 4,
|
|
backgroundColor: token.colorBgLayout,
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
{logs.length === 0 ? (
|
|
<Spin spinning={wsConnected} size="small" />
|
|
) : (
|
|
logs.map((log, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
color:
|
|
log.level === 'error' ? token.colorError :
|
|
log.level === 'success' ? token.colorSuccess :
|
|
log.level === 'warning' ? token.colorWarning :
|
|
token.colorText,
|
|
}}
|
|
>
|
|
{log.message}
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|