Files
live-hub-py/web/frontend/src/pages/HuyaTasksPage.tsx
T

2045 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Col, DatePicker, Dropdown, Form, Input, InputNumber, Modal, QRCode, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { MenuProps, TableProps } from 'antd';
import type { Dayjs } from 'dayjs';
import type { MouseEvent as ReactMouseEvent } from 'react';
import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined,
ImportOutlined, LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import {
huyaApi,
type HuyaAccountItem,
type HuyaConfig,
type HuyaGoodsItem,
type HuyaRechargeGoodsItem,
type HuyaTaskItem,
} from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
const FALLBACK_TASK_TYPES: Record<string, string> = {
get_bind_qr: '获取绑定二维码',
confirm_bind: '确认绑定',
query_points: '一键查询积分',
query_game_name: '一键查询游戏名',
query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表',
refresh_recharge_goods: '刷新充值商品列表',
exchange_goods: '兑换商品',
create_recharge_order: '生成支付二维码',
};
const QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <LinkOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
{ key: 'query_game_name', icon: <AppstoreOutlined /> },
{ key: 'query_exchange_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_goods', icon: <ReloadOutlined /> },
{ key: 'refresh_recharge_goods', icon: <ShoppingOutlined /> },
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_recharge_order', icon: <CreditCardOutlined /> },
];
const BATCH_COMPATIBLE_TASK_TYPES = new Set(['confirm_bind', 'query_game_name']);
function canRunDuringBatch(taskType: string): boolean {
return BATCH_COMPATIBLE_TASK_TYPES.has(taskType);
}
const STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
stopped: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
stopped: '已停止',
};
const ACCOUNT_STATUS_LABELS: Record<string, string> = {
imported: '已导入',
updated: '已更新',
password_imported: '待登录',
login_success: '登录成功',
password_changed: '已改密',
login_failed: '登录失败',
active: '正常',
invalid: '失效',
points_queried: '已查积分',
game_queried: '已查角色',
game_not_bound: '未绑定',
bind_qr_generated: '待扫码',
bind_confirmed: '已绑定',
goods_exchanged: '已兑换',
recharge_order_created: '待支付',
};
const ACCOUNT_STATUS_COLORS: Record<string, string> = {
imported: 'blue',
updated: 'cyan',
password_imported: 'warning',
login_success: 'success',
password_changed: 'success',
login_failed: 'error',
active: 'success',
invalid: 'error',
points_queried: 'success',
game_queried: 'success',
game_not_bound: 'default',
bind_qr_generated: 'processing',
bind_confirmed: 'success',
goods_exchanged: 'success',
recharge_order_created: 'processing',
};
const HUYA_WORKBENCH_ACCOUNT_IDS_KEY = 'huya_task_workbench_account_ids';
function readWorkbenchAccountIds(): number[] {
try {
const raw = localStorage.getItem(HUYA_WORKBENCH_ACCOUNT_IDS_KEY);
if (!raw) return [];
const values = JSON.parse(raw);
if (!Array.isArray(values)) return [];
return Array.from(new Set(values.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0)));
} catch {
return [];
}
}
function saveWorkbenchAccountIds(ids: number[]) {
const normalized = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (normalized.length === 0) {
localStorage.removeItem(HUYA_WORKBENCH_ACCOUNT_IDS_KEY);
return;
}
localStorage.setItem(HUYA_WORKBENCH_ACCOUNT_IDS_KEY, JSON.stringify(normalized));
}
function accountLabel(account: HuyaAccountItem): string {
const name = account.nickname || account.username || account.uid || `#${account.id}`;
const tag = account.tag ? ` [${account.tag}]` : '';
const phone = account.game_phone ? ` / ${account.game_phone}` : '';
return `${name}${tag}${phone}`;
}
function accountDisplayName(account: HuyaAccountItem): string {
return account.nickname || account.username || account.uid || `#${account.id}`;
}
function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
const value = result?.[key];
return typeof value === 'string' ? value : '';
}
function resultObject(result: Record<string, unknown> | null | undefined, key: string): Record<string, unknown> | null {
const value = result?.[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function resultProfileNick(result: Record<string, unknown> | null | undefined): string {
const profile = result?.profile;
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return '';
const nick = (profile as Record<string, unknown>).nick;
return typeof nick === 'string' ? nick : '';
}
function taskPayloadValue(task: HuyaTaskItem | null | undefined, key: string): string {
const payload = resultObject(task?.result, 'payload');
const value = payload?.[key];
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return '';
}
function taskProductText(task: HuyaTaskItem | null | undefined): string {
const result = task?.result;
return (
resultText(result, 'product_name')
|| taskPayloadValue(task, 'product_name')
|| resultText(result, 'product_id')
|| taskPayloadValue(task, 'product_id')
|| resultText(result, 'spu_id')
|| taskPayloadValue(task, 'spu_id')
);
}
function contextMenuPosition(x: number, y: number) {
if (typeof window === 'undefined') return { left: x, top: y };
return {
left: Math.max(8, Math.min(x, window.innerWidth - 220)),
top: Math.max(8, Math.min(y, window.innerHeight - 390)),
};
}
function goodsRawString(item: HuyaGoodsItem, key: string): string {
const value = item.raw?.[key];
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return '';
}
function goodsRawNumber(item: HuyaGoodsItem, key: string): number {
const value = item.raw?.[key];
if (typeof value === 'number') return value;
if (typeof value === 'string' && value.trim()) return Number(value) || 0;
return 0;
}
function goodsCategoryKey(item: HuyaGoodsItem): string {
return goodsRawString(item, 'category_id') || goodsRawString(item, 'category_name') || 'uncategorized';
}
function goodsCategoryLabel(item: HuyaGoodsItem): string {
return goodsRawString(item, 'category_name') || goodsRawString(item, 'category_id') || '未分类';
}
function formatPriceText(value: number | null | undefined): string {
if (!value) return '';
return ${(value / 100).toFixed(2)}`;
}
function hasMiniQrcode(task: HuyaTaskItem): boolean {
if (task.task_type !== 'get_bind_qr') return false;
if (resultText(task.result, 'mini_qrcode_image')) return true;
return task.result?.has_mini_qrcode === true;
}
function mergeTaskImageCache(
items: HuyaTaskItem[],
imageCache: Map<number, string>,
): HuyaTaskItem[] {
return items.map((task) => {
const image = resultText(task.result, 'mini_qrcode_image');
if (image) {
imageCache.set(task.id, image);
return task;
}
const cached = imageCache.get(task.id);
if (!cached || !task.result) return task;
return {
...task,
result: {
...task.result,
mini_qrcode_image: cached,
has_mini_qrcode: true,
},
};
});
}
function bindReadyForConfirm(task: HuyaTaskItem | null | undefined): boolean {
return task?.task_type === 'get_bind_qr' && task.result?.bind_ready_for_confirm === true;
}
function hasPaymentQrcode(task: HuyaTaskItem): boolean {
return task.task_type === 'create_recharge_order' && Boolean(resultText(task.result, 'pay_url'));
}
function paymentStatus(task: HuyaTaskItem | null | undefined): string {
const status = task?.result?.payment_status;
return typeof status === 'string' ? status : '';
}
function paymentStatusLabel(task: HuyaTaskItem | null | undefined): string {
const label = task?.result?.payment_status_label;
return typeof label === 'string' ? label : '';
}
function isPaymentFinished(task: HuyaTaskItem | null | undefined): boolean {
return task?.task_type === 'create_recharge_order' && paymentStatus(task) === 'paid';
}
export default function HuyaTasksPage() {
const { token } = theme.useToken();
const [form] = Form.useForm<HuyaConfig>();
const [accountPool, setAccountPool] = useState<HuyaAccountItem[]>([]);
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [tags, setTags] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedTaskType, setSelectedTaskType] = useState('query_points');
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [accountContextMenu, setAccountContextMenu] = useState<{ account: HuyaAccountItem; accountIds: number[]; x: number; y: number } | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importSearchText, setImportSearchText] = useState('');
const [importTagFilter, setImportTagFilter] = useState('');
const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]);
const [selectedGoodsCategory, setSelectedGoodsCategory] = useState('');
const [selectedExchangeGoodsId, setSelectedExchangeGoodsId] = useState('');
const [exchangeAt, setExchangeAt] = useState<Dayjs | null>(null);
const [selectedRechargeGoodsId, setSelectedRechargeGoodsId] = useState<string>('');
const [rechargeCount, setRechargeCount] = useState(1);
const [rechargePayChannel, setRechargePayChannel] = useState('Weixin');
const [concurrency, setConcurrency] = useState(() => {
const v = localStorage.getItem('huya_task_concurrency');
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
});
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [taskRecordsVisible, setTaskRecordsVisible] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null);
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
const [payTask, setPayTask] = useState<HuyaTaskItem | null>(null);
const [exchangeRecordsTask, setExchangeRecordsTask] = useState<HuyaTaskItem | null>(null);
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
const autoOpenQrReady = useRef(false);
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoOpenPayReady = useRef(false);
const qrImageCacheRef = useRef<Map<number, string>>(new Map());
const notifiedPaidTaskIds = useRef<Set<number>>(new Set());
const accountTableAreaRef = useRef<HTMLDivElement | null>(null);
const [accountTableAreaHeight, setAccountTableAreaHeight] = useState(460);
const tasksLoadingRef = useRef(false);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions();
const canTask = can('huya:task');
const canConfig = can('huya:config');
useEffect(() => {
localStorage.setItem('huya_task_concurrency', String(concurrency));
}, [concurrency]);
useEffect(() => {
const node = accountTableAreaRef.current;
if (!node) return;
const updateHeight = () => {
setAccountTableAreaHeight(Math.max(360, Math.floor(node.getBoundingClientRect().height)));
};
updateHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}
const observer = new ResizeObserver(updateHeight);
observer.observe(node);
return () => observer.disconnect();
}, []);
const rememberExistingQrcodes = useCallback((items: HuyaTaskItem[]) => {
if (autoOpenQrReady.current) return;
items.filter(hasMiniQrcode).forEach((task) => autoOpenedQrTaskIds.current.add(task.id));
autoOpenQrReady.current = true;
}, []);
const rememberExistingPaymentQrcodes = useCallback((items: HuyaTaskItem[]) => {
if (autoOpenPayReady.current) return;
items.filter(hasPaymentQrcode).forEach((task) => autoOpenedPayTaskIds.current.add(task.id));
autoOpenPayReady.current = true;
}, []);
const openQrTask = useCallback((task: HuyaTaskItem) => {
autoOpenedQrTaskIds.current.add(task.id);
const cachedImage = qrImageCacheRef.current.get(task.id);
const currentImage = resultText(task.result, 'mini_qrcode_image');
if (currentImage) {
qrImageCacheRef.current.set(task.id, currentImage);
setQrTask(task);
return;
}
if (cachedImage) {
setQrTask({
...task,
result: {
...(task.result || {}),
mini_qrcode_image: cachedImage,
has_mini_qrcode: true,
},
});
return;
}
setQrTask(task);
// 列表接口默认不带 base64,打开弹窗时再拉详情。
void huyaApi.getTask(task.id).then((detail) => {
const image = resultText(detail.result, 'mini_qrcode_image');
if (image) qrImageCacheRef.current.set(detail.id, image);
setQrTask((current) => (current && current.id === detail.id ? detail : current));
setTasks((prev) => prev.map((item) => (item.id === detail.id ? detail : item)));
}).catch(() => {
// 详情失败时仍展示已有状态,不打断操作。
});
}, []);
const openPayTask = useCallback((task: HuyaTaskItem) => {
autoOpenedPayTaskIds.current.add(task.id);
setPayTask(task);
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [accountResult, taskResult, goodsResult, rechargeGoodsResult, configResult, taskTypeResult, tagResult] = await Promise.allSettled([
huyaApi.listAccounts({ include_cookie: false }),
huyaApi.listTasks(),
huyaApi.listGoods(),
huyaApi.listRechargeGoods(),
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
huyaApi.taskTypes(),
huyaApi.listTags(),
]);
if (accountResult.status === 'fulfilled') {
const nextPool = accountResult.value;
const nextPoolById = new Map(nextPool.map((account) => [account.id, account]));
setAccountPool(nextPool);
setAccounts((prev) => {
const persistedIds = readWorkbenchAccountIds();
const sourceIds = prev.length > 0 ? prev.map((account) => account.id) : persistedIds;
const nextAccounts = sourceIds
.map((id) => nextPoolById.get(id))
.filter((account): account is HuyaAccountItem => Boolean(account));
saveWorkbenchAccountIds(nextAccounts.map((account) => account.id));
return nextAccounts;
});
setSelectedIds((prev) => prev.filter((id) => nextPoolById.has(id)));
}
if (taskResult.status === 'fulfilled') {
const nextTasks = mergeTaskImageCache(taskResult.value, qrImageCacheRef.current);
rememberExistingQrcodes(nextTasks);
rememberExistingPaymentQrcodes(nextTasks);
setTasks(nextTasks);
if (!nextTasks.some((task) => ['pending', 'running'].includes(task.status))) {
setBatchId(null);
}
}
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (rechargeGoodsResult.status === 'fulfilled') setRechargeGoods(rechargeGoodsResult.value);
if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value);
if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value });
if (tagResult.status === 'fulfilled') setTags(tagResult.value);
const failedLabels = [
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.reason)}` : '',
goodsResult.status === 'rejected' ? `兑换商品: ${getErrorMessage(goodsResult.reason)}` : '',
rechargeGoodsResult.status === 'rejected' ? `充值商品: ${getErrorMessage(rechargeGoodsResult.reason)}` : '',
configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
tagResult.status === 'rejected' ? `标签: ${getErrorMessage(tagResult.reason)}` : '',
].filter(Boolean);
if (failedLabels.length > 0) {
message.warning(`部分数据加载失败:${failedLabels.join('')}`);
}
} finally {
setLoading(false);
}
}, [canConfig, form, rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
const loadTasks = useCallback(async () => {
if (tasksLoadingRef.current) return;
tasksLoadingRef.current = true;
try {
const data = await huyaApi.listTasks();
const nextTasks = mergeTaskImageCache(data, qrImageCacheRef.current);
rememberExistingQrcodes(nextTasks);
rememberExistingPaymentQrcodes(nextTasks);
setTasks(nextTasks);
if (!nextTasks.some((task) => ['pending', 'running'].includes(task.status))) {
setBatchId(null);
}
} catch {
// 轮询失败不打扰操作,下一轮继续刷新。
} finally {
tasksLoadingRef.current = false;
}
}, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
useEffect(() => {
loadAll();
}, [loadAll]);
// 有活跃任务时 3 秒轮询;空闲时 15 秒轻量刷新,避免 Network 面板一直刷 tasks。
const hasActiveTasks = useMemo(
() => tasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
[tasks],
);
useEffect(() => {
const intervalMs = hasActiveTasks || wsConnected ? 3000 : 15000;
const timer = setInterval(loadTasks, intervalMs);
return () => clearInterval(timer);
}, [hasActiveTasks, loadTasks, wsConnected]);
useEffect(() => {
if (!autoOpenQrReady.current || qrTask) return;
const nextQrTask = tasks
.filter((task) => hasMiniQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextQrTask) openQrTask(nextQrTask);
}, [openQrTask, qrTask, tasks]);
useEffect(() => {
if (!autoOpenPayReady.current || payTask) return;
const nextPayTask = tasks
.filter((task) => hasPaymentQrcode(task) && !isPaymentFinished(task) && !autoOpenedPayTaskIds.current.has(task.id))
.sort((a, b) => b.id - a.id)[0];
if (nextPayTask) openPayTask(nextPayTask);
}, [openPayTask, payTask, tasks]);
useEffect(() => {
if (!payTask) return;
const latest = tasks.find((task) => task.id === payTask.id);
if (!latest) return;
if (isPaymentFinished(latest)) {
if (!notifiedPaidTaskIds.current.has(latest.id)) {
notifiedPaidTaskIds.current.add(latest.id);
message.success('虎牙支付成功');
}
setPayTask(null);
return;
}
if (latest !== payTask) setPayTask(latest);
}, [payTask, tasks]);
useEffect(() => {
if (!qrTask) return;
const latest = tasks.find((task) => task.id === qrTask.id);
if (latest && latest !== qrTask) setQrTask(latest);
}, [qrTask, tasks]);
useEffect(() => {
if (!accountContextMenu) return;
const close = () => setAccountContextMenu(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);
};
}, [accountContextMenu]);
const tagColorMap = useMemo(() => {
const map: Record<string, string> = {};
tags.forEach((tag, index) => {
map[tag] = ['blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano'][index % 8];
});
return map;
}, [tags]);
const filteredAccounts = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
return accounts.filter((account) => {
if (tagFilter && account.tag !== tagFilter) return false;
if (!keyword) return true;
return (
account.uid.toLowerCase().includes(keyword)
|| account.yyuid.toLowerCase().includes(keyword)
|| account.username.toLowerCase().includes(keyword)
|| account.nickname.toLowerCase().includes(keyword)
|| account.tag.toLowerCase().includes(keyword)
|| account.game_name.toLowerCase().includes(keyword)
|| account.game_channel.toLowerCase().includes(keyword)
|| account.game_phone.toLowerCase().includes(keyword)
|| account.cookie_preview.toLowerCase().includes(keyword)
);
});
}, [accounts, searchText, tagFilter]);
const importFilteredAccounts = useMemo(() => {
const importedIds = new Set(accounts.map((account) => account.id));
const keyword = importSearchText.trim().toLowerCase();
return accountPool.filter((account) => {
if (importedIds.has(account.id)) return false;
if (importTagFilter && account.tag !== importTagFilter) return false;
if (!keyword) return true;
return (
account.uid.toLowerCase().includes(keyword)
|| account.yyuid.toLowerCase().includes(keyword)
|| account.username.toLowerCase().includes(keyword)
|| account.nickname.toLowerCase().includes(keyword)
|| account.tag.toLowerCase().includes(keyword)
|| account.game_name.toLowerCase().includes(keyword)
|| account.game_channel.toLowerCase().includes(keyword)
|| account.game_phone.toLowerCase().includes(keyword)
|| account.cookie_preview.toLowerCase().includes(keyword)
);
});
}, [accountPool, accounts, importSearchText, importTagFilter]);
const latestTaskByAccount = useMemo(() => {
const map = new Map<number, HuyaTaskItem>();
tasks.forEach((task) => {
const current = map.get(task.account_id);
if (!current || task.id > current.id) map.set(task.account_id, task);
});
return map;
}, [tasks]);
const latestQueryGameTaskByAccount = useMemo(() => {
const map = new Map<number, HuyaTaskItem>();
tasks.forEach((task) => {
if (task.task_type !== 'query_game_name') return;
const current = map.get(task.account_id);
if (!current || task.id > current.id) map.set(task.account_id, task);
});
return map;
}, [tasks]);
const latestGoodsTaskByAccount = useMemo(() => {
const map = new Map<number, HuyaTaskItem>();
tasks.forEach((task) => {
if (!['exchange_goods', 'create_recharge_order'].includes(task.task_type)) return;
const current = map.get(task.account_id);
if (!current || task.id > current.id) map.set(task.account_id, task);
});
return map;
}, [tasks]);
const selectedAccounts = useMemo(() => {
const selected = new Set(selectedIds);
return accounts.filter((account) => selected.has(account.id));
}, [accounts, selectedIds]);
const selectedAccountText = selectedAccounts.length === 1
? accountLabel(selectedAccounts[0])
: `已选 ${selectedIds.length} 个账号`;
const runningTaskBatchId = useMemo(() => {
// 支付监听会长期 running;绑定二维码自动轮询角色时也算活跃。
// 仅当 get_bind_qr 已结束轮询但仍残留 running 时,不锁 UI。
const task = tasks.find((item) => {
if (!['pending', 'running'].includes(item.status)) return false;
if (item.task_type === 'create_recharge_order') {
const status = paymentStatus(item);
return !status || !['paid', 'timeout', 'stopped'].includes(status);
}
if (item.task_type === 'get_bind_qr') {
// 正在自动轮询扫码/角色
if (item.status === 'running' && item.result?.bind_polling === true) return true;
// 有二维码但未标记轮询:可能是历史僵尸,不锁 UI
if (hasMiniQrcode(item) && item.result?.bind_polling !== true) return false;
}
return true;
});
return task?.batch_id || null;
}, [tasks]);
const activeBatchId = runningTaskBatchId || (wsConnected ? batchId : null);
const batchBusy = Boolean(activeBatchId);
const isTaskActionDisabled = useCallback((taskType: string, accountIds = selectedIds) => (
!canTask
|| accountIds.length === 0
|| ((batchBusy || wsConnected) && !canRunDuringBatch(taskType))
), [batchBusy, canTask, selectedIds, wsConnected]);
const sortedGoods = useMemo(() => {
return [...goods].sort((a, b) => (
goodsRawNumber(a, 'category_sort') - goodsRawNumber(b, 'category_sort')
|| goodsRawNumber(a, 'raw_order') - goodsRawNumber(b, 'raw_order')
|| a.id - b.id
));
}, [goods]);
const sortedRechargeGoods = useMemo(() => {
return [...rechargeGoods].sort((a, b) => {
const aOrder = goodsRawNumber({ raw: a.raw } as HuyaGoodsItem, 'raw_order');
const bOrder = goodsRawNumber({ raw: b.raw } as HuyaGoodsItem, 'raw_order');
return aOrder - bOrder || a.id - b.id;
});
}, [rechargeGoods]);
const rechargeGoodsOptions = useMemo(() => {
return sortedRechargeGoods.map((item) => ({
value: item.spu_id,
label: `${item.name || item.spu_id}${item.price ? ` / ${formatPriceText(item.price)}` : ''}`,
}));
}, [sortedRechargeGoods]);
const selectedRechargeGoods = useMemo(() => {
return sortedRechargeGoods.find((item) => item.spu_id === selectedRechargeGoodsId) || null;
}, [sortedRechargeGoods, selectedRechargeGoodsId]);
useEffect(() => {
if (sortedRechargeGoods.length === 0) {
if (selectedRechargeGoodsId) setSelectedRechargeGoodsId('');
return;
}
if (!sortedRechargeGoods.some((item) => item.spu_id === selectedRechargeGoodsId)) {
setSelectedRechargeGoodsId(sortedRechargeGoods[0].spu_id);
}
}, [selectedRechargeGoodsId, sortedRechargeGoods]);
const goodsCategories = useMemo(() => {
const map = new Map<string, { key: string; label: string; sort: number; count: number }>();
sortedGoods.forEach((item) => {
const key = goodsCategoryKey(item);
const current = map.get(key);
if (current) {
current.count += 1;
return;
}
map.set(key, {
key,
label: goodsCategoryLabel(item),
sort: goodsRawNumber(item, 'category_sort'),
count: 1,
});
});
return Array.from(map.values()).sort((a, b) => a.sort - b.sort || a.label.localeCompare(b.label));
}, [sortedGoods]);
useEffect(() => {
if (goodsCategories.length === 0) {
if (selectedGoodsCategory) setSelectedGoodsCategory('');
return;
}
if (!goodsCategories.some((item) => item.key === selectedGoodsCategory)) {
setSelectedGoodsCategory(goodsCategories[0].key);
}
}, [goodsCategories, selectedGoodsCategory]);
const goodsOptions = useMemo(() => {
return sortedGoods.map((item) => ({
value: item.product_id,
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`,
}));
}, [sortedGoods]);
const selectedExchangeGoods = useMemo(() => {
return sortedGoods.find((item) => item.product_id === selectedExchangeGoodsId) || null;
}, [selectedExchangeGoodsId, sortedGoods]);
useEffect(() => {
if (sortedGoods.length === 0) {
if (selectedExchangeGoodsId) setSelectedExchangeGoodsId('');
return;
}
if (!sortedGoods.some((item) => item.product_id === selectedExchangeGoodsId)) {
setSelectedExchangeGoodsId(sortedGoods[0].product_id);
}
}, [selectedExchangeGoodsId, sortedGoods]);
const createPayload = (taskType: string) => {
if (taskType === 'exchange_goods') {
return {
product_id: selectedExchangeGoods?.product_id || selectedExchangeGoodsId,
product_name: selectedExchangeGoods?.name || '',
scheduled_at: exchangeAt ? exchangeAt.toISOString() : '',
};
}
if (taskType !== 'create_recharge_order') return {};
return {
spu_id: selectedRechargeGoods?.spu_id || selectedRechargeGoodsId,
sku_id: selectedRechargeGoods?.sku_id || '',
product_name: selectedRechargeGoods?.name || '',
count: rechargeCount,
pay_channel: rechargePayChannel,
};
};
const startTask = async (taskType = selectedTaskType, accountIds = selectedIds) => {
if (accountIds.length === 0) {
message.warning('请先选择虎牙 CK');
return;
}
if ((activeBatchId || wsConnected) && !canRunDuringBatch(taskType)) {
message.warning('当前已有虎牙批次在运行,请先停止或等待结束');
return;
}
if (taskType === 'create_recharge_order' && !selectedRechargeGoodsId) {
message.warning('请先选择充值商品');
return;
}
if (taskType === 'exchange_goods' && !selectedExchangeGoodsId) {
message.warning('请先选择兑换商品');
return;
}
setStarting(true);
try {
const result = await huyaApi.createTasks({
account_ids: accountIds,
task_type: taskType,
concurrency,
payload: createPayload(taskType),
});
setBatchId(result.batch_id);
message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`);
await loadTasks();
connectLogs(`/api/huya/ws/${result.batch_id}`, {
onClose: () => {
setStarting(false);
setStopping(false);
void loadAll();
},
onResult: () => {
setBatchId(null);
setStarting(false);
setStopping(false);
void loadAll();
},
onError: () => {
setStarting(false);
setStopping(false);
void loadAll();
},
});
} catch (e: unknown) {
message.error(getErrorMessage(e));
setStarting(false);
}
};
const handleStopBatch = async () => {
if (!activeBatchId) {
// 没有可识别活跃批次时,尝试清理历史残留 running。
const stale = tasks.find((item) => ['pending', 'running'].includes(item.status));
if (!stale?.batch_id) {
message.warning('当前没有可停止的虎牙批次');
return;
}
setStopping(true);
try {
const result = await huyaApi.stopBatch(stale.batch_id);
message.success(result.message);
setBatchId(null);
setStarting(false);
void loadAll();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setStopping(false);
}
return;
}
setStopping(true);
try {
const result = await huyaApi.stopBatch(activeBatchId);
message.success(result.message);
setStarting(false);
void loadTasks();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setStopping(false);
}
};
const accountActionItems: MenuProps['items'] = QUICK_ACTIONS.map((item) => ({
key: item.key,
icon: item.icon,
label: taskTypes[item.key] || item.key,
}));
const importAccountsToWorkbench = (ids: number[]) => {
const accountById = new Map(accountPool.map((account) => [account.id, account]));
const uniqueIds = Array.from(new Set(ids)).filter((id) => accountById.has(id));
if (uniqueIds.length === 0) {
message.warning('请先选择要导入的账号');
return;
}
setAccounts((prev) => {
const prevIds = new Set(prev.map((account) => account.id));
const nextAccounts = [...prev];
uniqueIds.forEach((id) => {
if (!prevIds.has(id)) {
const account = accountById.get(id);
if (account) nextAccounts.push(account);
}
});
saveWorkbenchAccountIds(nextAccounts.map((account) => account.id));
return nextAccounts;
});
setSelectedIds(uniqueIds);
setImportSelectedIds([]);
setImportOpen(false);
message.success(`已导入 ${uniqueIds.length} 个账号到操作台`);
};
const removeSelectedAccounts = () => {
if (selectedIds.length === 0) {
message.warning('请先选择要移出的账号');
return;
}
const selected = new Set(selectedIds);
setAccounts((prev) => {
const nextAccounts = prev.filter((account) => !selected.has(account.id));
saveWorkbenchAccountIds(nextAccounts.map((account) => account.id));
return nextAccounts;
});
setSelectedIds([]);
setAccountContextMenu(null);
};
const clearWorkbenchAccounts = () => {
saveWorkbenchAccountIds([]);
setAccounts([]);
setSelectedIds([]);
setAccountContextMenu(null);
};
const runSingleAccountAction = (taskType: string, account: HuyaAccountItem) => {
setSelectedIds([account.id]);
setAccountContextMenu(null);
void startTask(taskType, [account.id]);
};
const runContextAccountAction = (taskType: string) => {
if (!accountContextMenu) return;
setSelectedIds(accountContextMenu.accountIds);
setAccountContextMenu(null);
void startTask(taskType, accountContextMenu.accountIds);
};
const saveConfig = async () => {
setSavingConfig(true);
try {
const values = await form.validateFields();
const result = await huyaApi.updateConfig(values);
form.setFieldsValue(result);
message.success('虎牙配置已保存');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setSavingConfig(false);
}
};
const successCount = tasks.filter((task) => task.status === 'success').length;
const plannedCount = tasks.filter((task) => task.status === 'planned').length;
const failedCount = tasks.filter((task) => ['failed', 'error'].includes(task.status)).length;
const qrResult = qrTask?.result || null;
const qrQueryTask = qrTask ? latestQueryGameTaskByAccount.get(qrTask.account_id) || null : null;
const qrQueryResult = qrTask && qrQueryTask && qrQueryTask.id > qrTask.id ? qrQueryTask.result : null;
const qrQueryRunning = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status === 'running');
const qrQueryFinished = Boolean(qrQueryTask && qrTask && qrQueryTask.id > qrTask.id && qrQueryTask.status !== 'running');
const qrImage = resultText(qrResult, 'mini_qrcode_image');
const qrBindPhase = resultText(qrResult, 'bind_phase');
const qrAutoPolling = qrTask?.status === 'running' && qrResult?.bind_polling === true;
const qrBindReady = bindReadyForConfirm(qrTask) || Boolean(resultText(qrQueryResult, 'role_name'));
const qrRoleSourceResult = resultText(qrQueryResult, 'role_name') ? qrQueryResult : qrResult;
const qrWaitingRole = qrAutoPolling || qrQueryRunning;
const qrGameTitle = resultText(qrRoleSourceResult, 'game_title');
const qrRoleName = resultText(qrRoleSourceResult, 'role_name');
const qrGameRole = resultObject(qrRoleSourceResult, 'game_role');
const qrRoleArea = typeof qrGameRole?.area_name === 'string' ? qrGameRole.area_name : '';
const qrRolePlat = typeof qrGameRole?.plat_name === 'string' ? qrGameRole.plat_name : '';
const qrRoleLine = qrBindReady ? [qrRolePlat, qrRoleArea, qrRoleName].filter(Boolean).join(' - ') : '';
const qrStatusText = qrBindReady
? '已识别角色,待确认'
: qrAutoPolling
? (
qrBindPhase === 'qrcode_completed'
? '等待角色同步'
: qrBindPhase === 'qrcode_scanned'
? '已扫码'
: qrBindPhase === 'qrcode_expired'
? '二维码已失效'
: '等待扫码绑定'
)
: qrQueryRunning
? '查询角色中'
: qrQueryFinished
? '未检测到角色'
: qrBindPhase === 'role_timeout'
? '未检测到角色'
: qrBindPhase === 'qrcode_completed'
? '等待角色同步'
: qrBindPhase === 'qrcode_scanned'
? '已扫码'
: qrBindPhase === 'qrcode_expired'
? '二维码已失效'
: '等待绑定';
const qrAccountName = qrTask
? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}`
: '';
const payResult = payTask?.result || null;
const payUrl = resultText(payResult, 'pay_url');
const payProductName = resultText(payResult, 'product_name');
const payAmountText = resultText(payResult, 'amount_text');
const payChannelLabel = resultText(payResult, 'pay_channel_label');
const payStatus = paymentStatus(payTask);
const payStatusText = paymentStatusLabel(payTask);
const payOrderId = payResult?.order_id;
const payAccountName = payTask
? payTask.account_nickname || payTask.account_uid || `#${payTask.account_id}`
: '';
const exchangeRecordsResult = exchangeRecordsTask?.result || null;
const exchangeRecordsRaw = exchangeRecordsResult?.records;
const exchangeRecords = Array.isArray(exchangeRecordsRaw)
? exchangeRecordsRaw.filter((item) => item && typeof item === 'object') as Record<string, unknown>[]
: [];
const exchangeRecordsAccountName = exchangeRecordsTask
? exchangeRecordsTask.account_nickname || exchangeRecordsTask.account_uid || `#${exchangeRecordsTask.account_id}`
: '';
const confirmQrBind = () => {
if (!qrTask || !qrBindReady) return;
const accountId = qrTask.account_id;
setQrTask(null);
void startTask('confirm_bind', [accountId]);
};
const queryQrRole = () => {
if (!qrTask) return;
const accountId = qrTask.account_id;
void startTask('query_game_name', [accountId]);
};
const renderTaskResult = (value: Record<string, unknown> | null, record: HuyaTaskItem) => {
if (hasMiniQrcode(record) || resultText(value, 'mini_qrcode_image')) {
if (bindReadyForConfirm(record) || Boolean(resultText(value, 'role_name'))) {
const roleName = resultText(value, 'role_name');
return (
<Space size={6}>
<Tag color="gold">待确认</Tag>
{roleName ? <Text>{roleName}</Text> : null}
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openQrTask(record)}>
查看
</Button>
</Space>
);
}
if (record.status === 'running' && value?.bind_polling === true) {
const phase = resultText(value, 'bind_phase');
const phaseText = phase === 'qrcode_scanned'
? '已扫码'
: phase === 'qrcode_completed'
? '等待角色'
: '等待扫码';
return (
<Space size={6}>
<Tag color="processing">{phaseText}</Tag>
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openQrTask(record)}>
查看二维码
</Button>
</Space>
);
}
return (
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openQrTask(record)}>
查看二维码
</Button>
);
}
if (record.task_type === 'get_bind_qr') {
if (value?.can_change_bind === false) {
const changeAt = resultText(value, 'change_available_at');
return (
<Space size={6}>
<Tag color="orange">不可更换</Tag>
{changeAt ? <Text type="secondary">{changeAt}</Text> : null}
</Space>
);
}
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'confirm_bind') {
if (value?.bind_confirmed === true) {
const gameRole = resultObject(value, 'game_role');
const roleName = typeof gameRole?.role_name === 'string' ? gameRole.role_name : '';
return (
<Space size={6}>
<Tag color="green">已绑定</Tag>
{roleName ? <Text>{roleName}</Text> : null}
</Space>
);
}
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'query_game_name') {
const roleName = resultText(value, 'role_name');
if (roleName) return <Tag color="blue">{roleName}</Tag>;
if (value?.is_bound === false) return <Tag>未绑定</Tag>;
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'query_exchange_records') {
const recordCount = value?.record_count;
if (typeof recordCount === 'number' && recordCount > 0) {
return (
<Button size="small" icon={<FieldTimeOutlined />} onClick={() => setExchangeRecordsTask(record)}>
查看记录
</Button>
);
}
if (typeof recordCount === 'number') return <Tag>记录 {recordCount} </Tag>;
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'refresh_goods') {
const goodsCount = value?.goods_count;
if (typeof goodsCount === 'number') return <Tag color="green">商品 {goodsCount} </Tag>;
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'exchange_goods') {
const productName = resultText(value, 'product_name');
const orderId = resultText(value, 'order_id');
if (record.status !== 'success') {
return productName ? <Text>{productName}</Text> : <Text type="secondary">-</Text>;
}
return (
<Space size={6}>
<Tag color="green">已兑换</Tag>
{productName ? <Text>{productName}</Text> : null}
{orderId ? <Text type="secondary">{orderId}</Text> : null}
</Space>
);
}
if (record.task_type === 'refresh_recharge_goods') {
const goodsCount = value?.goods_count;
const failedCount = value?.failed_count;
if (typeof goodsCount === 'number') {
return (
<Space size={6}>
<Tag color="green">充值商品 {goodsCount} </Tag>
{typeof failedCount === 'number' && failedCount > 0 ? <Tag color="orange">失败 {failedCount}</Tag> : null}
</Space>
);
}
return <Text type="secondary">-</Text>;
}
if (record.task_type === 'create_recharge_order') {
if (resultText(value, 'pay_url')) {
if (isPaymentFinished(record)) return <Tag color="green">已支付</Tag>;
const status = paymentStatus(record);
return (
<Space size={6}>
{status === 'timeout' ? <Tag color="orange">待支付</Tag> : <Tag color="processing">等待支付</Tag>}
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openPayTask(record)}>
支付码
</Button>
</Space>
);
}
return <Text type="secondary">-</Text>;
}
const availableScore = value?.available_score;
if (typeof availableScore === 'number') {
return <Tag color="blue">可用积分 {availableScore}</Tag>;
}
return value ? <Text code style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text> : <Text type="secondary">-</Text>;
};
const handleAccountRowContextMenu = (record: HuyaAccountItem, event: ReactMouseEvent) => {
event.preventDefault();
event.stopPropagation();
const accountIds = selectedIds.includes(record.id) && selectedIds.length > 0 ? selectedIds : [record.id];
setSelectedIds(accountIds);
setAccountContextMenu({
account: record,
accountIds,
x: event.clientX,
y: event.clientY,
});
};
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
{
title: '#',
width: 56,
align: 'center',
render: (_: unknown, record) => record.id,
},
{
title: '虎牙 CK',
width: 250,
render: (_: unknown, record) => (
<Space orientation="vertical" size={1} style={{ width: '100%' }}>
<Space size={6} wrap>
<Text strong>{accountDisplayName(record)}</Text>
{record.tag ? <Tag color={tagColorMap[record.tag]}>{record.tag}</Tag> : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {record.uid || record.yyuid || '-'}
</Text>
<Text code ellipsis style={{ maxWidth: 220, fontSize: 12 }}>
{record.cookie_preview || '-'}
</Text>
</Space>
),
},
{
title: '手机',
dataIndex: 'game_phone',
width: 128,
ellipsis: true,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '游戏名',
dataIndex: 'game_name',
width: 170,
ellipsis: true,
render: (value: string, record) => value ? (
<Space orientation="vertical" size={0}>
<Text>{value}</Text>
{record.game_channel ? <Text type="secondary" style={{ fontSize: 12 }}>{record.game_channel}</Text> : null}
</Space>
) : <Text type="secondary">未查</Text>,
},
{
title: '积分',
dataIndex: 'points',
width: 92,
align: 'center',
render: (points: number | null) => points ?? <Text type="secondary">未查</Text>,
sorter: (a, b) => (a.points ?? -1) - (b.points ?? -1),
},
{
title: '兑换商品',
width: 190,
ellipsis: true,
render: (_: unknown, record) => {
const task = latestGoodsTaskByAccount.get(record.id);
const product = taskProductText(task);
if (!task || !product) return <Text type="secondary">-</Text>;
return (
<Space orientation="vertical" size={1}>
<Text ellipsis style={{ maxWidth: 170 }}>{product}</Text>
<Tag color={STATUS_COLORS[task.status] || 'default'} style={{ width: 'fit-content' }}>
{taskTypes[task.task_type] || task.task_type}
</Tag>
</Space>
);
},
},
{
title: '数据状态',
width: 150,
render: (_: unknown, record) => {
const latest = latestTaskByAccount.get(record.id);
return (
<Space size={4} wrap>
<Tag color={ACCOUNT_STATUS_COLORS[record.status] || 'default'}>
{ACCOUNT_STATUS_LABELS[record.status] || record.status || '-'}
</Tag>
{latest ? (
<Tag color={STATUS_COLORS[latest.status] || 'default'}>
{STATUS_LABELS[latest.status] || latest.status}
</Tag>
) : null}
</Space>
);
},
},
{
title: '最近结果',
width: 260,
ellipsis: true,
render: (_: unknown, record) => {
const latest = latestTaskByAccount.get(record.id);
if (!latest) return <Text type="secondary">暂无任务</Text>;
return (
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
<Text ellipsis style={{ maxWidth: 238 }}>
{taskTypes[latest.task_type] || latest.task_type}{latest.message || '-'}
</Text>
{renderTaskResult(latest.result, latest)}
</Space>
);
},
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 154,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
{
title: '操作',
width: 76,
fixed: 'right',
align: 'center',
render: (_: unknown, record) => {
return (
<Dropdown
menu={{
items: accountActionItems,
onClick: ({ key }) => {
runSingleAccountAction(String(key), record);
},
}}
trigger={['click']}
>
<Button size="small" icon={<MoreOutlined />} disabled={!canTask} />
</Dropdown>
);
},
},
];
const importAccountColumns: TableProps<HuyaAccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 76, align: 'center' },
{
title: '虎牙 CK',
width: 230,
render: (_: unknown, record) => (
<Space orientation="vertical" size={1}>
<Space size={6} wrap>
<Text strong>{accountDisplayName(record)}</Text>
{record.tag ? <Tag color={tagColorMap[record.tag]}>{record.tag}</Tag> : null}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || record.yyuid || '-'}</Text>
</Space>
),
},
{
title: '手机',
dataIndex: 'game_phone',
width: 130,
ellipsis: true,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '游戏名',
dataIndex: 'game_name',
ellipsis: true,
render: (value: string, record) => value ? (
<Space orientation="vertical" size={0}>
<Text>{value}</Text>
{record.game_channel ? <Text type="secondary" style={{ fontSize: 12 }}>{record.game_channel}</Text> : null}
</Space>
) : <Text type="secondary">未查</Text>,
},
{
title: '积分',
dataIndex: 'points',
width: 86,
align: 'center',
render: (points: number | null) => points ?? <Text type="secondary">未查</Text>,
},
];
const taskColumns: TableProps<HuyaTaskItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
{
title: '任务',
dataIndex: 'task_type',
width: 150,
render: (value: string) => taskTypes[value] || value,
},
{
title: '账号',
width: 160,
render: (_: unknown, record) => record.account_nickname || record.account_uid || record.account_id,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (status: string) => (
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status}</Tag>
),
},
{ title: '消息', dataIndex: 'message', ellipsis: true },
{
title: '结果',
dataIndex: 'result',
width: 210,
ellipsis: true,
render: renderTaskResult,
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
const exchangeRecordColumns: TableProps<Record<string, unknown>>['columns'] = [
{
title: '序号',
width: 70,
align: 'center',
render: (_: unknown, record, index) => String(record.index || index + 1),
},
{
title: '消耗',
width: 110,
render: (_: unknown, record) => {
const scoreText = record.score_text;
if (typeof scoreText === 'string' && scoreText) return scoreText;
const score = record.score;
return typeof score === 'number' ? `${score}积分` : <Text type="secondary">-</Text>;
},
},
{
title: '奖品名称',
dataIndex: 'prize_name',
ellipsis: true,
render: (value: unknown) => typeof value === 'string' && value ? value : <Text type="secondary">-</Text>,
},
{
title: '兑换状态',
width: 110,
align: 'center',
render: (_: unknown, record) => {
const label = typeof record.status_label === 'string' ? record.status_label : '';
const color = label === '已发放' ? 'green' : 'orange';
return label ? <Tag color={color}>{label}</Tag> : <Text type="secondary">-</Text>;
},
},
{
title: '兑换时间',
width: 170,
render: (_: unknown, record) => {
const text = record.exchange_time_text;
return typeof text === 'string' && text ? text : <Text type="secondary">-</Text>;
},
},
];
const renderTaskTable = (pageSize = 12) => (
<Table
columns={taskColumns}
dataSource={tasks}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize, showTotal: (total) => `共 ${total} 条` }}
scroll={{ x: 920 }}
/>
);
const accountContextMenuPosition = accountContextMenu
? contextMenuPosition(accountContextMenu.x, accountContextMenu.y)
: null;
const accountTableBodyHeight = Math.max(320, accountTableAreaHeight - 42);
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flexShrink: 0, marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}>虎牙任务操作台</h2>
<Space wrap>
<Tag>操作台 {accounts.length}</Tag>
<Tag>账号库 {accountPool.length}</Tag>
<Tag color="blue">已选 {selectedIds.length}</Tag>
<Tag color="green">已查积分 {accounts.filter((item) => item.points !== null && item.points !== undefined).length}</Tag>
<Tag color="cyan">已绑定 {accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length}</Tag>
<Button icon={<ReloadOutlined />} onClick={loadAll} loading={loading}>
刷新
</Button>
{activeBatchId && <Tag color="processing">批次 {activeBatchId}</Tag>}
<Button
danger
icon={<StopOutlined />}
disabled={(!activeBatchId && !tasks.some((item) => ['pending', 'running'].includes(item.status))) || !canTask}
loading={stopping}
onClick={handleStopBatch}
>
停止
</Button>
</Space>
</div>
{accountContextMenu && accountContextMenuPosition ? (
<div
style={{
position: 'fixed',
left: accountContextMenuPosition.left,
top: accountContextMenuPosition.top,
zIndex: 2000,
width: 210,
padding: 6,
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgElevated,
boxShadow: token.boxShadowSecondary,
}}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
<div style={{ padding: '4px 8px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, marginBottom: 4 }}>
<Text strong ellipsis style={{ display: 'block' }}>
{accountContextMenu.accountIds.length > 1
? `已选 ${accountContextMenu.accountIds.length} 个账号`
: accountDisplayName(accountContextMenu.account)}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{accountContextMenu.accountIds.length > 1 ? '右键批量动作' : '右键账号动作'}
</Text>
</div>
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
{QUICK_ACTIONS.map((item) => (
<Button
key={item.key}
type="text"
size="small"
block
icon={item.icon}
disabled={isTaskActionDisabled(item.key, accountContextMenu.accountIds)}
onClick={() => runContextAccountAction(item.key)}
style={{ justifyContent: 'flex-start' }}
>
{taskTypes[item.key] || item.key}
</Button>
))}
</Space>
</div>
) : null}
<div
style={{
flex: 1,
minHeight: 0,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(330px, 380px)',
gap: 12,
overflow: 'hidden',
}}
>
<div style={{ minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingRight: 2 }}>
<Card
size="small"
title="账号表格"
extra={(
<Space size={6} wrap>
<Button size="small" type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
导入账号
</Button>
<Button size="small" onClick={() => setSelectedIds(filteredAccounts.map((item) => item.id))}>
全选结果
</Button>
<Button size="small" disabled={selectedIds.length === 0} onClick={removeSelectedAccounts}>
移出选中
</Button>
<Button size="small" danger disabled={accounts.length === 0} onClick={clearWorkbenchAccounts}>
清空表格
</Button>
</Space>
)}
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }}
>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10, alignItems: 'center' }}>
<Input.Search
allowClear
placeholder="搜索 UID、昵称、标签、游戏名、手机号、CK"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
style={{ width: 320 }}
prefix={<SearchOutlined />}
/>
<Select
allowClear
placeholder="按标签筛选"
value={tagFilter || undefined}
onChange={(value) => setTagFilter(value || '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
style={{ width: 150 }}
/>
<Text type="secondary">{filteredAccounts.length} / {accounts.length}</Text>
</div>
<div ref={accountTableAreaRef} style={{ flex: 1, minHeight: 360, overflow: 'hidden' }}>
<Table
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
}}
columns={accountColumns}
dataSource={filteredAccounts}
rowKey="id"
loading={loading}
size="small"
className="huya-task-account-table"
pagination={false}
scroll={filteredAccounts.length > 0 ? { x: 1520, y: accountTableBodyHeight } : undefined}
locale={{
emptyText: (
<div
style={{
minHeight: accountTableBodyHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: token.colorTextTertiary,
}}
>
操作台暂无账号,请点击右上角导入账号
</div>
),
}}
rowClassName={(record) => selectedIds.includes(record.id) ? 'ant-table-row-selected' : ''}
onRow={(record) => ({
onContextMenu: (event) => handleAccountRowContextMenu(record, event),
style: { cursor: 'context-menu' },
title: '右键打开账号动作',
})}
/>
</div>
</Card>
<Card
size="small"
title={<Space><FieldTimeOutlined />任务记录</Space>}
extra={(
<Space size={10} wrap>
<Text type="secondary"> {tasks.length}</Text>
<Text type="secondary">已计划 {plannedCount}</Text>
<Text style={{ color: token.colorSuccess }}>成功 {successCount}</Text>
<Text style={{ color: token.colorError }}>失败 {failedCount}</Text>
<Button size="small" onClick={() => setTaskRecordsVisible((value) => !value)}>
{taskRecordsVisible ? '收起' : '展开'}
</Button>
</Space>
)}
style={{ marginTop: 12 }}
styles={{ body: { padding: taskRecordsVisible ? 12 : 0 } }}
>
{taskRecordsVisible ? renderTaskTable(5) : null}
</Card>
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
title="虎牙实时日志"
emptyText="暂无虎牙任务日志"
height={120}
collapsible
defaultVisible={false}
spinWhenEmpty
style={{ marginTop: 8 }}
/>
</div>
<div style={{ minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Card size="small" title="批量动作" extra={<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedAccountText}</Tag>}>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%' }}>
<Button disabled>并发</Button>
<InputNumber
min={1}
max={10}
value={concurrency}
onChange={(value) => setConcurrency(value || 1)}
style={{ width: '100%' }}
/>
</Space.Compact>
<Select
value={selectedTaskType}
onChange={setSelectedTaskType}
options={Object.entries(taskTypes).map(([value, label]) => ({ value, label }))}
style={{ width: '100%' }}
/>
<Button
type="primary"
block
icon={<PlayCircleOutlined />}
loading={starting}
disabled={isTaskActionDisabled(selectedTaskType)}
onClick={() => startTask()}
>
创建任务
</Button>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{QUICK_ACTIONS
.filter((item) => !['refresh_goods', 'refresh_recharge_goods', 'exchange_goods', 'create_recharge_order'].includes(item.key))
.map((item) => (
<Tooltip key={item.key} title={taskTypes[item.key] || item.key}>
<Button
icon={item.icon}
onClick={() => startTask(item.key)}
disabled={isTaskActionDisabled(item.key)}
>
{taskTypes[item.key] || item.key}
</Button>
</Tooltip>
))}
</div>
</Space>
</Card>
<Card
size="small"
title={<Space><ShoppingOutlined />兑换</Space>}
extra={(
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_goods')}
disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected}
>
刷新
</Button>
)}
>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
{goodsCategories.length > 0 ? (
<Select
value={selectedGoodsCategory}
onChange={setSelectedGoodsCategory}
options={goodsCategories.map((item) => ({
value: item.key,
label: `${item.label} (${item.count})`,
}))}
style={{ width: '100%' }}
/>
) : null}
<Select
showSearch
allowClear
placeholder="选择兑换商品"
value={selectedExchangeGoodsId || undefined}
onChange={(value) => setSelectedExchangeGoodsId(value || '')}
options={goodsOptions}
style={{ width: '100%' }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<Space.Compact style={{ width: '100%' }}>
<Button disabled>PID</Button>
<Input
value={selectedExchangeGoodsId}
onChange={(event) => setSelectedExchangeGoodsId(event.target.value.trim())}
placeholder="商品 PID"
/>
</Space.Compact>
<DatePicker
showTime
allowClear
value={exchangeAt}
onChange={setExchangeAt}
placeholder="立即兑换"
style={{ width: '100%' }}
/>
<Button
block
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => startTask('exchange_goods')}
disabled={!canTask || selectedIds.length === 0 || !selectedExchangeGoodsId || batchBusy || wsConnected}
>
批量兑换商品
</Button>
</Space>
</Card>
<Card
size="small"
title={<Space><CreditCardOutlined />充值支付</Space>}
extra={(
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => startTask('refresh_recharge_goods')}
disabled={!canTask || selectedIds.length === 0 || batchBusy || wsConnected}
>
刷新
</Button>
)}
>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
<Select
showSearch
allowClear
placeholder="选择充值商品"
value={selectedRechargeGoodsId || undefined}
onChange={(value) => setSelectedRechargeGoodsId(value || '')}
options={rechargeGoodsOptions}
style={{ width: '100%' }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
min={1}
max={999}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
style={{ width: '100%' }}
/>
<Button disabled></Button>
</Space.Compact>
<Radio.Group value={rechargePayChannel} onChange={(event) => setRechargePayChannel(event.target.value)}>
<Radio value="Weixin">微信</Radio>
<Radio value="Zfb">支付宝</Radio>
</Radio.Group>
<Button
block
type="primary"
icon={<QrcodeOutlined />}
onClick={() => startTask('create_recharge_order')}
disabled={!canTask || selectedIds.length === 0 || !selectedRechargeGoodsId || batchBusy || wsConnected}
>
生成支付二维码
</Button>
</Space>
</Card>
<Card
size="small"
title={<Space><SettingOutlined />配置</Space>}
extra={canConfig && (
<Button size="small" type="primary" onClick={saveConfig} loading={savingConfig}>
保存
</Button>
)}
>
<Form form={form} layout="vertical" disabled={!canConfig}>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="直播间 ID" name="room_pid">
<Input placeholder="1199650619883" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="SID" name="sid">
<Input placeholder="2203" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="兑换活动 ID" name="outer_act_id">
<Input placeholder="9504" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定 bActId" name="bind_act_id">
<Input placeholder="9271" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="支付渠道" name="pay_channel">
<Select
options={[
{ value: 'Weixin', label: '微信' },
{ value: 'Zfb', label: '支付宝' },
]}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
</Space>
</div>
</div>
<Modal
title="导入账号到操作台"
open={importOpen}
onCancel={() => {
setImportOpen(false);
setImportSelectedIds([]);
}}
footer={[
<Button key="cancel" onClick={() => {
setImportOpen(false);
setImportSelectedIds([]);
}}>
取消
</Button>,
<Button
key="all"
disabled={importFilteredAccounts.length === 0}
onClick={() => importAccountsToWorkbench(importFilteredAccounts.map((account) => account.id))}
>
导入当前结果
</Button>,
<Button
key="ok"
type="primary"
disabled={importSelectedIds.length === 0}
onClick={() => importAccountsToWorkbench(importSelectedIds)}
>
导入选中
</Button>,
]}
width={860}
>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<Input.Search
allowClear
placeholder="搜索 UID、昵称、标签、游戏名、手机号、CK"
value={importSearchText}
onChange={(event) => setImportSearchText(event.target.value)}
style={{ width: 330 }}
prefix={<SearchOutlined />}
/>
<Select
allowClear
placeholder="按标签筛选"
value={importTagFilter || undefined}
onChange={(value) => setImportTagFilter(value || '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
style={{ width: 150 }}
/>
<Text type="secondary">
可导入 {importFilteredAccounts.length} / 账号库 {accountPool.length}
</Text>
</div>
<Table
rowSelection={{
selectedRowKeys: importSelectedIds,
onChange: (keys) => setImportSelectedIds(keys.map((key) => Number(key))),
}}
columns={importAccountColumns}
dataSource={importFilteredAccounts}
rowKey="id"
size="small"
pagination={{ pageSize: 8, showSizeChanger: false, showTotal: (total) => `共 ${total} 条` }}
scroll={{ x: 720, y: 360 }}
locale={{ emptyText: '没有可导入账号' }}
rowClassName={(record) => importSelectedIds.includes(record.id) ? 'ant-table-row-selected' : ''}
/>
</Space>
</Modal>
<Modal
title="兑换记录"
open={!!exchangeRecordsTask}
onCancel={() => setExchangeRecordsTask(null)}
footer={null}
width={760}
>
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
<Text type="secondary">
{exchangeRecordsAccountName}{exchangeRecordsResult?.sid ? ` / SID ${String(exchangeRecordsResult.sid)}` : ''}
</Text>
<Table
columns={exchangeRecordColumns}
dataSource={exchangeRecords}
rowKey={(record) => String(record.record_id || record.order_id || record.index)}
size="small"
pagination={false}
scroll={{ y: 360 }}
locale={{ emptyText: '暂无兑换记录' }}
/>
</Space>
</Modal>
<Modal
title="绑定小程序码"
open={!!qrTask}
onCancel={() => setQrTask(null)}
footer={null}
width={400}
>
{qrTask && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '8px 0 12px' }}>
<Space orientation="vertical" size={4} style={{ width: '100%', textAlign: 'center' }}>
<Text strong>{qrAccountName}</Text>
<Tag
color={qrBindReady ? 'gold' : qrBindPhase === 'role_timeout' || qrQueryFinished ? 'orange' : 'processing'}
style={{ alignSelf: 'center', marginInlineEnd: 0 }}
>
{qrStatusText}
</Tag>
</Space>
{qrImage ? (
<div
style={{
padding: 14,
borderRadius: 10,
background: '#fff',
lineHeight: 0,
}}
>
<img
src={qrImage}
alt="绑定小程序码"
style={{
width: 260,
height: 260,
objectFit: 'contain',
display: 'block',
borderRadius: 4,
background: '#fff',
}}
/>
</div>
) : (
<Text type="secondary">暂无二维码</Text>
)}
{qrRoleLine ? (
<Space orientation="vertical" size={2} style={{ width: '100%', textAlign: 'center' }}>
{qrGameTitle ? <Text type="secondary">{qrGameTitle}</Text> : null}
<Text strong>{qrRoleLine}</Text>
</Space>
) : null}
<Space size={8}>
<Button onClick={() => setQrTask(null)}>关闭</Button>
<Button
icon={<SearchOutlined />}
disabled={!canTask || batchBusy || starting || stopping || qrWaitingRole}
loading={starting || qrQueryRunning}
onClick={queryQrRole}
>
{qrAutoPolling ? '自动识别中' : '查询角色'}
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
disabled={!qrBindReady || !canTask || batchBusy || starting || stopping}
loading={starting}
onClick={confirmQrBind}
>
确认绑定
</Button>
</Space>
</div>
)}
</Modal>
<Modal
title="支付二维码"
open={!!payTask}
onCancel={() => setPayTask(null)}
footer={null}
width={380}
>
{payTask && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '8px 0 12px' }}>
<Space orientation="vertical" size={2} style={{ width: '100%', textAlign: 'center' }}>
<Text strong>{payProductName || '充值商品'}</Text>
<Text type="secondary">
{payChannelLabel || '支付'}{payAmountText ? ` / ${payAmountText}` : ''}{payAccountName ? ` / ${payAccountName}` : ''}
</Text>
{payStatusText ? (
<Tag color={payStatus === 'timeout' ? 'orange' : 'processing'} style={{ alignSelf: 'center', marginInlineEnd: 0 }}>
{payStatusText}
</Tag>
) : null}
{typeof payOrderId === 'number' || typeof payOrderId === 'string' ? (
<Text type="secondary" style={{ fontSize: 12 }}>订单号 {String(payOrderId)}</Text>
) : null}
</Space>
{payUrl ? (
<div style={{ padding: 14, borderRadius: 10, background: '#fff', lineHeight: 0 }}>
<QRCode value={payUrl} size={260} bordered={false} color="#000" bgColor="#fff" />
</div>
) : (
<Text type="secondary">暂无支付二维码</Text>
)}
</div>
)}
</Modal>
</div>
);
}