feat(douyu-pool): 完善微信扫码支付与任务闭环及常用快捷选择

- 前端在扫码支付弹窗中增加显眼充值金额卡片与多账号 Tabs 支付标注
- 前端增加常用与历史快捷皮肤选择胶囊栏,支持本地缓存记忆与一键切换
- 后端补给链首步引入 query_elite_status 并支持 require_opened 前置熔断校验
- 优化供给与预估花费文案展示,增加兑换结果大图预览与凭证复制功能
- 验证方式:全量 784 个 pytest 用例全部通过,前端 tsc/build 校验通过
This commit is contained in:
yml2213
2026-10-07 16:59:43 +08:00
parent 7c1a6caf47
commit 603fc6eadc
5 changed files with 732 additions and 62 deletions
+1 -1
View File
@@ -208,8 +208,8 @@ class TestExchange(_ApiBase):
assert result.allocated == 1
tasks = self.session.query(DouyuTask).order_by(DouyuTask.id.asc()).all()
assert [t.task_type for t in tasks] == [
"query_elite_status",
"query_gold_balance",
"query_points",
"create_gold_qr",
"donate_elite_gift",
"exchange_goods",
+3 -2
View File
@@ -28,12 +28,13 @@ class TestBuildSupplyChain:
assert chain[0].payload == {"commodity_id": "C1", "num": 1}
def test_tier_b_refreshes_before_spending(self):
"""链首必须先刷余额 —— 否则 `_skip_when` 读的是旧值,会多充钱。"""
"""链首必须先查开通状态与余额 —— 否则未开通会误充值,旧值会多充钱。"""
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
assert [s.task_type for s in chain][:2] == [
"query_elite_status",
"query_gold_balance",
"query_points",
]
assert chain[0].payload == {"require_opened": True}
def test_tier_b_topup_amount_is_exact_gap(self):
# 缺口 980 分 → 98 个令 → 98 元
+7 -1
View File
@@ -635,9 +635,15 @@ def build_supply_chain(
)
]
# 检查手册开通状态与最新积分
status_step = (
SupplyStep("query_elite_status", {"require_opened": True})
if exchange_task_type == "exchange_goods"
else SupplyStep("query_points", {})
)
steps: list[SupplyStep] = [
status_step,
SupplyStep("query_gold_balance", {}),
SupplyStep("query_points", {}),
]
# 充值:余额够就跳过(闸门在执行时求值,读的是刚刷新过的账号字段)
for amount in recharge_amounts_for(candidate.topup_gifts):
@@ -117,6 +117,16 @@ class ManualMixin:
state = "已开通" if result["elite_opened"] else "未开通"
expire_text = self._format_expire_text(result["elite_expire_time"])
points = result["elite_manual_score"]
payload = self._task_payload(task)
if payload.get("require_opened") and not result["elite_opened"]:
self._mark_task(
db,
task,
"failed",
"精英宝典未开通,无法继续充值或兑换,请先开通手册",
result,
)
return
self._mark_task(
db,
task,
+711 -58
View File
@@ -1,24 +1,73 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Col, Descriptions, InputNumber, Row, Select, Space,
Statistic, Table, Tag, Typography, theme,
Alert, Badge, Button, Card, Col, Descriptions, InputNumber, Modal,
QRCode, Row, Select, Space, Statistic, Table, Tabs, Tag, Tooltip,
Typography, theme,
} from 'antd';
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
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 { douyuPoolApi } from '../api/modules';
import { douyuApi, douyuPoolApi } from '../api/modules';
import type {
DouyuPoolAccount, DouyuPoolExchangeResult, DouyuPoolLease, DouyuPoolSupply,
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();
@@ -26,12 +75,36 @@ export default function DouyuPoolPage() {
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();
@@ -45,7 +118,8 @@ export default function DouyuPoolPage() {
const loadLeases = useCallback(async () => {
try {
setLeases(await douyuPoolApi.leases(true));
const list = await douyuPoolApi.leases(true);
setLeases(list);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -69,8 +143,143 @@ export default function DouyuPoolPage() {
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({
@@ -78,10 +287,13 @@ export default function DouyuPoolPage() {
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}`);
message.success(`已分配 ${data.allocated} 个账号,正在执行补给与兑换(批次 ${data.batch_id})`);
}
await Promise.all([loadSupply(), loadLeases()]);
} catch (e: unknown) {
@@ -91,10 +303,35 @@ export default function DouyuPoolPage() {
}
};
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('已释放');
message.success('已释放租约');
await Promise.all([loadSupply(), loadLeases()]);
} catch (e: unknown) {
message.error(getErrorMessage(e));
@@ -109,9 +346,66 @@ export default function DouyuPoolPage() {
[goods],
);
const accountColumns: ColumnsType<DouyuPoolAccount> = [
{ title: '账号ID', dataIndex: 'account_id', width: 90 },
{ title: '账号', dataIndex: 'username', ellipsis: true },
// 账号维度聚合任务执行进度
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) => (
@@ -120,37 +414,118 @@ export default function DouyuPoolPage() {
</Tag>
),
},
{ title: '积分', dataIndex: 'points', width: 90 },
{ title: '当前积分', dataIndex: 'points', width: 95 },
{
title: '需充值', width: 160,
render: (_, row) => (row.topup_yuan > 0
? <Text type="warning">{row.topup_yuan} 元({row.recharge_orders.join(' + ')})</Text>
: <Text type="secondary">无需</Text>),
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: '可换绑时间', dataIndex: 'role_change_available_at', width: 180,
render: (value: string | null) => (value ? value.replace('T', ' ').slice(0, 19) : '—'),
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: '商品', dataIndex: 'commodity_id', width: 200 },
{ title: '批次', dataIndex: 'batch_id', width: 130 },
{ title: '商品ID', dataIndex: 'commodity_id', width: 220 },
{
title: '到期', dataIndex: 'expires_at', width: 170,
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: 100,
title: '操作', width: 160,
render: (_, row) => (
<Button size="small" danger onClick={() => void releaseLease(row.id)}>释放</Button>
<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={(
@@ -159,21 +534,61 @@ export default function DouyuPoolPage() {
</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">皮肤</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>选择要兑换的皮肤</Text>
<Select
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
placeholder="选择要兑换的皮肤"
placeholder="选择皮肤"
value={commodityId || undefined}
onChange={setCommodityId}
onChange={handleSelectGood}
options={goodsOptions}
/>
</Col>
<Col xs={12} md={4}>
<Text type="secondary">需要几个账号</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>需要几个账号</Text>
<InputNumber
min={1}
max={50}
@@ -183,14 +598,14 @@ export default function DouyuPoolPage() {
/>
</Col>
<Col xs={12} md={5}>
<Text type="secondary">积分不足时</Text>
<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: '只用已有积分' },
{ value: 'topup', label: '自动充值鱼翅补足' },
{ value: 'skip', label: '仅用已有积分 (不充值)' },
]}
/>
</Col>
@@ -201,6 +616,7 @@ export default function DouyuPoolPage() {
icon={<ThunderboltOutlined />}
loading={loading}
onClick={() => void submit()}
size="middle"
>
提交并自动分配
</Button>
@@ -208,65 +624,132 @@ export default function DouyuPoolPage() {
</Row>
{supply && (
<Row gutter={16} style={{ marginTop: 16 }}>
<Row gutter={16} style={{ marginTop: 20 }}>
<Col xs={12} md={6}>
<Statistic title="可直接兑(免充值)" value={supply.zero_topup} suffix="个" />
</Col>
<Col xs={12} md={6}>
<Statistic title="充值后可兑" value={supply.after_topup} suffix="个" />
<Statistic
title="免充值可用账号"
value={supply.zero_topup}
suffix="个"
valueStyle={{ color: token.colorSuccess }}
/>
</Col>
<Col xs={12} md={6}>
<Statistic
title="全部兑完预计花费"
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={supply.leased} suffix="个" />
<Statistic title="我的在租账号" value={leases.length} suffix="个" />
</Col>
</Row>
)}
{supply?.manual_payment_required && (
<Alert
style={{ marginTop: 12 }}
type="warning"
style={{ marginTop: 16 }}
type="info"
showIcon
message={`当前鱼翅渠道为「${supply.recharge_channel}」,需要人工扫码支付`}
description="系统会生成支付码并等待 10 分钟;超时后转入人工处理,需要你到「我的租约」里继续或放弃。"
message="当前充值渠道为「微信扫码支付」"
description="提交后若分配到需充值账号,系统将在此处自动弹出支付二维码。扫码支付后系统将自动完成赠令与皮肤兑换全流程。"
/>
)}
</Card>
{result && (
<Card title={`本次分配结果(批次 ${result.batch_id || '—'})`}>
<Descriptions size="small" column={4} style={{ marginBottom: 12 }}>
<Descriptions.Item label="需求">{result.requested}</Descriptions.Item>
<Descriptions.Item label="实配">{result.allocated}</Descriptions.Item>
<Descriptions.Item label="缺口">
<Text type={result.shortfall > 0 ? 'danger' : undefined}>{result.shortfall}</Text>
</Descriptions.Item>
<Descriptions.Item label="充值渠道">{result.recharge_channel || '—'}</Descriptions.Item>
</Descriptions>
{result.reasons.length > 0 && (
{/* 活跃批次执行与进度面板 */}
{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={result.accounts}
dataSource={accountProgressList}
locale={{ emptyText: '本次没有分配账号' }}
/>
</Card>
)}
<Card title="我的租约(用完自动收回,也可手动释放)">
{/* 我的租约列表 */}
<Card
title="我的租约(用完会自动收回,也可随时手动释放)"
extra={(
<Button icon={<ReloadOutlined />} size="small" onClick={() => void loadLeases()}>
刷新租约
</Button>
)}
>
<Table
rowKey="id"
size="small"
@@ -277,6 +760,176 @@ export default function DouyuPoolPage() {
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>
);
}