feat(huya): 多选登录改单线程队列并拆分路由与工作台

默认同时只登一个号,失败两次跳下一个,号间留间隔,结束后可一键重试失败账号。
顺带拆分虎牙路由与斗鱼工作台页面,并补齐 raw_cookie 重建入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 11:31:50 +08:00
co-authored by Cursor
parent 8dea9e3244
commit e81119117f
27 changed files with 6405 additions and 5409 deletions
+12 -2
View File
@@ -65,11 +65,19 @@ export const huyaApi = {
passwordLogin: (data: HuyaPasswordLoginRequest) =>
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
passwordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) =>
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>('/huya/accounts/password-login/selected', data),
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>(
'/huya/accounts/password-login/selected',
data,
{ timeout: 240000 },
),
appPasswordLogin: (data: HuyaAppPasswordLoginRequest) =>
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/app-password-login', data),
appPasswordLoginSelected: (data: HuyaPasswordLoginSelectedRequest) =>
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>('/huya/accounts/app-password-login/selected', data),
api.post<HuyaPasswordLoginBatchResult, HuyaPasswordLoginBatchResult>(
'/huya/accounts/app-password-login/selected',
data,
{ timeout: 240000 },
),
smsCode: (data: HuyaSmsCodeRequest) =>
api.post<HuyaSmsCodeResult, HuyaSmsCodeResult>('/huya/accounts/sms-code', data, { timeout: 120000 }),
smsLogin: (data: HuyaSmsLoginRequest) =>
@@ -125,6 +133,8 @@ export const huyaApi = {
deleteCookie: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/cookies/${id}`),
deleteCookies: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/cookies/batch', { params: { account_ids: accountIds.join(',') } }),
rebuildWebCookie: (accountId: number) =>
api.post<HuyaCookieItem, HuyaCookieItem>(`/huya/cookies/${accountId}/rebuild`),
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
+5
View File
@@ -658,6 +658,7 @@ export interface HuyaPasswordLoginBatchItem {
message: string;
account?: HuyaAccountItem;
sdid?: string;
attempts?: number;
}
export interface HuyaPasswordLoginBatchResult extends MessageCountResponse {
@@ -677,6 +678,9 @@ export interface HuyaPasswordLoginSelectedRequest {
assigned_only?: boolean;
has_cookie?: boolean;
force_new_device?: boolean;
max_attempts?: number;
interval_seconds?: number;
retry_delay_seconds?: number;
}
export interface HuyaCookieItem {
@@ -690,6 +694,7 @@ export interface HuyaCookieItem {
created_at: string | null;
cookie: string;
cookie_preview: string;
has_raw_cookie?: boolean;
}
export interface HuyaConfig {
File diff suppressed because it is too large Load Diff
+350 -64
View File
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Avatar,
Button,
Card,
Input,
InputNumber,
Modal,
Popconfirm,
Progress,
Select,
Space,
Table,
@@ -81,6 +83,40 @@ const STATUS_COLORS: Record<string, string> = {
exchange_record: 'purple',
};
const HUYA_LOGIN_INTERVAL_KEY = 'huya_login_interval_seconds';
const HUYA_LOGIN_DEFAULT_INTERVAL = 8;
const HUYA_LOGIN_MAX_ATTEMPTS = 2;
function readLoginIntervalSeconds(): number {
const stored = Number(localStorage.getItem(HUYA_LOGIN_INTERVAL_KEY));
if (!Number.isFinite(stored)) return HUYA_LOGIN_DEFAULT_INTERVAL;
return Math.min(30, Math.max(0, stored));
}
function sleepWithCountdown(
totalMs: number,
aborted: () => boolean,
onTick: (secondsLeft: number) => void,
): Promise<void> {
return new Promise((resolve) => {
const started = Date.now();
const tick = () => {
if (aborted()) {
resolve();
return;
}
const left = Math.max(0, totalMs - (Date.now() - started));
onTick(Math.ceil(left / 1000));
if (left <= 0) {
resolve();
return;
}
window.setTimeout(tick, Math.min(250, left));
};
tick();
});
}
function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
@@ -123,6 +159,16 @@ export default function HuyaAccountsPage() {
const [appPasswordLogging, setAppPasswordLogging] = useState(false);
const [loginModalTitle, setLoginModalTitle] = useState('虎牙 App 协议登录结果 (推荐)');
const [passwordLoginResults, setPasswordLoginResults] = useState<HuyaPasswordLoginBatchItem[]>([]);
const [loginQueuePhase, setLoginQueuePhase] = useState<'idle' | 'login' | 'wait' | 'done' | 'stopped'>('idle');
const [loginQueueIndex, setLoginQueueIndex] = useState(0);
const [loginQueueTotal, setLoginQueueTotal] = useState(0);
const [loginQueueUsername, setLoginQueueUsername] = useState('');
const [loginWaitLeft, setLoginWaitLeft] = useState(0);
const [loginIntervalSeconds, setLoginIntervalSeconds] = useState(readLoginIntervalSeconds);
const loginAbortRef = useRef(false);
const loginRunningRef = useRef(false);
const loginChannelRef = useRef<'app' | 'web'>('app');
const loginIntervalRef = useRef(loginIntervalSeconds);
const [smsLoginOpen, setSmsLoginOpen] = useState(false);
const [smsPhone, setSmsPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
@@ -239,6 +285,10 @@ export default function HuyaAccountsPage() {
setSelectedAllMatching(false);
};
useEffect(() => {
loginIntervalRef.current = loginIntervalSeconds;
}, [loginIntervalSeconds]);
useEffect(() => {
clearSelection();
}, [searchText, tagFilter]);
@@ -383,48 +433,216 @@ export default function HuyaAccountsPage() {
}
};
const resolveLoginAccountIds = async (): Promise<number[]> => {
if (selectedAllMatching) {
const data = await huyaApi.listAccountIds({
search: searchText.trim() || undefined,
tag: tagFilter || undefined,
});
return data.account_ids;
}
return selectedRowKeys.map((key) => Number(key));
};
const runLoginQueue = async (
ids: number[],
channel: 'app' | 'web',
options?: { append?: boolean },
) => {
if (ids.length === 0) {
message.warning('没有可登录的账号');
return;
}
if (loginRunningRef.current) {
message.warning('已有登录队列在执行');
return;
}
loginAbortRef.current = false;
loginRunningRef.current = true;
loginChannelRef.current = channel;
const channelLabel = channel === 'app' ? 'App 协议' : 'Web';
if (channel === 'app') {
setAppPasswordLogging(true);
} else {
setPasswordLogging(true);
}
setLoginQueueTotal(ids.length);
setLoginQueueIndex(0);
setLoginQueueUsername('');
setLoginWaitLeft(0);
setLoginQueuePhase('login');
setLoginModalTitle(`${channelLabel}登录中`);
if (!options?.append) {
setPasswordLoginResults([]);
}
setPasswordLoginResultOpen(true);
const usernameById = new Map(
accounts.map((account) => [
account.id,
account.username || account.nickname || String(account.id),
]),
);
let successCount = 0;
let failedCount = 0;
for (let index = 0; index < ids.length; index += 1) {
if (loginAbortRef.current) {
const remaining = ids.slice(index).map((accountId) => ({
line: accountId,
username: usernameById.get(accountId) || '',
success: false,
message: '队列已停止,未登录',
}));
setPasswordLoginResults((prev) => [...prev, ...remaining]);
failedCount += remaining.length;
setLoginQueuePhase('stopped');
break;
}
const accountId = ids[index];
setLoginQueueIndex(index + 1);
setLoginQueueUsername(usernameById.get(accountId) || `#${accountId}`);
setLoginQueuePhase('login');
try {
const payload = {
account_ids: [accountId],
all_matching: false,
search: '',
tag: '',
max_attempts: HUYA_LOGIN_MAX_ATTEMPTS,
interval_seconds: 0,
};
const result = channel === 'app'
? await huyaApi.appPasswordLoginSelected(payload)
: await huyaApi.passwordLoginSelected(payload);
const item = result.results?.[0] || {
line: accountId,
username: usernameById.get(accountId) || '',
success: false,
message: result.message || '登录失败',
};
if (item.success) successCount += 1;
else failedCount += 1;
setPasswordLoginResults((prev) => [...prev, item]);
} catch (e: unknown) {
failedCount += 1;
setPasswordLoginResults((prev) => [
...prev,
{
line: accountId,
username: usernameById.get(accountId) || '',
success: false,
message: getErrorMessage(e),
},
]);
}
const intervalSec = loginIntervalRef.current;
if (index < ids.length - 1 && !loginAbortRef.current && intervalSec > 0) {
setLoginQueuePhase('wait');
await sleepWithCountdown(
intervalSec * 1000,
() => loginAbortRef.current,
setLoginWaitLeft,
);
setLoginWaitLeft(0);
}
}
if (!loginAbortRef.current) {
setLoginQueuePhase('done');
}
loginRunningRef.current = false;
setAppPasswordLogging(false);
setPasswordLogging(false);
loadAccounts();
loadTags();
loadSummary();
if (loginAbortRef.current) {
message.warning(`${channelLabel}登录已停止:成功 ${successCount},失败/未执行 ${failedCount}`);
} else if (failedCount > 0) {
message.warning(`${channelLabel}登录完成:成功 ${successCount},失败 ${failedCount}`);
} else {
message.success(`${channelLabel}登录完成:成功 ${successCount} 条`);
}
};
const handleStopLoginQueue = () => {
if (!loginRunningRef.current) return;
loginAbortRef.current = true;
message.info('将在当前账号结束后停止队列');
};
const handleRetryFailedLogins = async () => {
const failedIds = passwordLoginResults
.filter((item) => !item.success)
.map((item) => Number(item.line))
.filter((id) => Number.isFinite(id) && id > 0);
if (failedIds.length === 0) {
message.warning('没有失败的账号可重试');
return;
}
setPasswordLoginResults((prev) => prev.filter((item) => item.success));
await runLoginQueue(failedIds, loginChannelRef.current, { append: true });
};
const handleAppLoginSelected = async () => {
if (selectedCount === 0) {
message.warning('请先选择虎牙账号');
return;
}
setAppPasswordLogging(true);
try {
const result = await huyaApi.appPasswordLoginSelected({
...selectionPayload,
});
setPasswordLoginResults(result.results || []);
setLoginModalTitle('虎牙 App 协议登录结果 (推荐)');
setPasswordLoginResultOpen(true);
if (result.failed > 0) {
message.warning(result.message);
} else {
message.success(result.message || 'App 登录成功,Cookie 已保存');
}
loadAccounts();
loadTags();
loadSummary();
const ids = await resolveLoginAccountIds();
await runLoginQueue(ids, 'app');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAppPasswordLogging(false);
}
};
// 行内单账号登录: 复用批量端点, 只传该行 id (需已导入密码)
const [rowLoginKey, setRowLoginKey] = useState('');
const handleRowLogin = async (record: HuyaAccountItem, channel: 'app' | 'web') => {
if (loginRunningRef.current) {
message.warning('已有登录队列在执行,请等待完成或先停止');
return;
}
setRowLoginKey(`${channel}-${record.id}`);
loginChannelRef.current = channel;
try {
const payload = { account_ids: [record.id], all_matching: false, search: '', tag: '' };
const result = channel === 'app'
? await huyaApi.appPasswordLoginSelected(payload)
: await huyaApi.passwordLoginSelected(payload);
? await huyaApi.appPasswordLoginSelected({
account_ids: [record.id],
all_matching: false,
search: '',
tag: '',
max_attempts: HUYA_LOGIN_MAX_ATTEMPTS,
interval_seconds: 0,
})
: await huyaApi.passwordLoginSelected({
account_ids: [record.id],
all_matching: false,
search: '',
tag: '',
max_attempts: HUYA_LOGIN_MAX_ATTEMPTS,
interval_seconds: 0,
});
setPasswordLoginResults(result.results || []);
setLoginQueuePhase('done');
setLoginQueueIndex(1);
setLoginQueueTotal(1);
setLoginQueueUsername(record.username || record.nickname || String(record.id));
setLoginModalTitle(channel === 'app' ? 'App 协议登录结果' : 'Web 旧版登录结果');
setPasswordLoginResultOpen(true);
const ok = (result.results || []).some((r) => r.success);
if (ok) { message.success('登录成功,Cookie 已保存'); } else { message.warning(result.message || '登录失败'); }
const ok = (result.results || []).some((item) => item.success);
if (ok) {
message.success('登录成功,Cookie 已保存');
} else {
message.warning(result.message || '登录失败');
}
loadAccounts();
loadSummary();
} catch (e: unknown) {
@@ -474,26 +692,11 @@ export default function HuyaAccountsPage() {
message.warning('请先选择虎牙账号');
return;
}
setPasswordLogging(true);
try {
const result = await huyaApi.passwordLoginSelected({
...selectionPayload,
});
setPasswordLoginResults(result.results || []);
setLoginModalTitle('虎牙 Web 密码登录结果 (旧版)');
setPasswordLoginResultOpen(true);
if (result.failed > 0) {
message.warning(result.message);
} else {
message.success(result.message || 'Web 登录成功,Cookie 已保存');
}
loadAccounts();
loadTags();
loadSummary();
const ids = await resolveLoginAccountIds();
await runLoginQueue(ids, 'web');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPasswordLogging(false);
}
};
@@ -579,6 +782,13 @@ export default function HuyaAccountsPage() {
<Tag color={success ? 'success' : 'error'}>{success ? '成功' : '失败'}</Tag>
),
},
{
title: '尝试',
dataIndex: 'attempts',
width: 70,
align: 'center',
render: (value?: number) => value || '-',
},
{
title: '保存账号',
width: 160,
@@ -897,6 +1107,7 @@ export default function HuyaAccountsPage() {
type="primary"
icon={<MobileOutlined />}
loading={rowLoginKey === `app-${record.id}`}
disabled={Boolean(rowLoginKey) || appPasswordLogging || passwordLogging}
onClick={() => handleRowLogin(record, 'app')}
/>
</Tooltip>
@@ -917,6 +1128,7 @@ export default function HuyaAccountsPage() {
size="small"
icon={<LoginOutlined />}
loading={rowLoginKey === `web-${record.id}`}
disabled={Boolean(rowLoginKey) || appPasswordLogging || passwordLogging}
onClick={() => handleRowLogin(record, 'web')}
/>
</Tooltip>
@@ -1421,25 +1633,31 @@ export default function HuyaAccountsPage() {
<Space wrap size={8}>
{canImport && (
<Button
type="primary"
size="small"
icon={<MobileOutlined />}
loading={appPasswordLogging}
onClick={handleAppLoginSelected}
>
App 协议登录 ({selectedCount})
</Button>
<Tooltip title="逐个登录,同时只登一个号;失败 2 次后跳下一个,号间默认间隔 8 秒">
<Button
type="primary"
size="small"
icon={<MobileOutlined />}
loading={appPasswordLogging}
disabled={passwordLogging}
onClick={handleAppLoginSelected}
>
App 协议登录 ({selectedCount})
</Button>
</Tooltip>
)}
{canImport && (
<Button
size="small"
icon={<LoginOutlined />}
loading={passwordLogging}
onClick={handleWebLoginSelected}
>
Web 登录
</Button>
<Tooltip title="逐个登录,同时只登一个号;失败 2 次后跳下一个,号间默认间隔 8 秒">
<Button
size="small"
icon={<LoginOutlined />}
loading={passwordLogging}
disabled={appPasswordLogging}
onClick={handleWebLoginSelected}
>
Web 登录
</Button>
</Tooltip>
)}
{canTag && (
<Button
@@ -1673,26 +1891,94 @@ export default function HuyaAccountsPage() {
<Modal
title={loginModalTitle}
open={passwordLoginResultOpen}
onCancel={() => setPasswordLoginResultOpen(false)}
onCancel={() => {
if (loginQueuePhase === 'login' || loginQueuePhase === 'wait') return;
setPasswordLoginResultOpen(false);
}}
footer={[
<Button key="ok" type="primary" onClick={() => setPasswordLoginResultOpen(false)}>
(loginQueuePhase === 'login' || loginQueuePhase === 'wait') ? (
<Button key="stop" danger onClick={handleStopLoginQueue}>
停止队列
</Button>
) : null,
(loginQueuePhase === 'done' || loginQueuePhase === 'stopped')
&& passwordLoginResults.some((item) => !item.success) ? (
<Button
key="retry"
icon={<ReloadOutlined />}
disabled={loginQueuePhase === 'login' || loginQueuePhase === 'wait'}
onClick={() => { void handleRetryFailedLogins(); }}
>
重试失败 ({passwordLoginResults.filter((item) => !item.success).length})
</Button>
) : null,
<Button
key="ok"
type="primary"
disabled={loginQueuePhase === 'login' || loginQueuePhase === 'wait'}
onClick={() => setPasswordLoginResultOpen(false)}
>
确定
</Button>,
]}
].filter(Boolean)}
maskClosable={loginQueuePhase !== 'login' && loginQueuePhase !== 'wait'}
closable={loginQueuePhase !== 'login' && loginQueuePhase !== 'wait'}
width={860}
>
<Alert
type="info"
type={loginQueuePhase === 'stopped' ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 12 }}
message="登录说明"
description="使用 App 协议登录时,系统已自动识别并通过 safe_auth 乱序滑块;若个别新账号提示“要求扫码验证 (qr_auth)”,则需在手机虎牙 App 上正常登录一次以建立设备信任。"
message={
loginQueuePhase === 'login'
? `正在登录 ${loginQueueIndex}/${loginQueueTotal} · ${loginQueueUsername || '—'}`
: loginQueuePhase === 'wait'
? `等待 ${loginWaitLeft}s 后登录下一个(${loginQueueIndex}/${loginQueueTotal})`
: loginQueuePhase === 'stopped'
? '队列已停止'
: '登录说明'
}
description={
loginQueuePhase === 'login' || loginQueuePhase === 'wait'
? `默认单线程,同时只登录一个号;失败最多尝试 ${HUYA_LOGIN_MAX_ATTEMPTS} 次再跳下一个。号与号之间间隔 ${loginIntervalSeconds} 秒。`
: '使用 App 协议登录时,系统已自动识别并通过 safe_auth 乱序滑块;若个别新账号提示“要求扫码验证 (qr_auth)”,则需在手机虎牙 App 上正常登录一次以建立设备信任。失败的账号可一键重试。'
}
/>
<Space wrap style={{ marginBottom: 12 }} align="center">
<Text type="secondary">号间间隔(秒)</Text>
<InputNumber
size="small"
min={0}
max={30}
value={loginIntervalSeconds}
disabled={loginQueuePhase === 'login' || loginQueuePhase === 'wait'}
onChange={(value) => {
const next = Math.min(30, Math.max(0, Number(value ?? HUYA_LOGIN_DEFAULT_INTERVAL)));
setLoginIntervalSeconds(next);
localStorage.setItem(HUYA_LOGIN_INTERVAL_KEY, String(next));
}}
style={{ width: 88 }}
/>
<Text type="secondary">
成功 {passwordLoginResults.filter((item) => item.success).length} · 失败 {passwordLoginResults.filter((item) => !item.success).length}
</Text>
</Space>
{(loginQueuePhase === 'login' || loginQueuePhase === 'wait') && loginQueueTotal > 0 && (
<Progress
percent={Math.round(
((loginQueuePhase === 'login'
? Math.max(0, loginQueueIndex - 1)
: loginQueueIndex) / loginQueueTotal) * 100,
)}
status="active"
style={{ marginBottom: 12 }}
/>
)}
{passwordLoginResults.length > 0 && (
<Table
columns={passwordLoginResultColumns}
dataSource={passwordLoginResults}
rowKey={(record) => `${record.line}-${record.username || 'empty'}`}
rowKey={(record, index) => `${record.line}-${record.username || 'empty'}-${index}`}
size="small"
pagination={false}
scroll={{ x: 720, y: 260 }}
+22 -2
View File
@@ -3,7 +3,7 @@ import {
Button, Card, Col, Dropdown, Input, Popconfirm, Row, Space, Statistic, Table, Tag, theme, Typography,
} from 'antd';
import { message } from '../utils/antdMessage';
import { CopyOutlined, DeleteOutlined, DownloadOutlined, SearchOutlined } from '@ant-design/icons';
import { CopyOutlined, DeleteOutlined, DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { huyaApi, type BasicSummary, type HuyaCookieItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
@@ -52,6 +52,7 @@ export default function HuyaCookiePage() {
const canManage = can('huya:account');
const canView = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
const canExport = can('huya:cookie:export') || canManage;
const canRebuild = can('huya:import') || can('huya:task') || canExport;
const loadCookies = useCallback(async () => {
setLoading(true);
@@ -145,6 +146,16 @@ export default function HuyaCookiePage() {
}
};
const handleRebuild = async (record: HuyaCookieItem) => {
try {
await huyaApi.rebuildWebCookie(record.account_id);
message.success(`已从原始凭据重建 ${record.account_username} 的网页 Cookie`);
loadCookies();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要清除的 Cookie');
@@ -201,7 +212,7 @@ export default function HuyaCookiePage() {
},
{
title: '操作',
width: 130,
width: 210,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, record: HuyaCookieItem) => (
@@ -209,6 +220,15 @@ export default function HuyaCookiePage() {
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopyCookie(record)}>
复制
</Button>
{canRebuild && record.has_raw_cookie && (
<Popconfirm
title="用 App 原始凭据重建网页 Cookie?"
description="不会重新登录或下单,只补齐网页设备字段。"
onConfirm={() => handleRebuild(record)}
>
<Button size="small" icon={<ReloadOutlined />}>重建</Button>
</Popconfirm>
)}
{canExport && (
<Popconfirm title="确认清除这条 Cookie?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
import type { ReactNode } from 'react';
import {
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
QrcodeOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined,
} from '@ant-design/icons';
import type { DouyuGoodsItem, DouyuTaskItem } from '../../api/modules';
export type HandbookKind = 'elite' | 'esports' | 'peace';
export const TASK_STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
stopped: 'warning',
};
export const TASK_STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
stopped: '已停止',
};
export const ELITE_QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
{ key: 'query_game_name', icon: <SearchOutlined /> },
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
];
export const ESPORTS_QUICK_ACTIONS = [
{ key: 'prepare_esports_bind', icon: <QrcodeOutlined /> },
{ key: 'query_esports_game_name', icon: <SearchOutlined /> },
{ key: 'create_esports_qr', icon: <CreditCardOutlined /> },
{ key: 'query_esports_points', icon: <SearchOutlined /> },
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'refresh_esports_goods', icon: <ReloadOutlined /> },
{ key: 'exchange_esports_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_esports_chicken_gift', icon: <GiftOutlined /> },
{ key: 'donate_esports_firework_gift', icon: <GiftOutlined /> },
];
export const PEACE_QUICK_ACTIONS = [
{ key: 'get_xpd_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'query_xpd_bind_info', icon: <SearchOutlined /> },
{ key: 'confirm_xpd_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_xpd_role', icon: <SearchOutlined /> },
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
{ key: 'query_xpd_purchase_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'exchange_xpd_goods', icon: <ShoppingOutlined /> },
];
export const ELITE_TASK_TYPES = new Set([
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'create_gold_qr',
'donate_elite_gift',
'query_points',
'lock_goods',
'pay_locked_order',
'exchange_goods',
'query_game_name',
'query_change_bind_time',
'query_limited_goods',
'query_gold_balance',
'refresh_goods',
'query_exchange_records',
'prefetch_csrf_token',
]);
export const ESPORTS_TASK_TYPES = new Set([
'prepare_esports_bind',
'get_esports_bind_qr',
'query_esports_game_name',
'confirm_esports_bind',
'create_esports_qr',
'query_esports_points',
'query_gold_balance',
'query_change_bind_time',
'query_limited_goods',
'refresh_esports_goods',
'exchange_esports_goods',
'create_gold_qr',
'donate_esports_chicken_gift',
'donate_esports_firework_gift',
]);
export const PEACE_TASK_TYPES = new Set([
'get_xpd_bind_qr',
'query_xpd_bind_info',
'confirm_xpd_bind',
'query_xpd_role',
'refresh_xpd_goods',
'query_xpd_balance',
'query_xpd_fragments',
'query_xpd_purchase_records',
'exchange_xpd_goods',
]);
export const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
export const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
export const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode';
// v2 避开旧版页面切换时写入其他工作台的共享账号列表。
export const DOUYU_WORKBENCH_IDS_STORAGE_KEY = (kind: HandbookKind) => `douyu_task_workbench_ids_v2_${kind}`;
export const CONFIRM_FAIL_TIP_SECONDS = 6;
export function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
const value = result?.[key];
return typeof value === 'string' ? value : '';
}
export function taskPayloadText(task: DouyuTaskItem, key: string): string {
const payload = task.result?.payload;
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return '';
return resultText(payload as Record<string, unknown>, key);
}
export function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
const value = result?.[key];
if (value === true) return true;
if (value === false || value == null) return false;
const text = String(value).trim().toLowerCase();
return text === '1' || text === 'true' || text === 'yes';
}
// 提取任务结果中的二维码 URL:绑定/电竞绑定用 url,支付类用 pay_url
export function taskQrUrl(task: DouyuTaskItem | null | undefined): string {
if (!task) return '';
if (task.task_type === 'get_bind_qr') return resultText(task.result, 'url');
if (task.task_type === 'get_xpd_bind_qr') return resultText(task.result, 'url');
if (['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) {
return resultText(task.result, 'pay_url');
}
if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(task.task_type)) {
return resultText(task.result, 'url');
}
return '';
}
export function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
if (!task) return false;
if (task.task_type === 'get_xpd_bind_qr') {
if (resultFlag(task.result, 'xpd_bound') || resultFlag(task.result, 'bind_confirmed')) return false;
// 轮询识别到角色后停在待确认(xpd_pending_confirm),有角色名即可确认绑定
return Boolean(resultText(task.result, 'role_name'));
}
if (task.task_type !== 'get_bind_qr') return false;
if (task.result?.bind_ready_for_confirm === true) return true;
const roleName = resultText(task.result, 'role_name');
if (!roleName) return false;
// 已确认绑定的角色不能再次直接确认
if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) {
return false;
}
return true;
}
export function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean {
if (!task) return false;
if (!['get_bind_qr', 'get_xpd_bind_qr'].includes(task.task_type)) return false;
// 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板
return Boolean(resultText(task.result, 'url'))
|| bindReadyForConfirm(task)
|| resultFlag(task.result, 'esports_bound');
}
export function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean {
return Boolean(
task
&& ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)
&& resultText(task.result, 'pay_url'),
);
}
export function paymentArrived(task: DouyuTaskItem | null | undefined): boolean {
if (!task || !['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)) return false;
if (task.status !== 'success') return false;
return task.result?.gold_recharged === true
|| task.result?.elite_opened === true
|| task.result?.esports_opened === true;
}
export function bindPhaseText(phase: string, polling: boolean): string {
if (phase === 'role_ready') return '已识别角色,待确认';
if (phase === 'change_waiting') return '换绑冷却中';
if (phase === 'role_timeout') return '未检测到角色';
if (phase === 'stopped') return '任务已停止';
if (phase === 'confirmed') return '已绑定';
if (phase === 'confirm_failed') return '确认未生效';
if (polling || phase === 'waiting_scan') return '等待扫码绑定';
return phase || '等待绑定';
}
export function goodsLabel(item: DouyuGoodsItem): string {
const stock = item.raw?.count;
const stockText =
stock != null && Number.isFinite(Number(stock)) ? ` / 库存${stock}` : '';
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}${stockText}`;
}
// 道聚城 iPrice 为 10 倍点券单位(如 6800=680 点券),仅展示层换算,兑换请求仍传原值。
export const XPD_POINT_PRICE_SCALE = 10;
export function xpdPointPrice(item: DouyuGoodsItem): number {
const xpd = item as DouyuGoodsItem & { price?: number | null };
const raw = item.raw?.raw as Record<string, unknown> | undefined;
const rawPrice = Number(xpd.price ?? raw?.iPrice);
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 0;
return Math.round(rawPrice / XPD_POINT_PRICE_SCALE);
}
export function xpdGoodsLabel(item: DouyuGoodsItem): string {
const xpd = item as DouyuGoodsItem & { price?: number | null; goods_left?: number | null };
const raw = item.raw?.raw as Record<string, unknown> | undefined;
const pointPrice = xpdPointPrice(item);
const fragmentPrice = Number(raw?.iJb2Price);
const prices = [
Number.isFinite(pointPrice) && pointPrice > 0 ? `${pointPrice}点券` : '',
Number.isFinite(fragmentPrice) && fragmentPrice > 0 ? `${fragmentPrice}碎片` : '',
].filter(Boolean);
const stock = xpd.goods_left;
const stockText = stock != null && Number.isFinite(Number(stock))
? (Number(stock) < 0 ? '库存未公开' : `库存${stock}`)
: '库存未知';
return `${item.name || item.commodity_id}${prices.length ? ` / ${prices.join(' 或 ')}` : ' / 暂不可兑换'} / ${stockText}`;
}
export function xpdGoodsPaymentOptions(item: DouyuGoodsItem | undefined): Array<{ value: '1' | '5'; label: string }> {
if (!item) return [];
const raw = item.raw?.raw as Record<string, unknown> | undefined;
const pointPrice = xpdPointPrice(item);
const fragmentPrice = Number(raw?.iJb2Price);
return [
Number.isFinite(pointPrice) && pointPrice > 0
? { value: '1' as const, label: `使用点券兑换(${pointPrice}点券)` }
: null,
Number.isFinite(fragmentPrice) && fragmentPrice > 0
? { value: '5' as const, label: `使用扭蛋碎片兑换(${fragmentPrice}碎片)` }
: null,
].filter((option): option is { value: '1' | '5'; label: string } => option !== null);
}
export function formatWaitSeconds(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(Number(seconds))) return '';
const total = Math.max(0, Math.floor(Number(seconds)));
if (total <= 0) return '可换绑';
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (days > 0) return `${days}天${hours}小时${minutes}分`;
if (hours > 0) return `${hours}小时${minutes}分${secs}秒`;
return `${minutes}分${secs}秒`;
}
export function esportsRebindStatus(canChangeTime: number | null | undefined): {
available: boolean;
text: string;
} | null {
if (canChangeTime == null || Number.isNaN(Number(canChangeTime))) return null;
const timestamp = Math.floor(Number(canChangeTime));
if (timestamp <= 0 || timestamp <= Math.floor(Date.now() / 1000)) {
return { available: true, text: '可换绑' };
}
return {
available: false,
text: new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false }),
};
}
export function resultNumber(result: Record<string, unknown> | null | undefined, key: string): number | null {
const value = result?.[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
return null;
}
export function limitedGoodsName(item: unknown): string {
if (!item || typeof item !== 'object') return '';
const record = item as Record<string, unknown>;
const value = record.commodityName ?? record.name;
return typeof value === 'string' ? value : '';
}
export function limitedGoodsTaskText(task: DouyuTaskItem | null | undefined): string {
if (!task) return '';
if (task.message?.trim()) return task.message.trim();
const count = resultNumber(task.result, 'limited_count');
if (count == null) return '';
if (count <= 0) return '无限制商品';
const goods = task.result?.limited_goods;
const names = Array.isArray(goods)
? goods.map(limitedGoodsName).filter(Boolean).slice(0, 5)
: [];
return names.length ? `限兑 ${count} 个: ${names.join(', ')}` : `限兑 ${count} 个`;
}
export function savedPositiveInteger(key: string, fallback = 1): number {
const value = Number(localStorage.getItem(key));
return Number.isInteger(value) && value >= 1 ? value : fallback;
}
export const HANDBOOK_META: Record<HandbookKind, {
title: string;
description: string;
taskTypes: Set<string>;
quickActions: Array<{ key: string; icon: ReactNode }>;
}> = {
elite: {
title: '精英宝典工作台',
description: '精英宝典绑定、开通、积分与兑换任务',
taskTypes: ELITE_TASK_TYPES,
quickActions: ELITE_QUICK_ACTIONS,
},
esports: {
title: '电竞手册工作台',
description: '电竞手册角色绑定与开通任务',
taskTypes: ESPORTS_TASK_TYPES,
quickActions: ESPORTS_QUICK_ACTIONS,
},
peace: {
title: '和平小店工作台',
description: '和平小店绑定角色、商品与点券余额查询',
taskTypes: PEACE_TASK_TYPES,
quickActions: PEACE_QUICK_ACTIONS,
},
};