1058 lines
42 KiB
TypeScript
1058 lines
42 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Button, Card, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tag, Typography,
|
|
} from 'antd';
|
|
import type { TableProps } from 'antd';
|
|
import {
|
|
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
|
ImportOutlined, QrcodeOutlined, ReloadOutlined,
|
|
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
|
} from '@ant-design/icons';
|
|
import {
|
|
douyuApi,
|
|
type DouyuConfig,
|
|
type DouyuGoodsItem,
|
|
type DouyuTaskAccountItem,
|
|
type DouyuTaskItem,
|
|
} from '../api/modules';
|
|
import RealtimeLogPanel from '../components/RealtimeLogPanel';
|
|
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
|
|
import { formatTime } from '../utils/time';
|
|
import { getErrorMessage } from '../utils/error';
|
|
import { message } from '../utils/antdMessage';
|
|
|
|
const { Text } = Typography;
|
|
|
|
const QUICK_ACTIONS = [
|
|
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
|
|
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
|
|
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
|
|
{ key: 'query_points', icon: <SearchOutlined /> },
|
|
{ key: 'query_game_name', icon: <SearchOutlined /> },
|
|
{ key: 'query_change_bind_time', icon: <FieldTimeOutlined /> },
|
|
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
|
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
|
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
|
|
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
|
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
|
|
];
|
|
|
|
const 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 BIND_STATUS_LABELS: Record<string, string> = {
|
|
bind_qr_generated: '待扫码',
|
|
game_queried: '已识别角色',
|
|
game_not_bound: '未绑定',
|
|
bind_confirmed: '已绑定',
|
|
change_time_queried: '已查换绑',
|
|
};
|
|
|
|
function resultText(result: Record<string, unknown> | null | undefined, key: string): string {
|
|
const value = result?.[key];
|
|
return typeof value === 'string' ? value : '';
|
|
}
|
|
|
|
function resultFlag(result: Record<string, unknown> | null | undefined, key: string): boolean {
|
|
const value = result?.[key];
|
|
if (value === true) return true;
|
|
if (value === false || value == null) return false;
|
|
const text = String(value).trim().toLowerCase();
|
|
return text === '1' || text === 'true' || text === 'yes';
|
|
}
|
|
|
|
function taskPayUrl(task: DouyuTaskItem | null | undefined): string {
|
|
return resultText(task?.result, 'pay_url') || resultText(task?.result, 'url');
|
|
}
|
|
|
|
function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
|
|
if (!task || task.task_type !== 'get_bind_qr') return false;
|
|
if (task.result?.bind_ready_for_confirm === true) return true;
|
|
const roleName = resultText(task.result, 'role_name');
|
|
if (!roleName) return false;
|
|
// 已确认绑定的角色不能再次直接确认
|
|
if (resultFlag(task.result, 'bind_confirmed') || resultFlag(task.result, 'is_bound_act')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function hasBindQrcode(task: DouyuTaskItem | null | undefined): boolean {
|
|
if (!task || task.task_type !== 'get_bind_qr') return false;
|
|
// 有二维码,或已有待确认角色(无需再扫码)都应可展示绑定面板
|
|
return Boolean(resultText(task.result, 'url')) || bindReadyForConfirm(task);
|
|
}
|
|
|
|
function hasPaymentQrcode(task: DouyuTaskItem | null | undefined): boolean {
|
|
return Boolean(task && ['create_elite_qr', 'create_gold_qr'].includes(task.task_type) && resultText(task.result, 'pay_url'));
|
|
}
|
|
|
|
function bindPhaseText(phase: string, polling: boolean): string {
|
|
if (phase === 'role_ready') return '已识别角色,待确认';
|
|
if (phase === 'change_waiting') return '换绑冷却中';
|
|
if (phase === 'role_timeout') return '未检测到角色';
|
|
if (phase === 'stopped') return '任务已停止';
|
|
if (phase === 'confirmed') return '已绑定';
|
|
if (phase === 'confirm_failed') return '确认未生效';
|
|
if (polling || phase === 'waiting_scan') return '等待扫码绑定';
|
|
return phase || '等待绑定';
|
|
}
|
|
|
|
function goodsLabel(item: DouyuGoodsItem): string {
|
|
return `${item.name || item.commodity_id}${item.score ? ` / ${item.score}积分` : ''}`;
|
|
}
|
|
|
|
function formatWaitSeconds(seconds: number | null | undefined): string {
|
|
if (seconds == null || Number.isNaN(Number(seconds))) return '';
|
|
const total = Math.max(0, Math.floor(Number(seconds)));
|
|
if (total <= 0) return '可换绑';
|
|
const days = Math.floor(total / 86400);
|
|
const hours = Math.floor((total % 86400) / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
const secs = total % 60;
|
|
if (days > 0) return `${days}天${hours}小时${minutes}分`;
|
|
if (hours > 0) return `${hours}小时${minutes}分${secs}秒`;
|
|
return `${minutes}分${secs}秒`;
|
|
}
|
|
|
|
function resultNumber(result: Record<string, unknown> | null | undefined, key: string): number | null {
|
|
const value = result?.[key];
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export default function DouyuTasksPage() {
|
|
const [accounts, setAccounts] = useState<DouyuTaskAccountItem[]>([]);
|
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
const [goods, setGoods] = useState<DouyuGoodsItem[]>([]);
|
|
const [tasks, setTasks] = useState<DouyuTaskItem[]>([]);
|
|
const [taskTypes, setTaskTypes] = useState<Record<string, string>>({});
|
|
const [config, setConfig] = useState<DouyuConfig | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [runningBatchId, setRunningBatchId] = useState('');
|
|
const [configOpen, setConfigOpen] = useState(false);
|
|
const [concurrency, setConcurrency] = useState(3);
|
|
const [goldAmount, setGoldAmount] = useState(1);
|
|
const [giftCount, setGiftCount] = useState(1);
|
|
const [selectedGoodsId, setSelectedGoodsId] = useState('');
|
|
const [accountSearch, setAccountSearch] = useState('');
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [importPool, setImportPool] = useState<DouyuTaskAccountItem[]>([]);
|
|
const [importSearch, setImportSearch] = useState('');
|
|
const [importSelectedIds, setImportSelectedIds] = useState<number[]>([]);
|
|
const [importLoading, setImportLoading] = useState(false);
|
|
|
|
// 绑定/支付弹窗以 task 为唯一数据源
|
|
const [qrTask, setQrTask] = useState<DouyuTaskItem | null>(null);
|
|
const [payTask, setPayTask] = useState<DouyuTaskItem | null>(null);
|
|
const autoOpenedQrTaskIds = useRef<Set<number>>(new Set());
|
|
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
|
|
const autoOpenQrReady = useRef(false);
|
|
const autoOpenPayReady = useRef(false);
|
|
|
|
const logs = useWebSocketLogs();
|
|
const latestQueryGameTaskByAccount = useMemo(() => {
|
|
const map = new Map<number, DouyuTaskItem>();
|
|
for (const task of tasks) {
|
|
if (task.task_type !== 'query_game_name') continue;
|
|
const previous = map.get(task.account_id);
|
|
if (!previous || task.id > previous.id) map.set(task.account_id, task);
|
|
}
|
|
return map;
|
|
}, [tasks]);
|
|
|
|
// 换绑时间:优先最近一次查询/拦截任务结果,回退账号落库字段
|
|
const latestChangeWaitByAccount = useMemo(() => {
|
|
const map = new Map<number, { wait: number | null; text: string; taskId: number }>();
|
|
for (const task of tasks) {
|
|
if (!['query_change_bind_time', 'get_bind_qr'].includes(task.task_type)) continue;
|
|
if (!['success', 'failed'].includes(task.status)) continue;
|
|
const wait = resultNumber(task.result, 'change_role_wait_time');
|
|
// get_bind_qr 只有冷却拦截时才有 wait;无 wait 时跳过,避免覆盖
|
|
if (task.task_type === 'get_bind_qr' && wait == null) continue;
|
|
const existing = map.get(task.account_id);
|
|
if (existing && existing.taskId > task.id) continue;
|
|
const text = resultText(task.result, 'change_role_wait_text') || formatWaitSeconds(wait);
|
|
map.set(task.account_id, { wait, text, taskId: task.id });
|
|
}
|
|
return map;
|
|
}, [tasks]);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [accResult, goodsResult, taskResult, cfgResult, typeResult] = await Promise.allSettled([
|
|
douyuApi.listAccounts(),
|
|
douyuApi.listGoods(),
|
|
douyuApi.listTasks(),
|
|
douyuApi.getConfig(),
|
|
douyuApi.taskTypes(),
|
|
]);
|
|
if (accResult.status === 'fulfilled') setAccounts(accResult.value);
|
|
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
|
|
if (taskResult.status === 'fulfilled') {
|
|
setTasks(taskResult.value);
|
|
// 仅首次加载时屏蔽历史二维码自动弹窗;后续刷新不能把新任务一并标记掉
|
|
if (!autoOpenQrReady.current) {
|
|
for (const task of taskResult.value) {
|
|
if (hasBindQrcode(task)) autoOpenedQrTaskIds.current.add(task.id);
|
|
if (hasPaymentQrcode(task)) autoOpenedPayTaskIds.current.add(task.id);
|
|
}
|
|
autoOpenQrReady.current = true;
|
|
autoOpenPayReady.current = true;
|
|
}
|
|
}
|
|
if (cfgResult.status === 'fulfilled') setConfig(cfgResult.value);
|
|
if (typeResult.status === 'fulfilled') setTaskTypes(typeResult.value);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadTasks = useCallback(async () => {
|
|
try {
|
|
const data = await douyuApi.listTasks();
|
|
setTasks(data);
|
|
if (!data.some((task) => ['pending', 'running', 'planned'].includes(task.status))) {
|
|
// 批次已结束时清理 busy 标记,避免 onResult 丢失导致永久锁住
|
|
setRunningBatchId((current) => (current ? '' : current));
|
|
}
|
|
} catch {
|
|
// 轮询失败不打扰操作
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { loadData(); }, [loadData]);
|
|
|
|
const hasActiveTasks = useMemo(
|
|
() => tasks.some((task) => ['pending', 'running', 'planned'].includes(task.status)),
|
|
[tasks],
|
|
);
|
|
|
|
// 有活跃任务或 WS 连接时 3 秒刷 tasks;空闲 15 秒轻量刷新
|
|
useEffect(() => {
|
|
const intervalMs = hasActiveTasks || logs.connected || runningBatchId ? 3000 : 15000;
|
|
const timer = setInterval(loadTasks, intervalMs);
|
|
return () => clearInterval(timer);
|
|
}, [hasActiveTasks, loadTasks, logs.connected, runningBatchId]);
|
|
|
|
const openQrTask = useCallback((task: DouyuTaskItem) => {
|
|
autoOpenedQrTaskIds.current.add(task.id);
|
|
setPayTask(null);
|
|
setQrTask(task);
|
|
}, []);
|
|
|
|
const openPayTask = useCallback((task: DouyuTaskItem) => {
|
|
autoOpenedPayTaskIds.current.add(task.id);
|
|
setQrTask(null);
|
|
setPayTask(task);
|
|
}, []);
|
|
|
|
// 自动弹出最新绑定二维码(running 有 url 即可)
|
|
useEffect(() => {
|
|
if (!autoOpenQrReady.current || qrTask) return;
|
|
const nextQrTask = tasks
|
|
.filter((task) => hasBindQrcode(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) && !autoOpenedPayTaskIds.current.has(task.id))
|
|
.sort((a, b) => b.id - a.id)[0];
|
|
if (nextPayTask) openPayTask(nextPayTask);
|
|
}, [openPayTask, payTask, tasks]);
|
|
|
|
// 弹窗跟随最新 task 状态(后端 progress 写入后 UI 自动更新)
|
|
useEffect(() => {
|
|
if (!qrTask) return;
|
|
const latest = tasks.find((task) => task.id === qrTask.id);
|
|
if (latest && latest !== qrTask) setQrTask(latest);
|
|
}, [qrTask, tasks]);
|
|
|
|
useEffect(() => {
|
|
if (!payTask) return;
|
|
const latest = tasks.find((task) => task.id === payTask.id);
|
|
if (latest && latest !== payTask) setPayTask(latest);
|
|
}, [payTask, tasks]);
|
|
|
|
const batchBusy = Boolean(runningBatchId || logs.connected || hasActiveTasks);
|
|
|
|
const startTask = async (taskType: string, accountIds?: number[]) => {
|
|
const ids = accountIds || selectedIds;
|
|
if (ids.length === 0) {
|
|
message.warning('请先选择账号');
|
|
return;
|
|
}
|
|
// 绑定确认/查询允许在其他账号仍轮询时继续,避免卡在多账号批次里
|
|
const allowDuringBatch = taskType === 'confirm_bind' || taskType === 'query_game_name';
|
|
if (batchBusy && !allowDuringBatch) {
|
|
message.warning('当前已有批次在运行,请先停止或等待结束');
|
|
return;
|
|
}
|
|
if (taskType === 'exchange_goods' && !selectedGoodsId) {
|
|
message.warning('请先选择兑换商品');
|
|
return;
|
|
}
|
|
const payload: Record<string, unknown> = {};
|
|
if (taskType === 'exchange_goods') payload.commodity_id = selectedGoodsId;
|
|
if (taskType === 'create_gold_qr') payload.amount = goldAmount;
|
|
if (taskType === 'donate_elite_gift') payload.gift_count = giftCount;
|
|
try {
|
|
const result = await douyuApi.createTasks({
|
|
account_ids: ids,
|
|
task_type: taskType,
|
|
concurrency,
|
|
payload,
|
|
});
|
|
setRunningBatchId(result.batch_id);
|
|
logs.connect(`/api/douyu/ws/${result.batch_id}`, {
|
|
clear: true,
|
|
onResult: () => {
|
|
setRunningBatchId('');
|
|
void loadData();
|
|
},
|
|
});
|
|
message.success(`已创建 ${result.count} 个任务`);
|
|
setTimeout(() => { void loadTasks(); }, 400);
|
|
} catch (error) {
|
|
message.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const stopBatch = async () => {
|
|
if (!runningBatchId) return;
|
|
try {
|
|
await douyuApi.stopBatch(runningBatchId);
|
|
message.success('已停止');
|
|
} catch (error) {
|
|
message.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const saveConfig = async (values: DouyuConfig) => {
|
|
try {
|
|
const saved = await douyuApi.updateConfig(values);
|
|
setConfig(saved);
|
|
setConfigOpen(false);
|
|
message.success('配置已保存');
|
|
} catch (error) {
|
|
message.error(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
// ---- Import ----
|
|
const loadImportPool = useCallback(async () => {
|
|
setImportLoading(true);
|
|
try {
|
|
const result = await douyuApi.listAccounts({ search: importSearch });
|
|
setImportPool(result.filter((a) => !accounts.find((x) => x.id === a.id)));
|
|
} finally {
|
|
setImportLoading(false);
|
|
}
|
|
}, [importSearch, accounts]);
|
|
useEffect(() => { if (importOpen) void loadImportPool(); }, [importOpen, loadImportPool]);
|
|
|
|
const importAccounts = (ids: number[]) => {
|
|
const toImport = importPool.filter((a) => ids.includes(a.id));
|
|
setAccounts((prev) => {
|
|
const existing = new Set(prev.map((a) => a.id));
|
|
return [...prev, ...toImport.filter((a) => !existing.has(a.id))];
|
|
});
|
|
setImportSelectedIds([]);
|
|
setImportOpen(false);
|
|
message.success(`已导入 ${toImport.length} 个账号`);
|
|
};
|
|
|
|
const removeSelected = () => {
|
|
const sel = new Set(selectedIds);
|
|
setAccounts((prev) => prev.filter((a) => !sel.has(a.id)));
|
|
setSelectedIds([]);
|
|
};
|
|
|
|
// ---- Context menu ----
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
accountIds: number[]; x: number; y: number;
|
|
} | null>(null);
|
|
|
|
const handleRowContextMenu = (record: DouyuTaskAccountItem, event: React.MouseEvent) => {
|
|
event.preventDefault();
|
|
const ids = selectedIds.includes(record.id) ? selectedIds : [record.id];
|
|
setContextMenu({ accountIds: ids, x: event.clientX, y: event.clientY });
|
|
if (!selectedIds.includes(record.id)) setSelectedIds([record.id]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
const close = () => setContextMenu(null);
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') close();
|
|
};
|
|
window.addEventListener('click', close);
|
|
window.addEventListener('scroll', close, true);
|
|
window.addEventListener('resize', close);
|
|
window.addEventListener('keydown', closeOnEscape);
|
|
return () => {
|
|
window.removeEventListener('click', close);
|
|
window.removeEventListener('scroll', close, true);
|
|
window.removeEventListener('resize', close);
|
|
window.removeEventListener('keydown', closeOnEscape);
|
|
};
|
|
}, [contextMenu]);
|
|
|
|
const contextMenuStyle = contextMenu ? {
|
|
position: 'fixed' as const,
|
|
left: Math.max(8, Math.min(contextMenu.x, window.innerWidth - 220)),
|
|
top: Math.max(8, Math.min(contextMenu.y, window.innerHeight - 390)),
|
|
zIndex: 1050,
|
|
} : { display: 'none' };
|
|
|
|
const runContextAction = (taskType: string) => {
|
|
if (!contextMenu) return;
|
|
setContextMenu(null);
|
|
void startTask(taskType, contextMenu.accountIds);
|
|
};
|
|
|
|
// ---- QR panel derived state ----
|
|
const qrResult = qrTask?.result || null;
|
|
const qrUrl = resultText(qrResult, 'url');
|
|
const qrBindPhase = resultText(qrResult, 'bind_phase');
|
|
const qrAutoPolling = qrTask?.status === 'running' && qrResult?.bind_polling === true;
|
|
const 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 qrQueryRoleName = resultText(qrQueryResult, 'role_name');
|
|
const qrQueryIsBoundAct = resultFlag(qrQueryResult, 'is_bound_act') || resultFlag(qrQueryResult, 'bind_confirmed');
|
|
const qrQueryPending = Boolean(
|
|
qrQueryResult
|
|
&& qrQueryRoleName
|
|
&& (
|
|
qrQueryResult.bind_ready_for_confirm === true
|
|
|| (!qrQueryIsBoundAct)
|
|
),
|
|
);
|
|
const qrBindReady = bindReadyForConfirm(qrTask) || qrQueryPending;
|
|
const qrRoleSourceResult = qrQueryPending
|
|
? qrQueryResult
|
|
: (bindReadyForConfirm(qrTask) ? qrResult : null);
|
|
const qrRoleName = resultText(qrRoleSourceResult, 'role_name');
|
|
const qrAreaName = resultText(qrRoleSourceResult, 'area_name');
|
|
const qrPlatName = resultText(qrRoleSourceResult, 'plat_name');
|
|
const qrCurrentRoleName = resultText(qrResult, 'current_role_name')
|
|
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'role_name') : '')
|
|
|| (qrQueryIsBoundAct ? qrQueryRoleName : '');
|
|
const qrCurrentAreaName = resultText(qrResult, 'current_area_name')
|
|
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'area_name') : '')
|
|
|| (qrQueryIsBoundAct ? resultText(qrQueryResult, 'area_name') : '');
|
|
const qrCurrentPlatName = resultText(qrResult, 'current_plat_name')
|
|
|| (resultFlag(qrResult, 'is_bound_act') ? resultText(qrResult, 'plat_name') : '')
|
|
|| (qrQueryIsBoundAct ? resultText(qrQueryResult, 'plat_name') : '');
|
|
const qrRoleLine = qrBindReady
|
|
? [qrPlatName, qrAreaName, qrRoleName].filter(Boolean).join(' - ')
|
|
: '';
|
|
const qrBindSummary = resultText(qrResult, 'bind_summary') || resultText(qrQueryResult, 'bind_summary');
|
|
const qrStatusText = qrBindReady
|
|
? '已识别角色,待确认'
|
|
: qrQueryRunning
|
|
? '查询角色中'
|
|
: qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.status === 'success' && qrQueryIsBoundAct
|
|
? '查询结果:仍是当前绑定角色'
|
|
: bindPhaseText(qrBindPhase, qrAutoPolling);
|
|
const qrAccountName = qrTask
|
|
? (qrTask.account_nickname || qrTask.account_username || qrTask.account_uid || `#${qrTask.account_id}`)
|
|
: '';
|
|
|
|
const confirmQrBind = () => {
|
|
if (!qrTask || !qrBindReady) {
|
|
message.warning('请先扫码识别待确认角色');
|
|
return;
|
|
}
|
|
const accountId = qrTask.account_id;
|
|
setQrTask(null);
|
|
void startTask('confirm_bind', [accountId]);
|
|
};
|
|
|
|
const queryQrRole = () => {
|
|
if (!qrTask) return;
|
|
if (qrAutoPolling) {
|
|
message.info('正在自动识别角色,请稍候');
|
|
return;
|
|
}
|
|
void startTask('query_game_name', [qrTask.account_id]);
|
|
};
|
|
|
|
// Account table
|
|
const accountColumns: TableProps<DouyuTaskAccountItem>['columns'] = [
|
|
{
|
|
title: '账号', dataIndex: 'username', width: 150,
|
|
render: (_, record) => (
|
|
<Space direction="vertical" size={0}>
|
|
<Text strong>{record.nickname || record.username}</Text>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || '-'}</Text>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: '游戏名', dataIndex: 'game_name', width: 130,
|
|
render: (_, record) => {
|
|
const queryResult = latestQueryGameTaskByAccount.get(record.id)?.result || null;
|
|
const roleName = resultText(queryResult, 'role_name') || record.game_name;
|
|
const channel = (
|
|
[resultText(queryResult, 'area_name'), resultText(queryResult, 'plat_name')]
|
|
.filter(Boolean)
|
|
.join(' / ')
|
|
|| record.game_channel
|
|
);
|
|
return roleName ? (
|
|
<Space direction="vertical" size={0}>
|
|
<Text>{roleName}</Text>
|
|
{channel ? <Text type="secondary" style={{ fontSize: 12 }}>{channel}</Text> : null}
|
|
</Space>
|
|
) : <Text type="secondary">未查</Text>;
|
|
},
|
|
},
|
|
{
|
|
title: '积分', dataIndex: 'points', width: 70,
|
|
render: (v) => v ?? <Text type="secondary">-</Text>,
|
|
},
|
|
{
|
|
title: '鱼翅', dataIndex: 'gold_balance', width: 70,
|
|
render: (v) => v ?? <Text type="secondary">-</Text>,
|
|
},
|
|
{
|
|
title: '换绑时间', dataIndex: 'change_role_wait_time', width: 130,
|
|
render: (_, record) => {
|
|
const fromTask = latestChangeWaitByAccount.get(record.id);
|
|
const wait = fromTask?.wait ?? record.change_role_wait_time;
|
|
const text = fromTask?.text || formatWaitSeconds(wait);
|
|
if (wait == null && !text) {
|
|
return <Text type="secondary">未查</Text>;
|
|
}
|
|
if (wait != null && wait <= 0) {
|
|
return <Tag color="success">可换绑</Tag>;
|
|
}
|
|
if (wait != null && wait > 0) {
|
|
return (
|
|
<Space direction="vertical" size={0}>
|
|
<Tag color="orange">冷却中</Tag>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>{text || formatWaitSeconds(wait)}</Text>
|
|
</Space>
|
|
);
|
|
}
|
|
// 接口没返回数字但有文案
|
|
if (text === '可换绑') return <Tag color="success">可换绑</Tag>;
|
|
return <Text type="secondary">{text || '-'}</Text>;
|
|
},
|
|
sorter: (a, b) => {
|
|
const aw = latestChangeWaitByAccount.get(a.id)?.wait ?? a.change_role_wait_time ?? -1;
|
|
const bw = latestChangeWaitByAccount.get(b.id)?.wait ?? b.change_role_wait_time ?? -1;
|
|
return aw - bw;
|
|
},
|
|
},
|
|
{
|
|
title: '状态', dataIndex: 'bind_status', width: 100,
|
|
render: (v) => (v
|
|
? <Tag>{BIND_STATUS_LABELS[v] || v}</Tag>
|
|
: <Text type="secondary">-</Text>),
|
|
},
|
|
];
|
|
|
|
// Task table
|
|
const taskColumns: TableProps<DouyuTaskItem>['columns'] = [
|
|
{
|
|
title: '账号', dataIndex: 'account_username', width: 120,
|
|
render: (_, r) => r.account_nickname || r.account_username || r.account_uid,
|
|
},
|
|
{ title: '任务', dataIndex: 'task_type', width: 140, render: (v) => taskTypes[v] || v },
|
|
{
|
|
title: '状态', dataIndex: 'status', width: 80,
|
|
render: (v) => <Tag color={STATUS_COLORS[v]}>{STATUS_LABELS[v] || v}</Tag>,
|
|
},
|
|
{
|
|
title: '结果', dataIndex: 'message', ellipsis: true,
|
|
render: (_, r) => {
|
|
if (r.task_type === 'get_bind_qr') {
|
|
const roleName = resultText(r.result, 'role_name');
|
|
const phase = resultText(r.result, 'bind_phase');
|
|
const ready = bindReadyForConfirm(r);
|
|
const polling = r.status === 'running' && r.result?.bind_polling === true;
|
|
const bindUrl = resultText(r.result, 'url');
|
|
return (
|
|
<Space wrap size={4}>
|
|
<Text>{r.message || '-'}</Text>
|
|
{ready ? <Tag color="gold">{roleName || '待确认'}</Tag> : null}
|
|
{!ready && (polling || phase) ? (
|
|
<Tag color="processing">{bindPhaseText(phase, polling)}</Tag>
|
|
) : null}
|
|
{bindUrl && (
|
|
<Button size="small" icon={<QrcodeOutlined />} onClick={() => openQrTask(r)}>
|
|
查看
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
);
|
|
}
|
|
if (r.task_type === 'query_game_name') {
|
|
const roleName = resultText(r.result, 'role_name');
|
|
const areaName = resultText(r.result, 'area_name');
|
|
const platName = resultText(r.result, 'plat_name');
|
|
return (
|
|
<Space wrap size={4}>
|
|
<Text>{r.message || '-'}</Text>
|
|
{roleName ? <Tag color="blue">{roleName}</Tag> : null}
|
|
{(areaName || platName) ? (
|
|
<Text type="secondary">{[areaName, platName].filter(Boolean).join(' / ')}</Text>
|
|
) : null}
|
|
</Space>
|
|
);
|
|
}
|
|
if (r.task_type === 'confirm_bind') {
|
|
const roleName = resultText(r.result, 'role_name');
|
|
const confirmed = resultFlag(r.result, 'bind_confirmed');
|
|
return (
|
|
<Space wrap size={4}>
|
|
<Text>{r.message || '-'}</Text>
|
|
{roleName ? <Tag color={confirmed ? 'green' : 'orange'}>{roleName}</Tag> : null}
|
|
</Space>
|
|
);
|
|
}
|
|
const url = taskPayUrl(r);
|
|
return (
|
|
<Space wrap size={4}>
|
|
<Text>{r.message || '-'}</Text>
|
|
{url && (
|
|
<Button
|
|
size="small"
|
|
icon={<QrcodeOutlined />}
|
|
onClick={() => {
|
|
if (hasPaymentQrcode(r)) openPayTask(r);
|
|
else if (hasBindQrcode(r)) openQrTask(r);
|
|
}}
|
|
>
|
|
查看
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: '时间', dataIndex: 'finished_at', width: 150,
|
|
render: (_, r) => formatTime(r.finished_at || r.created_at),
|
|
},
|
|
];
|
|
|
|
const filteredAccounts = useMemo(() => {
|
|
if (!accountSearch.trim()) return accounts;
|
|
const kw = accountSearch.toLowerCase();
|
|
return accounts.filter((a) =>
|
|
(a.nickname || '').toLowerCase().includes(kw)
|
|
|| (a.username || '').toLowerCase().includes(kw)
|
|
|| (a.uid || '').toLowerCase().includes(kw)
|
|
|| (a.game_name || '').toLowerCase().includes(kw),
|
|
);
|
|
}, [accounts, accountSearch]);
|
|
|
|
const [configFormValues, setConfigFormValues] = useState<DouyuConfig | null>(null);
|
|
useEffect(() => {
|
|
if (configOpen && config) setConfigFormValues({ ...config });
|
|
}, [configOpen, config]);
|
|
|
|
const payUrl = resultText(payTask?.result, 'pay_url');
|
|
const payAccountName = payTask
|
|
? (payTask.account_nickname || payTask.account_username || payTask.account_uid || `#${payTask.account_id}`)
|
|
: '';
|
|
|
|
return (
|
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
{/* Header */}
|
|
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}>
|
|
<div>
|
|
<h2 style={{ margin: 0 }}>斗鱼任务操作台</h2>
|
|
<Text type="secondary">绑定、宝典、鱼翅、积分、兑换和余额任务</Text>
|
|
</div>
|
|
<Space>
|
|
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}>刷新</Button>
|
|
<Button icon={<SettingOutlined />} onClick={() => setConfigOpen(true)}>配置</Button>
|
|
{runningBatchId && <Button danger icon={<StopOutlined />} onClick={stopBatch}>停止</Button>}
|
|
</Space>
|
|
</Space>
|
|
|
|
{/* Main grid */}
|
|
<div style={{
|
|
flex: 1, minHeight: 0,
|
|
display: 'grid',
|
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(320px, 380px)',
|
|
gap: 12, overflow: 'hidden',
|
|
}}>
|
|
{/* Left: accounts + tasks */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
|
|
<Card
|
|
size="small" title="账号"
|
|
extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>}
|
|
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
|
|
>
|
|
<Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap>
|
|
<Space>
|
|
<Input
|
|
size="small" placeholder="搜索账号/昵称/游戏名" prefix={<SearchOutlined />}
|
|
value={accountSearch} onChange={(e) => setAccountSearch(e.target.value)}
|
|
style={{ width: 200 }} allowClear
|
|
/>
|
|
</Space>
|
|
<Space>
|
|
<Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>
|
|
{selectedIds.length > 0 && (
|
|
<Button size="small" danger onClick={removeSelected}>移出选中</Button>
|
|
)}
|
|
</Space>
|
|
</Space>
|
|
<Table
|
|
rowKey="id"
|
|
size="small"
|
|
loading={loading}
|
|
rowSelection={{
|
|
selectedRowKeys: selectedIds,
|
|
onChange: (keys) => setSelectedIds(keys.map(Number)),
|
|
}}
|
|
columns={accountColumns}
|
|
dataSource={filteredAccounts}
|
|
pagination={{ pageSize: 15, showSizeChanger: false }}
|
|
scroll={{ y: 'calc(100vh - 520px)' }}
|
|
onRow={(record) => ({
|
|
onContextMenu: (e) => handleRowContextMenu(record, e),
|
|
style: { cursor: 'context-menu' },
|
|
title: '右键打开账号动作',
|
|
})}
|
|
/>
|
|
</Card>
|
|
|
|
<Card
|
|
size="small" title="任务记录"
|
|
extra={
|
|
<Space size={12}>
|
|
<Tag color="success">成功 {tasks.filter((t) => t.status === 'success').length}</Tag>
|
|
<Tag color="error">失败 {tasks.filter((t) => ['failed', 'error'].includes(t.status)).length}</Tag>
|
|
</Space>
|
|
}
|
|
styles={{ body: { padding: 8 } }}
|
|
>
|
|
<Table
|
|
rowKey="id" size="small" columns={taskColumns} dataSource={tasks}
|
|
loading={loading} pagination={{ pageSize: 10, showSizeChanger: false }}
|
|
scroll={{ x: 700 }}
|
|
/>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right: operations + logs */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, overflow: 'auto' }}>
|
|
<Card size="small" title="操作" styles={{ body: { padding: 8 } }}>
|
|
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Text type="secondary">{selectedIds.length > 0 ? `已选 ${selectedIds.length} 个账号` : '点击/右键选择账号'}</Text>
|
|
<InputNumber size="small" min={1} max={10} value={concurrency} onChange={(v) => setConcurrency(v || 1)} style={{ width: 70 }} addonBefore="并发" />
|
|
</Space>
|
|
<Space wrap size={4}>
|
|
{QUICK_ACTIONS.map((item) => (
|
|
<Button
|
|
key={item.key} size="small" icon={item.icon}
|
|
onClick={() => startTask(item.key)}
|
|
disabled={batchBusy || selectedIds.length === 0}
|
|
>
|
|
{taskTypes[item.key] || item.key}
|
|
</Button>
|
|
))}
|
|
</Space>
|
|
<Card size="small" title="兑换" styles={{ body: { padding: 8 } }}>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
<Select
|
|
size="small" value={selectedGoodsId || undefined} onChange={setSelectedGoodsId}
|
|
options={goods.map((item) => ({ value: item.commodity_id, label: goodsLabel(item) }))}
|
|
placeholder="选择兑换商品" showSearch optionFilterProp="label"
|
|
style={{ width: '100%' }}
|
|
/>
|
|
<Space>
|
|
<Button size="small" icon={<ReloadOutlined />} onClick={() => startTask('refresh_goods')} disabled={batchBusy || selectedIds.length === 0}>
|
|
刷新商品
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
icon={<ShoppingOutlined />}
|
|
onClick={() => startTask('exchange_goods')}
|
|
disabled={batchBusy || selectedIds.length === 0 || !selectedGoodsId}
|
|
>
|
|
兑换商品
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</Card>
|
|
<Card size="small" title="充值与送礼" styles={{ body: { padding: 8 } }}>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
<Space>
|
|
<InputNumber size="small" min={1} value={goldAmount} onChange={(v) => setGoldAmount(v || 1)} style={{ width: 80 }} />
|
|
<Button size="small" icon={<CreditCardOutlined />} onClick={() => startTask('create_gold_qr')} disabled={batchBusy || selectedIds.length === 0}>
|
|
充值鱼翅
|
|
</Button>
|
|
</Space>
|
|
<Space>
|
|
<InputNumber size="small" min={1} value={giftCount} onChange={(v) => setGiftCount(v || 1)} style={{ width: 80 }} />
|
|
<Button size="small" icon={<GiftOutlined />} onClick={() => startTask('donate_elite_gift')} disabled={batchBusy || selectedIds.length === 0}>
|
|
赠送精英令
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</Card>
|
|
</Space>
|
|
</Card>
|
|
|
|
<RealtimeLogPanel logs={logs.logs} connected={logs.connected} height={200} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Context menu */}
|
|
{contextMenu && (
|
|
<div style={contextMenuStyle} onClick={(e) => e.stopPropagation()}>
|
|
<Card
|
|
size="small"
|
|
title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'}
|
|
styles={{ body: { padding: 4 } }}
|
|
style={{ width: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
|
|
>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
{QUICK_ACTIONS.map((item) => (
|
|
<Button
|
|
key={item.key} block size="small" type="text"
|
|
icon={item.icon}
|
|
onClick={() => runContextAction(item.key)}
|
|
disabled={batchBusy}
|
|
style={{ justifyContent: 'flex-start' }}
|
|
>
|
|
{taskTypes[item.key] || item.key}
|
|
</Button>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import modal */}
|
|
<Modal
|
|
title="导入账号到操作台"
|
|
open={importOpen}
|
|
onCancel={() => setImportOpen(false)}
|
|
footer={null}
|
|
width={700}
|
|
>
|
|
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}>
|
|
<Input
|
|
placeholder="搜索账号/昵称" prefix={<SearchOutlined />}
|
|
value={importSearch} onChange={(e) => setImportSearch(e.target.value)}
|
|
style={{ width: 250 }}
|
|
/>
|
|
<Space>
|
|
<Button onClick={() => importAccounts(importPool.map((a) => a.id))}>
|
|
导入全部 ({importPool.length})
|
|
</Button>
|
|
<Button type="primary" onClick={() => importAccounts(importSelectedIds)} disabled={importSelectedIds.length === 0}>
|
|
导入选中 ({importSelectedIds.length})
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
<Table
|
|
rowKey="id" size="small" loading={importLoading}
|
|
rowSelection={{ selectedRowKeys: importSelectedIds, onChange: (keys) => setImportSelectedIds(keys.map(Number)) }}
|
|
dataSource={importPool}
|
|
columns={[
|
|
{ title: '昵称', dataIndex: 'nickname', render: (_, r) => r.nickname || r.username },
|
|
{ title: 'UID', dataIndex: 'uid' },
|
|
{ title: '游戏名', dataIndex: 'game_name', render: (v) => v || '-' },
|
|
{ title: '积分', dataIndex: 'points', render: (v) => v ?? '-' },
|
|
]}
|
|
pagination={{ pageSize: 10 }}
|
|
locale={{ emptyText: '没有可导入的账号(操作台已包含所有账号)' }}
|
|
/>
|
|
</Modal>
|
|
|
|
{/* Config modal */}
|
|
<Modal
|
|
title="斗鱼活动配置"
|
|
open={configOpen}
|
|
onCancel={() => setConfigOpen(false)}
|
|
onOk={() => configFormValues && saveConfig(configFormValues)}
|
|
okText="保存" cancelText="取消"
|
|
width={600}
|
|
>
|
|
{configFormValues && (
|
|
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
|
{[
|
|
{ label: 'manualID', key: 'manual_id' },
|
|
{ label: 'RID', key: 'rid' },
|
|
{ label: '绑定二维码活动', key: 'bind_act_alias' },
|
|
{ label: '确认绑定活动', key: 'confirm_act_alias' },
|
|
{ label: '查询绑定活动(最新)', key: 'legacy_act_alias' },
|
|
{ label: '房间 ID', key: 'room_id' },
|
|
{ label: '精英令礼物 ID', key: 'gift_id' },
|
|
{ label: '皮肤 ID', key: 'skin_id' },
|
|
].map(({ label, key }) => (
|
|
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Text>{label}</Text>
|
|
<Input
|
|
style={{ width: 300 }}
|
|
value={String(configFormValues[key as keyof DouyuConfig] ?? '')}
|
|
onChange={(e) => setConfigFormValues((prev) => prev ? { ...prev, [key]: e.target.value } : prev)}
|
|
/>
|
|
</Space>
|
|
))}
|
|
{[
|
|
{ label: '宝典金额(分)', key: 'elite_amount' },
|
|
{ label: '鱼翅支付方式', key: 'gold_pay_type' },
|
|
].map(({ label, key }) => (
|
|
<Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
<Text>{label}</Text>
|
|
<InputNumber
|
|
style={{ width: 300 }}
|
|
value={Number(configFormValues[key as keyof DouyuConfig]) || 0}
|
|
onChange={(v) => setConfigFormValues((prev) => prev ? { ...prev, [key]: v ?? 0 } : prev)}
|
|
/>
|
|
</Space>
|
|
))}
|
|
</Space>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Bind QR Modal — driven by qrTask */}
|
|
<Modal
|
|
title={
|
|
<Space>
|
|
<QrcodeOutlined />
|
|
<span>{qrBindReady ? '已识别新角色' : qrStatusText}</span>
|
|
</Space>
|
|
}
|
|
open={!!qrTask}
|
|
onCancel={() => setQrTask(null)}
|
|
footer={null}
|
|
centered
|
|
width={420}
|
|
>
|
|
{qrTask && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
{qrAccountName && (
|
|
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>{qrAccountName}</Text>
|
|
)}
|
|
<Space direction="vertical" align="center" size={12}>
|
|
<Tag color={qrBindReady ? 'gold' : qrBindPhase === 'role_timeout' ? 'orange' : 'processing'}>
|
|
{qrStatusText}
|
|
</Tag>
|
|
{/* 已有待确认角色时可能不生成二维码(cjm 已是最新换绑态) */}
|
|
{qrUrl ? (
|
|
<QRCode value={qrUrl} size={220} bordered={false} />
|
|
) : qrBindReady ? (
|
|
<Text type="secondary">已识别待确认角色,无需再次扫码</Text>
|
|
) : qrTask?.status === 'running' ? (
|
|
<Text type="secondary">二维码生成中...</Text>
|
|
) : (
|
|
<Text type="secondary">本次未生成二维码</Text>
|
|
)}
|
|
{qrBindReady ? (
|
|
<>
|
|
<Tag color="gold" style={{ fontSize: 16, padding: '6px 16px' }}>
|
|
{qrRoleName}
|
|
</Tag>
|
|
{qrRoleLine && <Text type="secondary">{qrRoleLine}</Text>}
|
|
</>
|
|
) : qrCurrentRoleName ? (
|
|
<>
|
|
<Tag color="blue" style={{ fontSize: 14, padding: '4px 12px' }}>
|
|
当前绑定: {qrCurrentRoleName}
|
|
</Tag>
|
|
{(qrCurrentAreaName || qrCurrentPlatName) && (
|
|
<Text type="secondary">
|
|
{[qrCurrentPlatName, qrCurrentAreaName].filter(Boolean).join(' - ')}
|
|
</Text>
|
|
)}
|
|
</>
|
|
) : null}
|
|
{qrBindSummary ? (
|
|
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
|
{qrBindSummary}
|
|
</Text>
|
|
) : null}
|
|
{qrQueryTask && qrQueryTask.id > (qrTask?.id || 0) && qrQueryTask.message ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
最近查询: {qrQueryTask.message}
|
|
</Text>
|
|
) : null}
|
|
<Space>
|
|
<Button
|
|
icon={<SearchOutlined />}
|
|
onClick={queryQrRole}
|
|
loading={qrQueryRunning}
|
|
disabled={qrAutoPolling}
|
|
>
|
|
{qrAutoPolling ? '自动识别中' : '查询角色'}
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<CheckCircleOutlined />}
|
|
onClick={confirmQrBind}
|
|
disabled={!qrBindReady}
|
|
>
|
|
确认绑定
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Pay QR Modal */}
|
|
<Modal
|
|
title={
|
|
<Space>
|
|
<CreditCardOutlined />
|
|
<span>支付二维码</span>
|
|
</Space>
|
|
}
|
|
open={!!payTask}
|
|
onCancel={() => setPayTask(null)}
|
|
footer={null}
|
|
centered
|
|
width={400}
|
|
>
|
|
{payTask && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
{payAccountName && (
|
|
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>{payAccountName}</Text>
|
|
)}
|
|
<Space direction="vertical" align="center" size={12}>
|
|
{payUrl ? (
|
|
<QRCode value={payUrl} size={220} bordered={false} />
|
|
) : (
|
|
<Text type="secondary">支付码生成中...</Text>
|
|
)}
|
|
<Text>{payTask.message || '请扫码支付'}</Text>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|