- 前端在扫码支付弹窗中增加显眼充值金额卡片与多账号 Tabs 支付标注 - 前端增加常用与历史快捷皮肤选择胶囊栏,支持本地缓存记忆与一键切换 - 后端补给链首步引入 query_elite_status 并支持 require_opened 前置熔断校验 - 优化供给与预估花费文案展示,增加兑换结果大图预览与凭证复制功能 - 验证方式:全量 784 个 pytest 用例全部通过,前端 tsc/build 校验通过
936 lines
33 KiB
TypeScript
936 lines
33 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Alert, Badge, Button, Card, Col, Descriptions, InputNumber, Modal,
|
||
QRCode, Row, Select, Space, Statistic, Table, Tabs, Tag, Tooltip,
|
||
Typography, theme,
|
||
} from 'antd';
|
||
import {
|
||
CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
|
||
CopyOutlined, CreditCardOutlined, EyeOutlined, FireOutlined,
|
||
HistoryOutlined, ReloadOutlined, StopOutlined, SyncOutlined,
|
||
ThunderboltOutlined,
|
||
} 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;
|
||
|
||
const DOUYU_POOL_RECENT_GOODS_KEY = 'douyu_pool_recent_goods_ids';
|
||
|
||
function loadRecentGoodsIds(): string[] {
|
||
try {
|
||
const raw = localStorage.getItem(DOUYU_POOL_RECENT_GOODS_KEY);
|
||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveRecentGoodsId(id: string) {
|
||
if (!id) return;
|
||
try {
|
||
const existing = loadRecentGoodsIds().filter((x) => x !== id);
|
||
const updated = [id, ...existing].slice(0, 8);
|
||
localStorage.setItem(DOUYU_POOL_RECENT_GOODS_KEY, JSON.stringify(updated));
|
||
} catch {
|
||
// 忽略存储异常
|
||
}
|
||
}
|
||
|
||
/** 任务状态友好标签 */
|
||
const TASK_TYPE_NAMES: Record<string, string> = {
|
||
query_elite_status: '检查精英宝典状态',
|
||
query_gold_balance: '查询鱼翅余额',
|
||
query_points: '查询精英积分',
|
||
create_gold_qr: '鱼翅充值',
|
||
donate_elite_gift: '赠送精英令',
|
||
exchange_goods: '兑换精英宝典皮肤',
|
||
exchange_esports_goods: '兑换电竞手册皮肤',
|
||
};
|
||
|
||
/**
|
||
* 账号分配台(自动化工作台)。
|
||
*
|
||
* 客服操作闭环:选皮肤 → 填数量 → 提交分配 →
|
||
* 若需充值自动弹出微信支付码 → 扫码到账后自动赠送并兑换 → 产出兑换凭证。
|
||
*/
|
||
export default function DouyuPoolPage() {
|
||
const { token } = theme.useToken();
|
||
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());
|
||
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 [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);
|
||
}, []);
|
||
|
||
const loadGoods = useCallback(async () => {
|
||
try {
|
||
const rows = await douyuPoolApi.goods();
|
||
const usable = rows.filter((item) => (item.score ?? 0) > 0);
|
||
setGoods(usable);
|
||
if (usable.length > 0 && !commodityId) setCommodityId(usable[0].commodity_id);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
}, [commodityId]);
|
||
|
||
const loadLeases = useCallback(async () => {
|
||
try {
|
||
const list = await douyuPoolApi.leases(true);
|
||
setLeases(list);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { void loadGoods(); }, [loadGoods]);
|
||
useEffect(() => { void loadLeases(); }, [loadLeases]);
|
||
|
||
const loadSupply = useCallback(async () => {
|
||
if (!commodityId) return;
|
||
setPreviewing(true);
|
||
try {
|
||
setSupply(await douyuPoolApi.supply('elite', commodityId));
|
||
} catch (e: unknown) {
|
||
setSupply(null);
|
||
message.error(getErrorMessage(e));
|
||
} finally {
|
||
setPreviewing(false);
|
||
}
|
||
}, [commodityId]);
|
||
|
||
useEffect(() => { void loadSupply(); }, [loadSupply]);
|
||
|
||
// 轮询活跃批次任务
|
||
const pollBatchTasks = useCallback(async (batchId: string) => {
|
||
try {
|
||
const res = await douyuApi.listTasks('elite', batchId);
|
||
setBatchTasks(res);
|
||
return res;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}, []);
|
||
|
||
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]);
|
||
|
||
// 提取待支付任务列表(create_gold_qr 且有 pay_url)
|
||
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(id);
|
||
setRecentGoodsIds(loadRecentGoodsIds());
|
||
}, []);
|
||
|
||
// 快捷常用商品列表(最近使用历史 + 热门高价值套装)
|
||
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('狂战萌兔')) &&
|
||
!seen.has(g.commodity_id)
|
||
) {
|
||
list.push(g);
|
||
seen.add(g.commodity_id);
|
||
}
|
||
}
|
||
|
||
// 3. 兜底补足
|
||
for (const g of goods) {
|
||
if (list.length >= 6) break;
|
||
if (!seen.has(g.commodity_id)) {
|
||
list.push(g);
|
||
seen.add(g.commodity_id);
|
||
}
|
||
}
|
||
|
||
return list.slice(0, 6);
|
||
}, [goods, recentGoodsIds]);
|
||
|
||
const submit = async () => {
|
||
if (!commodityId) { message.warning('请先选择皮肤'); return; }
|
||
saveRecentGoodsId(commodityId);
|
||
setRecentGoodsIds(loadRecentGoodsIds());
|
||
setLoading(true);
|
||
try {
|
||
const data = await douyuPoolApi.exchange({
|
||
scope: 'elite', 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([loadSupply(), 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([loadSupply(), loadLeases()]);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const releaseLease = async (leaseId: number) => {
|
||
try {
|
||
await douyuPoolApi.releaseLease(leaseId);
|
||
message.success('已释放租约');
|
||
await Promise.all([loadSupply(), loadLeases()]);
|
||
} catch (e: unknown) {
|
||
message.error(getErrorMessage(e));
|
||
}
|
||
};
|
||
|
||
const goodsOptions = useMemo(
|
||
() => goods.map((item) => ({
|
||
value: item.commodity_id,
|
||
label: `${item.name}(${item.score ?? 0} 分)`,
|
||
})),
|
||
[goods],
|
||
);
|
||
|
||
// 账号维度聚合任务执行进度
|
||
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'].includes(t.task_type));
|
||
const exchangeTask = accTasks.find((t) => ['exchange_goods', 'exchange_esports_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: 'username', ellipsis: true },
|
||
{ title: '商品ID', dataIndex: 'commodity_id', width: 220 },
|
||
{
|
||
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}`)
|
||
: '';
|
||
|
||
return (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
{/* 顶部发起卡片 */}
|
||
<Card
|
||
title="发起兑换(系统自动分配账号)"
|
||
extra={(
|
||
<Button icon={<ReloadOutlined />} onClick={() => { void loadGoods(); void loadSupply(); void loadLeases(); }}>
|
||
刷新
|
||
</Button>
|
||
)}
|
||
>
|
||
{/* 常用 / 历史快捷标签 */}
|
||
{quickGoods.length > 0 && (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Space wrap size={[8, 8]} align="center">
|
||
<Text type="secondary" style={{ fontSize: 13, marginRight: 2 }}>
|
||
<HistoryOutlined style={{ marginRight: 4 }} />
|
||
常用 / 历史快捷:
|
||
</Text>
|
||
{quickGoods.map((item) => {
|
||
const isSelected = commodityId === item.commodity_id;
|
||
const isHot = item.name.includes('兔兔白日梦') || item.name.includes('狂战萌兔');
|
||
return (
|
||
<Tag.CheckableTag
|
||
key={item.commodity_id}
|
||
checked={isSelected}
|
||
onChange={() => handleSelectGood(item.commodity_id)}
|
||
style={{
|
||
padding: '3px 10px',
|
||
fontSize: 13,
|
||
borderRadius: 16,
|
||
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: 12, 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: 4 }}>选择要兑换的皮肤</Text>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ width: '100%' }}
|
||
placeholder="选择皮肤"
|
||
value={commodityId || undefined}
|
||
onChange={handleSelectGood}
|
||
options={goodsOptions}
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={4}>
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>需要几个账号</Text>
|
||
<InputNumber
|
||
min={1}
|
||
max={50}
|
||
style={{ width: '100%' }}
|
||
value={quantity}
|
||
onChange={(value) => setQuantity(Number(value) || 1)}
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={5}>
|
||
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>积分不足时策略</Text>
|
||
<Select
|
||
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
|
||
icon={<ThunderboltOutlined />}
|
||
loading={loading}
|
||
onClick={() => void submit()}
|
||
size="middle"
|
||
>
|
||
提交并自动分配
|
||
</Button>
|
||
</Col>
|
||
</Row>
|
||
|
||
{supply && (
|
||
<Row gutter={16} style={{ marginTop: 20 }}>
|
||
<Col xs={12} md={6}>
|
||
<Statistic
|
||
title="免充值可用账号"
|
||
value={supply.zero_topup}
|
||
suffix="个"
|
||
valueStyle={{ color: token.colorSuccess }}
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Statistic
|
||
title="需充值可用账号"
|
||
value={supply.after_topup}
|
||
suffix="个"
|
||
valueStyle={{ color: token.colorWarning }}
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Statistic
|
||
title={(
|
||
<span>
|
||
池内全量补足预算
|
||
<Tooltip title="将池内所有待充账号全部补足所需总花费(非单次分配费用)">
|
||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>ℹ️</Text>
|
||
</Tooltip>
|
||
</span>
|
||
)}
|
||
value={supply.cost.est_yuan}
|
||
suffix="元"
|
||
loading={previewing}
|
||
/>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Statistic title="我的在租账号" value={leases.length} suffix="个" />
|
||
</Col>
|
||
</Row>
|
||
)}
|
||
|
||
{supply?.manual_payment_required && (
|
||
<Alert
|
||
style={{ marginTop: 16 }}
|
||
type="info"
|
||
showIcon
|
||
message="当前充值渠道为「微信扫码支付」"
|
||
description="提交后若分配到需充值账号,系统将在此处自动弹出支付二维码。扫码支付后系统将自动完成赠令与皮肤兑换全流程。"
|
||
/>
|
||
)}
|
||
</Card>
|
||
|
||
{/* 活跃批次执行与进度面板 */}
|
||
{activeBatchId && (
|
||
<Card
|
||
title={(
|
||
<Space size={12}>
|
||
<span>本次分配与任务流水(批次 {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
|
||
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 />
|
||
<span>微信扫码充值</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>
|
||
);
|
||
}
|