Files
affiliate_dash/frontend/src/pages/Delivery.tsx
T
zhaoxing f0dea6a583 feat: Rock 兑换支持上游异步 pending 并记录发货全链路报文
问题:上游 0666.pro 兑换返回 HTTP 200 + status=pending(官方处理中),
旧逻辑仅凭「没有 error 字段」就判定成功并写 redeemed 终态,导致本地显示
已发货而上游仍是 bound,且 redeemed 为硬终态无法自愈。

后端:
- RedeemSkin 改为三态判定(success / pending / failed),不再无 error 即成功
- 新增 redeeming「发货处理中」状态;pending 不写 redeemed_at/fulfill_id
- 成功落库前二次 refresh 确认上游 redeemed 或 redeemed_at 才置终态
- 新增确认 worker:20s 轮询、30s→5m 指数退避、110001 全局冷却 60s、
  15 分钟未确认仅告警不回退,确认后同步订单为 delivered 并触发回调
- pending 期间禁止重复兑换,订单保持 delivering,不下发发货成功回调
- 新增 rock_delivery_logs 表与记录器:入站参数、内部状态、上游请求/响应
  原文全部留档,保留 180 天
- 分配/绑定校验/刷新/导入/删除/回收站/统计/客服查询全部适配 redeeming
- 迁移 026:redeeming 跟踪字段、部分索引与报文日志表

前端:
- Token 列表新增「发货处理中」筛选项、标签与超时提示
- 删除/回收站纳入 redeeming 危险状态
- 历史抽屉拆为「状态历史 / 发货报文」双页签,按 trace 展示报文详情
- 买家发货页在 pending 时提示官方处理中,无需重复提交

测试:新增 pending 不落终态、异常响应不误判、重复提交不重复兑换、
确认后同步订单、保持 pending 并退避 5 个用例;go build/vet/test 与
前端构建均通过。
2026-11-14 12:09:16 +08:00

