Files
live-hub-py/web/frontend/src/pages/HuyaTasksPage.tsx
T
2026-07-06 13:50:20 +08:00

1630 lines
60 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, message, Modal, QRCode, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
} from 'antd';
import type { MenuProps, TableProps } from 'antd';
import type { Dayjs } from 'dayjs';
import type { MouseEvent as ReactMouseEvent } from 'react';
import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined,
LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
} 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 STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
};
const STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
};
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',
};
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 isInteractiveTarget(target: EventTarget | null): boolean {
const element = target instanceof HTMLElement ? target : null;
if (!element) return false;
return Boolean(element.closest([
'a',
'button',
'input',
'textarea',
'select',
'[role="button"]',
'[role="menuitem"]',
'.ant-btn',
'.ant-checkbox',
'.ant-checkbox-wrapper',
'.ant-dropdown',
'.ant-dropdown-menu',
'.ant-input',
'.ant-picker',
'.ant-select',
'.ant-table-column-sorter',
].join(',')));
}
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 formatRemainText(value: string): string {
const text = String(value || '').trim();
if (!text || text.endsWith('%')) return text;
return /^-?\d+(\.\d+)?$/.test(text) ? `${text}%` : text;
}
function formatPriceText(value: number | null | undefined): string {
if (!value) return '';
return ${(value / 100).toFixed(2)}`;
}
function hasMiniQrcode(task: HuyaTaskItem): boolean {
return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image'));
}
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 [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 [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 [savingConfig, setSavingConfig] = 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 notifiedPaidTaskIds = useRef<Set<number>>(new Set());
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]);
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);
setQrTask(task);
}, []);
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(),
huyaApi.listTasks(),
huyaApi.listGoods(),
huyaApi.listRechargeGoods(),
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
huyaApi.taskTypes(),
huyaApi.listTags(),
]);
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
if (taskResult.status === 'fulfilled') {
rememberExistingQrcodes(taskResult.value);
rememberExistingPaymentQrcodes(taskResult.value);
setTasks(taskResult.value);
}
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 () => {
try {
const data = await huyaApi.listTasks();
rememberExistingQrcodes(data);
rememberExistingPaymentQrcodes(data);
setTasks(data);
} catch {
// 轮询失败不打扰操作,下一轮继续刷新。
}
}, [rememberExistingPaymentQrcodes, rememberExistingQrcodes]);
useEffect(() => {
loadAll();
}, [loadAll]);
useEffect(() => {
const timer = setInterval(loadTasks, 3000);
return () => clearInterval(timer);
}, [loadTasks]);
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 (!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 toggleAccountSelection = useCallback((accountId: number) => {
setSelectedIds((prev) => (
prev.includes(accountId)
? prev.filter((id) => id !== accountId)
: [...prev, accountId]
));
}, []);
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 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 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 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 filteredGoods = useMemo(() => {
if (!selectedGoodsCategory) return sortedGoods;
return sortedGoods.filter((item) => goodsCategoryKey(item) === selectedGoodsCategory);
}, [sortedGoods, 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 (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),
});
const finishTask = () => {
setBatchId(null);
setStarting(false);
void loadAll();
};
setBatchId(result.batch_id);
message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`);
await loadTasks();
connectLogs(`/api/huya/ws/${result.batch_id}`, {
onClose: finishTask,
onResult: finishTask,
onError: finishTask,
});
} catch (e: unknown) {
message.error(getErrorMessage(e));
setStarting(false);
}
};
const accountActionItems: MenuProps['items'] = QUICK_ACTIONS.map((item) => ({
key: item.key,
icon: item.icon,
label: taskTypes[item.key] || item.key,
}));
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 qrImage = resultText(qrResult, 'mini_qrcode_image');
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 renderTaskResult = (value: Record<string, unknown> | null, record: HuyaTaskItem) => {
const bindQrImage = resultText(value, 'mini_qrcode_image');
if (bindQrImage) {
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 handleAccountRowClick = (record: HuyaAccountItem, event: ReactMouseEvent) => {
if (isInteractiveTarget(event.target)) return;
setAccountContextMenu(null);
toggleAccountSelection(record.id);
};
const handleAccountRowContextMenu = (record: HuyaAccountItem, event: ReactMouseEvent) => {
if (isInteractiveTarget(event.target)) return;
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 direction="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 direction="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 direction="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 direction="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 || wsConnected} />
</Dropdown>
);
},
},
];
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 goodsColumns: TableProps<HuyaGoodsItem>['columns'] = [
{ title: '商品ID', dataIndex: 'product_id', width: 88, ellipsis: true },
{
title: '名称',
dataIndex: 'name',
width: 230,
render: (value: string) => (
<Text
title={value}
style={{
display: 'block',
lineHeight: '20px',
whiteSpace: 'normal',
wordBreak: 'break-all',
}}
>
{value || '-'}
</Text>
),
},
{
title: '分类',
width: 96,
render: (_: unknown, record) => {
const label = goodsCategoryLabel(record);
return label ? <Tag>{label}</Tag> : <Text type="secondary">-</Text>;
},
},
{
title: '价格',
dataIndex: 'price',
width: 78,
align: 'center',
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: '库存',
dataIndex: 'remain_text',
width: 72,
render: (value: string) => formatRemainText(value) || <Text type="secondary">-</Text>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 154,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
const rechargeGoodsColumns: TableProps<HuyaRechargeGoodsItem>['columns'] = [
{ title: 'SPU', dataIndex: 'spu_id', width: 120, ellipsis: true },
{ title: 'SKU', dataIndex: 'sku_id', width: 100, ellipsis: true },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{
title: '单价',
dataIndex: 'price',
width: 90,
align: 'center',
render: (value: number | null) => formatPriceText(value) || <Text type="secondary">-</Text>,
},
{
title: '库存',
dataIndex: 'stock',
width: 90,
align: 'center',
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: '来源',
dataIndex: 'task_name',
width: 140,
ellipsis: true,
render: (value: string) => value || <Text type="secondary">商品详情</Text>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 160,
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 taskSummary = (
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8, color: token.colorTextSecondary, flexWrap: 'wrap' }}>
<span> <b>{tasks.length}</b> 个任务</span>
<span>已计划 <b>{plannedCount}</b></span>
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
</div>
);
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;
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 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>
{batchId && <Tag color="processing">批次 {batchId}</Tag>}
</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 direction="vertical" size={2} style={{ width: '100%' }}>
{QUICK_ACTIONS.map((item) => (
<Button
key={item.key}
type="text"
size="small"
block
icon={item.icon}
disabled={!canTask || wsConnected}
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, overflowY: 'auto', overflowX: 'hidden', paddingRight: 2 }}>
<Card
size="small"
title="账号表格"
extra={(
<Space size={6} wrap>
<Button size="small" onClick={() => setSelectedIds(filteredAccounts.map((item) => item.id))}>
全选结果
</Button>
<Button size="small" onClick={() => setSelectedIds([])}>
清空
</Button>
</Space>
)}
>
<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>
<Table
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
}}
columns={accountColumns}
dataSource={filteredAccounts}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 12, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
scroll={{ x: 1520 }}
rowClassName={(record) => selectedIds.includes(record.id) ? 'ant-table-row-selected' : ''}
onRow={(record) => ({
onClick: (event) => handleAccountRowClick(record, event),
onContextMenu: (event) => handleAccountRowContextMenu(record, event),
style: { cursor: 'pointer' },
title: '点击选中,右键打开账号动作',
})}
/>
</Card>
<Row gutter={12} style={{ marginTop: 12 }}>
<Col xs={24} xl={12}>
<Card size="small" title={<Space><ShoppingOutlined />兑换商品</Space>}>
<Table
columns={goodsColumns}
dataSource={filteredGoods}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 260 }}
locale={{ emptyText: '暂无兑换商品,请先刷新商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedExchangeGoodsId(record.product_id),
})}
rowClassName={(record) => record.product_id === selectedExchangeGoodsId ? 'ant-table-row-selected' : ''}
/>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card size="small" title={<Space><CreditCardOutlined />充值商品</Space>}>
<Table
columns={rechargeGoodsColumns}
dataSource={sortedRechargeGoods}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 260 }}
locale={{ emptyText: '暂无充值商品,请先刷新充值商品列表' }}
onRow={(record) => ({
onClick: () => setSelectedRechargeGoodsId(record.spu_id),
})}
rowClassName={(record) => record.spu_id === selectedRechargeGoodsId ? 'ant-table-row-selected' : ''}
/>
</Card>
</Col>
</Row>
<Card size="small" title={<Space><FieldTimeOutlined />任务记录</Space>} style={{ marginTop: 12 }}>
{taskSummary}
{renderTaskTable(8)}
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
title="虎牙实时日志"
emptyText="暂无虎牙任务日志"
collapsible
spinWhenEmpty
style={{ marginTop: 8 }}
/>
</Card>
</div>
<div style={{ minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card size="small" title="批量动作" extra={<Tag color={selectedIds.length ? 'blue' : 'default'}>{selectedAccountText}</Tag>}>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<InputNumber
min={1}
max={10}
value={concurrency}
onChange={(value) => setConcurrency(value || 1)}
addonBefore="并发"
style={{ width: '100%' }}
/>
<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={!canTask || selectedIds.length === 0 || wsConnected}
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={!canTask || selectedIds.length === 0 || wsConnected}
>
{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 || wsConnected}
>
刷新
</Button>
)}
>
<Space direction="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())}
/>
<Input
addonBefore="PID"
value={selectedExchangeGoodsId}
onChange={(event) => setSelectedExchangeGoodsId(event.target.value.trim())}
placeholder="商品 PID"
/>
<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 || 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 || wsConnected}
>
刷新
</Button>
)}
>
<Space direction="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())}
/>
<InputNumber
min={1}
max={999}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
addonAfter="份"
style={{ width: '100%' }}
/>
<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 || 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={!!exchangeRecordsTask}
onCancel={() => setExchangeRecordsTask(null)}
footer={null}
width={760}
>
<Space direction="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={360}
>
{qrTask && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '8px 0 12px' }}>
<Text strong style={{ maxWidth: '100%', textAlign: 'center' }}>{qrAccountName}</Text>
{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>
)}
</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 direction="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>
);
}