feat(douyu-pool): 完善微信扫码支付与任务闭环及常用快捷选择
- 前端在扫码支付弹窗中增加显眼充值金额卡片与多账号 Tabs 支付标注 - 前端增加常用与历史快捷皮肤选择胶囊栏,支持本地缓存记忆与一键切换 - 后端补给链首步引入 query_elite_status 并支持 require_opened 前置熔断校验 - 优化供给与预估花费文案展示,增加兑换结果大图预览与凭证复制功能 - 验证方式:全量 784 个 pytest 用例全部通过,前端 tsc/build 校验通过
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user