新增电竞手册独立绑定流程

This commit is contained in:
yml2213
2026-07-28 17:12:08 +08:00
parent 63962811f8
commit d7a8666fe4
13 changed files with 3725 additions and 145 deletions
+3 -1
View File
@@ -72,7 +72,9 @@ function AppContent() {
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="douyu/tasks" element={lazyRoute(<DouyuTasksPage />)} />
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage handbook="esports" />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/register" element={lazyRoute(<HuyaRegisterPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
+8
View File
@@ -196,6 +196,11 @@ export interface DouyuTaskAccountItem {
exchange_balance: number | null;
bind_status: string;
change_role_wait_time: number | null;
esports_points: number | null;
esports_game_name: string;
esports_game_channel: string;
esports_bind_status: string;
esports_change_role_wait_time: number | null;
assigned_to: number | null;
assigned_username: string | null;
}
@@ -208,6 +213,9 @@ export interface DouyuConfig {
legacy_act_alias: string;
room_id: string;
elite_amount: number;
esports_manual_id: string;
esports_act_alias: string;
esports_amount: number;
gold_pay_type: number;
gift_id: string;
skin_id: string;
+3 -1
View File
@@ -6,6 +6,7 @@ import {
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
@@ -71,7 +72,8 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
}
// 虎牙
+510 -142
View File
@@ -24,7 +24,9 @@ import { message } from '../utils/antdMessage';
const { Text } = Typography;
const QUICK_ACTIONS = [
type HandbookKind = 'elite' | 'esports';
const ELITE_QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
@@ -38,8 +40,37 @@ const QUICK_ACTIONS = [
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
];
const BIND_ACTION_KEYS = ['get_bind_qr', 'confirm_bind', 'query_game_name', 'query_change_bind_time'];
const QUERY_ACTION_KEYS = ['query_points', 'query_limited_goods', 'query_gold_balance'];
const ESPORTS_QUICK_ACTIONS = [
{ key: 'prepare_esports_bind', icon: <QrcodeOutlined /> },
{ key: 'query_esports_game_name', icon: <SearchOutlined /> },
{ key: 'create_esports_qr', icon: <CreditCardOutlined /> },
];
const ELITE_BIND_ACTION_KEYS = ['get_bind_qr', 'confirm_bind', 'query_game_name', 'query_change_bind_time'];
const ELITE_QUERY_ACTION_KEYS = ['query_points', 'query_limited_goods', 'query_gold_balance'];
const ELITE_TASK_TYPES = new Set([
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'create_gold_qr',
'donate_elite_gift',
'query_points',
'exchange_goods',
'query_game_name',
'query_change_bind_time',
'query_limited_goods',
'query_gold_balance',
'refresh_goods',
'query_exchange_records',
'prefetch_csrf_token',
]);
const ESPORTS_TASK_TYPES = new Set([
'prepare_esports_bind',
'get_esports_bind_qr',
'query_esports_game_name',
'confirm_esports_bind',
'create_esports_qr',
]);
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
@@ -58,6 +89,14 @@ const BIND_STATUS_LABELS: Record<string, string> = {
game_queried: '已识别角色',
game_not_bound: '未绑定',
bind_confirmed: '已绑定',
esports_bind_qr_generated: '电竞待绑定',
esports_role_queried: '已查电竞角色',
esports_bind_ready: '电竞待确认',
esports_role_switching: '电竞换角色',
esports_bind_confirming: '电竞绑定中',
esports_bound: '电竞已绑定',
esports_qr_created: '电竞待开通',
esports_opened: '电竞已开通',
change_time_queried: '已查换绑',
};
@@ -93,17 +132,25 @@ function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean {
if (!task || task.task_type !== 'get_bind_qr') return false;
// 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板
return Boolean(resultText(task.result, 'url')) || bindReadyForConfirm(task);
return Boolean(resultText(task.result, 'url'))
|| bindReadyForConfirm(task)
|| resultFlag(task.result, 'esports_bound');
}
function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean {
return Boolean(task && ['create_elite_qr', 'create_gold_qr'].includes(task.task_type) && resultText(task.result, 'pay_url'));
return Boolean(
task
&& ['create_elite_qr', 'create_esports_qr', 'create_gold_qr'].includes(task.task_type)
&& resultText(task.result, 'pay_url'),
);
}
function paymentArrived(task: DouyuTaskItem | null | undefined): boolean {
if (!task || !['create_elite_qr', 'create_gold_qr'].includes(task.task_type)) return false;
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;
return task.result?.gold_recharged === true
|| task.result?.elite_opened === true
|| task.result?.esports_opened === true;
}
function bindPhaseText(phase: string, polling: boolean): string {
@@ -149,7 +196,14 @@ function savedPositiveInteger(key: string, fallback = 1): number {
return Number.isInteger(value) && value >= 1 ? value : fallback;
}
export default function DouyuTasksPage() {
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
const isEsportsHandbook = handbook === 'esports';
const handbookTitle = isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台';
const handbookDescription = isEsportsHandbook
? '电竞手册角色绑定与开通任务'
: '精英宝典绑定、开通、积分与兑换任务';
const activeTaskTypes = isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES;
const quickActions = isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS;
const { can } = usePermissions();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
@@ -174,10 +228,13 @@ export default function DouyuTasksPage() {
// 绑定/支付弹窗以 task 为唯一数据源
const [qrTask, setQrTask] = useState<DouyuTaskItem | null>(null);
const [esportsBindTask, setEsportsBindTask] = useState<DouyuTaskItem | null>(null);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
const autoOpenedEsportsBindTaskIds = useRef<Set<number>>(new Set());
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenEsportsBindReady = useRef(false);
const autoOpenPayReady = useRef(false);
const logs = useWebSocketLogs();
@@ -192,22 +249,41 @@ export default function DouyuTasksPage() {
return map;
}, [tasks]);
const latestEsportsStateTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (!['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
.includes(task.task_type) || !task.result) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const visibleTasks = useMemo(
() => tasks.filter((task) => activeTaskTypes.has(task.task_type)),
[activeTaskTypes, tasks],
);
// 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段
const latestChangeWaitByAccount = useMemo(() => {
const map = new Map<number, { wait: number | null; text: string; taskId: number }>();
for (const task of tasks) {
if (!['query_change_bind_time', 'get_bind_qr'].includes(task.task_type)) continue;
const taskTypes = isEsportsHandbook
? ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
: ['query_change_bind_time', 'get_bind_qr'];
if (!taskTypes.includes(task.task_type)) continue;
if (!['success', 'failed'].includes(task.status)) continue;
const wait = resultNumber(task.result, 'change_role_wait_time');
// get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖
if (task.task_type === 'get_bind_qr' && wait == null) continue;
if (!isEsportsHandbook && task.task_type === 'get_bind_qr' && wait == null) continue;
const existing = map.get(task.account_id);
if (existing && existing.taskId > task.id) continue;
const text = resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait);
map.set(task.account_id, { wait, text, taskId: task.id });
}
return map;
}, [tasks]);
}, [isEsportsHandbook, tasks]);
const loadData = useCallback(async () => {
setLoading(true);
@@ -227,9 +303,13 @@ export default function DouyuTasksPage() {
if (!autoOpenQrReady.current) {
for (const task of taskResult.value) {
if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id);
if (task.task_type === 'prepare_esports_bind' && task.result?.esports_bind_dialog === true) {
autoOpenedEsportsBindTaskIds.current.add(task.id);
}
if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id);
}
autoOpenQrReady.current = true;
autoOpenEsportsBindReady.current = true;
autoOpenPayReady.current = true;
}
}
@@ -264,8 +344,8 @@ export default function DouyuTasksPage() {
}, [giftCount]);
const hasActiveTasks = useMemo(
() => tasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
[tasks],
() => visibleTasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
[visibleTasks],
);
// 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新
@@ -277,33 +357,55 @@ export default function DouyuTasksPage() {
const openQrTask = useCallback((task: DouyuTaskItem) => {
autoOpenedQrTaskIds.current.add(task.id);
setEsportsBindTask(null);
setPayTask(null);
setQrTask(task);
}, []);
const openEsportsBindTask = useCallback((task: DouyuTaskItem) => {
autoOpenedEsportsBindTaskIds.current.add(task.id);
setQrTask(null);
setPayTask(null);
setEsportsBindTask(task);
}, []);
const openPayTask = useCallback((task: DouyuTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id);
setQrTask(null);
setEsportsBindTask(null);
setPayTask(task);
}, []);
// 自动弹出最新绑定二维码(running 有 url 即可)
useEffect(() => {
if (!autoOpenQrReady.current || qrTask) return;
const nextQrTask = tasks
const nextQrTask = visibleTasks
.filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextQrTask) openQrTask(nextQrTask);
}, [openQrTask, qrTask, tasks]);
}, [openQrTask, qrTask, visibleTasks]);
// 电竞手册先查状态再打开独立绑定面板,不把切换角色二维码当作主流程。
useEffect(() => {
if (!autoOpenEsportsBindReady.current || esportsBindTask) return;
const nextTask = visibleTasks
.filter((task) => (
task.task_type === 'prepare_esports_bind'
&& task.result?.esports_bind_dialog === true
&& !autoOpenedEsportsBindTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id)[0];
if (nextTask) openEsportsBindTask(nextTask);
}, [esportsBindTask, openEsportsBindTask, visibleTasks]);
// 自动弹出支付二维码
useEffect(() => {
if (!autoOpenPayReady.current || payTask) return;
const nextPayTask = tasks
const nextPayTask = visibleTasks
.filter((task) => hasPaymentQrcode(task) && !paymentArrived(task) && !autoOpenedPayTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, tasks]);
}, [openPayTask, payTask, visibleTasks]);
// 弹窗跟随最新 task 状态(后端 progress 写入后 UI 自动更新)
useEffect(() => {
@@ -312,6 +414,12 @@ export default function DouyuTasksPage() {
if (latest && latest !== qrTask) setQrTask(latest);
}, [qrTask, tasks]);
useEffect(() => {
if (!esportsBindTask) return;
const latest = tasks.find((task) => task.id === esportsBindTask.id);
if (latest && latest !== esportsBindTask) setEsportsBindTask(latest);
}, [esportsBindTask, tasks]);
useEffect(() => {
if (!payTask) return;
const latest = tasks.find((task) => task.id === payTask.id);
@@ -325,14 +433,25 @@ export default function DouyuTasksPage() {
const batchBusy = Boolean(runningBatchId || logs.connected || hasActiveTasks);
const startTask = async (taskType: string, accountIds?: number[]) => {
const startTask = async (
taskType: string,
accountIds?: number[],
extraPayload: Record<string, unknown> = {},
) => {
const ids = accountIds || selectedIds;
if (ids.length === 0) {
message.warning('请先选择账号');
return;
}
// 绑定确认/查询允许在其他账号仍轮询时继续,避免卡在多账号批次里
const allowDuringBatch = taskType === 'confirm_bind' || taskType === 'query_game_name';
const allowDuringBatch = [
'confirm_bind',
'confirm_esports_bind',
'query_game_name',
'query_esports_game_name',
'prepare_esports_bind',
'get_esports_bind_qr',
].includes(taskType);
if (batchBusy && !allowDuringBatch) {
message.warning('当前已有批次在运行,请先停止或等待结束');
return;
@@ -341,7 +460,7 @@ export default function DouyuTasksPage() {
message.warning('请先选择兑换商品');
return;
}
const payload: Record<string, unknown> = {};
const payload: Record<string, unknown> = { ...extraPayload };
if (taskType === 'exchange_goods') payload.commodity_id = selectedGoodsId;
if (taskType === 'create_gold_qr') payload.amount = goldAmount;
if (taskType === 'donate_elite_gift') payload.gift_count = giftCount;
@@ -460,12 +579,87 @@ export default function DouyuTasksPage() {
void startTask(taskType, contextMenu.accountIds);
};
// ---- 电竞手册绑定面板 ----
const esportsRelatedTasks = esportsBindTask
? tasks
.filter((task) => (
task.account_id === esportsBindTask.account_id
&& task.id >= esportsBindTask.id
&& ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
.includes(task.task_type)
))
.sort((a, b) => b.id - a.id)
: [];
const esportsLatestStateTask = esportsRelatedTasks.find((task) => task.result) || esportsBindTask;
const esportsLatestRoleTask = esportsRelatedTasks.find(
(task) => task.task_type === 'query_esports_game_name',
) || null;
const esportsLatestQrTask = esportsRelatedTasks.find(
(task) => Boolean(resultText(task.result, 'url')),
) || null;
const esportsConfirmRunning = esportsRelatedTasks.some(
(task) => task.task_type === 'confirm_esports_bind' && ['planned', 'pending', 'running'].includes(task.status),
);
const esportsRoleQueryRunning = Boolean(
esportsLatestRoleTask && ['planned', 'pending', 'running'].includes(esportsLatestRoleTask.status),
);
const esportsResult = esportsLatestStateTask?.result || esportsBindTask?.result || null;
const esportsBound = resultFlag(esportsResult, 'esports_bound') || resultFlag(esportsResult, 'bind_confirmed');
const esportsRoleName = resultText(esportsResult, 'role_name');
const esportsAreaName = resultText(esportsResult, 'area_name');
const esportsPlatName = resultText(esportsResult, 'plat_name');
const esportsRoleLine = [esportsPlatName, esportsAreaName].filter(Boolean).join(' / ');
const esportsCanChangeRole = Boolean(esportsBindTask && !esportsConfirmRunning);
const esportsChangeWait = resultNumber(esportsResult, 'change_role_wait_time');
const esportsQrUrl = resultText(esportsLatestQrTask?.result, 'url');
const esportsAccountName = esportsBindTask
? (
esportsBindTask.account_nickname
|| esportsBindTask.account_username
|| esportsBindTask.account_uid
|| `#${esportsBindTask.account_id}`
)
: '';
const esportsStatusText = esportsBound
? '电竞手册已绑定'
: esportsConfirmRunning
? '正在完成电竞手册绑定'
: esportsRoleQueryRunning
? '正在查询最新角色'
: esportsLatestStateTask?.message || '加载电竞手册绑定状态';
const esportsCanConfirm = Boolean(
esportsBindTask
&& !esportsBound
&& esportsRoleName
&& !esportsConfirmRunning,
);
const queryEsportsRole = () => {
if (!esportsBindTask || esportsRoleQueryRunning) return;
void startTask('query_esports_game_name', [esportsBindTask.account_id]);
};
const switchEsportsRole = () => {
if (!esportsBindTask || !esportsCanChangeRole) return;
void startTask('get_esports_bind_qr', [esportsBindTask.account_id]);
};
const confirmEsportsBindFromDialog = () => {
if (!esportsBindTask || !esportsCanConfirm) {
message.warning('请先确认当前角色');
return;
}
void startTask('confirm_esports_bind', [esportsBindTask.account_id]);
};
// ---- QR panel derived state ----
const qrResult = qrTask?.result || null;
const qrUrl = resultText(qrResult, 'url');
const qrBindPhase = resultText(qrResult, 'bind_phase');
const qrAutoPolling = qrTask?.status === 'running' && qrResult?.bind_polling === true;
const qrQueryTask = qrTask ? latestQueryGameTaskByAccount.get(qrTask.account_id) || null : null;
const qrQueryTask = qrTask
? latestQueryGameTaskByAccount.get(qrTask.account_id) || null
: null;
const qrQueryResult = qrTask && qrQueryTask && qrQueryTask.id > qrTask.id ? qrQueryTask.result : null;
const qrQueryRunning = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status === 'running');
const qrQueryRoleName = resultText(qrQueryResult, 'role_name');
@@ -485,14 +679,29 @@ export default function DouyuTasksPage() {
const qrRoleName = resultText(qrRoleSourceResult, 'role_name');
const qrAreaName = resultText(qrRoleSourceResult, 'area_name');
const qrPlatName = resultText(qrRoleSourceResult, 'plat_name');
const qrCurrentRoleName = resultText(qrResult, 'current_role_name')
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'role_name') : '')
const qrCurrentRoleName = qrQueryRoleName
|| resultText(qrResult, 'current_role_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'role_name')
: ''
)
|| (qrQueryIsBoundAct ? qrQueryRoleName : '');
const qrCurrentAreaName = resultText(qrResult, 'current_area_name')
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'area_name') : '')
const qrCurrentAreaName = resultText(qrQueryResult, 'area_name')
|| resultText(qrResult, 'current_area_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'area_name')
: ''
)
|| (qrQueryIsBoundAct ? resultText(qrQueryResult, 'area_name') : '');
const qrCurrentPlatName = resultText(qrResult, 'current_plat_name')
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'plat_name') : '')
const qrCurrentPlatName = resultText(qrQueryResult, 'plat_name')
|| resultText(qrResult, 'current_plat_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'plat_name')
: ''
)
|| (qrQueryIsBoundAct ? resultText(qrQueryResult, 'plat_name') : '');
const qrRoleLine = qrBindReady
? [qrPlatName, qrAreaName, qrRoleName].filter(Boolean).join(' - ')
@@ -542,13 +751,18 @@ export default function DouyuTasksPage() {
{
title: '游戏名', dataIndex: 'game_name', width: 130,
render: (_, record) => {
const queryResult = latestQueryGameTaskByAccount.get(record.id)?.result || null;
const roleName = resultText(queryResult, 'role_name') || record.game_name;
const queryResult = (
isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id)
: latestQueryGameTaskByAccount.get(record.id)
)?.result || null;
const roleName = resultText(queryResult, 'role_name')
|| (isEsportsHandbook ? record.esports_game_name : record.game_name);
const channel = (
[resultText(queryResult, 'area_name'), resultText(queryResult, 'plat_name')]
.filter(Boolean)
.join(' / ')
|| record.game_channel
|| (isEsportsHandbook ? record.esports_game_channel : record.game_channel)
);
return roleName ? (
<Space direction="vertical" size={0}>
@@ -559,18 +773,22 @@ export default function DouyuTasksPage() {
},
},
{
title: '积分', dataIndex: 'points', width: 70,
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
width: 80,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
{
...(isEsportsHandbook ? [] : [{
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
}]),
{
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
render: (_, record) => {
const fromTask = latestChangeWaitByAccount.get(record.id);
const wait = fromTask?.wait ?? record.change_role_wait_time;
const wait = fromTask?.wait ?? (
isEsportsHandbook ? record.esports_change_role_wait_time : record.change_role_wait_time
);
const text = fromTask?.text || formatWaitSeconds(wait);
if (wait == null && !text) {
return <Text type="secondary"></Text>;
@@ -591,16 +809,21 @@ export default function DouyuTasksPage() {
return <Text type="secondary">{text || '-'}</Text>;
},
sorter: (a, b) => {
const aw = latestChangeWaitByAccount.get(a.id)?.wait ?? a.change_role_wait_time ?? -1;
const bw = latestChangeWaitByAccount.get(b.id)?.wait ?? b.change_role_wait_time ?? -1;
const aw = latestChangeWaitByAccount.get(a.id)?.wait
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
const bw = latestChangeWaitByAccount.get(b.id)?.wait
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
return aw - bw;
},
},
{
title: '状态', dataIndex: 'bind_status', width: 100,
render: (v) => (v
? <Tag>{BIND_STATUS_LABELS[v] || v}</Tag>
: <Text type="secondary">-</Text>),
render: (_, record) => {
const value = isEsportsHandbook ? record.esports_bind_status : record.bind_status;
return value
? <Tag>{BIND_STATUS_LABELS[value] || value}</Tag>
: <Text type="secondary">-</Text>;
},
},
];
@@ -618,6 +841,22 @@ export default function DouyuTasksPage() {
{
title: '结果', dataIndex: 'message', ellipsis: true,
render: (_, r) => {
if (['prepare_esports_bind', 'get_esports_bind_qr'].includes(r.task_type)) {
const roleName = resultText(r.result, 'role_name');
const esportsBound = resultFlag(r.result, 'esports_bound') || resultFlag(r.result, 'bind_confirmed');
const bindUrl = resultText(r.result, 'url');
return (
<Space wrap size={4}>
<Text>{r.message || '-'}</Text>
{roleName ? <Tag color={esportsBound ? 'green' : 'blue'}>{roleName}</Tag> : null}
{bindUrl || r.result?.esports_bind_dialog === true ? (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openEsportsBindTask(r)}>
</Button>
) : null}
</Space>
);
}
if (r.task_type === 'get_bind_qr') {
const roleName = resultText(r.result, 'role_name');
const phase = resultText(r.result, 'bind_phase');
@@ -639,7 +878,7 @@ export default function DouyuTasksPage() {
</Space>
);
}
if (r.task_type === 'query_game_name') {
if (['query_game_name', 'query_esports_game_name'].includes(r.task_type)) {
const roleName = resultText(r.result, 'role_name');
const areaName = resultText(r.result, 'area_name');
const platName = resultText(r.result, 'plat_name');
@@ -663,6 +902,16 @@ export default function DouyuTasksPage() {
</Space>
);
}
if (r.task_type === 'confirm_esports_bind') {
const roleName = resultText(r.result, 'role_name');
const confirmed = resultFlag(r.result, 'bind_confirmed');
return (
<Space wrap size={4}>
<Text>{r.message || '-'}</Text>
{roleName ? <Tag color={confirmed ? 'green' : 'orange'}>{roleName}</Tag> : null}
</Space>
);
}
const url = taskPayUrl(r);
return (
<Space wrap size={4}>
@@ -696,7 +945,8 @@ export default function DouyuTasksPage() {
(a.nickname || '').toLowerCase().includes(kw)
|| (a.username || '').toLowerCase().includes(kw)
|| (a.uid || '').toLowerCase().includes(kw)
|| (a.game_name || '').toLowerCase().includes(kw),
|| (a.game_name || '').toLowerCase().includes(kw)
|| (a.esports_game_name || '').toLowerCase().includes(kw),
);
}, [accounts, accountSearch]);
@@ -710,7 +960,7 @@ export default function DouyuTasksPage() {
? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`)
: '';
const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号';
const actionByKey = useMemo(() => new Map(QUICK_ACTIONS.map((item) => [item.key, item])), []);
const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]);
const actionDisabled = (taskType: string) => {
if (batchBusy || selectedIds.length === 0) return true;
if (taskType === 'exchange_goods') return !selectedGoodsId;
@@ -757,8 +1007,8 @@ export default function DouyuTasksPage() {
{/* Header */}
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0 }}></h2>
<Text type="secondary"></Text>
<h2 style={{ margin: 0 }}>{handbookTitle}</h2>
<Text type="secondary">{handbookDescription}</Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}></Button>
@@ -822,15 +1072,15 @@ export default function DouyuTasksPage() {
size="small" title="任务记录"
extra={
<Space size={12}>
<Tag color="success"> {tasks.filter((t) => t.status === 'success').length}</Tag>
<Tag color="error"> {tasks.filter((t) => ['failed', 'error'].includes(t.status)).length}</Tag>
<Tag color="success"> {visibleTasks.filter((t) => t.status === 'success').length}</Tag>
<Tag color="error"> {visibleTasks.filter((t) => ['failed', 'error'].includes(t.status)).length}</Tag>
</Space>
}
style={{ flex: '0 0 300px', minHeight: 260, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: 8, overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
<Table
rowKey="id" size="small" columns={taskColumns} dataSource={tasks}
rowKey="id" size="small" columns={taskColumns} dataSource={visibleTasks}
style={{ flex: 1, minHeight: 0 }}
loading={loading} pagination={{ pageSize: 4, showSizeChanger: false, size: 'small' }}
scroll={{ x: 700, y: 150 }}
@@ -842,7 +1092,7 @@ export default function DouyuTasksPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, overflow: 'auto' }}>
<Card
size="small"
title="操作"
title={isEsportsHandbook ? '电竞手册操作' : '精英宝典操作'}
extra={<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedText}</Tag>}
styles={{ body: { padding: 8 } }}
>
@@ -859,92 +1109,116 @@ export default function DouyuTasksPage() {
/>
</Space.Compact>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<QrcodeOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{BIND_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{QUERY_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('exchange_goods', 'primary')}
{isEsportsHandbook ? (
<>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<QrcodeOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{renderActionButton('prepare_esports_bind', 'primary')}
{renderActionButton('query_esports_game_name')}
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
{renderActionButton('create_esports_qr', 'primary')}
</div>
</>
) : (
<>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<QrcodeOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{ELITE_BIND_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr')}
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<GiftOutlined />}
onClick={() => startTask('donate_elite_gift')}
disabled={actionDisabled('donate_elite_gift')}
>
</Button>
</Space.Compact>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span></span>
</div>
<div style={actionGridStyle}>
{ELITE_QUERY_ACTION_KEYS.map((key) => renderActionButton(key))}
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<ShoppingOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<Select
size="small"
value={selectedGoodsId || undefined}
onChange={setSelectedGoodsId}
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
placeholder="选择兑换商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_goods')}
{renderActionButton('exchange_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span></span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr', 'primary')}
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={goldAmount}
onChange={(v) => setGoldAmount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<CreditCardOutlined />}
onClick={() => startTask('create_gold_qr')}
disabled={actionDisabled('create_gold_qr')}
>
</Button>
</Space.Compact>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
style={{ width: '100%' }}
/>
<Button
size="small"
icon={<GiftOutlined />}
onClick={() => startTask('donate_elite_gift')}
disabled={actionDisabled('donate_elite_gift')}
>
</Button>
</Space.Compact>
</Space>
</div>
</>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
@@ -965,7 +1239,7 @@ export default function DouyuTasksPage() {
style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{QUICK_ACTIONS.map((item) => (
{quickActions.map((item) => (
<Button
key={item.key} block size="small" type="text"
icon={item.icon}
@@ -1011,8 +1285,16 @@ export default function DouyuTasksPage() {
columns={[
{ title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username },
{ title: 'UID', dataIndex: 'uid' },
{ title: '游戏名', dataIndex: 'game_name', render: (v) => v || '-' },
{ title: '积分', dataIndex: 'points', render: (v) => v ?? '-' },
{
title: '游戏名',
dataIndex: isEsportsHandbook ? 'esports_game_name' : 'game_name',
render: (v) => v || '-',
},
{
title: isEsportsHandbook ? '电竞积分' : '积分',
dataIndex: isEsportsHandbook ? 'esports_points' : 'points',
render: (v) => v ?? '-',
},
]}
pagination={{ pageSize: 10 }}
locale={{ emptyText: '没有可导入的账号(操作台已包含所有账号)' }}
@@ -1021,7 +1303,7 @@ export default function DouyuTasksPage() {
{/* Config modal */}
<Modal
title="斗鱼活动配置"
title={isEsportsHandbook ? '电竞手册配置' : '精英宝典配置'}
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={() => configFormValues && saveConfig(configFormValues)}
@@ -1030,7 +1312,11 @@ export default function DouyuTasksPage() {
>
{configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{[
{(isEsportsHandbook ? [
{ label: '电竞手册 manualID', key: 'esports_manual_id' },
{ label: '电竞手册活动', key: 'esports_act_alias' },
{ label: '房间 ID', key: 'room_id' },
] : [
{ label: 'manualID', key: 'manual_id' },
{ label: 'RID', key: 'rid' },
{ label: '绑定二维码活动', key: 'bind_act_alias' },
@@ -1039,7 +1325,7 @@ export default function DouyuTasksPage() {
{ label: '房间 ID', key: 'room_id' },
{ label: '精英令礼物 ID', key: 'gift_id' },
{ label: '皮肤 ID', key: 'skin_id' },
].map(({ label, key }) => (
]).map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<Input
@@ -1049,10 +1335,12 @@ export default function DouyuTasksPage() {
/>
</Space>
))}
{[
{(isEsportsHandbook ? [
{ label: '电竞手册金额(分)', key: 'esports_amount' },
] : [
{ label: '宝典金额(分)', key: 'elite_amount' },
{ label: '鱼翅支付方式', key: 'gold_pay_type' },
].map(({ label, key }) => (
]).map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<InputNumber
@@ -1066,7 +1354,87 @@ export default function DouyuTasksPage() {
)}
</Modal>
{/* Bind QR Modal — driven by qrTask */}
{/* 电竞手册绑定弹窗:活动绑定与角色选择是独立流程。 */}
<Modal
title={
<Space>
<QrcodeOutlined />
<span></span>
</Space>
}
open={!!esportsBindTask}
onCancel={() => {
setEsportsBindTask(null);
}}
footer={null}
centered
width={440}
>
{esportsBindTask && (
<Space direction="vertical" style={{ width: '100%' }} size={14}>
{esportsAccountName ? (
<Text type="secondary">{esportsAccountName}</Text>
) : null}
<Tag color={esportsBound ? 'green' : esportsConfirmRunning ? 'processing' : 'blue'}>
{esportsStatusText}
</Tag>
{esportsRoleName ? (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}></Text>
<Text strong style={{ fontSize: 16 }}>{esportsRoleName}</Text>
{esportsRoleLine ? (
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>{esportsRoleLine}</Text>
) : null}
</div>
) : (
<Text type="secondary"></Text>
)}
{esportsChangeWait != null && esportsChangeWait > 0 && esportsRoleName ? (
<Text type="secondary">{formatWaitSeconds(esportsChangeWait)}</Text>
) : null}
{esportsQrUrl ? (
<div style={{ textAlign: 'center' }}>
<QRCode value={esportsQrUrl} size={220} bordered={false} />
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
</Text>
</div>
) : null}
<Space wrap>
<Button
icon={<SearchOutlined />}
onClick={queryEsportsRole}
loading={esportsRoleQueryRunning}
disabled={esportsConfirmRunning}
>
</Button>
<Button
icon={<QrcodeOutlined />}
onClick={switchEsportsRole}
disabled={!esportsCanChangeRole || esportsConfirmRunning}
>
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={confirmEsportsBindFromDialog}
disabled={!esportsCanConfirm}
loading={esportsConfirmRunning}
>
</Button>
</Space>
</Space>
)}
</Modal>
{/* 精英宝典绑定二维码弹窗。 */}
<Modal
title={
<Space>