Files
live-hub-py/web/frontend/src/pages/DouyuTasksPage.tsx
T
2026-08-08 00:57:32 +08:00

2451 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Tooltip, Typography, theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, GiftOutlined,
ImportOutlined, QrcodeOutlined, ReloadOutlined,
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import {
accountApi,
douyuApi,
type DouyuConfig,
type DouyuGoodsItem,
type DouyuTaskAccountItem,
type DouyuTaskItem,
} from '../api/modules';
import ExchangeResultImage from '../components/ExchangeResultImage';
import QrActions from '../components/QrActions';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import {
clipboardImageSupported,
copyImageToClipboard,
exchangeTaskSucceeded,
getExchangeImage,
} from '../utils/exchangeImage';
import { getErrorMessage } from '../utils/error';
import { formatTime } from '../utils/time';
import { message } from '../utils/antdMessage';
const { Text } = Typography;
type HandbookKind = 'elite' | 'esports' | 'peace';
const TASK_STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
stopped: 'warning',
};
const TASK_STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
stopped: '已停止',
};
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: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
];
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 /> },
];
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: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'exchange_xpd_goods', icon: <ShoppingOutlined /> },
];
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',
'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',
]);
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',
'exchange_xpd_goods',
]);
const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
const DOUYU_LAYOUT_MODE_STORAGE_KEY = 'douyu_task_layout_mode';
// v2 避开旧版页面切换时写入其他工作台的共享账号列表。
const DOUYU_WORKBENCH_IDS_STORAGE_KEY = (kind: HandbookKind) => `douyu_task_workbench_ids_v2_${kind}`;
const CONFIRM_FAIL_TIP_SECONDS = 6;
function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
const value = result?.[key];
return typeof value === 'string' ? value : '';
}
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
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 '';
}
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;
}
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');
}
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'),
);
}
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;
}
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 || '等待绑定';
}
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 点券),仅展示层换算,兑换请求仍传原值。
const XPD_POINT_PRICE_SCALE = 10;
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);
}
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}`;
}
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);
}
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}秒`;
}
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 }),
};
}
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;
}
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 : '';
}
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} 个`;
}
function savedPositiveInteger(key: string, fallback = 1): number {
const value = Number(localStorage.getItem(key));
return Number.isInteger(value) && value >= 1 ? value : fallback;
}
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
const isPeaceHandbook = handbook === 'peace';
const isEsportsHandbook = handbook === 'esports';
const handbookTitle = isPeaceHandbook
? '和平小店工作台'
: (isEsportsHandbook ? '电竞手册工作台' : '精英宝典工作台');
const handbookDescription = isPeaceHandbook
? '和平小店绑定角色、商品与点券余额查询'
: (isEsportsHandbook
? '电竞手册角色绑定与开通任务'
: '精英宝典绑定、开通、积分与兑换任务');
const activeTaskTypes = isPeaceHandbook
? PEACE_TASK_TYPES
: (isEsportsHandbook ? ESPORTS_TASK_TYPES : ELITE_TASK_TYPES);
const quickActions = isPeaceHandbook
? PEACE_QUICK_ACTIONS
: (isEsportsHandbook ? ESPORTS_QUICK_ACTIONS : ELITE_QUICK_ACTIONS);
const { can } = usePermissions();
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
// 工作台已导入账号 IDlocalStorage 持久化,默认空白,用户手动导入/移除)
const [workbenchIds, setWorkbenchIds] = useState<number[]>(() => {
const raw = localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook));
if (!raw) return [];
return raw.split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1);
});
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>({});
const [config, setConfig] = useState<DouyuConfig | null>(null);
const [loading, setLoading] = useState(false);
const [runningBatchIds, setRunningBatchIds] = useState<Set<string>>(new Set());
const [configOpen, setConfigOpen] = useState(false);
const [concurrency, setConcurrency] = useState(3);
const [goldAmount, setGoldAmount] = useState(() => savedPositiveInteger(DOUYU_GOLD_AMOUNT_STORAGE_KEY));
const [giftCount, setGiftCount] = useState(() => savedPositiveInteger(DOUYU_GIFT_COUNT_STORAGE_KEY));
const [selectedGoodsId, setSelectedGoodsId] = useState('');
const [xpdPayType, setXpdPayType] = useState<'1' | '5'>('1');
const [accountSearch, setAccountSearch] = useState('');
const [layoutMode, setLayoutMode] = useState<'stack' | 'split'>(() => {
const v = localStorage.getItem(DOUYU_LAYOUT_MODE_STORAGE_KEY);
return v === 'split' ? 'split' : 'stack';
});
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(360);
const [accountPageSize, setAccountPageSize] = useState<number>(() => {
const v = Number(localStorage.getItem('douyu_task_account_page_size'));
return [10, 20, 50, 100].includes(v) ? v : 20;
});
const [accountPage, setAccountPage] = useState(1);
const [accountTotal, setAccountTotal] = useState(0);
const tasksLoadingRef = useRef(false);
const [importOpen, setImportOpen] = useState(false);
const [importPool, setImportPool] = useState<DouyuTaskAccountItem[]>([]);
const [importSearch, setImportSearch] = useState('');
const [importTag, setImportTag] = useState('');
const [importTags, setImportTags] = useState<string[]>([]);
const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]);
const [importLoading, setImportLoading] = useState(false);
const [importAllLoading, setImportAllLoading] = useState(false);
const [importPage, setImportPage] = useState(1);
const [importTotal, setImportTotal] = useState(0);
const importPageSize = 20;
// 绑定/支付弹窗以 task 为唯一数据源;二维码弹窗支持多账号并行(Tabs 切换)
const [qrTaskIds, setQrTaskIds] = useState<number[]>([]);
const [activeQrTaskId, setActiveQrTaskId] = useState<number | null>(null);
const [esportsBindTask, setEsportsBindTask] = useState<DouyuTaskItem | null>(null);
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
const qrCanvasWrapRef = useRef<HTMLDivElement | null>(null);
const esportsQrCanvasWrapRef = useRef<HTMLDivElement | null>(null);
const payQrCanvasWrapRef = useRef<HTMLDivElement | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
const autoOpenedEsportsBindTaskIds = useRef<Set<number>>(new Set());
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoCopiedExchangeTaskIds = useRef<Set<number>>(new Set());
const autoNotifiedConfirmFailedTaskIds = useRef<Set<number>>(new Set());
const [confirmFailTip, setConfirmFailTip] = useState<{ taskId: number; message: string } | null>(null);
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
const autoOpenQrReady = useRef(false);
const autoOpenEsportsBindReady = useRef(false);
const autoOpenPayReady = useRef(false);
const logs = useWebSocketLogs();
const canConfig = can('douyu:config');
const latestQueryGameTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'query_game_name') continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const latestConfirmBindTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'confirm_bind') continue;
if (!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 latestXpdQueryTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (!['query_xpd_bind_info', 'query_xpd_role'].includes(task.task_type)) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const latestConfirmXpdBindTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'confirm_xpd_bind') continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
const latestConfirmBindFailureByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'confirm_bind') continue;
if (!['failed', 'error'].includes(task.status)) continue;
if (!(task.message || '').includes('待绑定游戏账号')) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
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],
);
// qrTask 由 activeQrTaskId 从最新 tasks 中派生,切换 Tab 即切换关注账号
const qrTask = useMemo(
() => (activeQrTaskId == null ? null : tasks.find((t) => t.id === activeQrTaskId) || null),
[tasks, activeQrTaskId],
);
// 换绑时间:优先最近一次查询/拦截/确认成功结果,回退账号落库字段
const latestChangeWaitByAccount = useMemo(() => {
const map = new Map<number, {
wait: number | null; canChangeTime: number | null; text: string; taskId: number;
}>();
for (const task of tasks) {
const taskTypes = isEsportsHandbook
? ['prepare_esports_bind', 'get_esports_bind_qr', 'query_esports_game_name', 'confirm_esports_bind']
: ['query_change_bind_time', 'get_bind_qr', 'confirm_bind'];
if (!taskTypes.includes(task.task_type)) continue;
if (!['success', 'failed'].includes(task.status)) continue;
if (
!isEsportsHandbook
&& task.task_type === 'confirm_bind'
&& (
task.status !== 'success'
|| !(
resultFlag(task.result, 'bind_confirmed')
|| resultFlag(task.result, 'is_bound_act')
)
)
) continue;
const wait = resultNumber(task.result, 'change_role_wait_time');
const canChangeTime = resultNumber(task.result, 'can_change_time');
// get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖
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 esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null;
const text = esportsStatus?.text || resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait);
map.set(task.account_id, { wait, canChangeTime, text, taskId: task.id });
}
return map;
}, [isEsportsHandbook, tasks]);
const latestLimitedGoodsTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of tasks) {
if (task.task_type !== 'query_limited_goods') continue;
if (!['success', 'failed', 'error'].includes(task.status)) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [tasks]);
// 每个账号最近一条任务(当前手册类型),用于账号表"最近结果"列快速查看二维码
const latestTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of visibleTasks) {
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [visibleTasks]);
// 每个账号最近一条兑换成功任务,用于"兑换图片"列
const latestExchangeTaskByAccount = useMemo(() => {
const map = new Map<number, DouyuTaskItem>();
for (const task of visibleTasks) {
if (!exchangeTaskSucceeded(task)) continue;
const previous = map.get(task.account_id);
if (!previous || task.id > previous.id) map.set(task.account_id, task);
}
return map;
}, [visibleTasks]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([
workbenchIds.length > 0
? douyuApi.listAccountsPaged({
ids: workbenchIds.join(','),
search: accountSearch.trim() || undefined,
page: accountPage,
page_size: accountPageSize,
})
: Promise.resolve({ items: [] as DouyuTaskAccountItem[], total: 0, page: accountPage, page_size: accountPageSize }),
isPeaceHandbook
? douyuApi.listPeaceGoods()
: (isEsportsHandbook ? douyuApi.listEsportsGoods() : douyuApi.listGoods()),
douyuApi.listTasks(),
canConfig ? douyuApi.getConfig() : Promise.resolve(null),
douyuApi.taskTypes(),
]);
if (accResult.status === 'fulfilled') {
setAccounts(accResult.value.items);
setAccountTotal(accResult.value.total);
if (accountPage > 1 && accResult.value.items.length === 0) {
setAccountPage(Math.max(1, Math.ceil(accResult.value.total / accountPageSize)));
}
}
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (taskResult.status === 'fulfilled') {
setTasks(taskResult.value);
// 仅首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉
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);
// 历史兑换成功任务不触发自动复制,避免页面加载时打扰
if (exchangeTaskSucceeded(task)) autoCopiedExchangeTaskIds.current.add(task.id);
// 历史确认绑定失败任务不重复弹提示(只提示一次)
if (
task.task_type === 'confirm_bind'
&& ['failed', 'error'].includes(task.status)
&& (task.message || '').includes('待绑定游戏账号')
) {
autoNotifiedConfirmFailedTaskIds.current.add(task.id);
}
}
autoOpenQrReady.current = true;
autoOpenEsportsBindReady.current = true;
autoOpenPayReady.current = true;
}
}
if (cfgResult.status === 'fulfilled' && cfgResult.value) setConfig(cfgResult.value);
if (typeResult.status === 'fulfilled') setTaskTypes(typeResult.value);
} finally {
setLoading(false);
}
}, [accountPage, accountPageSize, accountSearch, canConfig, isEsportsHandbook, isPeaceHandbook, workbenchIds]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try {
const data = await douyuApi.listTasks();
setTasks(data);
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
setRunningBatchIds(new Set());
}
} catch {
// 轮询失败不打扰操作
} finally {
tasksLoadingRef.current = false;
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode);
}, [layoutMode]);
// 工作台账号集合持久化:移除账号后刷新不再出现
useEffect(() => {
localStorage.setItem(
DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook),
workbenchIds.length ? workbenchIds.join(',') : '',
);
}, [handbook, workbenchIds]);
// 监听账号表格区域高度变化,动态计算 scroll.y 实现表体内部滚动
useEffect(() => {
const node = accountTableAreaRef.current;
if (!node) return;
const update = () => setAccountTableAreaHeight(Math.max(200, Math.floor(node.getBoundingClientRect().height)));
update();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, [layoutMode]);
useEffect(() => {
localStorage.setItem(DOUYU_GOLD_AMOUNT_STORAGE_KEY, String(goldAmount));
}, [goldAmount]);
useEffect(() => {
localStorage.setItem(DOUYU_GIFT_COUNT_STORAGE_KEY, String(giftCount));
}, [giftCount]);
const hasActiveTasks = useMemo(
() => visibleTasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
[visibleTasks],
);
// 正在执行任务的账号集合,用于按账号维度禁用,避免同一账号并发跑不同类型任务
const runningAccountIds = useMemo(() => {
const set = new Set<number>();
for (const task of visibleTasks) {
if (['pending', 'running', 'planned'].includes(task.status)) {
set.add(task.account_id);
}
}
return set;
}, [visibleTasks]);
// 从后端任务派生正在运行的批次 ID(刷新页面后仍可恢复,用于显示停止按钮)
const activeBatchIds = useMemo(() => {
const set = new Set<string>();
for (const task of visibleTasks) {
if (['pending', 'running', 'planned'].includes(task.status) && task.batch_id) {
set.add(task.batch_id);
}
}
return set;
}, [visibleTasks]);
const selectedHasRunning = selectedIds.some((id) => runningAccountIds.has(id));
// 任务状态由 WS(level=task) 即时推送;轮询仅作兜底(多标签页/断线重连),
// 有活跃任务或 WS 连接时 5 秒一次,空闲 30 秒轻量刷新
useEffect(() => {
const intervalMs = hasActiveTasks || logs.connected || runningBatchIds.size > 0 ? 5000 : 30000;
const timer = setInterval(loadTasks, intervalMs);
return () => clearInterval(timer);
}, [hasActiveTasks, loadTasks, logs.connected, runningBatchIds]);
const openQrTask = useCallback((task: DouyuTaskItem, activate = true) => {
autoOpenedQrTaskIds.current.add(task.id);
setQrTaskIds((prev) => (prev.includes(task.id) ? prev : [...prev, task.id]));
if (activate) setActiveQrTaskId(task.id);
// 绑定二维码与支付/电竞面板互斥,避免两个 Modal 叠加混淆
setEsportsBindTask(null);
setPayTask(null);
}, []);
const closeQrTask = useCallback((taskId: number) => {
setQrTaskIds((prev) => {
const next = prev.filter((id) => id !== taskId);
setActiveQrTaskId((cur) => (cur === taskId ? (next.length > 0 ? next[next.length - 1] : null) : cur));
return next;
});
}, []);
const closeAllQrTasks = useCallback(() => {
setQrTaskIds([]);
setActiveQrTaskId(null);
}, []);
const openEsportsBindTask = useCallback((task: DouyuTaskItem) => {
autoOpenedEsportsBindTaskIds.current.add(task.id);
closeAllQrTasks();
setPayTask(null);
setEsportsBindTask(task);
}, [closeAllQrTasks]);
const openPayTask = useCallback((task: DouyuTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id);
closeAllQrTasks();
setEsportsBindTask(null);
setPayTask(task);
}, [closeAllQrTasks]);
// 自动弹出最新绑定二维码(running 有 url 即可);多账号并行,不互斥
useEffect(() => {
if (!autoOpenQrReady.current) return;
const candidates = visibleTasks
.filter((task) => hasBindQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id);
if (candidates.length === 0) return;
// 全部加入标签栏但不抢焦点;仅在当前无 active 标签时激活最新(id 最大)的
for (const candidate of candidates) openQrTask(candidate, false);
setActiveQrTaskId((cur) => cur ?? candidates[0].id);
}, [openQrTask, 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 = 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, visibleTasks]);
// 兑换成功后自动生成结果图片并复制到剪贴板(批量时最后完成的覆盖剪贴板)
useEffect(() => {
if (!autoOpenQrReady.current) return;
const pending = visibleTasks
.filter((task) => exchangeTaskSucceeded(task) && !autoCopiedExchangeTaskIds.current.has(task.id))
.sort((a, b) => a.id - b.id);
if (pending.length === 0) return;
for (const task of pending) autoCopiedExchangeTaskIds.current.add(task.id);
const latest = pending[pending.length - 1];
void getExchangeImage(latest)
.then(({ blob }) => copyImageToClipboard(blob))
.then(
() => message.success(`兑换图片已复制到剪贴板:${latest.account_nickname || latest.account_username || `#${latest.account_id}`}`),
(error) => {
if (clipboardImageSupported()) {
message.warning('兑换图片已生成,复制到剪贴板失败');
} else {
message.info('当前环境不支持复制图片,请在兑换图片列点击查看');
}
console.warn('复制兑换图片失败', error);
},
);
}, [visibleTasks]);
// 确认绑定失败自动弹提示(页面中央弹窗,自动消失,一次性去重)
useEffect(() => {
if (!autoOpenQrReady.current) return;
const failedTasks = visibleTasks
.filter((task) => (
task.task_type === 'confirm_bind'
&& ['failed', 'error'].includes(task.status)
&& (task.message || '').includes('待绑定游戏账号')
&& !autoNotifiedConfirmFailedTaskIds.current.has(task.id)
))
.sort((a, b) => b.id - a.id);
if (failedTasks.length === 0) return;
for (const task of failedTasks) autoNotifiedConfirmFailedTaskIds.current.add(task.id);
const latest = failedTasks[0];
setConfirmFailTip({ taskId: latest.id, message: latest.message });
setQrTaskIds((prev) => {
const next = prev.filter((id) => {
const task = visibleTasks.find((item) => item.id === id);
return !task || task.account_id !== latest.account_id;
});
setActiveQrTaskId((cur) => (cur != null && !next.includes(cur) ? (next[next.length - 1] ?? null) : cur));
return next;
});
}, [visibleTasks]);
// 弹窗倒计时自动关闭
useEffect(() => {
if (!confirmFailTip) return;
setConfirmFailCountdown(CONFIRM_FAIL_TIP_SECONDS);
const timer = setInterval(() => {
setConfirmFailCountdown((cur) => {
if (cur <= 1) {
clearInterval(timer);
setConfirmFailTip(null);
return 0;
}
return cur - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [confirmFailTip]);
const copyExchangePreview = async () => {
if (!exchangePreview) return;
try {
const { blob } = await getExchangeImage(exchangePreview.task);
await copyImageToClipboard(blob);
message.success('已复制到剪贴板');
} catch (error) {
message.error(getErrorMessage(error));
}
};
// qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随
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);
if (!latest) return;
if (paymentArrived(latest)) {
setPayTask(null);
return;
}
if (latest !== payTask) setPayTask(latest);
}, [payTask, tasks]);
// 多批次并发:不再用全局 busy 标志阻塞 UI,刷新频率直接用各子条件判断
const selectedXpdGoods = useMemo(
() => goods.find((item) => item.commodity_id === selectedGoodsId),
[goods, selectedGoodsId],
);
const xpdPaymentOptions = useMemo(
() => xpdGoodsPaymentOptions(selectedXpdGoods),
[selectedXpdGoods],
);
const selectedXpdGoodsSoldOut = (() => {
const stock = (selectedXpdGoods as (DouyuGoodsItem & { goods_left?: number | null }) | undefined)?.goods_left;
return stock != null && Number.isFinite(Number(stock)) && Number(stock) === 0;
})();
useEffect(() => {
if (xpdPaymentOptions.length === 0) return;
if (!xpdPaymentOptions.some((option) => option.value === xpdPayType)) {
setXpdPayType(xpdPaymentOptions[0].value);
}
}, [xpdPayType, xpdPaymentOptions]);
const startTask = async (
taskType: string,
accountIds?: number[],
extraPayload: Record<string, unknown> = {},
) => {
const ids = accountIds || selectedIds;
if (ids.length === 0) {
message.warning('请先选择账号');
return;
}
// 多批次并发:不再拦截,允许在已有批次运行时继续创建新批次
if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType) && !selectedGoodsId) {
message.warning('请先选择兑换商品');
return;
}
if (taskType === 'exchange_xpd_goods') {
if (selectedXpdGoodsSoldOut) {
message.warning('该商品库存不足,请刷新商品列表后重试');
return;
}
if (!xpdPaymentOptions.some((option) => option.value === xpdPayType)) {
message.warning('该商品不支持当前兑换方式');
return;
}
}
const payload: Record<string, unknown> = { ...extraPayload };
if (['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(taskType)) {
payload.commodity_id = selectedGoodsId;
}
if (taskType === 'exchange_xpd_goods') payload.pay_type = xpdPayType;
if (taskType === 'create_gold_qr') payload.amount = goldAmount;
if (['donate_elite_gift', 'donate_esports_chicken_gift', 'donate_esports_firework_gift'].includes(taskType)) {
payload.gift_count = giftCount;
}
try {
const result = await douyuApi.createTasks({
account_ids: ids,
task_type: taskType,
concurrency,
payload,
});
setRunningBatchIds((prev) => new Set(prev).add(result.batch_id));
logs.connectBatch(result.batch_id, `/api/douyu/ws/${result.batch_id}`, {
clear: true,
onResult: () => {
setRunningBatchIds((prev) => {
const next = new Set(prev);
next.delete(result.batch_id);
return next;
});
void loadData();
},
onTask: (rawTask) => {
const task = rawTask as unknown as DouyuTaskItem;
// 已有任务原位更新保持排序;新任务插入头部(与后端 id desc 一致)
setTasks((prev) => {
if (prev.some((t) => t.id === task.id)) {
return prev.map((t) => (t.id === task.id ? task : t));
}
return [task, ...prev].slice(0, 100);
});
},
});
message.success(`已创建 ${result.count} 个任务`);
setTimeout(() => { void loadTasks(); }, 400);
} catch (error) {
message.error(getErrorMessage(error));
}
};
const stopBatch = async () => {
const ids = Array.from(new Set([...runningBatchIds, ...activeBatchIds]));
if (ids.length === 0) return;
try {
await Promise.all(ids.map((id) => douyuApi.stopBatch(id)));
message.success('已停止');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const saveConfig = async (values: DouyuConfig) => {
try {
const saved = await douyuApi.updateConfig(values);
setConfig(saved);
setConfigOpen(false);
message.success('配置已保存');
} catch (error) {
message.error(getErrorMessage(error));
}
};
// ---- Import ----
const loadImportPool = useCallback(async () => {
setImportLoading(true);
try {
// 候选池 = 账号库按搜索/标签过滤,剔除已导入工作台的账号
const result = await douyuApi.listAccountsPaged({
search: importSearch.trim() || undefined,
tag: importTag || undefined,
page: importPage,
page_size: importPageSize,
});
setImportPool(result.items.filter((a) => !workbenchIds.includes(a.id)));
setImportTotal(result.total);
} finally {
setImportLoading(false);
}
}, [importPage, importSearch, importTag, workbenchIds]);
useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]);
useEffect(() => {
if (!importOpen) return;
accountApi.listTags().then(setImportTags).catch(() => {});
}, [importOpen]);
// 切换搜索/标签时清空已选,避免计数与可见行不一致
useEffect(() => {
setImportPage(1);
setImportSelectedIds([]);
}, [importSearch, importTag]);
useEffect(() => { setImportSelectedIds([]); }, [importPage]);
const importAccounts = (ids: number[]) => {
const toImport = importPool.filter((a) => ids.includes(a.id));
setWorkbenchIds((prev) => [...new Set([...prev, ...toImport.map((a) => a.id)])]);
setAccounts((prev) => {
const existing = new Set(prev.map((a) => a.id));
return [...prev, ...toImport.filter((a) => !existing.has(a.id))];
});
setImportSelectedIds([]);
setImportOpen(false);
message.success(`已导入 ${toImport.length} 个账号`);
};
const importAllAccounts = async () => {
setImportAllLoading(true);
try {
const result = await douyuApi.listAccountIds({
search: importSearch.trim() || undefined,
tag: importTag || undefined,
});
const imported = result.account_ids.filter((id) => !workbenchIds.includes(id));
if (imported.length === 0) {
message.info('当前筛选条件下没有可新增的账号');
return;
}
setWorkbenchIds((prev) => [...new Set([...prev, ...imported])]);
setImportSelectedIds([]);
setImportOpen(false);
message.success(`已一键导入 ${imported.length} 个账号`);
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setImportAllLoading(false);
}
};
const removeSelected = () => {
const sel = new Set(selectedIds);
setWorkbenchIds((prev) => prev.filter((id) => !sel.has(id)));
setAccounts((prev) => prev.filter((a) => !sel.has(a.id)));
setSelectedIds([]);
message.success(`已移出 ${sel.size} 个账号`);
};
// ---- Context menu ----
const [contextMenu, setContextMenu] = useState<{
accountIds: number[]; x: number; y: number;
} | null>(null);
const handleRowContextMenu = (record: DouyuTaskAccountItem, event: React.MouseEvent) => {
event.preventDefault();
const ids = selectedIds.includes(record.id) ? selectedIds : [record.id];
setContextMenu({ accountIds: ids, x: event.clientX, y: event.clientY });
if (!selectedIds.includes(record.id)) setSelectedIds([record.id]);
};
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
window.addEventListener('click', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
window.addEventListener('keydown', closeOnEscape);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
window.removeEventListener('keydown', closeOnEscape);
};
}, [contextMenu]);
const contextMenuStyle = contextMenu ? {
position: 'fixed' as const,
left: Math.max(8, Math.min(contextMenu.x, window.innerWidth - 220)),
top: Math.max(8, Math.min(contextMenu.y, window.innerHeight - 390)),
zIndex: 1050,
} : { display: 'none' };
const runContextAction = (taskType: string) => {
if (!contextMenu) return;
setContextMenu(null);
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 esportsCanChangeTime = resultNumber(esportsResult, 'can_change_time');
const esportsRebind = esportsRebindStatus(esportsCanChangeTime);
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 isXpdBindTask = qrTask?.task_type === 'get_xpd_bind_qr';
const qrQueryTask = qrTask
? (isXpdBindTask
? (latestXpdQueryTaskByAccount.get(qrTask.account_id) || null)
: (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 qrXpdConfirmTask = isXpdBindTask && qrTask
? (latestConfirmXpdBindTaskByAccount.get(qrTask.account_id) || null)
: null;
const qrXpdBound = Boolean(qrXpdConfirmTask && qrXpdConfirmTask.status === 'success');
const qrXpdConfirmRunning = Boolean(qrXpdConfirmTask && ['planned', 'pending', 'running'].includes(qrXpdConfirmTask.status));
const qrQueryRoleName = resultText(qrQueryResult, 'role_name');
const qrQueryPendingRoleName = resultText(qrQueryResult, 'pending_role_name');
const qrQueryIsBoundAct = resultFlag(qrQueryResult, 'is_bound_act') || resultFlag(qrQueryResult, 'bind_confirmed');
const qrQueryPending = !isXpdBindTask && Boolean(
qrQueryResult
&& (
qrQueryPendingRoleName
|| (
qrQueryRoleName
&& (
qrQueryResult.bind_ready_for_confirm === true
|| (!qrQueryIsBoundAct)
)
)
),
);
const qrBindReady = (bindReadyForConfirm(qrTask) || qrQueryPending) && !qrXpdBound;
const qrRoleSourceResult = qrQueryPending
? qrQueryResult
: ((bindReadyForConfirm(qrTask) || qrXpdBound) ? qrResult : null);
// 待确认角色优先用 pending_* 字段(已绑定角色之外的扫码新角色)
const qrRoleName = resultText(qrRoleSourceResult, 'pending_role_name') || resultText(qrRoleSourceResult, 'role_name');
const qrAreaName = resultText(qrRoleSourceResult, 'pending_area_name') || resultText(qrRoleSourceResult, 'area_name');
const qrPlatName = resultText(qrRoleSourceResult, 'pending_plat_name') || resultText(qrRoleSourceResult, 'plat_name');
const qrCurrentRoleName = isXpdBindTask
? resultText(qrResult, 'before_role_name')
: (qrQueryRoleName
|| resultText(qrResult, 'current_role_name')
|| (
resultFlag(qrResult, 'is_bound_act')
? resultText(qrResult, 'role_name')
: ''
)
|| (qrQueryIsBoundAct ? qrQueryRoleName : ''));
const qrCurrentAreaName = isXpdBindTask
? resultText(qrResult, 'before_area_name')
: (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 = isXpdBindTask
? resultText(qrResult, 'before_plat_name')
: (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(' - ')
: '';
const qrBindSummary = resultText(qrResult, 'bind_summary') || resultText(qrQueryResult, 'bind_summary');
const qrStatusText = qrXpdBound
? '绑定成功'
: (isXpdBindTask && qrBindReady)
? '已识别角色,待确认'
: (isXpdBindTask && qrTask?.status === 'failed')
? '未检测到绑定'
: (isXpdBindTask && qrTask?.status === 'running')
? '等待扫码绑定'
: qrXpdConfirmRunning
? '正在确认绑定'
: qrBindReady
? '已识别角色,待确认'
: qrQueryRunning
? '查询角色中'
: qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.status === 'success' && qrQueryIsBoundAct
? '查询结果:仍是当前绑定角色'
: bindPhaseText(qrBindPhase, qrAutoPolling);
const qrAccountName = qrTask
? (qrTask.account_nickname || qrTask.account_username || qrTask.account_uid || `#${qrTask.account_id}`)
: '';
const confirmQrBind = () => {
if (!qrTask || !qrBindReady) {
message.warning('请先扫码识别待确认角色');
return;
}
const accountId = qrTask.account_id;
const taskId = qrTask.id;
closeQrTask(taskId);
void startTask(isXpdBindTask ? 'confirm_xpd_bind' : 'confirm_bind', [accountId]);
};
const queryQrRole = () => {
if (!qrTask) return;
if (qrAutoPolling) {
message.info('正在自动识别角色,请稍候');
return;
}
void startTask(isXpdBindTask ? 'query_xpd_bind_info' : 'query_game_name', [qrTask.account_id]);
};
// Account table
const accountColumns: TableProps<DouyuTaskAccountItem>['columns'] = [
{
title: '#', width: 45,
render: (_, __, index) => <Text type="secondary">{index + 1}</Text>,
},
{
title: '账号', dataIndex: 'username', width: 150,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || record.username}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || '-'}</Text>
</Space>
),
},
{
title: '游戏名', dataIndex: 'game_name', width: 170,
render: (_, record) => {
if (isPeaceHandbook) {
return record.xpd_game_name
? <Text ellipsis title={record.xpd_game_name}>{record.xpd_game_name}</Text>
: <Text type="secondary">未查</Text>;
}
const queryTask = isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id) || null
: latestQueryGameTaskByAccount.get(record.id) || null;
const confirmTask = isEsportsHandbook ? null : latestConfirmBindTaskByAccount.get(record.id) || null;
const confirmSucceeded = Boolean(
confirmTask
&& confirmTask.status === 'success'
&& (
resultFlag(confirmTask.result, 'bind_confirmed')
|| resultFlag(confirmTask.result, 'is_bound_act')
),
);
const stateTask = (
confirmSucceeded
&& (!queryTask || confirmTask!.id > queryTask.id)
) ? confirmTask : queryTask;
const confirmFailureTask = latestConfirmBindFailureByAccount.get(record.id);
const clearPending = Boolean(
!isEsportsHandbook
&& confirmFailureTask
&& stateTask
&& confirmFailureTask.id > stateTask.id,
);
const stateResult = stateTask?.result || null;
const stateRoleConfirmed = resultFlag(stateResult, 'is_bound_act') || resultFlag(stateResult, 'bind_confirmed');
const stateRoleName = clearPending && !stateRoleConfirmed ? '' : resultText(stateResult, 'role_name');
// role_name = 当前已生效绑定角色;pending_role_name = 扫码后待确认的新角色
const roleName = stateRoleName
|| (isEsportsHandbook ? record.esports_game_name : record.game_name);
const stateChannel = clearPending && !stateRoleConfirmed
? ''
: [resultText(stateResult, 'area_name'), resultText(stateResult, 'plat_name')]
.filter(Boolean)
.join(' / ');
const pendingRoleName = clearPending || confirmSucceeded ? '' : resultText(stateResult, 'pending_role_name');
const channel = stateChannel || (isEsportsHandbook ? record.esports_game_channel : record.game_channel);
const pendingChannel = clearPending || confirmSucceeded ? '' : [resultText(stateResult, 'pending_area_name'), resultText(stateResult, 'pending_plat_name')]
.filter(Boolean)
.join(' / ');
if (!roleName && !pendingRoleName) return <Text type="secondary">未查</Text>;
return (
<Space direction="vertical" size={2} style={{ lineHeight: 1.35 }}>
{roleName ? (
<Space direction="vertical" size={0}>
<Text ellipsis title={roleName}>
{pendingRoleName ? `当前: ${roleName}` : roleName}
</Text>
{channel ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis title={channel}>
{channel}
</Text>
) : null}
</Space>
) : null}
{pendingRoleName ? (
<Space direction="vertical" size={0}>
<Text type="warning" style={{ fontSize: 12 }} ellipsis title={pendingRoleName}>
待确认: {pendingRoleName}
</Text>
{pendingChannel ? (
<Text type="warning" style={{ fontSize: 12 }} ellipsis title={pendingChannel}>
{pendingChannel}
</Text>
) : null}
</Space>
) : null}
</Space>
);
},
},
{
title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
width: 80,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
// 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片
...(isPeaceHandbook
? [{
title: '扭蛋碎片', dataIndex: 'xpd_fragments', width: 90,
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
}] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>
: [
{
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
render: (v: number | null) => v ?? <Text type="secondary">-</Text>,
},
{
title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true,
render: (_, record) => {
const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id);
const text = limitedGoodsTaskText(latestLimitedTask);
if (!text) return <Text type="secondary">未查</Text>;
if (latestLimitedTask?.status !== 'success') {
return <Text type="secondary">{text}</Text>;
}
const count = resultNumber(latestLimitedTask.result, 'limited_count');
if (count === 0 || text === '无限制商品') return <Tag color="green">无限制商品</Tag>;
return <Text title={text}>{text}</Text>;
},
},
{
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
render: (_, record) => {
const fromTask = latestChangeWaitByAccount.get(record.id);
const canChangeTime = fromTask?.canChangeTime ?? (
isEsportsHandbook ? record.esports_can_change_time : null
);
const esportsStatus = isEsportsHandbook ? esportsRebindStatus(canChangeTime) : null;
if (esportsStatus) {
return esportsStatus.available
? <Tag color="success">可换绑</Tag>
: (
<Space direction="vertical" size={0}>
<Tag color="orange">暂不可换绑</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{esportsStatus.text}</Text>
</Space>
);
}
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>;
}
if (wait != null && wait <= 0) {
return <Tag color="success">可换绑</Tag>;
}
if (wait != null && wait > 0) {
return (
<Space direction="vertical" size={0}>
<Tag color="orange">冷却中</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{text || formatWaitSeconds(wait)}</Text>
</Space>
);
}
// 接口没返回数字但有文案
if (text === '可换绑') return <Tag color="success">可换绑</Tag>;
return <Text type="secondary">{text || '-'}</Text>;
},
sorter: (a, b) => {
const aw = isEsportsHandbook
? latestChangeWaitByAccount.get(a.id)?.canChangeTime ?? a.esports_can_change_time ?? -1
: latestChangeWaitByAccount.get(a.id)?.wait
?? (isEsportsHandbook ? a.esports_change_role_wait_time : a.change_role_wait_time) ?? -1;
const bw = isEsportsHandbook
? latestChangeWaitByAccount.get(b.id)?.canChangeTime ?? b.esports_can_change_time ?? -1
: latestChangeWaitByAccount.get(b.id)?.wait
?? (isEsportsHandbook ? b.esports_change_role_wait_time : b.change_role_wait_time) ?? -1;
return aw - bw;
},
},
] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>),
{
title: '最近操作', width: 220,
render: (_, record) => {
const latest = latestTaskByAccount.get(record.id);
if (!latest) return <Text type="secondary">暂无操作</Text>;
return (
<Space direction="vertical" size={1} style={{ width: '100%' }}>
<Space size={4} wrap>
<Tag color={TASK_STATUS_COLORS[latest.status] || 'default'}>
{TASK_STATUS_LABELS[latest.status] || latest.status}
</Tag>
<Text strong ellipsis style={{ maxWidth: 130 }}>
{taskTypes[latest.task_type] || latest.task_type}
</Text>
</Space>
{latest.message ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis title={latest.message}>
{latest.message}
</Text>
) : null}
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(latest.finished_at || latest.created_at)}
</Text>
</Space>
);
},
},
{
title: '二维码', width: 76, align: 'center',
render: (_, record) => {
const task = latestTaskByAccount.get(record.id);
const url = taskQrUrl(task);
if (!url) return null;
const openTask = () => {
if (!task) return;
if (hasBindQrcode(task)) openQrTask(task);
else if (hasPaymentQrcode(task)) openPayTask(task);
else if (task.task_type === 'get_xpd_bind_qr') openQrTask(task);
else openEsportsBindTask(task);
};
return (
<div onClick={openTask} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看大图">
<QRCode value={url} size={56} />
</div>
);
},
},
{
title: '兑换图片', width: 80, align: 'center',
render: (_, record) => {
const task = latestExchangeTaskByAccount.get(record.id);
if (!task) return <Text type="secondary">-</Text>;
return (
<ExchangeResultImage
task={task}
onClick={(t, url) => setExchangePreview({ task: t, url })}
/>
);
},
},
];
const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null);
useEffect(() => {
if (configOpen && config) setConfigFormValues({ ...config });
}, [configOpen, config]);
const payUrl = resultText(payTask?.result, 'pay_url');
const payAccountName = payTask
? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`)
: '';
const selectedText = selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '请先勾选账号';
const actionByKey = useMemo(() => new Map(quickActions.map((item) => [item.key, item])), [quickActions]);
const actionDisabled = (taskType: string) => {
if (selectedIds.length === 0) return true;
if (taskType === 'exchange_xpd_goods') {
return !selectedGoodsId
|| selectedXpdGoodsSoldOut
|| !xpdPaymentOptions.some((option) => option.value === xpdPayType);
}
if (['exchange_goods', 'exchange_esports_goods'].includes(taskType)) return !selectedGoodsId;
// 选中账号中有正在执行的任务时禁用,避免同账号并发不同类型任务(并发写状态字段)
return selectedHasRunning;
};
const renderActionButton = (taskType: string, type: 'default' | 'primary' = 'default') => {
const item = actionByKey.get(taskType);
return (
<Button
key={taskType}
size="small"
block
type={type}
icon={item?.icon}
onClick={() => startTask(taskType)}
disabled={actionDisabled(taskType)}
style={{ justifyContent: 'flex-start' }}
>
{taskTypes[taskType] || taskType}
</Button>
);
};
const sectionStyle = {
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 6,
padding: 8,
background: token.colorBgContainer,
};
const sectionTitleStyle = {
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 8,
fontWeight: 600,
};
const actionGridStyle = {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 6,
};
const compactOperationGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 8,
};
const operationExtra = (
<Space>
<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedText}</Tag>
<Space.Compact>
<Button size="small" disabled>并发</Button>
<InputNumber
size="small"
min={1}
max={10}
value={concurrency}
onChange={(v) => setConcurrency(v || 1)}
style={{ width: 70 }}
/>
</Space.Compact>
</Space>
);
const operationBody = (
<>
{isPeaceHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<SearchOutlined />
<span>小店查询</span>
</div>
<div style={actionGridStyle}>
{renderActionButton('get_xpd_bind_qr', 'primary')}
{renderActionButton('query_xpd_bind_info', 'primary')}
{renderActionButton('confirm_xpd_bind', 'primary')}
{renderActionButton('query_xpd_role', 'primary')}
{renderActionButton('query_xpd_balance', 'primary')}
{renderActionButton('query_xpd_fragments', 'primary')}
</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: xpdGoodsLabel(item) }))}
placeholder="选择小店商品"
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<Select
size="small"
value={xpdPaymentOptions.some((option) => option.value === xpdPayType) ? xpdPayType : undefined}
onChange={setXpdPayType}
options={xpdPaymentOptions}
placeholder={selectedGoodsId ? '该商品暂无可用兑换方式' : '先选择商品'}
disabled={!selectedGoodsId || xpdPaymentOptions.length === 0}
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('refresh_xpd_goods')}
{renderActionButton('exchange_xpd_goods', 'primary')}
</div>
</Space>
</div>
</div>
) : isEsportsHandbook ? (
<div style={compactOperationGridStyle}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span>开通手册</span>
</div>
{renderActionButton('create_esports_qr', 'primary')}
</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_esports_goods')}
{renderActionButton('exchange_esports_goods', 'primary')}
</div>
</Space>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>
<CreditCardOutlined />
<span>充值与送礼</span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
<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>
<InputNumber
size="small"
min={1}
value={giftCount}
onChange={(v) => setGiftCount(v || 1)}
addonAfter="赠送数量"
style={{ width: '100%' }}
/>
<div style={actionGridStyle}>
{renderActionButton('donate_esports_chicken_gift', 'primary')}
{renderActionButton('donate_esports_firework_gift', 'primary')}
</div>
</Space>
</div>
</div>
) : (
<div style={compactOperationGridStyle}>
<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>
</div>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
勾选账号后可批量执行;账号行右键可执行绑定与查询动作。
</Text>
</>
);
const accountsBody = (
<>
<Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between', flexShrink: 0 }} wrap>
<Space>
<Input
size="small" placeholder="搜索账号/昵称/游戏名" prefix={<SearchOutlined />}
value={accountSearch}
onChange={(e) => {
setAccountSearch(e.target.value);
setAccountPage(1);
}}
style={{ width: 200 }} allowClear
/>
</Space>
<Space>
<Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>
{selectedIds.length > 0 && (
<Button size="small" danger onClick={removeSelected}>移出选中</Button>
)}
</Space>
</Space>
<div ref={accountTableAreaRef} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Table
rowKey="id"
size="small"
className="douyu-task-record-table"
loading={loading}
locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
}}
columns={accountColumns}
dataSource={accounts}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accountTotal,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
size: 'small',
showLessItems: true,
showTotal: (total) => `共 ${total} 条`,
onChange: (page, size) => {
setAccountPage(size === accountPageSize ? page : 1);
if (size !== accountPageSize) {
setAccountPageSize(size);
localStorage.setItem('douyu_task_account_page_size', String(size));
}
},
}}
tableLayout="fixed"
scroll={{ x: 1180, y: Math.max(120, accountTableAreaHeight - 80) }}
onRow={(record) => ({
onContextMenu: (e) => handleRowContextMenu(record, e),
style: { cursor: 'context-menu' },
title: '右键打开账号动作',
})}
/>
</div>
</>
);
// 任务记录表:表头固定、表体内部滚动、分页器固定显示在底部
const taskRecordTableStyle = `
.douyu-task-record-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.douyu-task-record-table .ant-spin-nested-loading,
.douyu-task-record-table .ant-spin-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.douyu-task-record-table .ant-table {
flex: 1;
min-height: 0;
font-size: 12px;
display: flex;
flex-direction: column;
}
.douyu-task-record-table .ant-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.douyu-task-record-table .ant-table-header {
flex-shrink: 0;
}
.douyu-task-record-table .ant-table-body {
flex: 1;
min-height: 0;
overflow: auto !important;
}
.douyu-task-record-table .ant-table-thead > tr > th,
.douyu-task-record-table .ant-table-tbody > tr > td {
padding: 4px 8px;
line-height: 20px;
}
.douyu-task-record-table .ant-typography {
font-size: 12px;
}
.douyu-task-record-table .ant-tag {
font-size: 11px;
line-height: 18px;
margin-inline-end: 0;
padding-inline: 5px;
}
.douyu-task-record-table .ant-pagination {
flex-shrink: 0;
margin: 0;
padding: 6px 4px 2px;
border-top: 1px solid ${token.colorBorderSecondary};
}
`;
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<style>{taskRecordTableStyle}</style>
{/* 头部 */}
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0 }}>{handbookTitle}</h2>
<Text type="secondary">{handbookDescription}</Text>
</div>
<Space>
<Tooltip title={layoutMode === 'split' ? '切换为上下布局' : '切换为左右布局'}>
<Button
icon={<ColumnWidthOutlined />}
onClick={() => setLayoutMode((m) => (m === 'split' ? 'stack' : 'split'))}
/>
</Tooltip>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}>刷新</Button>
{canConfig && <Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}>配置</Button>}
{(activeBatchIds.size > 0 || runningBatchIds.size > 0) && <Button danger icon={<StopOutlined />} onClick={stopBatch}>停止</Button>}
</Space>
</Space>
{/* 操作工具栏 + 账号表格(支持上下/左右布局切换) */}
{layoutMode === 'split' ? (
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}>
<Card
size="small" title="账号"
extra={<Tag color="blue">已选 {selectedIds.length}/{accountTotal}</Tag>}
style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
{accountsBody}
</Card>
<Card
size="small"
title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra}
style={{ width: 340, flexShrink: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 8, flex: 1, minHeight: 0, overflowY: 'auto' } }}
>
{operationBody}
</Card>
</div>
) : (
<>
<Card
size="small"
title={isPeaceHandbook ? '和平小店操作' : (isEsportsHandbook ? '电竞手册操作' : '精英宝典操作')}
extra={operationExtra}
style={{ flexShrink: 0, marginBottom: 12 }}
styles={{ body: { padding: 8 } }}
>
{operationBody}
</Card>
<Card
size="small" title="账号"
extra={<Tag color="blue">已选 {selectedIds.length}/{accountTotal}</Tag>}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
>
{accountsBody}
</Card>
</>
)}
{/* 右键菜单 */}
{contextMenu && (
<div style={contextMenuStyle} onClick={(e) => e.stopPropagation()}>
<Card
size="small"
title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'}
styles={{ body: { padding: 4 } }}
style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{quickActions.map((item) => (
<Button
key={item.key} block size="small" type="text"
icon={item.icon}
onClick={() => runContextAction(item.key)}
disabled={contextMenu.accountIds.some((id) => runningAccountIds.has(id))}
style={{ justifyContent: 'flex-start' }}
>
{taskTypes[item.key] || item.key}
</Button>
))}
</Space>
</Card>
</div>
)}
{/* Import modal */}
<Modal
title="导入账号到操作台"
open={importOpen}
onCancel={() => setImportOpen(false)}
footer={null}
width={700}
>
<div style={{ marginBottom: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input
placeholder="搜索账号/昵称" prefix={<SearchOutlined />}
value={importSearch}
onChange={(e) => {
setImportSearch(e.target.value);
setImportPage(1);
}}
style={{ width: 220 }}
/>
<Select
placeholder="按标签筛选" allowClear showSearch
value={importTag || undefined}
onChange={(value?: string) => {
setImportTag(value || '');
setImportPage(1);
}}
style={{ width: 160 }}
options={importTags.map((t) => ({ value: t, label: t }))}
/>
</Space>
<Space wrap style={{ width: '100%', justifyContent: 'flex-start' }}>
<Button onClick={() => importAccounts(importPool.map((a) => a.id))}>
导入本页 ({importPool.length})
</Button>
<Button loading={importAllLoading} onClick={() => void importAllAccounts()}>
一键导入全部
</Button>
<Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
导入选中 ({importSelectedIds.length})
</Button>
</Space>
</div>
<Table
rowKey="id" size="small" loading={importLoading}
rowSelection={{ selectedRowKeys: importSelectedIds, onChange: (keys) => setImportSelectedIds(keys.map(Number)) }}
dataSource={importPool}
columns={[
{ title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username },
{ title: 'UID', dataIndex: 'uid' },
{ title: '标签', dataIndex: 'tag', width: 110, render: (v: string) => (v ? <Tag color="blue">{v}</Tag> : '-') },
{
title: '游戏名',
dataIndex: isPeaceHandbook ? 'xpd_game_name' : (isEsportsHandbook ? 'esports_game_name' : 'game_name'),
render: (v) => v || '-',
},
{
title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'),
dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'),
render: (v) => v ?? '-',
},
]}
pagination={{
current: importPage,
pageSize: importPageSize,
total: importTotal,
size: 'small',
showLessItems: true,
showTotal: (total) => `共 ${total} 条`,
onChange: setImportPage,
}}
locale={{ emptyText: '没有可导入的账号' }}
/>
</Modal>
{/* Config modal */}
<Modal
title={isPeaceHandbook ? '和平小店配置' : (isEsportsHandbook ? '电竞手册配置' : '精英宝典配置')}
open={configOpen}
onCancel={() => setConfigOpen(false)}
onOk={() => configFormValues && saveConfig(configFormValues)}
okText="保存" cancelText="取消"
width={600}
>
{configFormValues && (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{(isPeaceHandbook ? [
{ label: '小店活动代号', key: 'xpd_act_alias' },
{ label: '道聚城活动 ID', key: 'xpd_act_id' },
{ label: '房间 ID', key: 'xpd_rid' },
] : isEsportsHandbook ? [
{ label: '电竞手册 manualID', key: 'esports_manual_id' },
{ label: '电竞手册活动', key: 'esports_act_alias' },
{ label: '房间 ID', key: 'room_id' },
{ label: '冠军鸡腿礼物 ID', key: 'esports_chicken_gift_id' },
{ label: '冠军鸡腿皮肤 ID', key: 'esports_chicken_skin_id' },
{ label: '冠军烟花礼物 ID', key: 'esports_firework_gift_id' },
{ label: '冠军烟花皮肤 ID', key: 'esports_firework_skin_id' },
] : [
{ label: 'manualID', key: 'manual_id' },
{ label: 'RID', key: 'rid' },
{ label: '绑定二维码活动', key: 'bind_act_alias' },
{ label: '确认绑定活动', key: 'confirm_act_alias' },
{ label: '查询绑定活动(最新)', key: 'legacy_act_alias' },
{ label: '房间 ID', key: 'room_id' },
{ label: '精英令礼物 ID', key: 'gift_id' },
{ label: '皮肤 ID', key: 'skin_id' },
]).map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<Input
style={{ width: 300 }}
value={String(configFormValues[key as keyof DouyuConfig] ?? '')}
onChange={(e) => setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value } : prev)}
/>
</Space>
))}
{(isPeaceHandbook ? [] : isEsportsHandbook ? [
{ label: '电竞手册金额(分)', key: 'esports_amount' },
] : [
{ label: '宝典金额(分)', key: 'elite_amount' },
{ label: '鱼翅支付方式', key: 'gold_pay_type' },
]).map(({ label, key }) => (
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
<Text>{label}</Text>
<InputNumber
style={{ width: 300 }}
value={Number(configFormValues[key as keyof DouyuConfig]) || 0}
onChange={(v) => setConfigFormValues((prev) => prev ? { ...prev, [key]: v ?? 0 } : prev)}
/>
</Space>
))}
</Space>
)}
</Modal>
{/* 电竞手册绑定弹窗:活动绑定与角色选择是独立流程。 */}
<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>
)}
{esportsRebind && esportsRoleName ? (
<Text type="secondary">
{esportsRebind.available ? '当前可换绑' : `可换绑时间:${esportsRebind.text}`}
</Text>
) : null}
{esportsQrUrl ? (
<div style={{ textAlign: 'center' }}>
<div ref={esportsQrCanvasWrapRef}>
<QRCode value={esportsQrUrl} size={220} bordered={false} />
</div>
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
请在腾讯页面选择角色,完成后查询最新角色。
</Text>
</div>
) : null}
<QrActions wrapRef={esportsQrCanvasWrapRef} url={esportsQrUrl} disabled={!esportsQrUrl} />
<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>
<QrcodeOutlined />
<span>
{qrBindReady ? '已识别新角色' : qrStatusText}
{qrTaskIds.length > 1 && ` (${qrTaskIds.length})`}
</span>
</Space>
}
open={qrTaskIds.length > 0}
onCancel={() => closeAllQrTasks()}
footer={null}
centered
width={420}
>
{qrTaskIds.length > 1 && (
<Space size={4} wrap style={{ marginBottom: 12, justifyContent: 'center' }}>
{qrTaskIds.map((id) => {
const t = tasks.find((x) => x.id === id);
if (!t) return null;
const name = t.account_nickname || t.account_username || t.account_uid || `#${t.account_id}`;
return (
<Tag
key={id}
color={id === activeQrTaskId ? 'blue' : 'default'}
style={{ cursor: 'pointer' }}
closable
onClose={(e) => { e.preventDefault(); closeQrTask(id); }}
onClick={() => setActiveQrTaskId(id)}
>
{name}
</Tag>
);
})}
</Space>
)}
{qrTask && (
<div style={{ textAlign: 'center' }}>
{qrAccountName && (
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>{qrAccountName}</Text>
)}
<Space direction="vertical" align="center" size={12}>
<Tag color={qrXpdBound ? 'success' : qrBindReady ? 'gold' : qrBindPhase === 'role_timeout' ? 'orange' : 'processing'}>
{qrStatusText}
</Tag>
{qrUrl ? (
<div ref={qrCanvasWrapRef}>
<QRCode value={qrUrl} size={220} bordered={false} />
</div>
) : qrBindReady ? (
<Text type="secondary">绑定二维码暂未返回</Text>
) : qrTask?.status === 'running' ? (
<Text type="secondary">二维码生成中...</Text>
) : (
<Text type="secondary">本次未生成二维码</Text>
)}
<QrActions wrapRef={qrCanvasWrapRef} url={qrUrl} disabled={!qrUrl} />
{qrBindReady || qrXpdBound ? (
<>
<Tag color="gold" style={{ fontSize: 16, padding: '6px 16px' }}>
{qrRoleName}
</Tag>
{qrRoleLine && <Text type="secondary">{qrRoleLine}</Text>}
</>
) : qrCurrentRoleName ? (
<>
<Tag color="blue" style={{ fontSize: 14, padding: '4px 12px' }}>
当前绑定: {qrCurrentRoleName}
</Tag>
{(qrCurrentAreaName || qrCurrentPlatName) && (
<Text type="secondary">
{[qrCurrentPlatName, qrCurrentAreaName].filter(Boolean).join(' - ')}
</Text>
)}
</>
) : null}
{qrBindSummary ? (
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
{qrBindSummary}
</Text>
) : null}
{qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.message ? (
<Text type="secondary" style={{ fontSize: 12 }}>
最近查询: {qrQueryTask.message}
</Text>
) : null}
<Space>
<Button
icon={<SearchOutlined />}
onClick={queryQrRole}
loading={qrQueryRunning}
disabled={qrAutoPolling}
>
{qrAutoPolling ? '自动识别中' : '查询角色'}
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={confirmQrBind}
disabled={!qrBindReady}
>
确认绑定
</Button>
</Space>
</Space>
</div>
)}
</Modal>
{/* Pay QR Modal */}
<Modal
title={
<Space>
<CreditCardOutlined />
<span>支付二维码</span>
</Space>
}
open={!!payTask}
onCancel={() => setPayTask(null)}
footer={null}
centered
width={400}
>
{payTask && (
<div style={{ textAlign: 'center' }}>
{payAccountName && (
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>{payAccountName}</Text>
)}
<Space direction="vertical" align="center" size={12}>
{payUrl ? (
<div ref={payQrCanvasWrapRef}>
<QRCode value={payUrl} size={220} bordered={false} />
</div>
) : (
<Text type="secondary">支付码生成中...</Text>
)}
<QrActions wrapRef={payQrCanvasWrapRef} url={payUrl} disabled={!payUrl} />
<Text>{payTask.message || '请扫码支付'}</Text>
</Space>
</div>
)}
</Modal>
{/* 兑换结果图片预览 Modal */}
<Modal
title={
<Space>
<CopyOutlined />
<span>兑换结果图片</span>
</Space>
}
open={!!exchangePreview}
onCancel={() => setExchangePreview(null)}
footer={
<Space>
<Button onClick={() => setExchangePreview(null)}>关闭</Button>
<Button type="primary" icon={<CopyOutlined />} onClick={copyExchangePreview}>复制图片</Button>
</Space>
}
centered
width={340}
>
{exchangePreview && (
<Space direction="vertical" style={{ width: '100%' }} align="center" size={8}>
<img
src={exchangePreview.url}
alt="兑换结果"
style={{ width: '100%', borderRadius: 8, border: `1px solid ${token.colorBorderSecondary}` }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{exchangePreview.task.account_nickname
|| exchangePreview.task.account_username
|| `#${exchangePreview.task.account_id}`}
</Text>
</Space>
)}
</Modal>
{/* 确认绑定失败提示 Modal(居中,自动消失) */}
<Modal
title={<Space><ExclamationCircleOutlined style={{ color: '#faad14' }} /><span>绑定失败提示</span></Space>}
open={Boolean(confirmFailTip)}
onCancel={() => setConfirmFailTip(null)}
footer={null}
centered
closable={false}
width={420}
>
<div style={{ textAlign: 'center', padding: '16px 8px 8px' }}>
<ExclamationCircleOutlined style={{ fontSize: 42, color: '#faad14' }} />
<div style={{ fontSize: 14, marginTop: 14, lineHeight: 1.7 }}>
{confirmFailTip?.message || ''}
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 12, display: 'inline-block' }}>
{confirmFailCountdown} 秒后自动关闭
</Text>
</div>
</Modal>
</div>
);
}