import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, 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 = { planned: 'default', pending: 'default', running: 'processing', success: 'success', failed: 'error', error: 'error', stopped: 'warning', }; const TASK_STATUS_LABELS: Record = { planned: '已计划', pending: '等待中', running: '执行中', success: '成功', failed: '失败', error: '异常', stopped: '已停止', }; const ELITE_QUICK_ACTIONS = [ { key: 'get_bind_qr', icon: }, { key: 'confirm_bind', icon: }, { key: 'create_elite_qr', icon: }, { key: 'query_points', icon: }, { key: 'query_game_name', icon: }, { key: 'query_change_bind_time', icon: }, { key: 'query_limited_goods', icon: }, { key: 'query_gold_balance', icon: }, { key: 'exchange_goods', icon: }, { key: 'create_gold_qr', icon: }, { key: 'donate_elite_gift', icon: }, ]; const ESPORTS_QUICK_ACTIONS = [ { key: 'prepare_esports_bind', icon: }, { key: 'query_esports_game_name', icon: }, { key: 'create_esports_qr', icon: }, { key: 'query_esports_points', icon: }, { key: 'query_change_bind_time', icon: }, { key: 'query_limited_goods', icon: }, { key: 'query_gold_balance', icon: }, { key: 'refresh_esports_goods', icon: }, { key: 'exchange_esports_goods', icon: }, { key: 'create_gold_qr', icon: }, { key: 'donate_esports_chicken_gift', icon: }, { key: 'donate_esports_firework_gift', icon: }, ]; const PEACE_QUICK_ACTIONS = [ { key: 'get_xpd_bind_qr', icon: }, { key: 'query_xpd_bind_info', icon: }, { key: 'confirm_xpd_bind', icon: }, { key: 'query_xpd_role', icon: }, { key: 'query_xpd_balance', icon: }, { key: 'query_xpd_fragments', icon: }, { key: 'query_xpd_purchase_records', icon: }, { key: 'refresh_xpd_goods', icon: }, { key: 'exchange_xpd_goods', icon: }, ]; 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', 'query_xpd_purchase_records', '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 | null | undefined, key: string): string { const value = result?.[key]; return typeof value === 'string' ? value : ''; } function resultFlag(result: Record | 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 | 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 | 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 | 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 | 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; 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([]); // 工作台账号归属由服务端按用户保存;localStorage 仅用于首次升级迁移旧浏览器数据。 const [workbenchIds, setWorkbenchIds] = useState([]); const [workbenchReady, setWorkbenchReady] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [goods, setGoods] = useState([]); const [tasks, setTasks] = useState([]); const [taskTypes, setTaskTypes] = useState>({}); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(false); const [runningBatchIds, setRunningBatchIds] = useState>(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(null); const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(360); const [accountPageSize, setAccountPageSize] = useState(() => { 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([]); const [importSearch, setImportSearch] = useState(''); const [importTag, setImportTag] = useState(''); const [importTags, setImportTags] = useState([]); const [importSelectedIds, setImportSelectedIds] = useState([]); 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([]); const [activeQrTaskId, setActiveQrTaskId] = useState(null); const [esportsBindTask, setEsportsBindTask] = useState(null); const [payTask, setPayTask] = useState(null); const qrCanvasWrapRef = useRef(null); const esportsQrCanvasWrapRef = useRef(null); const payQrCanvasWrapRef = useRef(null); const autoOpenedQrTaskIds = useRef>(new Set()); const autoOpenedEsportsBindTaskIds = useRef>(new Set()); const autoOpenedPayTaskIds = useRef>(new Set()); const autoCopiedExchangeTaskIds = useRef>(new Set()); const autoNotifiedConfirmFailedTaskIds = useRef>(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 [xpdPurchaseRecordsAccountId, setXpdPurchaseRecordsAccountId] = useState(null); const locallyStartedBatchIds = useRef>(new Set()); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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 latestXpdPurchaseRecordsTaskByAccount = useMemo(() => { const map = new Map(); for (const task of tasks) { if (task.task_type !== 'query_xpd_purchase_records') continue; const previous = map.get(task.account_id); if (!previous || task.id > previous.id) map.set(task.account_id, task); } return map; }, [tasks]); 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(handbook), 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 (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, handbook, isEsportsHandbook, isPeaceHandbook, workbenchIds]); const loadTasks = useCallback(async () => { if (tasksLoadingRef.current) return; tasksLoadingRef.current = true; try { const data = await douyuApi.listTasks(handbook); setTasks(data); if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) { // 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住 setRunningBatchIds(new Set()); } } catch { // 轮询失败不打扰操作 } finally { tasksLoadingRef.current = false; } }, [handbook]); useEffect(() => { loadData(); }, [loadData]); useEffect(() => { let cancelled = false; void douyuApi.listWorkbenchAccounts(handbook) .then(async ({ account_ids, configured }) => { if (cancelled) return; if (configured) { setWorkbenchIds(account_ids); localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); return; } // 仅在服务端尚未保存时迁移当前浏览器的旧工作台列表,避免覆盖其他设备的配置。 const legacy = (localStorage.getItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)) || '') .split(',').map(Number).filter(Number.isInteger).filter((id) => id >= 1); if (legacy.length === 0) return; const saved = await douyuApi.updateWorkbenchAccounts(handbook, legacy); if (!cancelled) { setWorkbenchIds(saved.account_ids); localStorage.removeItem(DOUYU_WORKBENCH_IDS_STORAGE_KEY(handbook)); } }) .catch((error) => { if (!cancelled) message.error(`加载工作台账号失败: ${getErrorMessage(error)}`); }) .finally(() => { if (!cancelled) setWorkbenchReady(true); }); return () => { cancelled = true; }; }, [handbook]); useEffect(() => { localStorage.setItem(DOUYU_LAYOUT_MODE_STORAGE_KEY, layoutMode); }, [layoutMode]); // 监听账号表格区域高度变化,动态计算 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(); 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(); 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) => ( locallyStartedBatchIds.current.has(task.batch_id) && 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) => ( locallyStartedBatchIds.current.has(task.batch_id) && 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) => ( locallyStartedBatchIds.current.has(task.batch_id) && 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 = {}, ) => { 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 = { ...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, handbook_scope: handbook, concurrency, payload, }); // 只有本页面创建的批次才允许主动弹二维码,防止其他工作台/浏览器的任务打扰当前操作。 locallyStartedBatchIds.current.add(result.batch_id); 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; if (task.handbook_scope !== handbook) return; // 已有任务原位更新保持排序;新任务插入头部(与后端 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 saveWorkbenchIds = async (nextIds: number[]) => { const saved = await douyuApi.updateWorkbenchAccounts(handbook, nextIds); setWorkbenchIds(saved.account_ids); return saved.account_ids; }; const importAccounts = async (ids: number[]) => { const toImport = importPool.filter((a) => ids.includes(a.id)); const nextIds = [...new Set([...workbenchIds, ...toImport.map((a) => a.id)])]; try { await saveWorkbenchIds(nextIds); } catch (error) { message.error(getErrorMessage(error)); return; } 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; } await saveWorkbenchIds([...new Set([...workbenchIds, ...imported])]); setImportSelectedIds([]); setImportOpen(false); message.success(`已一键导入 ${imported.length} 个账号`); } catch (error) { message.error(getErrorMessage(error)); } finally { setImportAllLoading(false); } }; const removeSelected = async () => { const sel = new Set(selectedIds); try { await saveWorkbenchIds(workbenchIds.filter((id) => !sel.has(id))); } catch (error) { message.error(getErrorMessage(error)); return; } 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['columns'] = [ { title: '#', width: 45, render: (_, __, index) => {index + 1}, }, { title: '账号', dataIndex: 'username', width: 150, render: (_, record) => ( {record.nickname || record.username} UID {record.uid || '-'} ), }, { title: '游戏名', dataIndex: 'game_name', width: 170, render: (_, record) => { if (isPeaceHandbook) { return record.xpd_game_name ? {record.xpd_game_name} : 未查; } 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 未查; return ( {roleName ? ( {pendingRoleName ? `当前: ${roleName}` : roleName} {channel ? ( {channel} ) : null} ) : null} {pendingRoleName ? ( 待确认: {pendingRoleName} {pendingChannel ? ( {pendingChannel} ) : null} ) : null} ); }, }, { title: isPeaceHandbook ? '点券' : (isEsportsHandbook ? '电竞积分' : '积分'), dataIndex: isPeaceHandbook ? 'xpd_balance' : (isEsportsHandbook ? 'esports_points' : 'points'), width: 80, render: (v) => v ?? -, }, // 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片 ...(isPeaceHandbook ? [{ title: '扭蛋碎片', dataIndex: 'xpd_fragments', width: 90, render: (v: number | null) => v ?? -, }] as NonNullable['columns']> : [ { title: '鱼翅', dataIndex: 'gold_balance', width: 70, render: (v: number | null) => v ?? -, }, { title: '限制兑换', dataIndex: 'exchange_balance', width: 150, ellipsis: true, render: (_, record) => { const latestLimitedTask = latestLimitedGoodsTaskByAccount.get(record.id); const text = limitedGoodsTaskText(latestLimitedTask); if (!text) return 未查; if (latestLimitedTask?.status !== 'success') { return {text}; } const count = resultNumber(latestLimitedTask.result, 'limited_count'); if (count === 0 || text === '无限制商品') return 无限制商品; return {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 ? 可换绑 : ( 暂不可换绑 {esportsStatus.text} ); } 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 未查; } if (wait != null && wait <= 0) { return 可换绑; } if (wait != null && wait > 0) { return ( 冷却中 {text || formatWaitSeconds(wait)} ); } // 接口没返回数字但有文案 if (text === '可换绑') return 可换绑; return {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['columns']>), { title: '最近操作', width: 220, render: (_, record) => { const latest = latestTaskByAccount.get(record.id); if (!latest) return 暂无操作; return ( {TASK_STATUS_LABELS[latest.status] || latest.status} {taskTypes[latest.task_type] || latest.task_type} {latest.message ? ( {latest.message} ) : null} {formatTime(latest.finished_at || latest.created_at)} ); }, }, { 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 (
); }, }, { title: '兑换图片', width: 80, align: 'center', render: (_, record) => { const task = latestExchangeTaskByAccount.get(record.id); if (!task) return -; return ( setExchangePreview({ task: t, url })} /> ); }, }, ...(isPeaceHandbook ? [{ title: '兑换记录', width: 100, align: 'center' as const, render: (_: unknown, record: DouyuTaskAccountItem) => { const task = latestXpdPurchaseRecordsTaskByAccount.get(record.id); const running = task && ['planned', 'pending', 'running'].includes(task.status); return ( ); }, }] : []), ]; const [configFormValues, setConfigFormValues] = useState(null); useEffect(() => { if (configOpen && config) setConfigFormValues({ ...config }); }, [configOpen, config]); const payUrl = resultText(payTask?.result, 'pay_url'); const goldAmountLabel = '充值金额'; const isSupplierGoldRecharge = config?.gold_recharge_channel === 'supplier_api'; const goldRechargeChannelLabel = isSupplierGoldRecharge ? '供应商 API 直充' : '微信扫码'; const goldRechargeButtonLabel = isSupplierGoldRecharge ? 'API直充鱼翅' : '扫码充值鱼翅'; 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 ( ); }; 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 = ( {selectedText} setConcurrency(v || 1)} style={{ width: 70 }} /> ); const operationBody = ( <> {isPeaceHandbook ? (
小店查询
{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')}
小店商品
option.value === xpdPayType) ? xpdPayType : undefined} onChange={setXpdPayType} options={xpdPaymentOptions} placeholder={selectedGoodsId ? '该商品暂无可用兑换方式' : '先选择商品'} disabled={!selectedGoodsId || xpdPaymentOptions.length === 0} style={{ width: '100%' }} />
{renderActionButton('refresh_xpd_goods')} {renderActionButton('exchange_xpd_goods', 'primary')}
) : isEsportsHandbook ? (
开通手册
{renderActionButton('create_esports_qr', 'primary')}
皮肤兑换
({ value: item.commodity_id, label: goodsLabel(item) }))} placeholder="选择兑换商品" showSearch optionFilterProp="label" style={{ width: '100%' }} />
{renderActionButton('refresh_goods')} {renderActionButton('exchange_goods', 'primary')}
开通、充值与送礼
{renderActionButton('create_elite_qr', 'primary')} {`当前渠道:${goldRechargeChannelLabel}`} setGoldAmount(v || 1)} addonBefore={goldAmountLabel} style={{ width: '100%' }} /> setGiftCount(v || 1)} style={{ width: '100%' }} />
)} 勾选账号后可批量执行;账号行右键可执行绑定与查询动作。 ); const accountsBody = ( <> } value={accountSearch} onChange={(e) => { setAccountSearch(e.target.value); setAccountPage(1); }} style={{ width: 200 }} allowClear /> {selectedIds.length > 0 && ( )}
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: 1280, y: Math.max(120, accountTableAreaHeight - 80) }} onRow={(record) => ({ onContextMenu: (e) => handleRowContextMenu(record, e), style: { cursor: 'context-menu' }, title: '右键打开账号动作', })} /> ); // 任务记录表:表头固定、表体内部滚动、分页器固定显示在底部 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}; } `; const xpdPurchaseTask = xpdPurchaseRecordsAccountId == null ? null : latestXpdPurchaseRecordsTaskByAccount.get(xpdPurchaseRecordsAccountId) || null; const xpdPurchaseRecords = Array.isArray(xpdPurchaseTask?.result?.records) ? xpdPurchaseTask.result.records.filter( (item): item is Record => Boolean(item && typeof item === 'object'), ) : []; const xpdPurchaseAccount = accounts.find((account) => account.id === xpdPurchaseRecordsAccountId); return (
{/* 头部 */}

