feat(huya): 多选登录改单线程队列并拆分路由与工作台
默认同时只登一个号,失败两次跳下一个,号间留间隔,结束后可一键重试失败账号。 顺带拆分虎牙路由与斗鱼工作台页面,并补齐 raw_cookie 重建入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user