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 = { pending: '等待', sending: '发码', waiting: '等待验证码', logging: '保存', changing: '改密', success: '成功', error: '失败', stopped: '已停止', interrupted: '已中断', finished: '已完成', running: '运行中', }; const STATUS_COLORS: Record = { 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; 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(initialForm.passwordMode); const [passwordPrefix, setPasswordPrefix] = useState(initialForm.passwordPrefix); const [fixedPassword, setFixedPassword] = useState(initialForm.fixedPassword); const [useProxy, setUseProxy] = useState(initialForm.useProxy); const [batch, setBatch] = useState(null); const [history, setHistory] = useState([]); const [successLogs, setSuccessLogs] = useState([]); 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['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) => ( {STATUS_LABELS[status] || status} ), }, { 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['columns'] = [ { title: '批次', dataIndex: 'batch_id', width: 130, render: (value: string) => {value}, }, { title: '标签', dataIndex: 'tag', width: 120, render: (value: string) => value || '-', }, { title: '状态', dataIndex: 'status', width: 100, render: (status: string) => ( {STATUS_LABELS[status] || status} ), }, { 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) => ( ), }, ]; const logColumns: TableProps['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 ( 虎牙自动注册