1. 业务防乱(专属通道锁定):提货页面一旦生成二维码后,直接禁用'重新生成二维码'按钮并锁定游戏UID输入框,杜绝Token浪费与绑定混乱; 2. 频控治理(前端3秒冷却):手动点击'检查扫码状态'增加3秒倒计时防抖冷却,且对'过于频繁/110001'错误进行平滑提示,不再触发大红报错; 3. 频控治理(后端瞬时防抖与软降级):RockService增加800ms内存并发安全防抖缓存,当上游返回110001时自动优雅降级返回等待扫码状态,不抛出HTTP 400错误。
792 lines
31 KiB
TypeScript
792 lines
31 KiB
TypeScript
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.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)
|
||
}, 2500)
|
||
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)
|
||
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
|
||
)
|
||
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>
|
||
<Typography.Text type="secondary">
|
||
订单编号:{decodedOrderNo}
|
||
</Typography.Text>
|
||
</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="买家">{order.buyer_name || '-'}</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, 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: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||
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">
|
||
<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="游戏角色名">
|
||
<Typography.Text
|
||
strong
|
||
style={{ fontSize: 15, color: '#0f172a' }}
|
||
copyable={order.role_name ? { text: order.role_name } : false}
|
||
>
|
||
{order.role_name || '-'}
|
||
</Typography.Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="区服信息">{order.game_channel || '-'}</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' }}>
|
||
道具正在极速发货中...
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||
已成功提交上游发放节点,通常在 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 />}>队列发放中</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,
|
||
}
|