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

821 lines
26 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 { 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>
);
}