虎牙自动注册持久化成功流水与批次,支持失败续跑与随时导出

将注册批次/条目/成功记录落库,成功一个写入一条流水;服务中断可恢复并续跑失败项,换电脑也能导出 txt。同时修复 pending 被误判为运行中导致页面卡住的问题。
This commit is contained in:
yml2213
2026-07-12 20:05:04 +08:00
parent c3044dbf69
commit e18b2c6e0c
9 changed files with 1423 additions and 96 deletions
+12
View File
@@ -3,6 +3,7 @@ import type {
HuyaAccountItem,
HuyaAutoRegisterBatch,
HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest,
HuyaConfig,
HuyaCookieItem,
HuyaCookieImportResult,
@@ -13,6 +14,7 @@ import type {
HuyaPasswordLoginResult,
HuyaPasswordLoginSelectedRequest,
HuyaRechargeGoodsItem,
HuyaRegisterSuccessLog,
HuyaSmsCodeRequest,
HuyaSmsCodeResult,
HuyaSmsLoginRequest,
@@ -43,10 +45,20 @@ export const huyaApi = {
api.post<HuyaSmsLoginResult, HuyaSmsLoginResult>('/huya/accounts/sms-login', data, { timeout: 120000 }),
startAutoRegister: (data: HuyaAutoRegisterRequest) =>
api.post<HuyaAutoRegisterBatch, HuyaAutoRegisterBatch>('/huya/register/batches', data),
listAutoRegisterBatches: (limit = 50) =>
api.get<HuyaAutoRegisterBatch[], HuyaAutoRegisterBatch[]>('/huya/register/batches', { params: { limit } }),
getAutoRegisterBatch: (batchId: string) =>
api.get<HuyaAutoRegisterBatch, HuyaAutoRegisterBatch>(`/huya/register/batches/${batchId}`),
stopAutoRegisterBatch: (batchId: string) =>
api.post<MessageResponse, MessageResponse>(`/huya/register/batches/${batchId}/stop`),
retryAutoRegisterBatch: (batchId: string, data?: HuyaAutoRegisterRetryRequest) =>
api.post<HuyaAutoRegisterBatch, HuyaAutoRegisterBatch>(`/huya/register/batches/${batchId}/retry`, data || {}),
exportAutoRegisterBatch: (batchId: string) =>
api.get<Blob, Blob>(`/huya/register/batches/${batchId}/export`, { responseType: 'blob' }),
listRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) =>
api.get<HuyaRegisterSuccessLog[], HuyaRegisterSuccessLog[]>('/huya/register/success-logs', { params }),
exportRegisterSuccessLogs: (params?: { batch_id?: string; tag?: string; limit?: number }) =>
api.get<Blob, Blob>('/huya/register/success-logs/export', { responseType: 'blob', params }),
assign: (id: number, assigned_to: number | null) =>
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
batchAssign: (account_ids: number[], assigned_to: number | null) =>
+26
View File
@@ -223,6 +223,15 @@ export interface HuyaAutoRegisterRequest {
use_proxy?: boolean;
}
export interface HuyaAutoRegisterRetryRequest {
concurrency?: number;
wait_seconds?: number;
poll_interval?: number;
password_prefix?: string;
fixed_password?: string;
use_proxy?: boolean;
}
export interface HuyaAutoRegisterItem {
line: number;
phone: string;
@@ -267,6 +276,23 @@ export interface HuyaAutoRegisterBatch {
items: HuyaAutoRegisterItem[];
}
export interface HuyaRegisterSuccessLog {
id: number;
batch_id: string;
item_id: number | null;
account_id: number | null;
phone: string;
username: string;
uid: string;
password: string;
sms_url: string;
tag: string;
provider: string;
created_by: number | null;
created_at: string | null;
export_line: string;
}
export interface HuyaPasswordLoginBatchItem {
line: number;
username: string;
+353 -27
View File
@@ -3,8 +3,15 @@ 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, ReloadOutlined, StopOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAutoRegisterBatch, type HuyaAutoRegisterItem } from '../api/modules';
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';
@@ -48,6 +55,9 @@ const STATUS_LABELS: Record<string, string> = {
success: '成功',
error: '失败',
stopped: '已停止',
interrupted: '已中断',
finished: '已完成',
running: '运行中',
};
const STATUS_COLORS: Record<string, string> = {
@@ -59,9 +69,14 @@ const STATUS_COLORS: Record<string, string> = {
success: 'success',
error: 'error',
stopped: 'warning',
interrupted: 'warning',
finished: 'success',
running: 'processing',
};
const RUNNING_STATUS = new Set(['pending', 'running']);
// 只有真正 running 才锁 UI / 轮询;pending 可能是历史脏数据,不能当运行中
const RUNNING_STATUS = new Set(['running']);
const RETRYABLE_BATCH = new Set(['finished', 'stopped', 'error', 'interrupted', 'pending']);
function safeNumber(value: unknown, fallback: number) {
const n = Number(value);
@@ -96,6 +111,15 @@ function readStoredBatchId() {
}
}
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);
@@ -108,13 +132,26 @@ export default function HuyaRegisterPage() {
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 && RUNNING_STATUS.has(batch.status);
const isRunning = !!batch && batch.status === 'running';
const hasUnfinished = !!batch && (
(batch.failed_count || 0) + (batch.stopped_count || 0) > 0
|| (batch.items || []).some((item) => item.status !== 'success')
|| ((batch.success_count || 0) < (batch.total || 0))
);
const canRetry = !!batch && !isRunning && hasUnfinished
&& (RETRYABLE_BATCH.has(batch.status) || batch.status === 'pending');
const loadBatchById = useCallback(async (id: string) => {
if (!id) return;
@@ -125,10 +162,10 @@ export default function HuyaRegisterPage() {
localStorage.setItem(BATCH_STORAGE_KEY, data.batch_id);
} catch (e: unknown) {
const err = getErrorMessage(e);
if (err.includes('批次不存在') || err.includes('服务已重启')) {
if (err.includes('批次不存在')) {
localStorage.removeItem(BATCH_STORAGE_KEY);
setBatch(null);
message.warning('上次自动注册批次不存在,已清除恢复记录');
message.warning('批次不存在,已清除本地恢复记录');
} else {
message.error(err);
}
@@ -142,6 +179,30 @@ export default function HuyaRegisterPage() {
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,
@@ -163,7 +224,9 @@ export default function HuyaRegisterPage() {
if (storedBatchId) {
loadBatchById(storedBatchId);
}
}, [loadBatchById]);
loadHistory();
loadSuccessLogs();
}, [loadBatchById, loadHistory, loadSuccessLogs]);
useEffect(() => {
if (batch?.batch_id) {
@@ -179,11 +242,28 @@ export default function HuyaRegisterPage() {
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 retryableCount = useMemo(() => {
if (!batch?.items?.length) {
return (batch?.failed_count || 0) + (batch?.stopped_count || 0);
}
return batch.items.filter((item) => item.status !== 'success').length;
}, [batch]);
const handleStart = async () => {
if (!text.trim()) {
message.warning('请先粘贴手机号池');
@@ -207,6 +287,7 @@ export default function HuyaRegisterPage() {
});
setBatch(data);
message.success('自动注册批次已启动');
loadHistory();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
@@ -228,21 +309,75 @@ export default function HuyaRegisterPage() {
}
};
const handleExportSuccess = () => {
if (successRows.length === 0) {
message.warning('当前批次没有可导出的成功账号');
return;
const handleRetry = async () => {
if (!batchId) return;
setRetrying(true);
try {
const data = await huyaApi.retryAutoRegisterBatch(batchId, {
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,
});
setBatch(data);
message.success(`已开始续跑 ${retryableCount} 条失败/未完成项`);
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 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' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `huya-register-accounts-${batchId || 'success'}.txt`;
link.click();
URL.revokeObjectURL(url);
};
const columns: TableProps<HuyaAutoRegisterItem>['columns'] = [
@@ -296,6 +431,125 @@ export default function HuyaRegisterPage() {
},
];
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, {
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>
@@ -315,7 +569,7 @@ export default function HuyaRegisterPage() {
<Input
value={tag}
onChange={(event) => setTag(event.target.value)}
placeholder="注册批次"
placeholder="注册批次(导出历史可按标签过滤)"
disabled={isRunning}
/>
</Col>
@@ -393,8 +647,8 @@ export default function HuyaRegisterPage() {
/>
</div>
</Col>
<Col xs={24} md={4}>
<Space style={{ width: '100%', paddingTop: 22 }}>
<Col xs={24} md={12}>
<Space wrap style={{ width: '100%', paddingTop: 22 }}>
<Button
type="primary"
icon={<PlayCircleOutlined />}
@@ -413,21 +667,44 @@ export default function HuyaRegisterPage() {
>
</Button>
<Button
icon={<RedoOutlined />}
loading={retrying}
disabled={!canRetry}
onClick={handleRetry}
>
{retryableCount > 0 ? ` (${retryableCount})` : ''}
</Button>
<Button
icon={<DownloadOutlined />}
loading={exporting}
onClick={handleExportAllSuccess}
>
</Button>
</Space>
</Col>
</Row>
<Text type="secondary">
txt
</Text>
</Space>
</Card>
<Card
title="批次结果"
title="当前批次结果"
extra={(
<Space>
<Button icon={<ReloadOutlined />} loading={refreshing} disabled={!batchId} onClick={refreshBatch}>
</Button>
<Button icon={<DownloadOutlined />} disabled={successRows.length === 0} onClick={handleExportSuccess}>
<Button
icon={<DownloadOutlined />}
loading={exporting}
disabled={!batchId}
onClick={handleExportCurrentBatch}
>
</Button>
</Space>
)}
@@ -440,6 +717,11 @@ export default function HuyaRegisterPage() {
<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}
@@ -449,6 +731,50 @@ export default function HuyaRegisterPage() {
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>
);
}