- 扩展号池支持 peace (和平精英/小跑道) 活动 scope,映射 xpd_balance 与 exchange_xpd_goods 任务 - 新增 POST /douyu/pool/refresh-goods 接口与前端一键刷新,支持复用库内有效 CK 自动更新商品快照 - 前端 DouyuPoolPage 升级视觉设计与栅格布局,商品选择器支持按活动类型分类联动 - 修复 DouyuTask.result 类型守卫、account_events actor_id 类型防御与 runner 过滤规则边界值处理 - 验证方式:全量 pytest (797 通过)、pyright (0 错误)、tsc -b 及 ruff format/check 全部通过
1206 lines
44 KiB
TypeScript
1206 lines
44 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Alert, Badge, Button, Card, Col, Descriptions, InputNumber, Modal,
|
||
QRCode, Radio, Row, Select, Space, Statistic, Table, Tabs, Tag, Tooltip,
|
||
Typography, theme,
|
||
} from 'antd';
|
||
import {
|
||
CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
|
||
CopyOutlined, CreditCardOutlined, EyeOutlined, FireOutlined,
|
||
HistoryOutlined, ReloadOutlined, ShopOutlined, ShoppingCartOutlined, StopOutlined, SyncOutlined,
|
||
ThunderboltOutlined, TrophyOutlined,
|
||
} from '@ant-design/icons';
|
||
import { message } from '../utils/antdMessage';
|
||
import { getErrorMessage } from '../utils/error';
|
||
import { douyuApi, douyuPoolApi } from '../api/modules';
|
||
import type {
|
||
DouyuPoolExchangeResult, DouyuPoolLease, DouyuPoolSupply,
|
||
DouyuTaskItem,
|
||
} from '../api/modules';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import ExchangeResultImage from '../components/ExchangeResultImage';
|
||
import QrActions from '../components/QrActions';
|
||
import { copyImageToClipboard } from '../utils/exchangeImage';
|
||
import {
|
||
hasPaymentQrcode,
|
||
paymentArrived,
|
||
resultText,
|
||
taskQrUrl,
|
||
} from './douyu-tasks/config';
|
||
|
||
const { Text, Paragraph } = Typography;
|
||
|
||
export type PoolScope = 'elite' | 'esports' | 'peace';
|
||
|
||
interface ScopeConfig {
|
||
key: PoolScope;
|
||
title: string;
|
||
badge: string;
|
||
pointsLabel: string;
|
||
icon: React.ReactNode;
|
||
desc: string;
|
||
}
|
||
|
||
const SCOPE_CONFIGS: Record<PoolScope, ScopeConfig> = {
|
||
elite: {
|
||
key: 'elite',
|
||
title: '精英宝典',
|
||
badge: '💎',
|
||
pointsLabel: '精英积分',
|
||
icon: <ShoppingCartOutlined style={{ fontSize: 16 }} />,
|
||
desc: '和平精英精英宝典活动 · 消耗精英积分',
|
||
},
|
||
esports: {
|
||
key: 'esports',
|
||
title: 'PEL 电竞手册',
|
||
badge: '🏆',
|
||
pointsLabel: '电竞积分',
|
||
icon: <TrophyOutlined style={{ fontSize: 16 }} />,
|
||
desc: 'PEL 职业联赛电竞手册 · 消耗电竞积分',
|
||
},
|
||
peace: {
|
||
key: 'peace',
|
||
title: '和平小店',
|
||
badge: '🛒',
|
||
pointsLabel: '点券/积分',
|
||
icon: <ShopOutlined style={{ fontSize: 16 }} />,
|
||
desc: '斗鱼和平小店特惠商城 · 消耗小店点券/鱼翅',
|
||
},
|
||
};
|
||
|
||
function getRecentGoodsKey(scope: PoolScope): string {
|
||
return `douyu_pool_recent_goods_${scope}`;
|
||
}
|
||
|
||
function loadRecentGoodsIds(scope: PoolScope): string[] {
|
||
try {
|
||
const raw = localStorage.getItem(getRecentGoodsKey(scope));
|
||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveRecentGoodsId(scope: PoolScope, id: string) {
|
||
if (!id) return;
|
||
try {
|
||
const existing = loadRecentGoodsIds(scope).filter((x) => x !== id);
|
||
const updated = [id, ...existing].slice(0, 8);
|
||
localStorage.setItem(getRecentGoodsKey(scope), JSON.stringify(updated));
|
||
} catch {
|
||
// 忽略存储异常
|
||
}
|
||
}
|
||
|
||
/** 任务状态友好标签 */
|
||
const TASK_TYPE_NAMES: Record<string, string> = {
|
||
query_elite_status: '检查精英宝典状态',
|
||
query_gold_balance: '查询鱼翅余额',
|
||
query_points: '查询积分余额',
|
||
create_gold_qr: '鱼翅充值',
|
||
create_elite_qr: '精英令充值',
|
||
create_esports_qr: '电竞币充值',
|
||
donate_elite_gift: '赠送道具',
|
||
exchange_goods: '兑换精英宝典皮肤',
|
||
exchange_esports_goods: '兑换电竞手册皮肤',
|
||
exchange_xpd_goods: '兑换和平小店商品',
|
||
};
|
||
|
||
/**
|
||
* 账号分配台(自动化工作台)。
|
||
*
|
||
* 操作闭环:选活动 → 选皮肤 → 填数量 → 提交分配 →
|
||
* 若需充值自动弹出微信支付码 → 扫码到账后自动赠送并兑换 → 产出兑换凭证。
|
||
*/
|
||
export default function DouyuPoolPage() {
|
||
const { token } = theme.useToken();
|
||
|
||
// 当前选中的活动 scope
|
||
const [scope, setScope] = useState<PoolScope>('elite');
|
||
|
||
const [goods, setGoods] = useState<{ commodity_id: string; name: string; score: number | null }[]>([]);
|
||
const [commodityId, setCommodityId] = useState<string>('');
|
||
const [quantity, setQuantity] = useState(1);
|
||
const [allowTopup, setAllowTopup] = useState(true);
|
||
const [recentGoodsIds, setRecentGoodsIds] = useState<string[]>(() => loadRecentGoodsIds('elite'));
|
||
const [supply, setSupply] = useState<DouyuPoolSupply | null>(null);
|
||
const [result, setResult] = useState<DouyuPoolExchangeResult | null>(null);
|
||
const [leases, setLeases] = useState<DouyuPoolLease[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [refreshingGoods, setRefreshingGoods] = useState(false);
|
||
const [previewing, setPreviewing] = useState(false);
|
||
|
||
// 活跃批次与任务流水监控
|
||
const [activeBatchId, setActiveBatchId] = useState<string | null>(null);
|
||
const [batchTasks, setBatchTasks] = useState<DouyuTaskItem[]>([]);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
const [stoppingBatch, setStoppingBatch] = useState(false);
|
||
|
||
// 微信支付弹窗状态
|
||
const [payModalVisible, setPayModalVisible] = useState(false);
|
||
const [selectedPayTaskId, setSelectedPayTaskId] = useState<number | null>(null);
|
||
const payQrCanvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||
const autoOpenedPayTasks = useRef<Set<number>>(new Set());
|
||
|
||
// 兑换结果大图弹窗
|
||
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
||
const [copyingPreview, setCopyingPreview] = useState(false);
|
||
|
||
// 倒计时刷新
|
||
const [, setTick] = useState(0);
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => setTick((t) => t + 1), 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, []);
|
||
|
||
// 测算库存的具体实现(指定 scope 与 commodityId,彻底杜绝切换活动时的异步时序错乱)
|
||
const loadSupplyFor = useCallback(async (targetScope: PoolScope, targetCommodityId: string) => {
|
||
if (!targetCommodityId) {
|
||
setSupply(null);
|
||
return;
|
||
}
|
||
setPreviewing(true);
|
||
try {
|
||
const res = await douyuPoolApi.supply(targetScope, targetCommodityId);
|
||
setSupply(res);
|
||
} catch {
|
||
setSupply(null);
|
||
} finally {
|
||
setPreviewing(false);
|
||
}
|
||
}, []);
|
||
|
||
// 加载对应活动 scope 的商品列表并自动测算
|
||
const loadGoods = useCallback(async (targetScope: PoolScope) => {
|
||
try {
|
||
const rows = await douyuPoolApi.goods(targetScope);
|
||
const usable = rows.filter((item) => (item.score ?? 0) > 0);
|
||
setGoods(usable);
|
||
if (usable.length > 0) {
|
||
setCommodityId((prevId) => {
|
||
const exists = usable.some((item) => item.commodity_id === prevId);
|
||
const nextId = exists ? prevId : usable[0].commodity_id;
|
||
void loadSupplyFor(targetScope, nextId);
|
||
return nextId;
|
||
});
|
||
} else {
|
||
setCommodityId('');
|
||
setSupply(null);
|
||
}
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
}, [loadSupplyFor]);
|
||
|
||
const loadLeases = useCallback(async () => {
|
||
try {
|
||
const list = await douyuPoolApi.leases(true);
|
||
setLeases(list);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
}, []);
|
||
|
||
// 初始化加载
|
||
useEffect(() => {
|
||
void loadGoods('elite');
|
||
void loadLeases();
|
||
}, [loadGoods, loadLeases]);
|
||
|
||
// 切换活动 scope
|
||
const handleScopeChange = useCallback((newScope: PoolScope) => {
|
||
setScope(newScope);
|
||
setSupply(null);
|
||
setCommodityId('');
|
||
setRecentGoodsIds(loadRecentGoodsIds(newScope));
|
||
void loadGoods(newScope);
|
||
}, [loadGoods]);
|
||
|
||
// 一键刷新远端最新商品快照并刷新租约
|
||
const handleRefreshAll = async () => {
|
||
setRefreshingGoods(true);
|
||
try {
|
||
const res = await douyuPoolApi.refreshGoods(scope);
|
||
if (res.success) {
|
||
message.success(`已自动使用有效账号刷新 ${SCOPE_CONFIGS[scope].title} 商品列表(共 ${res.count} 个)`);
|
||
} else {
|
||
message.info(`已同步最新商品快照(共 ${res.count} 个)`);
|
||
}
|
||
await Promise.all([loadGoods(scope), loadLeases()]);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setRefreshingGoods(false);
|
||
}
|
||
};
|
||
|
||
// 轮询活跃批次任务
|
||
const pollBatchTasks = useCallback(async (batchId: string) => {
|
||
try {
|
||
const res = await douyuApi.listTasks(scope, batchId);
|
||
setBatchTasks(res);
|
||
return res;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}, [scope]);
|
||
|
||
useEffect(() => {
|
||
if (!activeBatchId) {
|
||
setBatchTasks([]);
|
||
return;
|
||
}
|
||
let timer: number | null = null;
|
||
let alive = true;
|
||
|
||
const run = async () => {
|
||
setBatchLoading(true);
|
||
const tasks = await pollBatchTasks(activeBatchId);
|
||
if (!alive) return;
|
||
setBatchLoading(false);
|
||
|
||
// 检查是否有仍在运行的任务,若有则持续 2 秒轮询一次
|
||
const hasActive = tasks.some((t) => ['planned', 'pending', 'running'].includes(t.status));
|
||
if (hasActive && alive) {
|
||
timer = window.setTimeout(() => { void run(); }, 2000);
|
||
}
|
||
};
|
||
|
||
void run();
|
||
return () => {
|
||
alive = false;
|
||
if (timer) window.clearTimeout(timer);
|
||
};
|
||
}, [activeBatchId, pollBatchTasks]);
|
||
|
||
// 提取待支付任务列表
|
||
const pendingPayTasks = useMemo(() => {
|
||
return batchTasks.filter(
|
||
(task) =>
|
||
['create_gold_qr', 'create_elite_qr', 'create_esports_qr'].includes(task.task_type) &&
|
||
(hasPaymentQrcode(task) || paymentArrived(task) || ['running', 'pending', 'success'].includes(task.status)),
|
||
);
|
||
}, [batchTasks]);
|
||
|
||
// 待支付任务变动时,若有新二维码生成,且用户未手动处理过,自动弹出支付窗口
|
||
useEffect(() => {
|
||
const freshPayTask = pendingPayTasks.find(
|
||
(t) =>
|
||
t.status === 'running' &&
|
||
hasPaymentQrcode(t) &&
|
||
!autoOpenedPayTasks.current.has(t.id),
|
||
);
|
||
if (freshPayTask) {
|
||
autoOpenedPayTasks.current.add(freshPayTask.id);
|
||
setSelectedPayTaskId(freshPayTask.id);
|
||
setPayModalVisible(true);
|
||
}
|
||
}, [pendingPayTasks]);
|
||
|
||
// 当前在支付弹窗中选中的任务
|
||
const activePayTask = useMemo(() => {
|
||
if (!selectedPayTaskId && pendingPayTasks.length > 0) {
|
||
return pendingPayTasks[0];
|
||
}
|
||
return batchTasks.find((t) => t.id === selectedPayTaskId) || pendingPayTasks[0] || null;
|
||
}, [batchTasks, pendingPayTasks, selectedPayTaskId]);
|
||
|
||
// 当前待支付任务对应的金额
|
||
const activePayAmountInfo = useMemo(() => {
|
||
if (!activePayTask) return { amount: null, orders: [] };
|
||
const matchedAccount = result?.accounts?.find((a) => a.account_id === activePayTask.account_id);
|
||
const amountFromMsg = activePayTask.message?.match(/(\d+)\s*元/)?.[1];
|
||
const amountFromPayload = (activePayTask.result?.payload as Record<string, unknown> | undefined)?.amount;
|
||
const amountFromResult = activePayTask.result?.amount || activePayTask.result?.gold_amount;
|
||
const amount = matchedAccount?.topup_yuan
|
||
?? (amountFromResult != null ? Number(amountFromResult) : null)
|
||
?? (amountFromPayload != null ? Number(amountFromPayload) : null)
|
||
?? (amountFromMsg ? Number(amountFromMsg) : null);
|
||
return {
|
||
amount,
|
||
orders: matchedAccount?.recharge_orders || [],
|
||
};
|
||
}, [activePayTask, result?.accounts]);
|
||
|
||
const handleSelectGood = useCallback((id: string) => {
|
||
setCommodityId(id);
|
||
saveRecentGoodsId(scope, id);
|
||
setRecentGoodsIds(loadRecentGoodsIds(scope));
|
||
void loadSupplyFor(scope, id);
|
||
}, [scope, loadSupplyFor]);
|
||
|
||
// 快捷常用商品列表(最近使用历史 + 热门高价值皮肤)
|
||
const quickGoods = useMemo(() => {
|
||
if (!goods.length) return [];
|
||
const map = new Map(goods.map((g) => [g.commodity_id, g]));
|
||
const list: typeof goods = [];
|
||
const seen = new Set<string>();
|
||
|
||
// 1. 最近使用过的历史商品
|
||
for (const id of recentGoodsIds) {
|
||
const item = map.get(id);
|
||
if (item && !seen.has(id)) {
|
||
list.push(item);
|
||
seen.add(id);
|
||
}
|
||
}
|
||
|
||
// 2. 热门高价值皮肤(如兔兔白日梦、狂战萌兔等)
|
||
for (const g of goods) {
|
||
if (
|
||
(g.name.includes('兔兔白日梦') || g.name.includes('狂战萌兔') || g.name.includes('套装') || (g.score ?? 0) >= 1000) &&
|
||
!seen.has(g.commodity_id)
|
||
) {
|
||
list.push(g);
|
||
seen.add(g.commodity_id);
|
||
}
|
||
}
|
||
|
||
// 3. 兜底补足
|
||
for (const g of goods) {
|
||
if (list.length >= 8) break;
|
||
if (!seen.has(g.commodity_id)) {
|
||
list.push(g);
|
||
seen.add(g.commodity_id);
|
||
}
|
||
}
|
||
|
||
return list.slice(0, 8);
|
||
}, [goods, recentGoodsIds]);
|
||
|
||
const submit = async () => {
|
||
if (!commodityId) {
|
||
message.warning('请先选择皮肤');
|
||
return;
|
||
}
|
||
saveRecentGoodsId(scope, commodityId);
|
||
setRecentGoodsIds(loadRecentGoodsIds(scope));
|
||
setLoading(true);
|
||
try {
|
||
const data = await douyuPoolApi.exchange({
|
||
scope,
|
||
commodity_id: commodityId,
|
||
quantity,
|
||
allow_topup: allowTopup,
|
||
allow_fallback: true,
|
||
});
|
||
setResult(data);
|
||
if (data.batch_id) {
|
||
setActiveBatchId(data.batch_id);
|
||
}
|
||
if (data.shortfall > 0) {
|
||
message.warning(`实配 ${data.allocated} / 需求 ${data.requested},缺 ${data.shortfall}`);
|
||
} else {
|
||
message.success(`已分配 ${data.allocated} 个账号,正在执行补给与兑换(批次 ${data.batch_id})`);
|
||
}
|
||
await Promise.all([loadSupplyFor(scope, commodityId), loadLeases()]);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleStopBatch = async () => {
|
||
if (!activeBatchId) return;
|
||
setStoppingBatch(true);
|
||
try {
|
||
await douyuPoolApi.stopBatch(activeBatchId);
|
||
message.success('已发送停止指令');
|
||
await pollBatchTasks(activeBatchId);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setStoppingBatch(false);
|
||
}
|
||
};
|
||
|
||
const handleReleaseBatch = async () => {
|
||
if (!activeBatchId) return;
|
||
try {
|
||
const res = await douyuPoolApi.releaseBatch(activeBatchId);
|
||
message.success(`已释放该批次 ${res.released} 个账号租约`);
|
||
await Promise.all([loadSupplyFor(scope, commodityId), loadLeases()]);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const releaseLease = async (leaseId: number) => {
|
||
try {
|
||
await douyuPoolApi.releaseLease(leaseId);
|
||
message.success('已释放租约');
|
||
await Promise.all([loadSupplyFor(scope, commodityId), loadLeases()]);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const goodsOptions = useMemo(
|
||
() => goods.map((item) => ({
|
||
value: item.commodity_id,
|
||
label: `${item.name}(${item.score ?? 0} ${SCOPE_CONFIGS[scope].pointsLabel})`,
|
||
})),
|
||
[goods, scope],
|
||
);
|
||
|
||
// 账号维度聚合任务执行进度
|
||
const accountProgressList = useMemo(() => {
|
||
if (!result?.accounts) return [];
|
||
return result.accounts.map((acc) => {
|
||
const accTasks = batchTasks.filter((t) => t.account_id === acc.account_id);
|
||
const payTask = accTasks.find((t) => ['create_gold_qr', 'create_elite_qr', 'create_esports_qr'].includes(t.task_type));
|
||
const exchangeTask = accTasks.find((t) => ['exchange_goods', 'exchange_esports_goods', 'exchange_xpd_goods'].includes(t.task_type));
|
||
const donateTask = accTasks.find((t) => t.task_type === 'donate_elite_gift');
|
||
const runningTask = accTasks.find((t) => ['running', 'pending'].includes(t.status));
|
||
const failedTask = accTasks.find((t) => ['failed', 'error', 'manual_required'].includes(t.status));
|
||
|
||
let currentStepText = '等待启动';
|
||
let stepTone: 'default' | 'processing' | 'success' | 'warning' | 'error' = 'default';
|
||
|
||
if (exchangeTask?.status === 'success') {
|
||
currentStepText = '✅ 兑换成功';
|
||
stepTone = 'success';
|
||
} else if (failedTask) {
|
||
currentStepText = `❌ ${failedTask.message || '执行失败'}`;
|
||
stepTone = 'error';
|
||
} else if (exchangeTask?.status === 'running') {
|
||
currentStepText = '🔄 正在兑换皮肤...';
|
||
stepTone = 'processing';
|
||
} else if (donateTask?.status === 'running') {
|
||
currentStepText = '🎁 正在赠送道具...';
|
||
stepTone = 'processing';
|
||
} else if (payTask?.status === 'running') {
|
||
currentStepText = hasPaymentQrcode(payTask) ? '💳 等待扫码支付' : '💳 生成支付码中...';
|
||
stepTone = 'warning';
|
||
} else if (runningTask) {
|
||
currentStepText = `⚡ ${TASK_TYPE_NAMES[runningTask.task_type] || runningTask.task_type}...`;
|
||
stepTone = 'processing';
|
||
}
|
||
|
||
return {
|
||
...acc,
|
||
tasks: accTasks,
|
||
payTask,
|
||
exchangeTask,
|
||
currentStepText,
|
||
stepTone,
|
||
};
|
||
});
|
||
}, [result?.accounts, batchTasks]);
|
||
|
||
const accountColumns: ColumnsType<(typeof accountProgressList)[number]> = [
|
||
{ title: '账号ID', dataIndex: 'account_id', width: 85 },
|
||
{
|
||
title: '账号', dataIndex: 'username', ellipsis: true,
|
||
render: (val: string, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text strong>{val || `#${row.account_id}`}</Text>
|
||
{row.role_change_available_at && (
|
||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||
换绑: {row.role_change_available_at.replace('T', ' ').slice(0, 16)}
|
||
</Text>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '梯队', dataIndex: 'tier', width: 90,
|
||
render: (tier: string) => (
|
||
<Tag color={tier === 'A' ? 'green' : 'orange'}>
|
||
{tier === 'A' ? '免充值' : '需充值'}
|
||
</Tag>
|
||
),
|
||
},
|
||
{ title: '当前积分', dataIndex: 'points', width: 95 },
|
||
{
|
||
title: '需充值金额', width: 150,
|
||
render: (_, row) => {
|
||
if (row.topup_yuan <= 0) return <Text type="secondary">无需充值</Text>;
|
||
const isSplit = row.recharge_orders && row.recharge_orders.length > 1;
|
||
return (
|
||
<Space direction="vertical" size={0}>
|
||
<Text type="warning" strong>¥ {row.topup_yuan} 元</Text>
|
||
{isSplit && (
|
||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||
(拆为 {row.recharge_orders.join(' + ')})
|
||
</Text>
|
||
)}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '当前执行状态', width: 200,
|
||
render: (_, row) => (
|
||
<Badge
|
||
status={row.stepTone}
|
||
text={<Text style={{ fontSize: 13 }}>{row.currentStepText}</Text>}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '操作 / 凭证', width: 160,
|
||
render: (_, row) => {
|
||
const canPay = row.payTask && hasPaymentQrcode(row.payTask) && row.payTask.status === 'running';
|
||
const isExchanged = row.exchangeTask && row.exchangeTask.status === 'success';
|
||
|
||
return (
|
||
<Space size={8}>
|
||
{canPay && (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<CreditCardOutlined />}
|
||
onClick={() => {
|
||
setSelectedPayTaskId(row.payTask!.id);
|
||
setPayModalVisible(true);
|
||
}}
|
||
>
|
||
扫码支付
|
||
</Button>
|
||
)}
|
||
{isExchanged && (
|
||
<ExchangeResultImage
|
||
task={row.exchangeTask!}
|
||
onClick={(task, url) => setExchangePreview({ task, url })}
|
||
/>
|
||
)}
|
||
</Space>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
const leaseColumns: ColumnsType<DouyuPoolLease> = [
|
||
{
|
||
title: '活动类型', dataIndex: 'scope', width: 130,
|
||
render: (val: string) => {
|
||
if (val === 'esports') return <Tag color="gold">🏆 电竞手册</Tag>;
|
||
if (val === 'peace') return <Tag color="purple">🛒 和平小店</Tag>;
|
||
return <Tag color="blue">💎 精英宝典</Tag>;
|
||
},
|
||
},
|
||
{ title: '账号', dataIndex: 'username', ellipsis: true },
|
||
{ title: '商品ID', dataIndex: 'commodity_id', width: 200 },
|
||
{
|
||
title: '批次号', dataIndex: 'batch_id', width: 140,
|
||
render: (val: string) => (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
style={{ padding: 0 }}
|
||
onClick={() => {
|
||
setActiveBatchId(val);
|
||
message.info(`已切换查看批次 ${val}`);
|
||
}}
|
||
>
|
||
{val || '—'}
|
||
</Button>
|
||
),
|
||
},
|
||
{
|
||
title: '到期时间', dataIndex: 'expires_at', width: 170,
|
||
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
|
||
},
|
||
{
|
||
title: '操作', width: 160,
|
||
render: (_, row) => (
|
||
<Space size={8}>
|
||
<Button
|
||
size="small"
|
||
icon={<EyeOutlined />}
|
||
onClick={() => {
|
||
setActiveBatchId(row.batch_id);
|
||
message.info(`已切换查看批次 ${row.batch_id}`);
|
||
}}
|
||
>
|
||
查看进度
|
||
</Button>
|
||
<Button size="small" danger onClick={() => void releaseLease(row.id)}>释放</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
// 支付二维码 URL
|
||
const payUrl = taskQrUrl(activePayTask);
|
||
const payAccountName = activePayTask
|
||
? (activePayTask.account_nickname || activePayTask.account_username || activePayTask.account_uid || `#${activePayTask.account_id}`)
|
||
: '';
|
||
|
||
const activeScopeInfo = SCOPE_CONFIGS[scope];
|
||
|
||
return (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
{/* 顶部主卡片:活动选择 + 皮肤配置 + 测算概览 */}
|
||
<Card
|
||
style={{
|
||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
|
||
borderRadius: 10,
|
||
}}
|
||
title={(
|
||
<Space size={10} align="center">
|
||
<ThunderboltOutlined style={{ color: token.colorPrimary, fontSize: 18 }} />
|
||
<span style={{ fontWeight: 600, fontSize: 16 }}>智能账号分配台</span>
|
||
<Tag color="cyan" style={{ borderRadius: 10, fontSize: 11, padding: '0 8px' }}>自动补给 · 自动回收</Tag>
|
||
</Space>
|
||
)}
|
||
extra={(
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
loading={refreshingGoods}
|
||
onClick={() => void handleRefreshAll()}
|
||
>
|
||
刷新商品与数据
|
||
</Button>
|
||
)}
|
||
>
|
||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||
{/* 第一步:活动选择(顶级切换) */}
|
||
<div
|
||
style={{
|
||
background: token.colorFillAlter,
|
||
padding: '12px 16px',
|
||
borderRadius: 8,
|
||
border: `1px solid ${token.colorBorderSecondary}`,
|
||
}}
|
||
>
|
||
<Row align="middle" justify="space-between" gutter={[16, 12]}>
|
||
<Col xs={24} sm={5}>
|
||
<Text strong style={{ fontSize: 14 }}>
|
||
<span style={{ color: token.colorPrimary, marginRight: 6 }}>①</span>
|
||
选择活动类型:
|
||
</Text>
|
||
</Col>
|
||
<Col xs={24} sm={19}>
|
||
<Radio.Group
|
||
value={scope}
|
||
onChange={(e) => handleScopeChange(e.target.value as PoolScope)}
|
||
buttonStyle="solid"
|
||
size="middle"
|
||
>
|
||
<Radio.Button value="elite" style={{ padding: '0 16px', height: 38, lineHeight: '36px' }}>
|
||
<Space size={6}>
|
||
<span>💎</span>
|
||
<span style={{ fontWeight: 500 }}>精英宝典</span>
|
||
<Tag color="blue" style={{ fontSize: 11, marginRight: 0, borderRadius: 4 }}>精英积分</Tag>
|
||
</Space>
|
||
</Radio.Button>
|
||
<Radio.Button value="esports" style={{ padding: '0 16px', height: 38, lineHeight: '36px' }}>
|
||
<Space size={6}>
|
||
<span>🏆</span>
|
||
<span style={{ fontWeight: 500 }}>PEL 电竞手册</span>
|
||
<Tag color="gold" style={{ fontSize: 11, marginRight: 0, borderRadius: 4 }}>电竞积分</Tag>
|
||
</Space>
|
||
</Radio.Button>
|
||
<Radio.Button value="peace" style={{ padding: '0 16px', height: 38, lineHeight: '36px' }}>
|
||
<Space size={6}>
|
||
<span>🛒</span>
|
||
<span style={{ fontWeight: 500 }}>和平小店</span>
|
||
<Tag color="purple" style={{ fontSize: 11, marginRight: 0, borderRadius: 4 }}>点券/余额</Tag>
|
||
</Space>
|
||
</Radio.Button>
|
||
</Radio.Group>
|
||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 16, display: 'inline-block' }}>
|
||
{activeScopeInfo.desc}
|
||
</Text>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
|
||
{/* 第二步:皮肤选择与常用标签 */}
|
||
<div>
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Text strong style={{ fontSize: 14 }}>
|
||
<span style={{ color: token.colorPrimary, marginRight: 6 }}>②</span>
|
||
选择要兑换的皮肤 & 分配参数:
|
||
</Text>
|
||
</div>
|
||
|
||
{/* 常用 / 历史快捷标签 */}
|
||
{quickGoods.length > 0 && (
|
||
<div style={{ marginBottom: 12, paddingLeft: 2 }}>
|
||
<Space wrap size={[8, 8]} align="center">
|
||
<Text type="secondary" style={{ fontSize: 12, marginRight: 2 }}>
|
||
<HistoryOutlined style={{ marginRight: 4 }} />
|
||
{activeScopeInfo.title} 常用 / 热门皮肤:
|
||
</Text>
|
||
{quickGoods.map((item) => {
|
||
const isSelected = commodityId === item.commodity_id;
|
||
const isHot = item.name.includes('兔兔白日梦') || item.name.includes('狂战萌兔') || (item.score ?? 0) >= 1000;
|
||
return (
|
||
<Tag.CheckableTag
|
||
key={item.commodity_id}
|
||
checked={isSelected}
|
||
onChange={() => handleSelectGood(item.commodity_id)}
|
||
style={{
|
||
padding: '3px 12px',
|
||
fontSize: 12,
|
||
borderRadius: 14,
|
||
border: isSelected ? undefined : `1px solid ${token.colorBorder}`,
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s',
|
||
}}
|
||
>
|
||
{isHot && <FireOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />}
|
||
{item.name}
|
||
<Text
|
||
type={isSelected ? undefined : 'secondary'}
|
||
style={{ fontSize: 11, marginLeft: 4, color: isSelected ? '#fff' : undefined }}
|
||
>
|
||
({item.score ?? 0}分)
|
||
</Text>
|
||
</Tag.CheckableTag>
|
||
);
|
||
})}
|
||
</Space>
|
||
</div>
|
||
)}
|
||
|
||
{/* 皮肤选择下拉 + 数量 + 补足策略 + 提交按钮 */}
|
||
<Row gutter={[16, 16]} align="bottom">
|
||
<Col xs={24} md={10}>
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||
选择皮肤商品 (支持拼音/汉字搜索)
|
||
</Text>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
placeholder={`选择 ${activeScopeInfo.title} 皮肤`}
|
||
value={commodityId || undefined}
|
||
onChange={handleSelectGood}
|
||
options={goodsOptions}
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={4}>
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||
需要账号数
|
||
</Text>
|
||
<InputNumber
|
||
min={1}
|
||
max={50}
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
value={quantity}
|
||
onChange={(value) => setQuantity(Number(value) || 1)}
|
||
addonAfter="个"
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={5}>
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||
积分不足时策略
|
||
</Text>
|
||
<Select
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
value={allowTopup ? 'topup' : 'skip'}
|
||
onChange={(value) => setAllowTopup(value === 'topup')}
|
||
options={[
|
||
{ value: 'topup', label: '允许自动充值补足差额' },
|
||
{ value: 'skip', label: '仅用已有积分 (不充值)' },
|
||
]}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={5}>
|
||
<Button
|
||
type="primary"
|
||
block
|
||
size="large"
|
||
icon={<ThunderboltOutlined />}
|
||
loading={loading}
|
||
onClick={() => void submit()}
|
||
style={{
|
||
height: 40,
|
||
fontWeight: 600,
|
||
fontSize: 15,
|
||
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.35)',
|
||
}}
|
||
>
|
||
提交并自动分配
|
||
</Button>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
|
||
{/* 第三步:实时测算与库存看板 */}
|
||
{supply && (
|
||
<div
|
||
style={{
|
||
background: token.colorFillAlter,
|
||
padding: '16px 20px',
|
||
borderRadius: 8,
|
||
border: `1px solid ${token.colorBorderSecondary}`,
|
||
}}
|
||
>
|
||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Text strong style={{ fontSize: 13 }}>
|
||
📊 当前皮肤库存测算(商品:{supply.commodity?.name || commodityId} · 所需 {supply.commodity?.score} {activeScopeInfo.pointsLabel})
|
||
</Text>
|
||
{previewing && <Tag icon={<SyncOutlined spin />} color="processing">测算刷新中</Tag>}
|
||
</div>
|
||
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={12} sm={6}>
|
||
<Card
|
||
size="small"
|
||
style={{
|
||
background: '#f6ffed',
|
||
borderColor: '#b7eb8f',
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<Statistic
|
||
title={<span style={{ color: '#389e0d', fontSize: 13, fontWeight: 500 }}>🟢 免充值直接可用</span>}
|
||
value={supply.zero_topup}
|
||
suffix={<span style={{ fontSize: 13, color: '#389e0d' }}>个账号</span>}
|
||
valueStyle={{ color: '#237804', fontWeight: 600, fontSize: 24 }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6}>
|
||
<Card
|
||
size="small"
|
||
style={{
|
||
background: '#fffbe6',
|
||
borderColor: '#ffe58f',
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<Statistic
|
||
title={<span style={{ color: '#d46b08', fontSize: 13, fontWeight: 500 }}>🟡 需充值补足可用</span>}
|
||
value={supply.after_topup}
|
||
suffix={<span style={{ fontSize: 13, color: '#d46b08' }}>个账号</span>}
|
||
valueStyle={{ color: '#d46b08', fontWeight: 600, fontSize: 24 }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6}>
|
||
<Card
|
||
size="small"
|
||
style={{
|
||
background: '#e6f7ff',
|
||
borderColor: '#91d5ff',
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<Statistic
|
||
title={(
|
||
<span style={{ color: '#096dd9', fontSize: 13, fontWeight: 500 }}>
|
||
池全量补足预估
|
||
<Tooltip title="将池内所有待充账号全部补足所需总花费(非本次单次分配费用)">
|
||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>ℹ️</Text>
|
||
</Tooltip>
|
||
</span>
|
||
)}
|
||
value={supply.cost.est_yuan}
|
||
suffix={<span style={{ fontSize: 13, color: '#096dd9' }}>元</span>}
|
||
valueStyle={{ color: '#096dd9', fontWeight: 600, fontSize: 24 }}
|
||
loading={previewing}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6}>
|
||
<Card
|
||
size="small"
|
||
style={{
|
||
background: '#fafafa',
|
||
borderColor: token.colorBorderSecondary,
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<Statistic
|
||
title={<span style={{ fontSize: 13, fontWeight: 500 }}>我的在租账号</span>}
|
||
value={leases.length}
|
||
suffix={<span style={{ fontSize: 13 }}>个</span>}
|
||
valueStyle={{ fontWeight: 600, fontSize: 24 }}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
)}
|
||
|
||
{supply?.manual_payment_required && (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="充值渠道为「微信扫码支付」"
|
||
description="提交分配后,若包含需充值账号,系统将在此处自动弹出微信支付码。扫码支付成功后,全流程自动推进并产出兑换凭证。"
|
||
style={{ borderRadius: 6 }}
|
||
/>
|
||
)}
|
||
</Space>
|
||
</Card>
|
||
|
||
{/* 活跃批次执行与进度面板 */}
|
||
{activeBatchId && (
|
||
<Card
|
||
style={{
|
||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
|
||
borderRadius: 10,
|
||
}}
|
||
title={(
|
||
<Space size={12}>
|
||
<span style={{ fontWeight: 600 }}>本次分配与任务流水(批次 {activeBatchId})</span>
|
||
{batchLoading ? (
|
||
<Tag icon={<SyncOutlined spin />} color="processing">实时同步中</Tag>
|
||
) : (
|
||
<Tag color="default">已同步</Tag>
|
||
)}
|
||
</Space>
|
||
)}
|
||
extra={(
|
||
<Space size={8}>
|
||
{pendingPayTasks.some((t) => hasPaymentQrcode(t) && t.status === 'running') && (
|
||
<Button
|
||
type="primary"
|
||
danger
|
||
icon={<CreditCardOutlined />}
|
||
onClick={() => setPayModalVisible(true)}
|
||
>
|
||
打开支付码
|
||
</Button>
|
||
)}
|
||
<Button
|
||
icon={<StopOutlined />}
|
||
loading={stoppingBatch}
|
||
onClick={() => void handleStopBatch()}
|
||
>
|
||
终止批次
|
||
</Button>
|
||
<Button danger onClick={() => void handleReleaseBatch()}>
|
||
释放本批租约
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
>
|
||
{result && (
|
||
<Descriptions size="small" column={{ xs: 1, sm: 2, md: 4 }} style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="需求数量">{result.requested} 个</Descriptions.Item>
|
||
<Descriptions.Item label="实配数量">
|
||
<Text strong type={result.allocated > 0 ? 'success' : 'danger'}>{result.allocated} 个</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="缺口">
|
||
<Text type={result.shortfall > 0 ? 'danger' : undefined}>{result.shortfall} 个</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="充值渠道">{result.recharge_channel || 'wechat_qr'}</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
|
||
{result?.reasons && result.reasons.length > 0 && (
|
||
<Paragraph type="warning" style={{ marginBottom: 12 }}>
|
||
{result.reasons.map((reason) => <div key={reason}>· {reason}</div>)}
|
||
</Paragraph>
|
||
)}
|
||
|
||
<Table
|
||
rowKey="account_id"
|
||
size="small"
|
||
pagination={false}
|
||
columns={accountColumns}
|
||
dataSource={accountProgressList}
|
||
locale={{ emptyText: '本次没有分配账号' }}
|
||
/>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 我的租约列表 */}
|
||
<Card
|
||
style={{
|
||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
|
||
borderRadius: 10,
|
||
}}
|
||
title="我的租约(用完会自动收回,也可随时手动释放)"
|
||
extra={(
|
||
<Button icon={<ReloadOutlined />} size="small" onClick={() => void loadLeases()}>
|
||
刷新租约
|
||
</Button>
|
||
)}
|
||
>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
columns={leaseColumns}
|
||
dataSource={leases}
|
||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||
locale={{ emptyText: '当前没有租用中的账号' }}
|
||
style={{ background: token.colorBgContainer }}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 微信支付二维码弹窗 */}
|
||
<Modal
|
||
title={(
|
||
<Space>
|
||
<CreditCardOutlined style={{ color: token.colorPrimary }} />
|
||
<span style={{ fontWeight: 600 }}>微信扫码充值</span>
|
||
</Space>
|
||
)}
|
||
open={payModalVisible}
|
||
onCancel={() => setPayModalVisible(false)}
|
||
footer={null}
|
||
centered
|
||
width={440}
|
||
>
|
||
{pendingPayTasks.length > 1 && (
|
||
<Tabs
|
||
size="small"
|
||
activeKey={String(activePayTask?.id || '')}
|
||
onChange={(key) => setSelectedPayTaskId(Number(key))}
|
||
items={pendingPayTasks.map((t, idx) => {
|
||
const acc = result?.accounts?.find((a) => a.account_id === t.account_id);
|
||
const yuan = acc?.topup_yuan;
|
||
return {
|
||
key: String(t.id),
|
||
label: `账号 #${idx + 1} (${t.account_username || t.account_id})${yuan ? ` · ¥${yuan}元` : ''}`,
|
||
};
|
||
})}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
)}
|
||
|
||
{activePayTask ? (
|
||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||
{payAccountName && (
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||
充值账号:<Text strong>{payAccountName}</Text>
|
||
</Text>
|
||
)}
|
||
|
||
{/* 突出展示充值金额 */}
|
||
<div
|
||
style={{
|
||
background: '#fffbe6',
|
||
border: '1px solid #ffe58f',
|
||
borderRadius: 8,
|
||
padding: '8px 16px',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
<Text type="secondary" style={{ fontSize: 13 }}>需充值金额:</Text>
|
||
<Text strong style={{ fontSize: 24, color: '#d46b08' }}>
|
||
¥ {activePayAmountInfo.amount != null ? `${activePayAmountInfo.amount} 元` : '—'}
|
||
</Text>
|
||
{activePayAmountInfo.orders.length > 1 && (
|
||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
|
||
(拆为 {activePayAmountInfo.orders.join(' + ')})
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{paymentArrived(activePayTask) ? (
|
||
<Space direction="vertical" align="center" size={16} style={{ width: '100%', padding: '20px 0' }}>
|
||
<CheckCircleOutlined style={{ fontSize: 48, color: token.colorSuccess }} />
|
||
<Alert
|
||
type="success"
|
||
showIcon
|
||
message="微信支付已到账"
|
||
description="系统正在自动进行赠送与兑换,请稍候..."
|
||
/>
|
||
<Button type="primary" onClick={() => setPayModalVisible(false)}>完成并关闭</Button>
|
||
</Space>
|
||
) : ['failed', 'error', 'stopped', 'manual_required'].includes(activePayTask.status) ? (
|
||
<Space direction="vertical" align="center" size={16} style={{ width: '100%', padding: '20px 0' }}>
|
||
<CloseCircleOutlined style={{ fontSize: 48, color: token.colorError }} />
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
message={activePayTask.status === 'stopped' ? '任务已停止' : '支付码任务未完成'}
|
||
description={activePayTask.message || '请重试或在后台查看'}
|
||
/>
|
||
<Button onClick={() => setPayModalVisible(false)}>关闭</Button>
|
||
</Space>
|
||
) : (
|
||
<Space direction="vertical" align="center" size={14} style={{ width: '100%' }}>
|
||
{payUrl ? (
|
||
<div ref={payQrCanvasWrapRef} style={{ background: '#fff', padding: 12, borderRadius: 8, display: 'inline-block' }}>
|
||
<QRCode value={payUrl} size={220} bordered={false} />
|
||
</div>
|
||
) : (
|
||
<div style={{ padding: '40px 0' }}>
|
||
<SyncOutlined spin style={{ fontSize: 28, color: token.colorPrimary, marginBottom: 12 }} />
|
||
<div><Text type="secondary">正在向斗鱼申请支付二维码...</Text></div>
|
||
</div>
|
||
)}
|
||
|
||
{payUrl && (
|
||
<>
|
||
<QrActions wrapRef={payQrCanvasWrapRef} url={payUrl} disabled={!payUrl} />
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
icon={<ClockCircleOutlined />}
|
||
message={activePayTask.message || '请使用微信扫码支付'}
|
||
description="支付码有效时间 10 分钟。扫码支付成功后,系统将自动检测到账并推进兑换。"
|
||
style={{ textAlign: 'left', width: '100%', marginTop: 8 }}
|
||
/>
|
||
</>
|
||
)}
|
||
</Space>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div style={{ textAlign: 'center', padding: '30px 0' }}>
|
||
<Text type="secondary">当前没有待支付的充值任务</Text>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* 兑换结果大图弹窗 */}
|
||
<Modal
|
||
title="兑换成功凭证"
|
||
open={!!exchangePreview}
|
||
onCancel={() => setExchangePreview(null)}
|
||
footer={(
|
||
<Space size={8}>
|
||
<Button
|
||
type="primary"
|
||
icon={<CopyOutlined />}
|
||
loading={copyingPreview}
|
||
onClick={async () => {
|
||
if (!exchangePreview) return;
|
||
setCopyingPreview(true);
|
||
try {
|
||
const res = await fetch(exchangePreview.url);
|
||
const blob = await res.blob();
|
||
await copyImageToClipboard(blob);
|
||
message.success('兑换凭证图片已复制到剪贴板');
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setCopyingPreview(false);
|
||
}
|
||
}}
|
||
>
|
||
复制凭证图片
|
||
</Button>
|
||
<Button onClick={() => setExchangePreview(null)}>关闭</Button>
|
||
</Space>
|
||
)}
|
||
centered
|
||
width={380}
|
||
>
|
||
{exchangePreview && (
|
||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||
<img
|
||
src={exchangePreview.url}
|
||
alt="兑换凭证"
|
||
style={{ width: '100%', maxHeight: 480, objectFit: 'contain', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||
/>
|
||
<div style={{ marginTop: 12 }}>
|
||
<Text type="secondary">{resultText(exchangePreview.task.result, 'commodity_id')}</Text>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</Space>
|
||
);
|
||
}
|