821 lines
26 KiB
TypeScript
821 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Button, Card, Col, Input, InputNumber, message, Row, Segmented, Space, Statistic, Switch, Table, Tag, Typography,
|
||
} from 'antd';
|
||
import type { TableProps } from 'antd';
|
||
import {
|
||
DownloadOutlined, PlayCircleOutlined, RedoOutlined, ReloadOutlined, StopOutlined,
|
||
} from '@ant-design/icons';
|
||
import {
|
||
huyaApi,
|
||
type HuyaAutoRegisterBatch,
|
||
type HuyaAutoRegisterItem,
|
||
type HuyaRegisterSuccessLog,
|
||
} from '../api/modules';
|
||
import { formatTime } from '../utils/time';
|
||
import { getErrorMessage } from '../utils/error';
|
||
|
||
const { TextArea } = Input;
|
||
const { Text, Title } = Typography;
|
||
|
||
type PasswordMode = 'random' | 'fixed';
|
||
|
||
interface StoredRegisterForm {
|
||
text: string;
|
||
tag: string;
|
||
concurrency: number;
|
||
waitSeconds: number;
|
||
pollInterval: number;
|
||
passwordMode: PasswordMode;
|
||
passwordPrefix: string;
|
||
fixedPassword: string;
|
||
useProxy: boolean;
|
||
}
|
||
|
||
const FORM_STORAGE_KEY = 'huya_auto_register_form';
|
||
const BATCH_STORAGE_KEY = 'huya_auto_register_batch_id';
|
||
const DEFAULT_FORM: StoredRegisterForm = {
|
||
text: '',
|
||
tag: '',
|
||
concurrency: 1,
|
||
waitSeconds: 180,
|
||
pollInterval: 5,
|
||
passwordMode: 'random',
|
||
passwordPrefix: 'hy',
|
||
fixedPassword: '',
|
||
useProxy: false,
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
pending: '等待',
|
||
sending: '发码',
|
||
waiting: '等待验证码',
|
||
logging: '保存',
|
||
changing: '改密',
|
||
success: '成功',
|
||
error: '失败',
|
||
stopped: '已停止',
|
||
interrupted: '已中断',
|
||
finished: '已完成',
|
||
running: '运行中',
|
||
};
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
pending: 'default',
|
||
sending: 'processing',
|
||
waiting: 'processing',
|
||
logging: 'processing',
|
||
changing: 'processing',
|
||
success: 'success',
|
||
error: 'error',
|
||
stopped: 'warning',
|
||
interrupted: 'warning',
|
||
finished: 'success',
|
||
running: 'processing',
|
||
};
|
||
|
||
// 只有真正 running 才锁 UI / 轮询(见 isRunning);pending 可能是历史脏数据
|
||
const RETRYABLE_BATCH = new Set(['finished', 'stopped', 'error', 'interrupted', 'pending']);
|
||
|
||
function safeNumber(value: unknown, fallback: number) {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
}
|
||
|
||
function readStoredForm(): StoredRegisterForm {
|
||
try {
|
||
const raw = localStorage.getItem(FORM_STORAGE_KEY);
|
||
if (!raw) return DEFAULT_FORM;
|
||
const parsed = JSON.parse(raw) as Partial<StoredRegisterForm>;
|
||
const passwordMode = parsed.passwordMode === 'fixed' ? 'fixed' : 'random';
|
||
return {
|
||
...DEFAULT_FORM,
|
||
...parsed,
|
||
concurrency: safeNumber(parsed.concurrency, DEFAULT_FORM.concurrency),
|
||
waitSeconds: safeNumber(parsed.waitSeconds, DEFAULT_FORM.waitSeconds),
|
||
pollInterval: safeNumber(parsed.pollInterval, DEFAULT_FORM.pollInterval),
|
||
passwordMode,
|
||
useProxy: Boolean(parsed.useProxy),
|
||
};
|
||
} catch {
|
||
return DEFAULT_FORM;
|
||
}
|
||
}
|
||
|
||
function readStoredBatchId() {
|
||
try {
|
||
return localStorage.getItem(BATCH_STORAGE_KEY) || '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function downloadBlob(blob: Blob, filename: string) {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
export default function HuyaRegisterPage() {
|
||
const [initialForm] = useState(readStoredForm);
|
||
const [text, setText] = useState(initialForm.text);
|
||
const [tag, setTag] = useState(initialForm.tag);
|
||
const [concurrency, setConcurrency] = useState(initialForm.concurrency);
|
||
const [waitSeconds, setWaitSeconds] = useState(initialForm.waitSeconds);
|
||
const [pollInterval, setPollInterval] = useState(initialForm.pollInterval);
|
||
const [passwordMode, setPasswordMode] = useState<PasswordMode>(initialForm.passwordMode);
|
||
const [passwordPrefix, setPasswordPrefix] = useState(initialForm.passwordPrefix);
|
||
const [fixedPassword, setFixedPassword] = useState(initialForm.fixedPassword);
|
||
const [useProxy, setUseProxy] = useState(initialForm.useProxy);
|
||
const [batch, setBatch] = useState<HuyaAutoRegisterBatch | null>(null);
|
||
const [history, setHistory] = useState<HuyaAutoRegisterBatch[]>([]);
|
||
const [successLogs, setSuccessLogs] = useState<HuyaRegisterSuccessLog[]>([]);
|
||
const [starting, setStarting] = useState(false);
|
||
const [retrying, setRetrying] = useState(false);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [stopping, setStopping] = useState(false);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
const [logsLoading, setLogsLoading] = useState(false);
|
||
const [exporting, setExporting] = useState(false);
|
||
const restoredBatchRef = useRef(false);
|
||
|
||
const batchId = batch?.batch_id || '';
|
||
const isRunning = !!batch && batch.status === 'running';
|
||
// 默认可「继续」的条目:未开始/已停止/中断中(不含 error、success)
|
||
const resumableCount = useMemo(() => {
|
||
if (!batch?.items?.length) {
|
||
// 摘要无明细时:用 停止数 + (总数-成功-失败-停止) 估算未完成
|
||
const total = batch?.total || 0;
|
||
const success = batch?.success_count || 0;
|
||
const failed = batch?.failed_count || 0;
|
||
const stopped = batch?.stopped_count || 0;
|
||
return Math.max(0, stopped + (total - success - failed - stopped));
|
||
}
|
||
return batch.items.filter((item) => (
|
||
item.status === 'pending'
|
||
|| item.status === 'stopped'
|
||
|| item.status === 'sending'
|
||
|| item.status === 'waiting'
|
||
|| item.status === 'changing'
|
||
|| item.status === 'logging'
|
||
)).length;
|
||
}, [batch]);
|
||
|
||
const failedCount = useMemo(() => {
|
||
if (!batch?.items?.length) return batch?.failed_count || 0;
|
||
return batch.items.filter((item) => item.status === 'error').length;
|
||
}, [batch]);
|
||
|
||
const canContinue = !!batch && !isRunning && resumableCount > 0
|
||
&& (RETRYABLE_BATCH.has(batch.status) || batch.status === 'pending');
|
||
const canRetryFailed = !!batch && !isRunning && failedCount > 0
|
||
&& (RETRYABLE_BATCH.has(batch.status) || batch.status === 'pending');
|
||
|
||
const loadBatchById = useCallback(async (id: string) => {
|
||
if (!id) return;
|
||
setRefreshing(true);
|
||
try {
|
||
const data = await huyaApi.getAutoRegisterBatch(id);
|
||
setBatch(data);
|
||
localStorage.setItem(BATCH_STORAGE_KEY, data.batch_id);
|
||
} catch (e: unknown) {
|
||
const err = getErrorMessage(e);
|
||
if (err.includes('批次不存在')) {
|
||
localStorage.removeItem(BATCH_STORAGE_KEY);
|
||
setBatch(null);
|
||
message.warning('批次不存在,已清除本地恢复记录');
|
||
} else {
|
||
message.error(err);
|
||
}
|
||
} finally {
|
||
setRefreshing(false);
|
||
}
|
||
}, []);
|
||
|
||
const refreshBatch = useCallback(async () => {
|
||
if (!batchId) return;
|
||
await loadBatchById(batchId);
|
||
}, [batchId, loadBatchById]);
|
||
|
||
const loadHistory = useCallback(async () => {
|
||
setHistoryLoading(true);
|
||
try {
|
||
const rows = await huyaApi.listAutoRegisterBatches(50);
|
||
setHistory(rows);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setHistoryLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadSuccessLogs = useCallback(async () => {
|
||
setLogsLoading(true);
|
||
try {
|
||
const rows = await huyaApi.listRegisterSuccessLogs({ limit: 100 });
|
||
setSuccessLogs(rows);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setLogsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify({
|
||
text,
|
||
tag,
|
||
concurrency,
|
||
waitSeconds,
|
||
pollInterval,
|
||
passwordMode,
|
||
passwordPrefix,
|
||
fixedPassword,
|
||
useProxy,
|
||
}));
|
||
}, [text, tag, concurrency, waitSeconds, pollInterval, passwordMode, passwordPrefix, fixedPassword, useProxy]);
|
||
|
||
useEffect(() => {
|
||
if (restoredBatchRef.current) return;
|
||
restoredBatchRef.current = true;
|
||
const storedBatchId = readStoredBatchId();
|
||
if (storedBatchId) {
|
||
loadBatchById(storedBatchId);
|
||
}
|
||
loadHistory();
|
||
loadSuccessLogs();
|
||
}, [loadBatchById, loadHistory, loadSuccessLogs]);
|
||
|
||
useEffect(() => {
|
||
if (batch?.batch_id) {
|
||
localStorage.setItem(BATCH_STORAGE_KEY, batch.batch_id);
|
||
}
|
||
}, [batch?.batch_id]);
|
||
|
||
useEffect(() => {
|
||
if (!isRunning || !batchId) return undefined;
|
||
const timer = window.setInterval(() => {
|
||
refreshBatch();
|
||
}, 2000);
|
||
return () => window.clearInterval(timer);
|
||
}, [batchId, isRunning, refreshBatch]);
|
||
|
||
useEffect(() => {
|
||
if (isRunning) return undefined;
|
||
// 批次结束时刷新历史与成功流水
|
||
if (batch && (batch.status === 'finished' || batch.status === 'stopped' || batch.status === 'error')) {
|
||
loadHistory();
|
||
loadSuccessLogs();
|
||
}
|
||
return undefined;
|
||
}, [batch?.status, isRunning, loadHistory, loadSuccessLogs, batch]);
|
||
|
||
const successRows = useMemo(
|
||
() => (batch?.items || []).filter((item) => item.status === 'success' && item.password && (item.username || item.uid)),
|
||
[batch],
|
||
);
|
||
|
||
const handleStart = async () => {
|
||
if (!text.trim()) {
|
||
message.warning('请先粘贴手机号池');
|
||
return;
|
||
}
|
||
if (passwordMode === 'fixed' && !fixedPassword.trim()) {
|
||
message.warning('请先填写固定密码');
|
||
return;
|
||
}
|
||
setStarting(true);
|
||
try {
|
||
const data = await huyaApi.startAutoRegister({
|
||
text,
|
||
tag: tag.trim(),
|
||
concurrency,
|
||
wait_seconds: waitSeconds,
|
||
poll_interval: pollInterval,
|
||
password_prefix: passwordMode === 'random' ? (passwordPrefix.trim() || 'hy') : 'hy',
|
||
fixed_password: passwordMode === 'fixed' ? fixedPassword.trim() : '',
|
||
use_proxy: useProxy,
|
||
});
|
||
setBatch(data);
|
||
message.success('自动注册批次已启动');
|
||
loadHistory();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setStarting(false);
|
||
}
|
||
};
|
||
|
||
const handleStop = async () => {
|
||
if (!batchId) return;
|
||
setStopping(true);
|
||
try {
|
||
const result = await huyaApi.stopAutoRegisterBatch(batchId);
|
||
message.success(result.message);
|
||
refreshBatch();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setStopping(false);
|
||
}
|
||
};
|
||
|
||
const buildRetryPayload = (mode: 'continue' | 'retry_failed' | 'all_unfinished' = 'continue') => ({
|
||
mode,
|
||
concurrency,
|
||
wait_seconds: waitSeconds,
|
||
poll_interval: pollInterval,
|
||
password_prefix: passwordMode === 'random' ? (passwordPrefix.trim() || 'hy') : undefined,
|
||
fixed_password: passwordMode === 'fixed' ? fixedPassword.trim() : undefined,
|
||
use_proxy: useProxy,
|
||
});
|
||
|
||
const handleContinue = async () => {
|
||
if (!batchId) return;
|
||
setRetrying(true);
|
||
try {
|
||
const data = await huyaApi.retryAutoRegisterBatch(batchId, buildRetryPayload('continue'));
|
||
setBatch(data);
|
||
message.success(`已从停止处继续,处理 ${resumableCount} 条未完成号码(跳过已成功/已失败)`);
|
||
loadHistory();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setRetrying(false);
|
||
}
|
||
};
|
||
|
||
const handleRetryFailed = async () => {
|
||
if (!batchId) return;
|
||
setRetrying(true);
|
||
try {
|
||
const data = await huyaApi.retryAutoRegisterBatch(batchId, buildRetryPayload('retry_failed'));
|
||
setBatch(data);
|
||
message.success(`已开始重试 ${failedCount} 条失败号码`);
|
||
loadHistory();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setRetrying(false);
|
||
}
|
||
};
|
||
|
||
const handleExportCurrentBatch = async () => {
|
||
if (!batchId) return;
|
||
setExporting(true);
|
||
try {
|
||
// 优先服务端历史流水(换电脑可用);若无记录再回退当前页内存
|
||
try {
|
||
const blob = await huyaApi.exportAutoRegisterBatch(batchId);
|
||
await downloadBlob(blob, `huya-register-${batchId}.txt`);
|
||
message.success('已导出当前批次成功账号');
|
||
return;
|
||
} catch {
|
||
// fallthrough
|
||
}
|
||
if (successRows.length === 0) {
|
||
message.warning('当前批次没有可导出的成功账号');
|
||
return;
|
||
}
|
||
const body = successRows
|
||
.map((item) => `${item.username || item.uid}----${item.password}----${item.phone}----${item.sms_url}`)
|
||
.join('\n');
|
||
const blob = new Blob([body], { type: 'text/plain;charset=utf-8' });
|
||
await downloadBlob(blob, `huya-register-accounts-${batchId}.txt`);
|
||
message.success('已导出当前批次成功账号');
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const handleExportAllSuccess = async () => {
|
||
setExporting(true);
|
||
try {
|
||
const blob = await huyaApi.exportRegisterSuccessLogs({
|
||
tag: tag.trim() || undefined,
|
||
limit: 5000,
|
||
});
|
||
const name = tag.trim()
|
||
? `huya-register-success-${tag.trim()}.txt`
|
||
: 'huya-register-success-all.txt';
|
||
await downloadBlob(blob, name);
|
||
message.success(tag.trim() ? `已按标签「${tag.trim()}」导出成功历史` : '已导出全部成功历史');
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const columns: TableProps<HuyaAutoRegisterItem>['columns'] = [
|
||
{ title: '行', dataIndex: 'line', width: 64 },
|
||
{ title: '手机号', dataIndex: 'phone', width: 150 },
|
||
{
|
||
title: '虎牙号',
|
||
width: 150,
|
||
render: (_, item) => item.username || item.uid || '-',
|
||
},
|
||
{
|
||
title: '密码',
|
||
dataIndex: 'password',
|
||
width: 130,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{ title: '平台', dataIndex: 'provider', width: 90 },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 110,
|
||
render: (status: string) => (
|
||
<Tag color={STATUS_COLORS[status] || 'default'}>
|
||
{STATUS_LABELS[status] || status}
|
||
</Tag>
|
||
),
|
||
},
|
||
{ title: '注册码', dataIndex: 'code', width: 100 },
|
||
{ title: '改密码', dataIndex: 'change_code', width: 100 },
|
||
{
|
||
title: '轮询',
|
||
width: 90,
|
||
render: (_, item) => `${item.attempts}/${item.change_attempts}`,
|
||
},
|
||
{
|
||
title: '账号',
|
||
width: 160,
|
||
render: (_, item) => item.uid || (item.account_id ? `#${item.account_id}` : '-'),
|
||
},
|
||
{
|
||
title: '消息',
|
||
dataIndex: 'message',
|
||
ellipsis: true,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '完成时间',
|
||
dataIndex: 'finished_at',
|
||
width: 170,
|
||
render: (value: string | null) => formatTime(value),
|
||
},
|
||
];
|
||
|
||
const historyColumns: TableProps<HuyaAutoRegisterBatch>['columns'] = [
|
||
{
|
||
title: '批次',
|
||
dataIndex: 'batch_id',
|
||
width: 130,
|
||
render: (value: string) => <Text code>{value}</Text>,
|
||
},
|
||
{
|
||
title: '标签',
|
||
dataIndex: 'tag',
|
||
width: 120,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (status: string) => (
|
||
<Tag color={STATUS_COLORS[status] || 'default'}>
|
||
{STATUS_LABELS[status] || status}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '进度',
|
||
width: 160,
|
||
render: (_, row) => `${row.success_count}/${row.total} 成功 · 失败 ${row.failed_count}`,
|
||
},
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'created_at',
|
||
width: 170,
|
||
render: (value: string | null) => formatTime(value),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 220,
|
||
render: (_, row) => (
|
||
<Space size={4}>
|
||
<Button
|
||
size="small"
|
||
onClick={() => loadBatchById(row.batch_id)}
|
||
>
|
||
查看
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<RedoOutlined />}
|
||
disabled={row.status === 'running' || (row.success_count >= row.total && row.total > 0)}
|
||
onClick={async () => {
|
||
try {
|
||
setRetrying(true);
|
||
const data = await huyaApi.retryAutoRegisterBatch(row.batch_id, {
|
||
mode: 'continue',
|
||
concurrency,
|
||
wait_seconds: waitSeconds,
|
||
poll_interval: pollInterval,
|
||
use_proxy: useProxy,
|
||
});
|
||
setBatch(data);
|
||
message.success('已从停止处继续未完成号码');
|
||
loadHistory();
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setRetrying(false);
|
||
}
|
||
}}
|
||
>
|
||
继续
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<DownloadOutlined />}
|
||
disabled={(row.success_count || 0) <= 0}
|
||
onClick={async () => {
|
||
try {
|
||
const blob = await huyaApi.exportAutoRegisterBatch(row.batch_id);
|
||
await downloadBlob(blob, `huya-register-${row.batch_id}.txt`);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
}}
|
||
>
|
||
导出
|
||
</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const logColumns: TableProps<HuyaRegisterSuccessLog>['columns'] = [
|
||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||
{
|
||
title: '批次',
|
||
dataIndex: 'batch_id',
|
||
width: 120,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||
{
|
||
title: '虎牙号',
|
||
width: 140,
|
||
render: (_, row) => row.username || row.uid || '-',
|
||
},
|
||
{ title: '密码', dataIndex: 'password', width: 120 },
|
||
{
|
||
title: '标签',
|
||
dataIndex: 'tag',
|
||
width: 100,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'created_at',
|
||
width: 170,
|
||
render: (value: string | null) => formatTime(value),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
<Title level={3} style={{ margin: 0 }}>虎牙自动注册</Title>
|
||
|
||
<Card title="手机号池">
|
||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||
<TextArea
|
||
rows={9}
|
||
value={text}
|
||
onChange={(event) => setText(event.target.value)}
|
||
placeholder="手机号----短信查询URL;也支持:虎牙号----密码----手机号----验证码链接"
|
||
disabled={isRunning}
|
||
/>
|
||
<Row gutter={[12, 12]}>
|
||
<Col xs={24} md={8}>
|
||
<Text type="secondary">标签</Text>
|
||
<Input
|
||
value={tag}
|
||
onChange={(event) => setTag(event.target.value)}
|
||
placeholder="注册批次(导出历史可按标签过滤)"
|
||
disabled={isRunning}
|
||
/>
|
||
</Col>
|
||
<Col xs={8} md={4}>
|
||
<Text type="secondary">并发</Text>
|
||
<InputNumber
|
||
min={1}
|
||
max={5}
|
||
value={concurrency}
|
||
onChange={(value) => setConcurrency(Number(value || 1))}
|
||
disabled={isRunning}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
<Col xs={8} md={4}>
|
||
<Text type="secondary">等待秒数</Text>
|
||
<InputNumber
|
||
min={15}
|
||
max={600}
|
||
value={waitSeconds}
|
||
onChange={(value) => setWaitSeconds(Number(value || 180))}
|
||
disabled={isRunning}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
<Col xs={8} md={4}>
|
||
<Text type="secondary">轮询间隔</Text>
|
||
<InputNumber
|
||
min={1}
|
||
max={30}
|
||
value={pollInterval}
|
||
onChange={(value) => setPollInterval(Number(value || 5))}
|
||
disabled={isRunning}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<Text type="secondary">密码设置</Text>
|
||
<Space.Compact style={{ width: '100%' }}>
|
||
<Segmented
|
||
value={passwordMode}
|
||
options={[
|
||
{ label: '随机', value: 'random' },
|
||
{ label: '固定', value: 'fixed' },
|
||
]}
|
||
onChange={(value) => setPasswordMode(value as 'random' | 'fixed')}
|
||
disabled={isRunning}
|
||
/>
|
||
{passwordMode === 'random' ? (
|
||
<Input
|
||
value={passwordPrefix}
|
||
maxLength={8}
|
||
onChange={(event) => setPasswordPrefix(event.target.value)}
|
||
disabled={isRunning}
|
||
/>
|
||
) : (
|
||
<Input.Password
|
||
value={fixedPassword}
|
||
maxLength={64}
|
||
onChange={(event) => setFixedPassword(event.target.value)}
|
||
disabled={isRunning}
|
||
/>
|
||
)}
|
||
</Space.Compact>
|
||
</Col>
|
||
<Col xs={12} md={4}>
|
||
<Text type="secondary">代理</Text>
|
||
<div style={{ height: 32, display: 'flex', alignItems: 'center' }}>
|
||
<Switch
|
||
checked={useProxy}
|
||
checkedChildren="开"
|
||
unCheckedChildren="关"
|
||
onChange={setUseProxy}
|
||
disabled={isRunning}
|
||
/>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Space wrap style={{ width: '100%', paddingTop: 22 }}>
|
||
<Button
|
||
type="primary"
|
||
icon={<PlayCircleOutlined />}
|
||
loading={starting}
|
||
disabled={isRunning}
|
||
onClick={handleStart}
|
||
>
|
||
开始
|
||
</Button>
|
||
<Button
|
||
danger
|
||
icon={<StopOutlined />}
|
||
loading={stopping}
|
||
disabled={!isRunning}
|
||
onClick={handleStop}
|
||
>
|
||
停止
|
||
</Button>
|
||
<Button
|
||
icon={<RedoOutlined />}
|
||
loading={retrying}
|
||
disabled={!canContinue}
|
||
onClick={handleContinue}
|
||
>
|
||
继续未完成{resumableCount > 0 ? ` (${resumableCount})` : ''}
|
||
</Button>
|
||
<Button
|
||
loading={retrying}
|
||
disabled={!canRetryFailed}
|
||
onClick={handleRetryFailed}
|
||
>
|
||
重试失败{failedCount > 0 ? ` (${failedCount})` : ''}
|
||
</Button>
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
loading={exporting}
|
||
onClick={handleExportAllSuccess}
|
||
>
|
||
导出全部成功历史
|
||
</Button>
|
||
</Space>
|
||
</Col>
|
||
</Row>
|
||
<Text type="secondary">
|
||
「继续未完成」从停止处往下跑,跳过已成功和已失败;需要重跑失败号再用「重试失败」。成功账号会立即写入流水,换电脑也可导出 txt。
|
||
</Text>
|
||
</Space>
|
||
</Card>
|
||
|
||
<Card
|
||
title="当前批次结果"
|
||
extra={(
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} loading={refreshing} disabled={!batchId} onClick={refreshBatch}>
|
||
刷新
|
||
</Button>
|
||
<Button
|
||
icon={<DownloadOutlined />}
|
||
loading={exporting}
|
||
disabled={!batchId}
|
||
onClick={handleExportCurrentBatch}
|
||
>
|
||
导出本批成功
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
>
|
||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||
<Col xs={12} md={4}><Statistic title="总数" value={batch?.total || 0} /></Col>
|
||
<Col xs={12} md={4}><Statistic title="成功" value={batch?.success_count || 0} /></Col>
|
||
<Col xs={12} md={4}><Statistic title="失败" value={batch?.failed_count || 0} /></Col>
|
||
<Col xs={12} md={4}><Statistic title="运行中" value={batch?.running_count || 0} /></Col>
|
||
<Col xs={12} md={4}><Statistic title="停止" value={batch?.stopped_count || 0} /></Col>
|
||
<Col xs={12} md={4}><Statistic title="批次" value={batch?.batch_id || '-'} /></Col>
|
||
</Row>
|
||
{batch?.message ? (
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||
{STATUS_LABELS[batch.status] || batch.status} · {batch.message}
|
||
</Text>
|
||
) : null}
|
||
<Table
|
||
rowKey="line"
|
||
columns={columns}
|
||
dataSource={batch?.items || []}
|
||
loading={refreshing && !isRunning}
|
||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||
scroll={{ x: 1320 }}
|
||
/>
|
||
</Card>
|
||
|
||
<Card
|
||
title="历史批次"
|
||
extra={(
|
||
<Button icon={<ReloadOutlined />} loading={historyLoading} onClick={loadHistory}>
|
||
刷新历史
|
||
</Button>
|
||
)}
|
||
>
|
||
<Table
|
||
rowKey="batch_id"
|
||
columns={historyColumns}
|
||
dataSource={history}
|
||
loading={historyLoading}
|
||
pagination={{ pageSize: 10 }}
|
||
scroll={{ x: 900 }}
|
||
onRow={(row) => ({
|
||
style: row.batch_id === batchId ? { background: 'rgba(22, 119, 255, 0.06)' } : undefined,
|
||
})}
|
||
/>
|
||
</Card>
|
||
|
||
<Card
|
||
title="成功流水(最近 100 条)"
|
||
extra={(
|
||
<Space>
|
||
<Button icon={<ReloadOutlined />} loading={logsLoading} onClick={loadSuccessLogs}>
|
||
刷新
|
||
</Button>
|
||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExportAllSuccess}>
|
||
导出
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
>
|
||
<Table
|
||
rowKey="id"
|
||
columns={logColumns}
|
||
dataSource={successLogs}
|
||
loading={logsLoading}
|
||
pagination={{ pageSize: 10 }}
|
||
scroll={{ x: 900 }}
|
||
/>
|
||
</Card>
|
||
</Space>
|
||
);
|
||
}
|