Files
affiliate_dash/frontend/src/pages/Delivery.tsx
T
yml2213 78e2fe4c6a fix(delivery): 禁用Rock二次生成二维码并治理上游110001频控报错
1. 业务防乱(专属通道锁定):提货页面一旦生成二维码后,直接禁用'重新生成二维码'按钮并锁定游戏UID输入框,杜绝Token浪费与绑定混乱;
2. 频控治理(前端3秒冷却):手动点击'检查扫码状态'增加3秒倒计时防抖冷却,且对'过于频繁/110001'错误进行平滑提示,不再触发大红报错;
3. 频控治理(后端瞬时防抖与软降级):RockService增加800ms内存并发安全防抖缓存,当上游返回110001时自动优雅降级返回等待扫码状态,不抛出HTTP 400错误。
2026-10-05 18:25:35 +08:00

792 lines
31 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.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,
}