Files
live-hub-py/web/frontend/src/pages/HuyaTasksPage.tsx
T
yml2213 73536d62c8 feat(huya): 工作台补手机号/精英宝典列与一键查询
- 手机号列:外部 CK 的 `----手机号` 本来就落在 huya_accounts.game_phone,
  列表一直没展示;现在单独成列,并纳入搜索
- 精英宝典列:显示「已开通/未开通/未查询」+ 开通时间 + 有效期
  · 开通状态 = 积分中心 getRedeemNeedGoods 的 tag5(真机对照网页:1=已开通,
    0=未开通,此时 tag7 会列出开通套餐),拿不准时为 NULL(显示「未查询」)
  · 开通时间 = revenueWebUI.queryUserOrderList 里已支付的宝典订单 pay_time
    (按 SPU 白名单 + 订单名含「精英宝典」识别,未支付订单不计,取最近一次)
  · 有效期 = PlayMallUI.queryAllocation 页面配置的本赛季节点 tag10/tag11
    (2026-10-15 抓包:精英宝典福利季丨Q4,2026-10-09 ~ 2026-12-31 23:59:59),
    不写死索引,递归找带 tag10/tag11 的节点
- 新任务 query_all「一键查询」:勾选账号一次跑完角色绑定 + 宝典开通 + 积分,
  按部分失败如实汇总(某个接口挂了不影响其它结论),并把宝典状态写回账号
- 迁移 20261015_0053:huya_accounts 新增 elite_opened / elite_open_time /
  elite_expire_time(时间存 Unix 秒,与斗鱼 elite_expire_time 同约定)
- 开通商品按页面「赠送指定礼物得积分」那一排对齐成 6 款(去掉页面已不用的
  旧版精英宝典 hy-5879340),列表接口只返回账号级快照并按 spu 去重
- 建 WSS 会话失败(积分中心端点静默不回包)时回退 HTTP cdnws,
  不再让整批任务判失败
- 列表列宽改按比例分配,避免宽屏下被拉出奇怪的宽度;最近操作不再重复状态
- 新增 tests/test_huya_elite_query.py(10 条);同步迁移头版本断言

真机验证:#65 已开通(10-13 20:41:45,有效期 12-31)、#70 新开通
(10-15 16:37:37)判定与订单/网页一致
2026-10-15 16:53:30 +08:00

2062 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button, Card, Col, DatePicker, Dropdown, Form, Input, InputNumber, Modal, QRCode, Radio, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
} from 'antd';
import { message } from '../utils/antdMessage';
import type { MenuProps, TableProps } from 'antd';
import type { Dayjs } from 'dayjs';
import type { MouseEvent as ReactMouseEvent } from 'react';
import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined,
ImportOutlined, LinkOutlined, MoreOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import {
huyaApi,
type HuyaAccountItem,
type HuyaConfig,
type HuyaGoodsItem,
type HuyaRechargeGoodsItem,
type HuyaTaskItem,
} from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
const FALLBACK_TASK_TYPES: Record<string, string> = {
get_bind_qr: '获取绑定二维码',
confirm_bind: '确认绑定',
query_points: '一键查询积分',
query_game_name: '一键查询游戏名',
query_all: '一键查询(角色+宝典+积分)',
query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表',
refresh_recharge_goods: '刷新充值商品列表',
exchange_goods: '兑换商品',
create_recharge_order: '生成支付二维码',
};
const QUICK_ACTIONS = [
{ key: 'query_all', icon: <SearchOutlined /> },
{ 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)}` : ''} / ${item.spu_id}`,
}));
}, [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>;
}
// 一键查询:积分 + 宝典开通 + 角色,一处看全(明细在任务 message 里)
if (record.task_type === 'query_all') {
const tags: Array<{ text: string; color: string }> = [];
if (typeof value?.points === 'number') tags.push({ text: `积分 ${value.points}`, color: 'blue' });
if (typeof value?.elite_opened === 'boolean') {
tags.push({ text: value.elite_opened ? '已开通宝典' : '未开通宝典', color: value.elite_opened ? 'green' : 'default' });
}
if (typeof value?.role_name === 'string' && value.role_name) tags.push({ text: `角色 ${value.role_name}`, color: 'cyan' });
if (tags.length) {
return <Space size={6} wrap>{tags.map((tag) => <Tag key={tag.text} color={tag.color}>{tag.text}</Tag>)}</Space>;
}
}
// 积分结果:新版走积分中心(points),旧任务记录里是活动接口的 available_score
const points = typeof value?.points === 'number' ? value.points : value?.available_score;
if (typeof points === 'number') {
const level = value?.grow_level;
return <Tag color="blue">可用积分 {points}{typeof level === 'number' ? ` · 等级 ${level}` : ''}</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>
);
}