feat: 优化发货交付页面与凭据生成,支持一键复制结果图片及官方商品图片本地缓存

- 交付界面重构:隐藏内部订单号、流水号与发货渠道,展示聚焦于买家游戏UID、角色名与交付时间
- 发货结果凭据生成:采用 Canvas 生成高清发货存证图,优化存证说明文案与自适应排版
- 一键复制图片:支持直接将结果凭证图片写入系统剪贴板,方便客服在聊天窗口快捷粘贴
- 官方图片本地化缓存:全量下载乐玩商品官方主图并建立本地持久化路由,规避外部源站跨域及破损问题
- 修复乐玩渠道角色UID提取与履约状态幂等回填逻辑
This commit is contained in:
yml2213
2026-09-14 17:19:12 +08:00
parent f7f48d0feb
commit 5f642eb7a6
34 changed files with 1428 additions and 66 deletions
+3
View File
@@ -240,7 +240,10 @@ export const lewanApi = {
request.get('/platform/lewan/products', { params }).then((r) => r.data.data as LewanProductListResult),
syncCatalog: (data: SyncLewanProductInput) =>
request.post('/platform/lewan/sync-catalog', data).then((r) => r.data.data as ProductCatalogItem),
batchSyncCatalog: (products: SyncLewanProductInput[]) =>
request.post('/platform/lewan/batch-sync-catalog', { products }).then((r) => r.data.data as { synced_count: number }),
updateRouting: (id: number, data: UpdateProductRoutingInput) =>
request.put(`/platform/products/${id}/routing`, data).then((r) => r.data.data as { updated: boolean }),
}
+432 -37
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import {
Alert,
@@ -7,6 +7,7 @@ import {
Form,
Image,
Input,
Modal,
Result,
Space,
Spin,
@@ -18,12 +19,21 @@ import {
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: '待发货' },
@@ -33,17 +43,34 @@ const statusMap: Record<string, { color: string; text: string }> = {
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 { channel = 'dlc', orderNo = '' } = useParams()
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 [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 decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo])
const deliveryAuth = useMemo(() => {
@@ -53,21 +80,122 @@ export default function Delivery() {
}, [searchParams])
const gameAccount = Form.useWatch('game_account', form)
useEffect(() => {
if (!decodedOrderNo) {
setError('发货链接缺少订单号')
setLoading(false)
return
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)
}
setLoading(true)
deliveryApi.getOrder(decodedOrderNo, deliveryAuth)
.then((data) => {
}
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) => setError(e instanceof Error ? e.message : '订单加载失败'))
.finally(() => setLoading(false))
}, [decodedOrderNo, deliveryAuth])
} 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 bindAccount = async () => {
const values = await form.validateFields()
@@ -94,7 +222,17 @@ export default function Delivery() {
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 } : prev)
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 || prev.role_name,
}
: prev
)
message.success(result.message || '已提交')
} catch (e) {
message.error(e instanceof Error ? e.message : '提交失败')
@@ -126,13 +264,24 @@ export default function Delivery() {
<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>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
游戏道具发货
</Typography.Title>
<Typography.Text type="secondary">
{channel.toUpperCase()} · {decodedOrderNo}
</Typography.Text>
<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}>
@@ -151,40 +300,222 @@ export default function Delivery() {
<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 || channel.toUpperCase()}</Descriptions.Item>
<Descriptions.Item label="渠道">{order.product?.game || '自动发货'}</Descriptions.Item>
</Descriptions>
{order.ship_fail_reason && (
{order.ship_fail_reason && order.status !== 'ship_failed' && (
<Alert type="error" showIcon message={order.ship_fail_reason} />
)}
{!order.can_ship && (
<Alert type="warning" showIcon message={order.cannot_ship_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>
{order.can_ship && (
{/* 状态 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 }}>
{order.product?.image ? (
<Image
src={resolveProductImage(order.product.image)}
width={72}
height={72}
alt={order.product.name}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
/>
) : (
<div style={{ width: 72, height: 72, background: '#e2e8f0', borderRadius: 8, display: 'grid', placeItems: 'center', color: '#64748b' }}>
道具
</div>
)}
<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>
<Tag color="purple">{order.amount} 积分</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' && (
<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:待绑定扫码 */}
{order.status === 'paid' && (
<section style={panelStyle}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Steps
current={step}
items={[
{ title: '填写 UID', icon: <ClockCircleOutlined /> },
{ title: '扫码绑定', icon: <QrcodeOutlined /> },
{ title: '输入账号', icon: <ClockCircleOutlined /> },
{ title: '微信/QQ 扫码绑定', icon: <QrcodeOutlined /> },
{ title: '提交发货', icon: <CheckCircleOutlined /> },
]}
/>
<Form form={form} layout="vertical" initialValues={{ game_account: order.game_uid || '' }}>
<Form form={form} layout="vertical" initialValues={{ game_account: order.buyer_name || '' }}>
<Form.Item
name="game_account"
label="游戏 UID"
rules={[
{ required: true, message: '请输入游戏 UID' },
{ pattern: /^\d+$/, message: '游戏 UID 仅支持数字' },
]}
label="游戏账号(用于接收充值)"
rules={[{ required: true, message: '请输入接收道具的游戏账号 UID' }]}
extra="请务必核对游戏账号 UID,充值成功后无法撤回"
>
<Input size="large" inputMode="numeric" placeholder="请输入游戏 UID" />
<Input placeholder="请输入游戏账号 UID" size="large" />
</Form.Item>
</Form>
@@ -241,6 +572,70 @@ export default function Delivery() {
)}
</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>
)
}
+175 -14
View File
@@ -60,6 +60,8 @@ export default function PlatformProducts() {
const [lewanPage, setLewanPage] = useState(1)
const [lewanTotal, setLewanTotal] = useState(0)
const [lewanKeyword, setLewanKeyword] = useState('')
const [selectedLewanMap, setSelectedLewanMap] = useState<Map<string, LewanProductItem>>(new Map())
const [batchSubmitting, setBatchSubmitting] = useState(false)
// 路由配置 Modal 状态
const [configModalOpen, setConfigModalOpen] = useState(false)
@@ -219,13 +221,14 @@ export default function PlatformProducts() {
// 打开将乐玩商品导入为自营目录的 Modal
const openSyncModal = (lewanProduct: LewanProductItem) => {
syncForm.resetFields()
const unitPrice = Number.parseFloat(lewanProduct.effectiveUnitPrice || '')
const unitPriceFen = Number.parseFloat(lewanProduct.effectiveUnitPrice || '')
const unitPriceYuan = Number.isFinite(unitPriceFen) ? Number((unitPriceFen / 100).toFixed(2)) : 0
syncForm.setFieldsValue({
lewan_product_id: lewanProduct.id,
name: lewanProduct.name,
category: lewanProduct.categoryName || '未分类',
sku: `LEWAN_${lewanProduct.id.slice(0, 8).toUpperCase()}`,
price_yuan: Number.isFinite(unitPrice) ? unitPrice.toFixed(2) : 0,
price_yuan: unitPriceYuan,
delivery_type: lewanProduct.deliveryType || 'LINK_BINDING',
})
setSyncModalOpen(true)
@@ -256,6 +259,92 @@ export default function PlatformProducts() {
}
}
// 批量导入选中的乐玩商品
const handleBatchSync = () => {
const selectedItems = Array.from(selectedLewanMap.values())
if (selectedItems.length === 0) return
Modal.confirm({
title: `确认批量导入选中的 ${selectedItems.length} 个乐玩商品?`,
content: (
<div>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
将自动按乐玩供货价生成自营商品并配置为乐玩履约模式,同步加入平台自营目录。
</Paragraph>
<div
style={{
maxHeight: 200,
overflowY: 'auto',
background: '#f8fafc',
padding: '8px 12px',
borderRadius: 6,
fontSize: 12,
border: '1px solid #e2e8f0',
}}
>
{selectedItems.map((item, idx) => {
const unitPriceFen = Number.parseFloat(item.effectiveUnitPrice || '')
const unitPriceYuan = Number.isFinite(unitPriceFen)
? (unitPriceFen / 100).toFixed(2)
: '0.00'
return (
<div
key={item.id}
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '3px 0',
borderBottom: idx < selectedItems.length - 1 ? '1px dashed #e2e8f0' : 'none',
}}
>
<span style={{ fontWeight: 500, marginRight: 8 }}>{item.name}</span>
<span style={{ color: '#2563eb', whiteSpace: 'nowrap' }}>{unitPriceYuan} 元</span>
</div>
)
})}
</div>
</div>
),
okText: `确认导入 (${selectedItems.length})`,
cancelText: '取消',
width: 500,
onOk: async () => {
setBatchSubmitting(true)
try {
const payload: SyncLewanProductInput[] = selectedItems.map((p) => {
const unitPriceFen = Number.parseFloat(p.effectiveUnitPrice || '')
return {
lewan_product_id: p.id,
name: p.name,
category: p.categoryName || '未分类',
sku: `LEWAN_${p.id.slice(0, 8).toUpperCase()}`,
price: Number.isFinite(unitPriceFen) ? Math.round(unitPriceFen) : 1000,
delivery_type: p.deliveryType || 'LINK_BINDING',
}
})
let count = 0
try {
const res = await lewanApi.batchSyncCatalog(payload)
count = res.synced_count
} catch {
// 兼容后端尚未重启的情况:自动降级为循环单条导入
for (const item of payload) {
await lewanApi.syncCatalog(item)
count++
}
}
message.success(`成功批量导入 ${count} 个商品至自营目录!`)
setSelectedLewanMap(new Map())
loadCatalog()
} catch (e) {
message.error(e instanceof Error ? e.message : '批量导入失败')
} finally {
setBatchSubmitting(false)
}
},
})
}
// 快捷从乐玩列表快速关联到当前配置的商品
const quickAttachLewanProduct = (p: LewanProductItem) => {
configForm.setFieldsValue({
@@ -437,10 +526,10 @@ export default function PlatformProducts() {
{
title: '供货价',
dataIndex: 'effectiveUnitPrice',
width: 100,
width: 110,
render: (v?: string) => {
const n = Number.parseFloat(v || '')
return Number.isFinite(n) ? `${n.toFixed(2)} 元` : '-'
return Number.isFinite(n) ? `${(n / 100).toFixed(2)} 元` : '-'
},
},
{
@@ -803,25 +892,41 @@ export default function PlatformProducts() {
<Space align="center">
<CloudDownloadOutlined style={{ color: '#2563eb' }} />
<span>乐玩开放平台商品库 (实时拉取)</span>
{selectedLewanMap.size > 0 && (
<Tag color="blue">已勾选 {selectedLewanMap.size} 项</Tag>
)}
</Space>
}
placement="right"
width={780}
width={820}
open={lewanDrawerOpen}
onClose={() => setLewanDrawerOpen(false)}
extra={
<Button
icon={<ReloadOutlined />}
size="small"
onClick={() => loadLewanProducts(lewanPage, lewanKeyword)}
loading={lewanLoading}
>
刷新乐玩库
</Button>
<Space>
{selectedLewanMap.size > 0 && (
<Button
type="primary"
size="small"
icon={<ImportOutlined />}
loading={batchSubmitting}
onClick={handleBatchSync}
>
批量导入 ({selectedLewanMap.size})
</Button>
)}
<Button
icon={<ReloadOutlined />}
size="small"
onClick={() => loadLewanProducts(lewanPage, lewanKeyword)}
loading={lewanLoading}
>
刷新乐玩库
</Button>
</Space>
}
>
<Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 16 }}>
此处实时连通乐玩开放平台 API 拉取最新可供货商品。您可以点击「导入为自营」直接生成自营商品,或在配置弹窗中直接关联该商品。
此处实时连通乐玩开放平台 API 拉取最新可供货商品。勾选多项可一键批量导入,或点击单行「导入为自营」单独配置。
</Paragraph>
<Space style={{ marginBottom: 16, width: '100%' }}>
@@ -843,8 +948,64 @@ export default function PlatformProducts() {
</Button>
</Space>
{selectedLewanMap.size > 0 && (
<div
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#eff6ff',
border: '1px solid #bfdbfe',
padding: '8px 16px',
borderRadius: 8,
}}
>
<Space>
<CheckCircleOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span>
已跨页勾选 <strong>{selectedLewanMap.size}</strong> 个商品
</span>
</Space>
<Space>
<Button size="small" onClick={() => setSelectedLewanMap(new Map())}>
清空选择
</Button>
<Button
type="primary"
size="small"
icon={<ImportOutlined />}
loading={batchSubmitting}
onClick={handleBatchSync}
>
批量导入为自营 ({selectedLewanMap.size})
</Button>
</Space>
</div>
)}
<Table
rowKey="id"
rowSelection={{
selectedRowKeys: Array.from(selectedLewanMap.keys()),
onChange: (keys: React.Key[], selectedRows: LewanProductItem[]) => {
setSelectedLewanMap((prev) => {
const next = new Map(prev)
const currentPageIds = new Set(lewanProducts.map((p) => p.id))
for (const id of currentPageIds) {
if (!keys.includes(id)) {
next.delete(id)
}
}
for (const row of selectedRows) {
if (row && row.id) {
next.set(row.id, row)
}
}
return next
})
},
}}
loading={lewanLoading}
columns={lewanColumns}
dataSource={lewanProducts}
+2
View File
@@ -185,6 +185,8 @@ export interface DeliveryOrderInfo {
product?: DeliveryProduct
buyer_name: string
amount: number
provider_order_no?: string
provider_name?: string
created_at: string
shipped_at?: string | null
ship_fail_reason?: string
+24
View File
@@ -0,0 +1,24 @@
/**
* 解析商品图片地址:
* 1. 本地相对路径(以 / 开头)直接返回;
* 2. 乐玩/官方商品 UUID 地址,优先使用已下载至本地的图片缓存;
* 3. 其他外链图片走后端持久化缓存代理 (/api/delivery/v1/image-proxy),杜绝跨域拦截与画布污染。
*/
export function resolveProductImage(url?: string | null): string {
if (!url) return ''
const trimmed = url.trim()
if (!trimmed) return ''
if (trimmed.startsWith('/')) {
return trimmed
}
// 匹配乐玩官方商品 ID:/products/<uuid>/...
const uuidMatch = trimmed.match(/\/products\/([a-zA-Z0-9-]+)(?:\/|\.|$)/)
if (uuidMatch && uuidMatch[1]) {
return `/images/products/${uuidMatch[1]}.webp`
}
// 其它外部地址统一经由代理及本地磁盘缓存返回
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(trimmed)}`
}
+277
View File
@@ -0,0 +1,277 @@
import type { DeliveryOrderInfo } from '../types'
import { formatDateTime } from './time'
import { resolveProductImage } from './productImage'
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Failed to load image'))
img.src = src
})
}
/**
* 绘制高保真发货凭证卡片图片,返回 base64 PNG dataURL
*/
export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise<string> {
const width = 1080
const height = 1160
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Canvas 2D context not available')
}
// 1. 背景底色
ctx.fillStyle = '#f8fafc'
ctx.fillRect(0, 0, width, height)
// 2. 主卡片容器
const cardX = 40
const cardY = 40
const cardW = width - 80
const cardH = height - 80
const cardRadius = 24
// 卡片阴影与白色背景
ctx.save()
ctx.fillStyle = '#ffffff'
ctx.shadowColor = 'rgba(15, 23, 42, 0.08)'
ctx.shadowBlur = 32
ctx.shadowOffsetY = 16
fillRoundRect(ctx, cardX, cardY, cardW, cardH, cardRadius)
ctx.restore()
// 3. 顶部 Header (深色渐变)
const headerH = 220
const grad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + headerH)
grad.addColorStop(0, '#0f172a')
grad.addColorStop(1, '#1e293b')
ctx.save()
clipRoundRect(ctx, cardX, cardY, cardW, headerH, [cardRadius, cardRadius, 0, 0])
ctx.fillStyle = grad
ctx.fillRect(cardX, cardY, cardW, headerH)
// 顶部装饰圆点
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
ctx.beginPath()
ctx.arc(cardX + cardW - 60, cardY + 40, 120, 0, Math.PI * 2)
ctx.fill()
// 平台徽标 / 标题
ctx.fillStyle = '#38bdf8'
ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText('GAME FULFILLMENT · OFFICIAL RECEIPT', cardX + 48, cardY + 60)
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 44px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText('游戏道具发货电子凭证', cardX + 48, cardY + 124)
ctx.fillStyle = '#94a3b8'
ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText('官方直充已完成 · 售后维权与电子核销依据', cardX + 48, cardY + 168)
ctx.restore()
// 4. 发货成功电子印章 (Stamp)
drawSuccessStamp(ctx, cardX + cardW - 170, cardY + 140)
// 5. 商品展示卡片
const prodCardY = cardY + headerH + 32
const prodCardH = 210
ctx.fillStyle = '#f8fafc'
fillRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16)
ctx.strokeStyle = '#e2e8f0'
ctx.lineWidth = 1
strokeRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16)
// 尝试加载商品图片(优先命中本地下载缓存)
let productImgLoaded = false
if (order.product?.image) {
try {
const imgURL = resolveProductImage(order.product.image)
const img = await loadImage(imgURL)
const imgSize = 146
const imgX = cardX + 64
const imgY = prodCardY + 32
ctx.save()
clipRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12)
ctx.drawImage(img, imgX, imgY, imgSize, imgSize)
ctx.restore()
ctx.strokeStyle = '#cbd5e1'
strokeRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12)
productImgLoaded = true
} catch {
productImgLoaded = false
}
}
// 商品文字信息
const textLeft = productImgLoaded ? cardX + 240 : cardX + 72
ctx.fillStyle = '#0f172a'
ctx.font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText(truncateString(order.product?.name || '游戏道具商品', 20), textLeft, prodCardY + 70)
// 商品标签 (SKU & 游戏)
ctx.fillStyle = '#64748b'
ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
const skuText = `SKU: ${order.product?.sku || '-'}`
const gameText = order.product?.game ? ` · ${order.product.game}` : ''
ctx.fillText(skuText + gameText, textLeft, prodCardY + 120)
// 金额与数量
ctx.fillStyle = '#16a34a'
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText(`已发放: 1 件 | 价值: ${order.amount} 积分`, textLeft, prodCardY + 168)
// 6. 核心凭证字段明细表
const tableY = prodCardY + prodCardH + 32
const tableW = cardW - 80
const fields = [
{ label: '游戏账号 UID', value: order.game_uid || '-', highlight: true },
{ label: '游戏角色名', value: order.role_name || '-', highlight: true },
{ label: '区服信息', value: order.game_channel || '-' },
{ label: '下单时间', value: formatDateTime(order.created_at) },
{ label: '交付时间', value: order.shipped_at ? formatDateTime(order.shipped_at) : '已交付' },
]
let curY = tableY
const rowHeight = 72
fields.forEach((f, idx) => {
ctx.fillStyle = idx % 2 === 0 ? '#f8fafc' : '#ffffff'
fillRoundRect(ctx, cardX + 40, curY, tableW, rowHeight, 8)
// 标签文字
ctx.fillStyle = '#64748b'
ctx.font = '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText(f.label, cardX + 68, curY + 46)
// 数值文字
ctx.fillStyle = f.highlight ? '#0f172a' : '#334155'
ctx.font = f.highlight
? 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
: '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
const valText = truncateString(f.value, 36)
ctx.fillText(valText, cardX + 320, curY + 46)
curY += rowHeight + 8
})
// 7. 底部防伪与存证声明
const footerY = curY + 24
ctx.strokeStyle = '#e2e8f0'
ctx.beginPath()
ctx.moveTo(cardX + 40, footerY)
ctx.lineTo(cardX + cardW - 40, footerY)
ctx.stroke()
ctx.fillStyle = '#94a3b8'
ctx.font = '22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.fillText('注:此电子凭证为系统自动发货存证,道具已成功充值至指定角色,请登录游戏查收。', cardX + 48, footerY + 40)
ctx.fillText(`凭据生成时间:${formatDateTime(new Date().toISOString())}`, cardX + 48, footerY + 76)
return canvas.toDataURL('image/png')
}
// 辅助:绘制发货成功的印章
function drawSuccessStamp(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.save()
ctx.translate(x, y)
ctx.rotate((-14 * Math.PI) / 180) // 稍倾斜印章质感
// 外圈圆形边框
ctx.strokeStyle = '#22c55e'
ctx.lineWidth = 4
ctx.beginPath()
ctx.arc(0, 0, 68, 0, Math.PI * 2)
ctx.stroke()
// 内圈虚线
ctx.setLineDash([4, 4])
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.arc(0, 0, 60, 0, Math.PI * 2)
ctx.stroke()
// 印章文字
ctx.fillStyle = '#22c55e'
ctx.font = 'bold 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('已发货完成', 0, -12)
ctx.font = 'bold 15px sans-serif'
ctx.fillText('VERIFIED', 0, 16)
ctx.restore()
}
function fillRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number | number[]
) {
ctx.beginPath()
roundRectPath(ctx, x, y, width, height, radius)
ctx.fill()
}
function strokeRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number | number[]
) {
ctx.beginPath()
roundRectPath(ctx, x, y, width, height, radius)
ctx.stroke()
}
function clipRoundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number | number[]
) {
ctx.beginPath()
roundRectPath(ctx, x, y, width, height, radius)
ctx.clip()
}
function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number | number[]
) {
const r = typeof radius === 'number' ? [radius, radius, radius, radius] : radius
const [tl, tr, br, bl] = r
ctx.moveTo(x + tl, y)
ctx.lineTo(x + width - tr, y)
ctx.quadraticCurveTo(x + width, y, x + width, y + tr)
ctx.lineTo(x + width, y + height - br)
ctx.quadraticCurveTo(x + width, y + height, x + width - br, y + height)
ctx.lineTo(x + bl, y + height)
ctx.quadraticCurveTo(x, y + height, x, y + height - bl)
ctx.lineTo(x, y + tl)
ctx.quadraticCurveTo(x, y, x + tl, y)
ctx.closePath()
}
function truncateString(str: string, maxLen: number): string {
if (!str) return '-'
if (str.length <= maxLen) return str
return str.slice(0, maxLen - 1) + '…'
}