Files
live-hub-py/web/frontend/src/pages/DouyuPoolPage.tsx
T
yml2213 bb20533c90 feat(douyu-pool): 支持和平精英号池兑换与商品自动刷新,优化号池操作面板
- 扩展号池支持 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 全部通过
2026-10-08 13:15:21 +08:00

1206 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
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>
);
}