2441 lines
97 KiB
TypeScript
2441 lines
97 KiB
TypeScript
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}`;
|
||
}
|
||
|
||
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 = Number(xpd.price ?? raw?.iPrice);
|
||
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))
|
||
? `库存${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 xpd = item as DouyuGoodsItem & { price?: number | null };
|
||
const raw = item.raw?.raw as Record<string, unknown> | undefined;
|
||
const pointPrice = Number(xpd.price ?? raw?.iPrice);
|
||
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[]>([]);
|
||
// 工作台已导入账号 ID(localStorage 持久化,默认空白,用户手动导入/移除)
|
||
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>
|
||
);
|
||
}
|