feat: 优化支付码队列与支付超时

This commit is contained in:
yml
2026-11-13 21:21:19 +08:00
parent 76b344eb83
commit 8ba947e60f
11 changed files with 592 additions and 413 deletions
+1 -1
View File
@@ -1277,7 +1277,7 @@ class HuyaHttpClient:
},
)
try:
with self._urlopen(req, timeout=15) as resp:
with self._urlopen(req, timeout=30) as resp:
resp_data = resp.read()
if resp_data[:2] == b"\x1f\x8b":
import gzip
+1 -1
View File
@@ -1416,7 +1416,7 @@ class HuyaWssClient:
await self.ws.send(msg.encode())
try:
body = await asyncio.wait_for(future, 15.0)
body = await asyncio.wait_for(future, 30.0)
except TimeoutError:
self._pending.pop(req_id, None)
self.logger("[✗] payOrderSubmitV5 超时")
+1 -1
View File
@@ -306,7 +306,7 @@ class WeiboClient:
self,
cookie: str = "",
*,
timeout: float = 20.0,
timeout: float = 30.0,
session: requests.Session | None = None,
):
self.cookie = normalize_weibo_cookie(cookie) if cookie else ""
+2 -2
View File
@@ -99,7 +99,7 @@ def extract_pay_qr_code(html: str) -> str:
return code if is_alipay_url(code) else ""
def resolve_pay_targets(pay_url: str, *, timeout: float = 6.0) -> PayTargets:
def resolve_pay_targets(pay_url: str, *, timeout: float = 15.0) -> PayTargets:
"""跟随支付宝网关的 302,取**收银台页面**和页面里**真正的扫码内容**。
真机实测(2026-10-15):`openapi.alipay.com/gateway.do?...` 会 302 到
@@ -139,6 +139,6 @@ def resolve_pay_targets(pay_url: str, *, timeout: float = 6.0) -> PayTargets:
return PayTargets(cashier_url=final_url, qr_code=extract_pay_qr_code(html))
def resolve_pay_entry_url(pay_url: str, *, timeout: float = 6.0) -> str:
def resolve_pay_entry_url(pay_url: str, *, timeout: float = 15.0) -> str:
"""只要收银台直达地址(约 93 字符);解析不到返回空串。"""
return resolve_pay_targets(pay_url, timeout=timeout).cashier_url
+5 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
@@ -18,7 +19,10 @@ HUYA_RECHARGE_ACT_ID = 25135
# shopAppInfo.scene(2026-10-13 积分中心抓包实测 7;订单侧 scene 由
# core.huya.wss_client.ORDER_SCENE 决定,两者不是同一个值)
HUYA_RECHARGE_APP_SCENE = SHOP_APP_SCENE
HUYA_PAYMENT_POLL_SECONDS = 180
HUYA_PAYMENT_POLL_SECONDS = max(
60,
int(os.getenv("HUYA_PAYMENT_POLL_SECONDS", "600")),
)
HUYA_PAYMENT_POLL_INTERVAL = 3
# 内置开通商品候选。**必须同时满足** getGoodsInfoV5=200(能看到)与
# listPayChannelV5=200(能买到)——真机(2026-10-15,hy_300546644 / uid 1199667339402):
+2 -2
View File
@@ -72,7 +72,7 @@ export const weiboApi = {
api.post<WeiboAccountItem, WeiboAccountItem>(
`/weibo/accounts/${accountId}/enable`,
undefined,
{ timeout: 120000 },
{ timeout: 600000 },
),
refreshAccount: (accountId: number) =>
api.post<WeiboAccountItem, WeiboAccountItem>(
@@ -201,7 +201,7 @@ export const weiboApi = {
api.post<WeiboPaymentStatusResult, WeiboPaymentStatusResult>(
`/weibo/accounts/${accountId}/recharge/status`,
{ pay_id: payId },
{ timeout: 30000 },
{ timeout: 45000 },
),
deleteAccount: (accountId: number) =>
api.delete<MessageResponse, MessageResponse>(`/weibo/accounts/${accountId}`),
@@ -0,0 +1,229 @@
import {
Alert,
Button,
QRCode,
Space,
Spin,
Tag,
Typography,
} from 'antd';
import {
CopyOutlined,
ReloadOutlined,
ZoomInOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
export type PaymentQrQueuePhase = 'queued' | 'generating' | 'ready' | 'error';
export interface PaymentQrQueueItem {
key: string | number;
title: string;
subtitle?: string;
description?: string;
phase: PaymentQrQueuePhase;
qrValue?: string;
qrImage?: string;
copyValue?: string;
statusLabel?: string;
statusColor?: string;
warning?: string;
error?: string;
refreshing?: boolean;
}
interface PaymentQrQueueProps {
items: PaymentQrQueueItem[];
emptyText: string;
queuedText?: string;
onZoom?: (item: PaymentQrQueueItem) => void;
onCopy?: (item: PaymentQrQueueItem) => void;
onRefresh?: (item: PaymentQrQueueItem) => void;
}
export default function PaymentQrQueue({
items,
emptyText,
queuedText = '正在排队生成支付码',
onZoom,
onCopy,
onRefresh,
}: PaymentQrQueueProps) {
if (items.length === 0) {
return (
<div style={{ padding: 28, textAlign: 'center' }}>
<Text type="secondary">{emptyText}</Text>
</div>
);
}
const queuedCount = items.filter(
(item) => item.phase === 'queued' || item.phase === 'generating',
).length;
return (
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
{queuedCount > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{queuedText}({queuedCount} 个)
</Text>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))',
gap: 12,
maxHeight: '64vh',
overflowY: 'auto',
paddingRight: 4,
}}
>
{items.map((item) => {
const hasQr = Boolean(item.qrValue || item.qrImage);
const copyValue = item.copyValue || item.qrValue || '';
return (
<div
key={item.key}
style={{
border: '1px solid rgba(128, 128, 128, .24)',
borderRadius: 8,
padding: 12,
textAlign: 'center',
background: 'rgba(255, 255, 255, .015)',
}}
>
<Text strong ellipsis title={item.title}>
{item.title}
</Text>
{item.subtitle && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.subtitle}
</Text>
</div>
)}
{item.phase === 'queued' && (
<div style={{ padding: '42px 0' }}>
<Spin size="small" />
<div>
<Text type="secondary">排队中</Text>
</div>
</div>
)}
{item.phase === 'generating' && (
<div style={{ padding: '42px 0' }}>
<Spin />
<div style={{ marginTop: 8 }}>
<Text type="secondary">正在创建订单并解析二维码</Text>
</div>
</div>
)}
{item.phase === 'error' && (
<Alert
type="error"
showIcon
style={{ marginTop: 10, textAlign: 'left' }}
title="支付码不可用"
description={item.error || '未知原因'}
/>
)}
{item.phase === 'ready' && (
<Space
orientation="vertical"
size={7}
style={{ width: '100%', marginTop: 8 }}
>
{hasQr ? (
<div
style={{
display: 'inline-block',
background: '#fff',
padding: 6,
lineHeight: 0,
borderRadius: 6,
}}
>
{item.qrImage ? (
<img
src={item.qrImage}
alt="支付二维码"
style={{
width: 160,
height: 160,
objectFit: 'contain',
}}
/>
) : (
<QRCode
value={item.qrValue || ''}
size={160}
bordered={false}
color="#000"
bgColor="#fff"
/>
)}
</div>
) : (
<Alert
type="warning"
showIcon
title="订单已创建但未解析到二维码"
description="可复制支付链接后手动打开"
/>
)}
{item.description && (
<Text type="secondary" style={{ fontSize: 12 }}>
{item.description}
</Text>
)}
{item.statusLabel && (
<Tag color={item.statusColor || 'processing'}>
{item.statusLabel}
</Tag>
)}
{item.warning && (
<Text type="warning" style={{ fontSize: 12 }}>
{item.warning}
</Text>
)}
{(onZoom || onCopy || onRefresh) && (
<Space wrap style={{ justifyContent: 'center' }}>
{onZoom && hasQr && (
<Button
size="small"
icon={<ZoomInOutlined />}
onClick={() => onZoom(item)}
>
放大扫码
</Button>
)}
{onCopy && copyValue && (
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => onCopy(item)}
>
复制链接
</Button>
)}
{onRefresh && (
<Button
size="small"
icon={<ReloadOutlined />}
loading={item.refreshing}
onClick={() => onRefresh(item)}
>
刷新状态
</Button>
)}
</Space>
)}
</Space>
)}
</div>
);
})}
</div>
</Space>
);
}
@@ -0,0 +1,155 @@
import {
Alert,
Button,
InputNumber,
QRCode,
Space,
Tag,
Typography,
} from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import type {
WeiboPaymentStatusResult,
WeiboRechargeOrderResult,
} from '../api/modules';
import { WEIBO_PAYMENT_STATUS_LABELS } from '../utils/weiboPayment';
const { Text } = Typography;
interface WeiboRechargePanelProps {
enabled: boolean;
amount: number;
onAmountChange: (value: number) => void;
creating: boolean;
onCreate: () => void;
order: WeiboRechargeOrderResult | null;
paymentStatus: WeiboPaymentStatusResult | null;
statusLoading: boolean;
onRefreshStatus: () => void;
onRefreshBalance?: () => void;
}
export default function WeiboRechargePanel({
enabled,
amount,
onAmountChange,
creating,
onCreate,
order,
paymentStatus,
statusLoading,
onRefreshStatus,
onRefreshBalance,
}: WeiboRechargePanelProps) {
const paid = paymentStatus?.status === 'PAY_STATUS_SUCCESS';
const failed = paymentStatus?.status === 'PAY_STATUS_FAILED'
|| paymentStatus?.status === 'PAY_STATUS_CLOSED';
return (
<>
<Text strong>助威充值</Text>
<Space wrap style={{ marginTop: 8 }}>
<Space.Compact>
<InputNumber
min={1}
max={1000}
precision={0}
value={amount}
onChange={(value) => onAmountChange(Number(value) || 1)}
style={{ width: 110 }}
/>
<Button disabled>元</Button>
</Space.Compact>
<Button
type="primary"
loading={creating}
disabled={!enabled}
onClick={onCreate}
>
创建订单
</Button>
</Space>
{order && (
<Space
orientation="vertical"
size={8}
style={{ width: '100%', marginTop: 10 }}
>
{paid ? (
<Alert
type="success"
showIcon
title="支付成功,二维码已自动收起"
description="可继续刷新账号资料核对微豆到账。"
/>
) : failed ? (
<Alert
type="error"
showIcon
title={WEIBO_PAYMENT_STATUS_LABELS[paymentStatus?.status || ''] || '支付失败'}
description="该二维码已不可继续使用。"
/>
) : (
<div style={{ textAlign: 'center' }}>
<QRCode
value={order.alipay_qr_code}
size={180}
bordered={false}
color="#000"
bgColor="#fff"
/>
</div>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{order.amount_yuan} 元 · 预计{' '}
{order.single_reward.toLocaleString()} 微豆
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
订单号{' '}
<Text copyable style={{ fontSize: 12 }}>
{order.order_id}
</Text>
</Text>
<Space wrap>
<Button
size="small"
icon={<ReloadOutlined />}
loading={statusLoading}
onClick={onRefreshStatus}
>
刷新支付状态
</Button>
{order.desktop_pay_url && !paid && !failed && (
<Button
size="small"
href={order.desktop_pay_url}
target="_blank"
rel="noreferrer"
>
电脑端支付
</Button>
)}
{onRefreshBalance && (
<Button size="small" onClick={onRefreshBalance}>
刷新到账
</Button>
)}
</Space>
{paymentStatus && (
<Tag
color={
paid
? 'success'
: failed
? 'error'
: 'processing'
}
>
{WEIBO_PAYMENT_STATUS_LABELS[paymentStatus.status]
|| paymentStatus.status}
</Tag>
)}
</Space>
)}
</>
);
}
+63 -47
View File
@@ -7,11 +7,12 @@ import {
import type { TableProps } from 'antd';
import {
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined, ZoomInOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, SwapOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
import HuyaConfigFields from '../components/HuyaConfigFields';
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
import PaymentQrQueue, { type PaymentQrQueueItem } from '../components/PaymentQrQueue';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
@@ -189,6 +190,10 @@ function taskQrUrl(task: HuyaTaskItem | undefined): string {
return resultText(task, 'bind_redirect_url') || resultText(task, 'url');
}
function payTaskLabel(task: HuyaTaskItem): string {
return task.account_nickname || task.account_uid || `#${task.account_id}`;
}
export default function HuyaElitePage() {
const navigate = useNavigate();
const { can } = usePermissions();
@@ -569,30 +574,65 @@ export default function HuyaElitePage() {
}
}, [rechargeGoods, selectedRechargeSpu]);
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
// 开通支付码:同一批次里每个账号一个码,未支付的排前面;已支付/已开通的只留一行状态
const payTasks = useMemo(() => {
const list = tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
&& payBatchIds.includes(task.batch_id)
&& Boolean(resultText(task, 'pay_url') || resultText(task, 'mini_qrcode_image')));
return [...list].sort((a, b) => Number(needsScanPay(b)) - Number(needsScanPay(a)) || a.id - b.id);
}, [tasks, payBatchIds]);
const payPendingTasks = payTasks.filter(needsScanPay);
const payDoneTasks = payTasks.filter((task) => !needsScanPay(task));
// 支付完成后立即从待扫码队列移除,只保留仍需处理的账号。
const payAllTasks = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
&& payBatchIds.includes(task.batch_id)
&& Boolean(resultText(task, 'pay_url') || resultText(task, 'mini_qrcode_image'))), [tasks, payBatchIds]);
const payTasks = useMemo(() => payAllTasks.filter(needsScanPay), [payAllTasks]);
const payDoneCount = payAllTasks.length - payTasks.length;
const payQrTaskIds = useMemo(() => new Set(payAllTasks.map((task) => task.id)), [payAllTasks]);
// 多选账号时一次生成多个码:个别账号失败(限购、CK 失效…)要单独点名,否则少一个码看不出原因
const payFailedTasks = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
&& payBatchIds.includes(task.batch_id)
&& ['failed', 'error', 'stopped'].includes(task.status)), [tasks, payBatchIds]);
&& !payQrTaskIds.has(task.id)
&& ['failed', 'error', 'stopped'].includes(task.status)), [payBatchIds, payQrTaskIds, tasks]);
// 还在排队/生成中的码(并发不够时,后面的码要等前面的码付完或超时才轮到)
const payQueuedCount = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
&& payBatchIds.includes(task.batch_id)
&& ['planned', 'pending', 'running'].includes(task.status)
&& !resultText(task, 'pay_url')).length, [tasks, payBatchIds]);
const payTaskById = useMemo(() => new Map(payAllTasks.map((task) => [task.id, task])), [payAllTasks]);
const payQueueItems = useMemo<PaymentQrQueueItem[]>(() => {
const pending = payTasks
.map((task) => ({
key: task.id,
title: payTaskLabel(task),
subtitle: `UID ${task.account_uid || '-'}`,
description: [resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' '),
phase: 'ready' as const,
qrValue: payQrValue(task),
qrImage: resultText(task, 'mini_qrcode_image') ? miniQrcodeSrc(resultText(task, 'mini_qrcode_image')) : '',
copyValue: payQrValue(task),
statusLabel: resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status,
statusColor: TASK_COLORS[task.status] || 'default',
warning: isLoopbackUrl(payQrValue(task)) ? '二维码是本机地址(localhost),手机无法扫码' : '',
}))
.sort((a, b) => Number(a.key) - Number(b.key));
const queued = tasks
.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type)
&& payBatchIds.includes(task.batch_id)
&& ['planned', 'pending', 'running'].includes(task.status)
&& !resultText(task, 'pay_url'))
.map((task) => ({
key: `queued-${task.id}`,
title: payTaskLabel(task),
subtitle: `UID ${task.account_uid || '-'}`,
phase: task.status === 'running' ? 'generating' as const : 'queued' as const,
}));
const failed = payFailedTasks.map((task) => ({
key: `failed-${task.id}`,
title: payTaskLabel(task),
subtitle: `UID ${task.account_uid || '-'}`,
phase: 'error' as const,
error: task.message || '支付码生成失败',
}));
return [...pending, ...queued, ...failed];
}, [payBatchIds, payFailedTasks, payTasks, tasks]);
const openPayTasks = (batchId?: string) => {
if (batchId) setPayBatchIds((prev) => (prev.includes(batchId) ? prev : [...prev, batchId]));
setPayOpen(true);
};
const payTaskLabel = (task: HuyaTaskItem) => task.account_nickname || task.account_uid || `#${task.account_id}`;
const copyPayLink = async (text: string, label: string) => {
if (!text) { message.warning('没有可复制的链接'); return; }
try { await navigator.clipboard.writeText(text); message.success(`${label}已复制`); }
@@ -717,46 +757,22 @@ export default function HuyaElitePage() {
</Modal>
<Modal
open={payOpen}
title={payTasks.length ? `支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '支付码'}
title={`支付码${payQueueItems.length ? `(${payTasks.length} 个待扫码${payQueuedCount ? ` · ${payQueuedCount} 个排队中` : ''}${payDoneCount ? ` · ${payDoneCount} 个已支付` : ''})` : ''}`}
width={920}
centered
onCancel={() => setPayOpen(false)}
footer={<Space><Text type="secondary">多选账号会同时生成多个码并排展示,各自独立等待支付;支付后自动确认开通状态</Text><Button onClick={() => setPayOpen(false)}>关闭</Button><Button onClick={() => { setPayBatchIds([]); setPayOpen(false); }}>清空列表</Button></Space>}
>
{payTasks.length === 0 ? <div style={{ padding: 24, textAlign: 'center' }}><Text type="secondary">正在为选中的账号生成支付码…</Text></div> : <div style={{ maxHeight: '62vh', overflowY: 'auto' }}>
{payQueuedCount > 0 && <div style={{ marginBottom: 10, textAlign: 'left' }}><Text type="secondary" style={{ fontSize: 12 }}>还有 {payQueuedCount} 个账号在排队生成支付码(并发不够时会等已出的码付完/超时,可调大右侧「并发」)</Text></div>}
{payPendingTasks.length > 0 && <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', gap: 12 }}>
{payPendingTasks.map((task) => {
const qrValue = payQrValue(task);
return (
<div key={task.id} style={{ border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'center' }}>
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={miniQrcodeSrc(resultText(task, 'mini_qrcode_image'))} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
<div><Text strong ellipsis>{payTaskLabel(task)}</Text></div>
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
<div><Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text></div>
<div style={{ marginTop: 4 }}><Tag color={TASK_COLORS[task.status] || 'default'}>{resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status}</Tag></div>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis title={task.message}>{task.message || '-'}</Text>
{isLoopbackUrl(qrValue) && <div><Text type="warning" style={{ fontSize: 12 }}>二维码是本机地址(localhost),手机扫不开</Text></div>}
<div style={{ marginTop: 6 }}><Space size={6}><Button size="small" icon={<ZoomInOutlined />} onClick={() => setZoomPayTask(task)}>放大扫码</Button><Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(qrValue, '支付链接')}>复制链接</Button></Space></div>
</div>
);
})}
</div>}
{payFailedTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
<Text type="secondary" style={{ fontSize: 12 }}>生成失败(没有二维码)</Text>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{payFailedTasks.map((task) => <Tag key={task.id} color="error" title={task.message}>{payTaskLabel(task)} · {task.message || '生成失败'}</Tag>)}
</div>
</div>}
{payDoneTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{payDoneTasks.map((task) => (
<Tag key={task.id} color={task.result?.elite_opened === true ? 'success' : task.result?.elite_opening === true ? 'processing' : 'blue'}>{payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : task.result?.elite_opening === true ? '开通中' : resultText(task, 'payment_status_label') || '已支付'}</Tag>
))}
</div>
</div>}
</div>}
<PaymentQrQueue
items={payQueueItems}
emptyText={payDoneCount > 0 ? '待扫码队列已清空,支付完成的账号已自动移除' : '当前没有待扫码支付码'}
queuedText="还有账号在排队生成支付码"
onZoom={(item) => {
const task = payTaskById.get(Number(item.key));
if (task) setZoomPayTask(task);
}}
onCopy={(item) => void copyPayLink(item.copyValue || item.qrValue || '', '支付链接')}
/>
</Modal>
<Modal open={zoomPayTask !== null} title={`扫码支付 · ${zoomPayTask ? payTaskLabel(zoomPayTask) : ''}`} footer={null} onCancel={() => setZoomPayTask(null)} width={680} centered>
{zoomPayTask && <div style={{ textAlign: 'center' }}>
+127 -358
View File
@@ -40,7 +40,6 @@ import {
SearchOutlined,
StopOutlined,
SwapOutlined,
ZoomInOutlined,
} from '@ant-design/icons';
import {
weiboApi,
@@ -56,12 +55,15 @@ import {
type WeiboRechargeOrderResult,
type WeiboWorkbenchBatchImportResult,
} from '../api/modules';
import PaymentQrQueue, { type PaymentQrQueueItem } from '../components/PaymentQrQueue';
import WeiboRechargePanel from '../components/WeiboRechargePanel';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { copyTextToClipboard } from '../utils/clipboard';
import { runWithConcurrency } from '../utils/concurrency';
import { WEIBO_PAYMENT_STATUS_LABELS as PAYMENT_STATUS_LABELS } from '../utils/weiboPayment';
const { Text, Title } = Typography;
const { TextArea } = Input;
@@ -72,13 +74,6 @@ const CK_STATUS: Record<string, { label: string; color: string }> = {
error: { label: '查询失败', color: 'warning' },
};
const PAYMENT_STATUS_LABELS: Record<string, string> = {
PAY_STATUS_PENDING: '等待支付',
PAY_STATUS_SUCCESS: '支付成功',
PAY_STATUS_FAILED: '支付失败',
PAY_STATUS_CLOSED: '订单关闭',
};
const WORKBENCH_IMPORT_STATUS: Record<
WeiboWorkbenchBatchImportResult['items'][number]['status'],
{ label: string; color: string }
@@ -1378,6 +1373,11 @@ export default function WeiboAccountsPage({
paymentStatus: status,
statusError: '',
});
if (status.status === 'PAY_STATUS_SUCCESS' || status.status === 'PAY_STATUS_FAILED' || status.status === 'PAY_STATUS_CLOSED') {
setBatchRechargeZoom((current) => (
current?.account.id === accountId ? null : current
));
}
if (status.status !== 'PAY_STATUS_SUCCESS') {
patchBatchRechargeEntry(accountId, {
creditPhase: '',
@@ -2054,10 +2054,17 @@ export default function WeiboAccountsPage({
() => knownTags.map((tag) => ({ value: tag })),
[knownTags],
);
const batchRechargeReadyCount = batchRechargeEntries.filter(
const batchRechargePaidCount = batchRechargeEntries.filter(
(entry) => entry.paymentStatus?.status === 'PAY_STATUS_SUCCESS',
).length;
// 支付成功后立即从待扫码队列移除;后台仍继续核对待到账。
const batchRechargeVisibleEntries = batchRechargeEntries.filter(
(entry) => entry.paymentStatus?.status !== 'PAY_STATUS_SUCCESS',
);
const batchRechargeReadyCount = batchRechargeVisibleEntries.filter(
(entry) => entry.phase === 'ready',
).length;
const batchRechargePendingPaymentCount = batchRechargeEntries.filter(
const batchRechargePendingPaymentCount = batchRechargeVisibleEntries.filter(
(entry) =>
entry.phase === 'ready' &&
(!entry.paymentStatus ||
@@ -2066,6 +2073,58 @@ export default function WeiboAccountsPage({
const batchRechargeCreditedCount = batchRechargeEntries.filter(
(entry) => entry.creditPhase === 'credited',
).length;
const batchRechargeCreditFailures = batchRechargeEntries.filter(
(entry) =>
entry.paymentStatus?.status === 'PAY_STATUS_SUCCESS' &&
entry.creditPhase === 'error',
);
const batchRechargeQueueItems = batchRechargeVisibleEntries.map(
(entry): PaymentQrQueueItem => {
const order = entry.order;
const payment = entry.paymentStatus;
const paymentFailed = payment?.status === 'PAY_STATUS_FAILED'
|| payment?.status === 'PAY_STATUS_CLOSED';
if (entry.phase === 'error' || paymentFailed) {
return {
key: entry.account.id,
title: entry.account.nickname || entry.account.uid,
subtitle: `UID ${entry.account.uid}`,
phase: 'error',
error: entry.error
|| entry.statusError
|| (payment ? PAYMENT_STATUS_LABELS[payment.status] || payment.status : '支付码生成失败'),
};
}
if (entry.phase === 'queued' || entry.phase === 'creating') {
return {
key: entry.account.id,
title: entry.account.nickname || entry.account.uid,
subtitle: `UID ${entry.account.uid}`,
phase: entry.phase === 'creating' ? 'generating' : 'queued',
};
}
return {
key: entry.account.id,
title: entry.account.nickname || entry.account.uid,
subtitle: `UID ${entry.account.uid}`,
description: order
? `${order.amount_yuan} 元 · 预计 ${order.single_reward.toLocaleString()} 微豆`
: '',
phase: 'ready',
qrValue: order?.alipay_qr_code || '',
copyValue: order?.alipay_qr_code || order?.desktop_pay_url || '',
statusLabel: payment
? PAYMENT_STATUS_LABELS[payment.status] || payment.status
: '等待支付',
statusColor: 'processing',
warning: entry.statusError || '',
refreshing: entry.checkingStatus,
};
},
);
const batchRechargeEntryByAccountId = new Map(
batchRechargeVisibleEntries.map((entry) => [entry.account.id, entry]),
);
const detailPointIncome =
detailPointResult?.items.reduce(
(total, item) => total + Math.max(0, item.point_num),
@@ -2576,71 +2635,17 @@ export default function WeiboAccountsPage({
paddingTop: 12,
}}
>
<Text strong>助威充值</Text>
<Space wrap style={{ marginTop: 8 }}>
<Space.Compact>
<InputNumber
min={1}
max={1000}
precision={0}
value={rechargeAmount}
onChange={(value) =>
setRechargeAmount(Number(value) || 1)
}
style={{ width: 110 }}
/>
<Button disabled>元</Button>
</Space.Compact>
<Button
type="primary"
loading={rechargeCreating}
disabled={!featureAccount.is_enabled}
onClick={() => void createRecharge()}
>
创建订单
</Button>
</Space>
{rechargeOrder && (
<Space
orientation="vertical"
size={8}
style={{ width: '100%', marginTop: 10 }}
>
<div style={{ textAlign: 'center' }}>
<QRCode
value={rechargeOrder.alipay_qr_code}
size={180}
bordered={false}
color="#000"
bgColor="#fff"
/>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{rechargeOrder.amount_yuan} 元 · 预计{' '}
{rechargeOrder.single_reward.toLocaleString()} 微豆
</Text>
<Button
size="small"
icon={<ReloadOutlined />}
loading={paymentStatusLoading}
onClick={() => void refreshPaymentStatus()}
>
刷新支付状态
</Button>
{paymentStatus && (
<Tag
color={
paymentStatus.status === 'PAY_STATUS_SUCCESS'
? 'success'
: 'processing'
}
>
{PAYMENT_STATUS_LABELS[paymentStatus.status] ||
paymentStatus.status}
</Tag>
)}
</Space>
)}
<WeiboRechargePanel
enabled={featureAccount.is_enabled}
amount={rechargeAmount}
onAmountChange={setRechargeAmount}
creating={rechargeCreating}
onCreate={() => void createRecharge()}
order={rechargeOrder}
paymentStatus={paymentStatus}
statusLoading={paymentStatusLoading}
onRefreshStatus={() => void refreshPaymentStatus()}
/>
</div>
)}
<Popconfirm
@@ -2826,7 +2831,7 @@ export default function WeiboAccountsPage({
type="warning"
showIcon
title="请使用支付宝逐个扫码支付"
description="弹窗打开时每 8 秒自动查询待支付和待确认到账项;支付成功后继续按订单号核对微豆到账。可随时取消“自动查询”,弹窗不会因为部分订单支付成功而自动关闭。"
description="弹窗打开时每 8 秒自动查询待支付状态;支付成功的二维码会立即从待扫码队列移除,后台继续按订单号核对微豆到账。"
/>
<Space wrap>
<Tag color="processing">
@@ -2845,181 +2850,43 @@ export default function WeiboAccountsPage({
}
</Tag>
<Tag color="blue">
已支付{' '}
{
batchRechargeEntries.filter(
(entry) =>
entry.paymentStatus?.status === 'PAY_STATUS_SUCCESS',
).length
}
已支付 {batchRechargePaidCount}
</Tag>
</Space>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))',
gap: 12,
maxHeight: '64vh',
overflowY: 'auto',
paddingRight: 4,
<PaymentQrQueue
items={batchRechargeQueueItems}
emptyText={
batchRechargePaidCount > 0
? '待扫码队列已清空,支付成功的账号已自动移除'
: '当前没有待扫码支付码'
}
queuedText="正在排队生成微博支付码"
onZoom={(item) => {
const entry = batchRechargeEntryByAccountId.get(Number(item.key));
if (entry) setBatchRechargeZoom(entry);
}}
>
{batchRechargeEntries.map((entry) => {
const order = entry.order;
const payment = entry.paymentStatus;
const paymentColor =
payment?.status === 'PAY_STATUS_SUCCESS'
? 'success'
: payment?.status === 'PAY_STATUS_FAILED' ||
payment?.status === 'PAY_STATUS_CLOSED'
? 'error'
: 'processing';
return (
<div
key={entry.account.id}
style={{
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
padding: 12,
textAlign: 'center',
background: token.colorBgContainer,
}}
>
<Text strong ellipsis>
{entry.account.nickname || entry.account.uid}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {entry.account.uid}
</Text>
</div>
{entry.phase === 'queued' && (
<div style={{ padding: '42px 0' }}>
<Spin size="small" />
<div>
<Text type="secondary">排队中</Text>
</div>
</div>
)}
{entry.phase === 'creating' && (
<div style={{ padding: '42px 0' }}>
<Spin />
<div style={{ marginTop: 8 }}>
<Text type="secondary">正在创建订单并解析二维码</Text>
</div>
</div>
)}
{entry.phase === 'error' && (
<Alert
type="error"
showIcon
style={{ marginTop: 10, textAlign: 'left' }}
title="下单失败"
description={entry.error || '未知原因'}
/>
)}
{entry.phase === 'ready' && order && (
<Space
orientation="vertical"
size={8}
style={{ width: '100%', marginTop: 8 }}
>
{order.alipay_qr_code ? (
<div
style={{
display: 'inline-block',
background: '#fff',
padding: 6,
lineHeight: 0,
borderRadius: 6,
}}
>
<QRCode
value={order.alipay_qr_code}
size={160}
bordered={false}
color="#000"
bgColor="#fff"
/>
</div>
) : (
<Alert
type="warning"
showIcon
title="订单已创建但未解析到二维码"
description="可复制支付链接后手动打开"
/>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
{order.amount_yuan} 元 · 预计{' '}
{order.single_reward.toLocaleString()} 微豆
</Text>
<Tag color={paymentColor}>
{payment
? PAYMENT_STATUS_LABELS[payment.status] || payment.status
: '待检查支付状态'}
</Tag>
{payment?.status === 'PAY_STATUS_SUCCESS' && (
<Tag
color={
entry.creditPhase === 'credited'
? 'success'
: entry.creditPhase === 'error'
? 'error'
: 'processing'
}
>
{entry.creditPhase === 'credited'
? `已到账 +${entry.creditPoints ?? 0}`
: entry.creditPhase === 'pending'
? '支付成功,微豆暂未查到'
: entry.creditPhase === 'error'
? '到账查询失败'
: '正在核对到账'}
</Tag>
)}
{entry.creditError && (
<Text type="danger" style={{ fontSize: 12 }}>
{entry.creditError}
</Text>
)}
{entry.statusError && (
<Text type="danger" style={{ fontSize: 12 }}>
{entry.statusError}
</Text>
)}
<Space wrap style={{ justifyContent: 'center' }}>
{order.alipay_qr_code && (
<Button
size="small"
icon={<ZoomInOutlined />}
onClick={() => setBatchRechargeZoom(entry)}
>
放大
</Button>
)}
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => void copyBatchRechargeLink(entry)}
>
复制链接
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={entry.checkingStatus}
onClick={() => void checkBatchRechargeEntry(entry)}
>
刷新状态
</Button>
</Space>
</Space>
)}
</div>
);
})}
</div>
onCopy={(item) => {
const entry = batchRechargeEntryByAccountId.get(Number(item.key));
if (entry) void copyBatchRechargeLink(entry);
}}
onRefresh={(item) => {
const entry = batchRechargeEntryByAccountId.get(Number(item.key));
if (entry) void checkBatchRechargeEntry(entry);
}}
/>
{batchRechargeCreditFailures.length > 0 && (
<Alert
type="error"
showIcon
title={`已支付但微豆到账确认失败(${batchRechargeCreditFailures.length} 个)`}
description={batchRechargeCreditFailures
.slice(0, 5)
.map((entry) =>
`${entry.account.nickname || entry.account.uid}:${entry.creditError || '未查询到到账记录'}`,
)
.join(';')}
/>
)}
</Space>
</Modal>
@@ -3916,116 +3783,18 @@ export default function WeiboAccountsPage({
{!canRecharge ? (
<Alert type="warning" showIcon title="没有充值权限" />
) : (
<Space wrap>
<Space.Compact>
<InputNumber
min={1}
max={1000}
precision={0}
value={rechargeAmount}
onChange={(value) => setRechargeAmount(Number(value) || 1)}
style={{ width: 110 }}
/>
<Button disabled>元</Button>
</Space.Compact>
<Button
type="primary"
loading={rechargeCreating}
disabled={!featureAccount.is_enabled}
onClick={() => void createRecharge()}
>
创建充值订单
</Button>
</Space>
)}
{rechargeOrder && (
<Space
align="start"
size={24}
wrap
style={{ alignItems: 'flex-start' }}
>
<div
style={{
width: 240,
padding: 12,
background: '#fff',
borderRadius: 6,
textAlign: 'center',
}}
>
<QRCode
value={rechargeOrder.alipay_qr_code}
size={216}
bordered={false}
color="#000"
bgColor="#fff"
/>
</div>
<Space orientation="vertical" size={10}>
<Descriptions
size="small"
column={1}
items={[
{
key: 'amount',
label: '支付金额',
children: `${rechargeOrder.amount_yuan} 元`,
},
{
key: 'reward',
label: '预计微豆',
children: rechargeOrder.single_reward.toLocaleString(),
},
{
key: 'order',
label: '订单号',
children: (
<Text copyable style={{ fontSize: 12 }}>
{rechargeOrder.order_id}
</Text>
),
},
]}
/>
<Space wrap>
<Button
icon={<ReloadOutlined />}
loading={paymentStatusLoading}
onClick={() => void refreshPaymentStatus()}
>
刷新支付状态
</Button>
{rechargeOrder.desktop_pay_url && (
<Button
href={rechargeOrder.desktop_pay_url}
target="_blank"
rel="noreferrer"
>
电脑端支付
</Button>
)}
<Button onClick={() => void refreshAfterRecharge()}>
刷新到账
</Button>
</Space>
{paymentStatus && (
<Alert
type={
paymentStatus.status === 'PAY_STATUS_SUCCESS'
? 'success'
: 'info'
}
showIcon
title={
PAYMENT_STATUS_LABELS[paymentStatus.status] ||
paymentStatus.status
}
description={paymentStatus.sub_status}
/>
)}
</Space>
</Space>
<WeiboRechargePanel
enabled={featureAccount.is_enabled}
amount={rechargeAmount}
onAmountChange={setRechargeAmount}
creating={rechargeCreating}
onCreate={() => void createRecharge()}
order={rechargeOrder}
paymentStatus={paymentStatus}
statusLoading={paymentStatusLoading}
onRefreshStatus={() => void refreshPaymentStatus()}
onRefreshBalance={() => void refreshAfterRecharge()}
/>
)}
</Space>
),
+6
View File
@@ -0,0 +1,6 @@
export const WEIBO_PAYMENT_STATUS_LABELS: Record<string, string> = {
PAY_STATUS_PENDING: '等待支付',
PAY_STATUS_SUCCESS: '支付成功',
PAY_STATUS_FAILED: '支付失败',
PAY_STATUS_CLOSED: '订单关闭',
};