2045 lines
77 KiB
TypeScript
2045 lines
77 KiB
TypeScript
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>
|
||
);
|
||
}
|