824 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import {
Alert,
Button,
Descriptions,
Form,
Image,
Input,
Modal,
Result,
Space,
Spin,
Steps,
Tag,
Typography,
message,
} from 'antd'
import {
CheckCircleOutlined,
ClockCircleOutlined,
CopyOutlined,
DownloadOutlined,
EyeOutlined,
LoadingOutlined,
QrcodeOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SendOutlined,
SyncOutlined,
} from '@ant-design/icons'
import { deliveryApi } from '../api'
import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types'
import { formatDateTime } from '../utils/time'
import { generateDeliveryVoucher } from '../utils/voucher'
import { resolveProductImage } from '../utils/productImage'
const statusMap: Record<string, { color: string; text: string }> = {
paid: { color: 'orange', text: '待发货' },
delivering: { color: 'blue', text: '发货中' },
delivered: { color: 'green', text: '已交付' },
ship_failed: { color: 'red', text: '发货失败' },
cancelled: { color: 'default', text: '已取消' },
}
function dataURLtoBlob(dataurl: string): Blob {
const arr = dataurl.split(',')
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new Blob([u8arr], { type: mime })
}
export default function Delivery() {
const { orderNo = '' } = useParams()
const [searchParams] = useSearchParams()
const [form] = Form.useForm<{ game_account: string }>()
const [order, setOrder] = useState<DeliveryOrderInfo | null>(null)
const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null)
const [boundInfo, setBoundInfo] = useState<{
bound: boolean
role_name?: string
game_account?: string
game_channel?: string
} | null>(null)
const [checkingBind, setCheckingBind] = useState(false)
const [submitResult, setSubmitResult] = useState<DeliverySubmitResult | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [binding, setBinding] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [voucherImageURL, setVoucherImageURL] = useState('')
const [generatingImage, setGeneratingImage] = useState(false)
const [copyingImage, setCopyingImage] = useState(false)
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [manualCheckCooldown, setManualCheckCooldown] = useState(0)
useEffect(() => {
if (manualCheckCooldown <= 0) return
const timer = setTimeout(() => {
setManualCheckCooldown((prev) => Math.max(0, prev - 1))
}, 1000)
return () => clearTimeout(timer)
}, [manualCheckCooldown])
const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo])
const deliveryAuth = useMemo(() => {
const exp = searchParams.get('exp') || ''
const sign = searchParams.get('sign') || ''
return exp && sign ? { exp, sign } : undefined
}, [searchParams])
const gameAccount = Form.useWatch('game_account', form)
const getOrGenerateVoucher = async () => {
if (!order) return ''
if (voucherImageURL) return voucherImageURL
setGeneratingImage(true)
try {
const url = await generateDeliveryVoucher(order)
setVoucherImageURL(url)
return url
} catch (e) {
message.error('生成发货凭证图失败:' + (e instanceof Error ? e.message : '请稍后重试'))
return ''
} finally {
setGeneratingImage(false)
}
}
const handleDownloadVoucher = async () => {
const url = await getOrGenerateVoucher()
if (!url) return
const link = document.createElement('a')
link.href = url
link.download = `发货凭据_${order?.order_no || 'order'}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('凭证图片已生成并开始下载')
}
const handlePreviewVoucher = async () => {
setPreviewModalOpen(true)
await getOrGenerateVoucher()
}
const handleCopyVoucherImage = async () => {
setCopyingImage(true)
try {
const url = await getOrGenerateVoucher()
if (!url) {
return
}
if (
typeof window !== 'undefined' &&
navigator.clipboard &&
typeof ClipboardItem !== 'undefined' &&
navigator.clipboard.write
) {
const blob = dataURLtoBlob(url)
const item = new ClipboardItem({ 'image/png': blob })
await navigator.clipboard.write([item])
message.success('结果图片已复制到剪贴板!可直接粘贴到聊天窗口(Ctrl+V / ⌘+V)')
} else {
setPreviewModalOpen(true)
message.info('当前浏览器环境不支持直接写入剪贴板图片,已为您打开大图,可长按或右键复制图片')
}
} catch {
setPreviewModalOpen(true)
message.warning('写入剪贴板失败,已为您打开凭据预览,可长按或右键复制图片')
} finally {
setCopyingImage(false)
}
}
const fetchOrder = useCallback(
async (silent = false) => {
if (!decodedOrderNo) {
setError('发货链接缺少订单号')
setLoading(false)
return
}
if (!silent) {
setLoading(true)
}
try {
const data = await deliveryApi.getOrder(decodedOrderNo, deliveryAuth)
setOrder(data)
setError('')
} catch (e) {
if (!silent) {
setError(e instanceof Error ? e.message : '订单加载失败')
}
} finally {
if (!silent) {
setLoading(false)
}
}
},
[decodedOrderNo, deliveryAuth]
)
useEffect(() => {
fetchOrder(false)
}, [fetchOrder])
// 当订单处于发货中 (delivering) 时,每 3 秒自动轮询发货进度
useEffect(() => {
if (order?.status !== 'delivering') {
return
}
const timer = setInterval(() => {
fetchOrder(true)
}, 3000)
return () => clearInterval(timer)
}, [order?.status, fetchOrder])
const handleManualRefresh = async () => {
setRefreshing(true)
try {
await fetchOrder(true)
message.success('状态已刷新')
} catch {
message.error('刷新状态失败')
} finally {
setRefreshing(false)
}
}
const checkBindStatus = useCallback(
async (silent = false) => {
if (!bindResult?.bind_uuid) return
if (!silent) setCheckingBind(true)
try {
const res = await deliveryApi.getBindResult(decodedOrderNo, bindResult.bind_uuid, deliveryAuth)
if (res.bind_url) {
setBindResult((prev) => {
if (!prev || prev.bind_url === res.bind_url) return prev
return { ...prev, bind_url: res.bind_url, qr_url: res.qr_url || prev.qr_url }
})
}
if (res.bound) {
setBoundInfo({
bound: true,
role_name: res.role_name,
game_account: res.game_account,
game_channel: res.game_channel,
})
if (!silent) {
message.success(`扫码授权成功!已绑定角色:${res.role_name || res.game_account}`)
}
} else if (!silent) {
message.info(res.message || '尚未检测到扫码授权,请使用微信/QQ扫码后重试')
}
} catch (e) {
if (!silent) {
const errStr = e instanceof Error ? e.message : String(e || '')
if (errStr.includes('频繁') || errStr.includes('110001')) {
message.info('正在同步扫码状态,请稍候...')
} else {
message.error(errStr || '检查绑定状态失败')
}
}
} finally {
if (!silent) setCheckingBind(false)
}
},
[bindResult?.bind_uuid, decodedOrderNo, deliveryAuth]
)
const handleManualCheckBind = () => {
if (manualCheckCooldown > 0 || checkingBind) return
setManualCheckCooldown(3)
checkBindStatus(false)
}
useEffect(() => {
if (!bindResult?.bind_uuid || boundInfo?.bound || submitting || submitResult) {
return
}
const timer = setInterval(() => {
checkBindStatus(true)
}, 5000)
return () => clearInterval(timer)
}, [bindResult?.bind_uuid, boundInfo?.bound, submitting, submitResult, checkBindStatus])
const bindAccount = async () => {
const values = await form.validateFields()
setBinding(true)
setSubmitResult(null)
setBoundInfo(null)
try {
const result = await deliveryApi.bind(decodedOrderNo, values.game_account, deliveryAuth)
setBindResult(result)
message.success('绑定二维码已生成,请使用手机微信/QQ扫码授权')
} catch (e) {
message.error(e instanceof Error ? e.message : '生成二维码失败')
} finally {
setBinding(false)
}
}
const submitDelivery = async () => {
const values = await form.validateFields()
if (!bindResult?.bind_uuid) {
message.warning('请先生成绑定二维码')
return
}
if (!boundInfo?.bound) {
message.warning('请先完成手机微信/QQ扫码授权后再提交发货')
return
}
setSubmitting(true)
try {
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth)
setSubmitResult(result)
// pending 时订单仍是 delivering,保留 submitResult 让买家页展示「官方处理中」文案。
setOrder((prev) =>
prev
? {
...prev,
status: result.status,
can_ship: false,
game_uid: values.game_account,
role_name: (result.game_account as Record<string, string>)?.role_name || boundInfo?.role_name || prev.role_name,
}
: prev
)
if (result.pending) {
message.info(result.message || '官方处理中,系统会自动确认发货结果')
} else {
message.success(result.message || '发货已提交')
}
} catch (e) {
message.error(e instanceof Error ? e.message : '提交发货失败')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<CenteredShell>
<Spin size="large" />
</CenteredShell>
)
}
if (error || !order) {
return (
<CenteredShell>
<Result status="warning" title="订单不可发货" subTitle={error || '订单不存在'} />
</CenteredShell>
)
}
const status = statusMap[order.status] || { color: 'default', text: order.status }
const step = submitResult ? 2 : boundInfo?.bound ? 2 : bindResult ? 1 : 0
return (
<main style={{ minHeight: '100vh', background: '#f5f7fb', padding: '32px 16px' }}>
<div style={{ maxWidth: 880, margin: '0 auto' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
游戏道具发货
</Typography.Title>
</div>
{order.status === 'delivering' && (
<Button
icon={<ReloadOutlined />}
loading={refreshing}
onClick={handleManualRefresh}
>
刷新状态
</Button>
)}
</header>
<section style={panelStyle}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space align="start" style={{ width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 8 }}>
{order.product?.name || '待发货商品'}
</Typography.Title>
<Typography.Text code>{order.product?.sku || '-'}</Typography.Text>
</div>
<Tag color={status.color}>{status.text}</Tag>
</Space>
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered>
<Descriptions.Item label="买家">
{(() => {
const role = (order.role_name || boundInfo?.role_name || '').trim()
if (role && role !== '-') return role
const buyer = (order.buyer_name || '').trim()
if (buyer && buyer !== '-') return buyer
const uid = (order.game_uid || gameAccount || '').trim()
if (uid && uid !== '-') return `玩家 (${uid})`
return '游戏玩家'
})()}
</Descriptions.Item>
<Descriptions.Item label="金额">{order.amount} 积分</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTime(order.created_at)}</Descriptions.Item>
<Descriptions.Item label="渠道">{order.product?.game || '和平精英'}</Descriptions.Item>
</Descriptions>
{order.ship_fail_reason && order.status !== 'ship_failed' && (
<Alert type="error" showIcon message={order.ship_fail_reason} />
)}
{!order.can_ship &&
order.status !== 'delivering' &&
order.status !== 'delivered' &&
order.status !== 'ship_failed' && (
<Alert type="warning" showIcon message={order.cannot_ship_reason || '当前订单暂不可发货'} />
)}
</Space>
</section>
{/* 状态 1:已发货完成 */}
{order.status === 'delivered' && (
<section style={panelStyle}>
<Result
status="success"
title="道具发放成功!"
subTitle="系统已为您完成道具充值发放,请进入游戏查收邮件或背包。"
extra={[
<Button
type="primary"
key="copy-img"
icon={<CopyOutlined />}
loading={copyingImage || generatingImage}
onClick={handleCopyVoucherImage}
style={{ background: '#16a34a', borderColor: '#16a34a' }}
>
复制结果图片
</Button>,
<Button
key="download"
icon={<DownloadOutlined />}
loading={generatingImage}
onClick={handleDownloadVoucher}
>
下载凭据图片
</Button>,
<Button
key="preview"
icon={<EyeOutlined />}
loading={generatingImage}
onClick={handlePreviewVoucher}
>
预览凭据图片
</Button>,
<Button key="refresh" icon={<ReloadOutlined />} onClick={handleManualRefresh} loading={refreshing}>
刷新核验
</Button>,
]}
>
<div style={{ background: '#fafafa', border: '1px solid #f0f0f0', padding: '20px 24px', borderRadius: 12 }}>
{/* 商品卡片信息 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 20, paddingBottom: 16, borderBottom: '1px solid #e2e8f0', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1, minWidth: 220 }}>
{(() => {
const imgSrc = resolveProductImage({ sku: order.product?.sku, name: order.product?.name, url: order.product?.image })
if (!imgSrc) {
return (
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
道具
</div>
)
}
return (
<Image
src={imgSrc}
width={72}
height={72}
alt={order.product?.name || '商品'}
style={{
objectFit: 'contain',
background: '#f8fafc',
borderRadius: 8,
border: '1px solid #e2e8f0',
padding: 4,
}}
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='72' height='72'%3E%3Crect fill='%23e2e8f0' width='72' height='72'/%3E%3C/svg%3E"
/>
)
})()}
<div>
<div style={{ fontSize: 18, fontWeight: 600, color: '#0f172a', marginBottom: 4 }}>
{order.product?.name || '游戏道具商品'}
</div>
<Space wrap size="small">
<Tag color="blue">{order.product?.sku || '-'}</Tag>
{order.product?.game && <Tag color="cyan">{order.product.game}</Tag>}
<Tag color="green">已发放 1 件</Tag>
</Space>
</div>
</div>
<Button
type="primary"
ghost
icon={<CopyOutlined />}
loading={copyingImage || generatingImage}
onClick={handleCopyVoucherImage}
>
复制结果图片
</Button>
</div>
<Descriptions column={{ xs: 1, sm: 2 }} size="middle">
{order.role_name && order.role_name.trim() !== '' && order.role_name.trim() !== '-' && (
<Descriptions.Item label="游戏角色名">
<Typography.Text
strong
style={{ fontSize: 15, color: '#0f172a' }}
copyable={{ text: order.role_name }}
>
{order.role_name}
</Typography.Text>
</Descriptions.Item>
)}
<Descriptions.Item label="游戏区服">
{order.game_channel || (order.game_uid?.length === 10 ? '微信区' : '手Q / 微信区')}
</Descriptions.Item>
<Descriptions.Item label="兑换账号 UID">
<Typography.Text
code
strong
style={{ fontSize: 15 }}
copyable={order.game_uid || gameAccount ? { text: order.game_uid || gameAccount } : false}
>
{order.game_uid || gameAccount || '-'}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="下单时间">{formatDateTime(order.created_at)}</Descriptions.Item>
<Descriptions.Item label="完成时间">
{order.shipped_at ? formatDateTime(order.shipped_at) : '已完成'}
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, padding: '12px 16px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 8, display: 'flex', alignItems: 'center', gap: 10, color: '#15803d', fontSize: 13 }}>
<SafetyCertificateOutlined style={{ fontSize: 20, color: '#16a34a', flexShrink: 0 }} />
<div>
<strong>官方履约已完成:</strong>本次道具发货已成功直充到账。点击「复制结果图片」可直接复制凭据图并在微信、客服聊天窗直接粘贴(Ctrl+V / ⌘+V)。
</div>
</div>
</div>
</Result>
</section>
)}
{/* 状态 2:发货处理中 */}
{order.status === 'delivering' && (
<section style={{ ...panelStyle, borderTop: '4px solid #1677ff' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 28, color: '#1677ff' }} spin />} />
<div>
<Typography.Title level={4} style={{ margin: 0, color: '#1677ff' }}>
{submitResult?.pending ? '上游官方正在处理发货...' : '道具正在极速发货中...'}
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{submitResult?.pending
? '上游已受理兑换,腾讯官方仍在处理中,系统会自动确认结果,无需重复提交。'
: '已成功提交上游发放节点,通常在 10 ~ 30 秒内到账,页面会自动更新。'}
</Typography.Text>
</div>
</div>
<div style={{ background: '#f0f5ff', border: '1px solid #d6e4ff', padding: '16px 20px', borderRadius: 8 }}>
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="发放目标 UID">
<Typography.Text strong code>{order.game_uid || gameAccount || '-'}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="识别角色名">
<Typography.Text strong>{order.role_name || '正在识别...'}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="游戏区服">{order.game_channel || '自动识别'}</Descriptions.Item>
<Descriptions.Item label="处理进度">
<Tag color="processing" icon={<SyncOutlined spin />}>
{submitResult?.pending ? '官方处理中' : '队列发放中'}
</Tag>
</Descriptions.Item>
</Descriptions>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
系统正在自动刷新进度(每 3 秒)
</Typography.Text>
<Button
icon={<ReloadOutlined />}
loading={refreshing}
onClick={handleManualRefresh}
>
刷新发货进度
</Button>
</div>
</Space>
</section>
)}
{/* 状态 3:发货失败且不可自动重试 */}
{order.status === 'ship_failed' && !order.can_ship && (
<section style={panelStyle}>
<Result
status="error"
title="发货遇到异常"
subTitle={order.ship_fail_reason || order.cannot_ship_reason || '渠道发货未成功,请核对信息或联系客服'}
extra={[
<Button key="retry" type="primary" onClick={handleManualRefresh} loading={refreshing}>
重新查询订单状态
</Button>,
]}
/>
</section>
)}
{/* 状态 4:待绑定扫码(含未提交上游的 ship_failed 可重试) */}
{(order.status === 'paid' || (order.status === 'ship_failed' && order.can_ship)) && (
<section style={panelStyle}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{order.status === 'ship_failed' && (
<Alert
type="warning"
showIcon
message="上次发货未成功,可重新绑定并提交"
description={order.ship_fail_reason || order.cannot_ship_reason || undefined}
/>
)}
<Steps
current={step}
items={[
{ title: '输入账号', icon: <ClockCircleOutlined /> },
{ title: '微信/QQ 扫码绑定', icon: <QrcodeOutlined /> },
{ title: '提交发货', icon: <CheckCircleOutlined /> },
]}
/>
<Form form={form} layout="vertical" initialValues={{ game_account: order.buyer_name || '' }}>
<Form.Item
name="game_account"
label="游戏账号(用于接收充值)"
rules={[{ required: true, message: '请输入接收道具的游戏账号 UID' }]}
extra={bindResult ? '已锁定专属发货通道,如需修改账号请刷新页面重试' : '请务必核对游戏账号 UID,充值成功后无法撤回'}
>
<Input
placeholder="请输入游戏账号 UID"
size="large"
disabled={Boolean(bindResult)}
/>
</Form.Item>
</Form>
<Space wrap>
<Button
type={bindResult ? 'default' : 'primary'}
icon={<QrcodeOutlined />}
loading={binding}
onClick={bindAccount}
disabled={Boolean(bindResult)}
>
{bindResult ? '专属二维码已生成' : '生成绑定二维码'}
</Button>
{bindResult && !boundInfo?.bound && (
<Button
icon={<ReloadOutlined />}
loading={checkingBind}
disabled={manualCheckCooldown > 0}
onClick={handleManualCheckBind}
>
{manualCheckCooldown > 0 ? `检查扫码状态 (${manualCheckCooldown}s)` : '检查扫码状态'}
</Button>
)}
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
disabled={!bindResult || !gameAccount || !boundInfo?.bound}
onClick={submitDelivery}
style={
boundInfo?.bound
? { background: '#16a34a', borderColor: '#16a34a' }
: undefined
}
>
{boundInfo?.bound ? '扫码已确认,立即发货' : '请先微信/QQ扫码授权'}
</Button>
</Space>
{bindResult && !boundInfo?.bound && (
<Alert
type="info"
showIcon
icon={<Spin size="small" />}
message="正在等待扫码授权"
description="请使用手机微信或手机QQ扫描下方二维码完成角色授权。系统检测到授权成功后将自动解锁发货按钮。"
/>
)}
{boundInfo?.bound && (
<Alert
type="success"
showIcon
message="✅ 扫码授权成功!已绑定游戏角色"
description={
<div style={{ marginTop: 4 }}>
<div>角色名称:<strong>{boundInfo.role_name || '-'}</strong></div>
<div>游戏账号 UID:<strong>{boundInfo.game_account || gameAccount || '-'}</strong></div>
{boundInfo.game_channel && <div>游戏区服:{boundInfo.game_channel}</div>}
<div style={{ color: '#16a34a', fontWeight: 500, marginTop: 6 }}>
已确认绑定无误,请点击上方「扫码已确认,立即发货」按钮完成充值发货。
</div>
</div>
}
/>
)}
{bindResult && (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
<Image
src={bindResult.qr_url}
width={220}
height={220}
alt="绑定二维码"
style={{ background: '#fff', border: '1px solid #e5e7eb' }}
/>
<Space direction="vertical" size="small" style={{ flex: 1, minWidth: 260 }}>
<Typography.Text strong>绑定凭证</Typography.Text>
<Typography.Text code copyable={{ text: bindResult.bind_uuid }}>
{bindResult.bind_uuid}
</Typography.Text>
<Typography.Link href={bindResult.bind_url} target="_blank" rel="noreferrer">
打开绑定链接
</Typography.Link>
</Space>
</div>
)}
{submitResult && (
<Alert
type="success"
showIcon
message={submitResult.message}
description={submitResult.provider_order_no ? `上游单号:${submitResult.provider_order_no}` : undefined}
/>
)}
</Space>
</section>
)}
</Space>
</div>
<Modal
title={
<Space>
<SafetyCertificateOutlined style={{ color: '#16a34a' }} />
<span>发货电子凭证预览</span>
</Space>
}
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
footer={[
<Button key="close" onClick={() => setPreviewModalOpen(false)}>
关闭
</Button>,
<Button
key="copy"
icon={<CopyOutlined />}
loading={copyingImage}
onClick={handleCopyVoucherImage}
>
复制图片到剪贴板
</Button>,
<Button
key="download"
type="primary"
icon={<DownloadOutlined />}
loading={generatingImage}
onClick={handleDownloadVoucher}
style={{ background: '#16a34a', borderColor: '#16a34a' }}
>
保存凭证图片
</Button>,
]}
width={640}
centered
>
{generatingImage ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 16, color: '#64748b' }}>正在为您绘制高清发货凭证图片...</div>
</div>
) : voucherImageURL ? (
<div style={{ textAlign: 'center' }}>
<img
src={voucherImageURL}
alt="发货电子凭证"
style={{
width: '100%',
maxHeight: '68vh',
objectFit: 'contain',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
}}
/>
<div style={{ marginTop: 12, color: '#64748b', fontSize: 13 }}>
提示:支持长按或右键复制/保存图片到本地相册,作为官方维权及售后保障证据。
</div>
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Button onClick={getOrGenerateVoucher}>生成凭证</Button>
</div>
)}
</Modal>
</main>
)
}
function CenteredShell({ children }: { children: ReactNode }) {
return (
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: '#f5f7fb', padding: 24 }}>
{children}
</main>
)
}
const panelStyle: CSSProperties = {
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: 24,
}