Files
live-hub-py/web/frontend/src/pages/LoginTasksPage.tsx
T
2026-07-24 12:48:02 +08:00

556 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo, useCallback } 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<string, string> = {
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<string, string> = {
pending: '等待中',
running: '登录中',
success: '成功',
failed: '失败',
error: '异常',
account_cancelled: '账号已注销',
password_wrong: '账号密码错误',
account_unverified: '账号未认证',
account_verified: '账号已认证',
account_auth_unknown: '认证状态未知',
};
type BatchMode = 'login' | 'check';
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 [starting, setStarting] = 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;
});
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);
// 值变化时自动持久化
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<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[], 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);
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 (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 checkedCount = tasks.filter((t) => [
'account_cancelled',
'password_wrong',
'account_unverified',
'account_verified',
'account_auth_unknown',
].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={`登录设置(接口 ${apiStrategy},并发 ${concurrency},超时 ${maxTotalTime || '∞'}s,重试 ${maxLoginRetries || '∞'})`}>
<Button
icon={<SettingOutlined />}
onClick={() => setSettingsOpen(true)}
size="small"
/>
</Tooltip>
<Button
icon={<SafetyCertificateOutlined />}
loading={loading}
onClick={handleBatchCheck}
disabled={selectedIds.length === 0 || starting}
size="small"
>
检测账号
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={loading}
onClick={handleBatchLogin}
disabled={selectedIds.length === 0 || starting}
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>
{checkedCount > 0 && <span>检测 <b>{checkedCount}</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}
disabled={starting}
>
重试失败 ({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 }}
/>
<Modal
title="登录设置"
open={settingsOpen}
onCancel={() => setSettingsOpen(false)}
footer={[
<Button
key="reset"
onClick={() => {
setConcurrency(3);
setMaxTotalTime(300);
setMaxLoginRetries(0);
setApiStrategy('wgapi');
}}
>
恢复默认
</Button>,
<Button key="ok" type="primary" onClick={() => setSettingsOpen(false)}>
完成
</Button>,
]}
>
<Form layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
label="登录接口"
tooltip="选择登录使用的接口版本。wgapi 为新版(推荐),iframe 为旧版备选。"
>
<Select
value={apiStrategy}
onChange={setApiStrategy}
style={{ width: 200 }}
options={[
{ value: 'wgapi', label: 'wgapi(新版,推荐)' },
{ value: 'iframe', label: 'iframe(旧版备选)' },
]}
/>
</Form.Item>
<Form.Item
label="并发数"
tooltip="同时登录的账号数。1 为顺序执行,最多 10。"
>
<Space.Compact>
<InputNumber
min={1}
max={10}
value={concurrency}
onChange={(v) => setConcurrency(v || 1)}
style={{ width: 120 }}
/>
<Button disabled>个</Button>
</Space.Compact>
</Form.Item>
<Form.Item
label="单账号超时"
tooltip="单个账号登录的总时长上限,超时则放弃。0 表示不限。"
>
<Space.Compact>
<InputNumber
min={0}
max={3600}
step={30}
value={maxTotalTime}
onChange={(v) => setMaxTotalTime(v ?? 0)}
style={{ width: 120 }}
placeholder="0=不限"
/>
<Button disabled>秒</Button>
</Space.Compact>
</Form.Item>
<Form.Item
label="登录整体重试次数"
tooltip="单个账号最多重试多少轮(每轮换新代理)。0 表示无限重试直到成功或超时。"
>
<Space.Compact>
<InputNumber
min={0}
max={50}
value={maxLoginRetries}
onChange={(v) => setMaxLoginRetries(v ?? 0)}
style={{ width: 120 }}
placeholder="0=无限"
/>
<Button disabled>次</Button>
</Space.Compact>
</Form.Item>
</Form>
</Modal>
</div>
);
}