Files
live-hub-py/web/frontend/src/pages/DouyuPoolPage.tsx
T
yml2213 603fc6eadc feat(douyu-pool): 完善微信扫码支付与任务闭环及常用快捷选择
- 前端在扫码支付弹窗中增加显眼充值金额卡片与多账号 Tabs 支付标注
- 前端增加常用与历史快捷皮肤选择胶囊栏,支持本地缓存记忆与一键切换
- 后端补给链首步引入 query_elite_status 并支持 require_opened 前置熔断校验
- 优化供给与预估花费文案展示,增加兑换结果大图预览与凭证复制功能
- 验证方式:全量 784 个 pytest 用例全部通过,前端 tsc/build 校验通过
2026-10-07 16:59:43 +08:00

936 lines
33 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, 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>
);
}