Files
live-hub-py/web/frontend/src/pages/LoginTasksPage.tsx
T

417 lines
15 KiB
TypeScript

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<string, string> = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
};
const STATUS_LABELS: Record<string, string> = {
pending: '等待中',
running: '登录中',
success: '成功',
failed: '失败',
error: '异常',
};
interface SelectGroupOption {
label: string;
options: { value: number; label: string }[];
}
export default function LoginTasksPage() {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [tasks, setTasks] = useState<LoginTaskItem[]>([]);
const [loading, setLoading] = 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;
});
// 值变化时自动持久化
useEffect(() => { localStorage.setItem('login_concurrency', String(concurrency)); }, [concurrency]);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
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<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 = 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) => <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={(() => {
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: 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;
})()}
maxTagCount="responsive"
showSearch
size="small"
filterOption={(input, option) => {
if (!option) return false;
const label = (option as { label?: string }).label || '';
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 || wsConnected}
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>
{/* 实时日志 - 底部可折叠 */}
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
collapsible
spinWhenEmpty
style={{ marginTop: 4 }}
/>
</div>
);
}