{handbookTitle}

{handbookDescription}
{canConfig && } {(activeBatchIds.size > 0 || runningBatchIds.size > 0) && }
{/* 操作工具栏 + 账号表格(支持上下/左右布局切换) */} {layoutMode === 'split' ? (
已选 {selectedIds.length}/{accountTotal}} 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} {operationBody}
) : ( <> {operationBody} 已选 {selectedIds.length}/{accountTotal}} 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} )} {/* 右键菜单 */} {contextMenu && (
e.stopPropagation()}> 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }} > {quickActions.map((item) => ( ))}
)} {/* Import modal */} setImportOpen(false)} footer={null} width={700} >
} value={importSearch} onChange={(e) => { setImportSearch(e.target.value); setImportPage(1); }} style={{ width: 220 }} />
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 ? {v} : '-') }, { 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: '没有可导入的账号' }} /> {/* Config modal */} setConfigOpen(false)} onOk={() => configFormValues && saveConfig(configFormValues)} okText="保存" cancelText="取消" width={600} > {configFormValues && ( {!isPeaceHandbook && ( <> 鱼翅充值渠道 setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value, } : prev)} /> ))} )} )} {(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 }) => ( {label} setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value } : prev)} /> ))} {(isPeaceHandbook ? [] : isEsportsHandbook ? [ { label: '电竞手册金额(分)', key: 'esports_amount' }, ] : [ { label: '宝典金额(分)', key: 'elite_amount' }, { label: '鱼翅支付方式', key: 'gold_pay_type' }, ]).map(({ label, key }) => ( {label} setConfigFormValues((prev) => prev ? { ...prev, [key]: v ?? 0 } : prev)} /> ))} )} {/* 电竞手册绑定弹窗:活动绑定与角色选择是独立流程。 */} 绑定电竞手册角色 } open={!!esportsBindTask} onCancel={() => { setEsportsBindTask(null); }} footer={null} centered width={440} > {esportsBindTask && ( {esportsAccountName ? ( {esportsAccountName} ) : null} {esportsStatusText} {esportsRoleName ? (
当前游戏角色 {esportsRoleName} {esportsRoleLine ? ( {esportsRoleLine} ) : null}
) : ( 当前未检测到游戏角色,请先切换角色。 )} {esportsRebind && esportsRoleName ? ( {esportsRebind.available ? '当前可换绑' : `可换绑时间:${esportsRebind.text}`} ) : null} {esportsQrUrl ? (
请在腾讯页面选择角色,完成后查询最新角色。
) : null}
)}
{/* 精英宝典绑定二维码弹窗。 */} {qrBindReady ? '已识别新角色' : qrStatusText} {qrTaskIds.length > 1 && ` (${qrTaskIds.length})`} } open={qrTaskIds.length > 0} onCancel={() => closeAllQrTasks()} footer={null} centered width={420} > {qrTaskIds.length > 1 && ( {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 ( { e.preventDefault(); closeQrTask(id); }} onClick={() => setActiveQrTaskId(id)} > {name} ); })} )} {qrTask && (
{qrAccountName && ( {qrAccountName} )} {qrStatusText} {qrUrl ? (
) : qrBindReady ? ( 绑定二维码暂未返回 ) : qrTask?.status === 'running' ? ( 二维码生成中... ) : ( 本次未生成二维码 )} {qrBindReady || qrXpdBound ? ( <> {qrRoleName} {qrRoleLine && {qrRoleLine}} ) : qrCurrentRoleName ? ( <> 当前绑定: {qrCurrentRoleName} {(qrCurrentAreaName || qrCurrentPlatName) && ( {[qrCurrentPlatName, qrCurrentAreaName].filter(Boolean).join(' - ')} )} ) : null} {qrBindSummary ? ( {qrBindSummary} ) : null} {qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.message ? ( 最近查询: {qrQueryTask.message} ) : null}
)}
{/* Pay QR Modal */} 支付二维码 } open={!!payTask} onCancel={() => setPayTask(null)} footer={null} centered width={400} > {payTask && (
{payAccountName && ( {payAccountName} )} {payUrl ? (
) : ( 支付码生成中... )} {payTask.message || '请扫码支付'}
)}
{/* 和平小店兑换记录 Modal */} 和平小店兑换记录 } open={xpdPurchaseRecordsAccountId != null} onCancel={() => setXpdPurchaseRecordsAccountId(null)} footer={null} width={980} centered > {xpdPurchaseAccount?.nickname || xpdPurchaseAccount?.username || `#${xpdPurchaseRecordsAccountId}`} {xpdPurchaseTask?.result?.total != null ? `,共 ${String(xpdPurchaseTask.result.total)} 条` : ''} {xpdPurchaseTask && ['failed', 'error'].includes(xpdPurchaseTask.status) ? ( ) : (
String(record.serial || `${record.buy_time || ''}-${index}`)} size="small" loading={!xpdPurchaseTask || ['planned', 'pending', 'running'].includes(xpdPurchaseTask.status)} dataSource={xpdPurchaseRecords} pagination={{ pageSize: 10, showLessItems: true }} scroll={{ x: 850 }} columns={[ { title: '商品', dataIndex: 'goods_name', width: 240, render: (value: unknown) => String(value || '-'), }, { title: '兑换时间', dataIndex: 'buy_time', width: 170, render: (value: unknown) => String(value || '-'), }, { title: '支付时间', dataIndex: 'pay_time', width: 170, render: (value: unknown) => String(value || '-'), }, { title: '价格', width: 90, render: (_: unknown, record: Record) => record.pay_amount ?? record.price ?? '-', }, { title: '状态', dataIndex: 'status', width: 90, render: (value: unknown) => ( {String(value) === '3' ? '已完成' : String(value || '未知')} ), }, { title: '角色', dataIndex: 'role_name', width: 150, ellipsis: true, render: (value: unknown) => String(value || '-'), }, { title: '订单号', dataIndex: 'serial', width: 250, ellipsis: true, render: (value: unknown) => String(value || '-'), }, ]} locale={{ emptyText: xpdPurchaseTask ? '暂无兑换记录' : '正在查询兑换记录...' }} /> )} {/* 兑换结果图片预览 Modal */} 兑换结果图片 } open={!!exchangePreview} onCancel={() => setExchangePreview(null)} footer={ } centered width={340} > {exchangePreview && ( 兑换结果 {exchangePreview.task.account_nickname || exchangePreview.task.account_username || `#${exchangePreview.task.account_id}`} )} {/* 确认绑定失败提示 Modal(居中,自动消失) */} 绑定失败提示} open={Boolean(confirmFailTip)} onCancel={() => setConfirmFailTip(null)} footer={null} centered closable={false} width={420} >
{confirmFailTip?.message || ''}
{confirmFailCountdown} 秒后自动关闭
); }