feat(delivery-voucher): 优化发货凭据为一键复制深海蓝高清图并补齐商品本地图片
1. 管理后台 Rock 表格优化:将原「官方发货凭证(FulfillId)」列升级为「发货结果图片」列,提供一键「复制结果图」与「高清预览」Modal,点击即可自动生成凭据大图并直接写入剪贴板(支持在微信/QQ客服聊天框中直接 Ctrl+V 粘贴图片); 2. 商品图片本地缓存与展示修复:从官方 HAR 提取黑色 ROCK 长袖 T 恤与蓝色运动服上衣高清立绘(rock_black_tshirt.webp / rock_blue_sportswear.webp),优化 productImage 自动识别与 Delivery 提货页图片自适应居中,彻底消除灰色占位块; 3. 标杆级发货凭据绘制重构:参考腾讯官方电竞风格设计,重构 640x960 视网膜双倍高清 Canvas 凭据绘制(深海蓝微光渐变背景、顶部微光加粗「兑换成功」、正圆形双层光环立绘、详细角色区服参数对齐、底部翡翠绿发光胶囊按钮),全方位超越 0666 官方粗糙原图; 4. 履约结果字段持久化:后端发货成功后同步持久化角色名、游戏大区与 UID 至 fulfillment_orders 结果报文,确保提货页区服与角色信息完整呈现。
This commit is contained in:
@@ -231,15 +231,6 @@ func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder
|
||||
currencyName = token.TargetCurrency
|
||||
}
|
||||
|
||||
patch := map[string]interface{}{
|
||||
"provider": ProviderRock,
|
||||
"provider_order_no": fulfillIDStr,
|
||||
"fulfill_id": token.UpstreamFulfillID,
|
||||
"skin": skinName,
|
||||
"currency": currencyName,
|
||||
"currency_quantity": token.TargetCurrencyQuantity,
|
||||
}
|
||||
|
||||
roleName := token.ActualRoleName
|
||||
if roleName == "" {
|
||||
roleName = resultDataString(order.ResultData, "role_name")
|
||||
@@ -249,6 +240,18 @@ func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder
|
||||
channel = resultDataString(order.ResultData, "game_channel")
|
||||
}
|
||||
|
||||
patch := map[string]interface{}{
|
||||
"provider": ProviderRock,
|
||||
"provider_order_no": fulfillIDStr,
|
||||
"fulfill_id": token.UpstreamFulfillID,
|
||||
"skin": skinName,
|
||||
"currency": currencyName,
|
||||
"currency_quantity": token.TargetCurrencyQuantity,
|
||||
"role_name": roleName,
|
||||
"game_channel": channel,
|
||||
"game_uid": expectedUID,
|
||||
}
|
||||
|
||||
return &ProviderSubmitResult{
|
||||
ProviderOrderNo: fulfillIDStr,
|
||||
NextStatus: model.OrderStatusDelivered,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -431,7 +431,7 @@ export default function Delivery() {
|
||||
<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 })
|
||||
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' }}>
|
||||
@@ -445,7 +445,13 @@ export default function Delivery() {
|
||||
width={72}
|
||||
height={72}
|
||||
alt={order.product?.name || '商品'}
|
||||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
|
||||
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"
|
||||
/>
|
||||
)
|
||||
@@ -492,7 +498,9 @@ export default function Delivery() {
|
||||
{order.role_name || '-'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="区服信息">{order.game_channel || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="区服信息">
|
||||
{order.game_channel || (order.game_uid?.length === 10 ? '微信区' : '手Q / 微信区')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">{formatDateTime(order.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">
|
||||
{order.shipped_at ? formatDateTime(order.shipped_at) : '已完成'}
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ControlOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
FireOutlined,
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -36,6 +39,8 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../utils/voucher'
|
||||
import { formatDateTime } from '../utils/time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -49,6 +54,11 @@ export default function PlatformRock() {
|
||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
||||
const [purchaseCount, setPurchaseCount] = useState(50)
|
||||
|
||||
// 凭证图片操作状态
|
||||
const [copyingVoucherId, setCopyingVoucherId] = useState<number | null>(null)
|
||||
const [previewVoucherUrl, setPreviewVoucherUrl] = useState('')
|
||||
const [previewVoucherOpen, setPreviewVoucherOpen] = useState(false)
|
||||
|
||||
// Token 列表与筛选
|
||||
const [tokens, setTokens] = useState<RockTokenItem[]>([])
|
||||
const [totalTokens, setTotalTokens] = useState(0)
|
||||
@@ -208,6 +218,68 @@ export default function PlatformRock() {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制/生成发货凭证图片
|
||||
const handleCopyVoucher = async (record: RockTokenItem) => {
|
||||
setCopyingVoucherId(record.id)
|
||||
try {
|
||||
const isRockBlue = record.target_skin?.includes('蓝')
|
||||
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
|
||||
const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤')
|
||||
const channel = record.actual_channel || record.region || '安卓-微信'
|
||||
const timeStr = record.redeemed_at || record.bound_at || record.created_at
|
||||
|
||||
const dataURL = await generateVoucherDataURL({
|
||||
productName,
|
||||
productSKU: sku,
|
||||
roleName: record.actual_role_name || '-',
|
||||
roleUID: record.expected_uid || record.actual_role_id || '-',
|
||||
channel,
|
||||
currencyText: '幸运币 x 10',
|
||||
redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()),
|
||||
voucherNo: record.upstream_fulfill_id ? String(record.upstream_fulfill_id) : (record.assigned_order_no || undefined),
|
||||
})
|
||||
|
||||
const copied = await copyVoucherDataUrlToClipboard(dataURL)
|
||||
if (copied) {
|
||||
message.success('发货结果图片已复制到剪贴板!可直接在微信/QQ聊天框粘贴 (Ctrl+V)')
|
||||
} else {
|
||||
setPreviewVoucherUrl(dataURL)
|
||||
setPreviewVoucherOpen(true)
|
||||
message.info('已生成凭据图片,已为您打开预览窗口,支持直接保存或右键复制')
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setCopyingVoucherId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewVoucher = async (record: RockTokenItem) => {
|
||||
try {
|
||||
const isRockBlue = record.target_skin?.includes('蓝')
|
||||
const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt'
|
||||
const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤')
|
||||
const channel = record.actual_channel || record.region || '安卓-微信'
|
||||
const timeStr = record.redeemed_at || record.bound_at || record.created_at
|
||||
|
||||
const dataURL = await generateVoucherDataURL({
|
||||
productName,
|
||||
productSKU: sku,
|
||||
roleName: record.actual_role_name || '-',
|
||||
roleUID: record.expected_uid || record.actual_role_id || '-',
|
||||
channel,
|
||||
currencyText: '幸运币 x 10',
|
||||
redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()),
|
||||
voucherNo: record.upstream_fulfill_id ? String(record.upstream_fulfill_id) : (record.assigned_order_no || undefined),
|
||||
})
|
||||
|
||||
setPreviewVoucherUrl(dataURL)
|
||||
setPreviewVoucherOpen(true)
|
||||
} catch (e) {
|
||||
message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RockTokenItem> = [
|
||||
{
|
||||
title: 'ID / 提取码',
|
||||
@@ -333,24 +405,59 @@ export default function PlatformRock() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '官方发货凭证 (FulfillId)',
|
||||
dataIndex: 'upstream_fulfill_id',
|
||||
width: 180,
|
||||
render: (fulfillId, record) =>
|
||||
fulfillId ? (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text code copyable strong style={{ color: '#059669', fontSize: 12 }}>
|
||||
{fulfillId}
|
||||
</Text>
|
||||
<div>
|
||||
<Tag color="default" style={{ fontSize: 11 }}>
|
||||
{record.target_skin || '和平精英道具'}
|
||||
title: '发货结果图片',
|
||||
key: 'voucher',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
if (record.status !== 'redeemed' && !record.upstream_fulfill_id) {
|
||||
return <Text type="secondary">-</Text>
|
||||
}
|
||||
const isCopying = copyingVoucherId === record.id
|
||||
return (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
loading={isCopying}
|
||||
onClick={() => handleCopyVoucher(record)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||
borderColor: '#059669',
|
||||
boxShadow: '0 1px 2px rgba(16, 185, 129, 0.2)',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
复制结果图
|
||||
</Button>
|
||||
<Tooltip title="查看高清深海蓝发货凭据">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreviewVoucher(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Tag color="cyan" style={{ fontSize: 11, margin: 0 }}>
|
||||
{record.target_skin || '黑色ROCK长袖T恤'}
|
||||
</Tag>
|
||||
{record.upstream_fulfill_id && (
|
||||
<Text
|
||||
type="secondary"
|
||||
copyable={{ text: String(record.upstream_fulfill_id) }}
|
||||
style={{ fontSize: 11, fontFamily: 'monospace' }}
|
||||
title={`官方凭据号: ${record.upstream_fulfill_id}`}
|
||||
>
|
||||
{record.upstream_fulfill_id}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -948,6 +1055,82 @@ export default function PlatformRock() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* 发货结果图片高清预览弹窗 */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<SafetyCertificateOutlined style={{ color: '#10b981' }} />
|
||||
<span>发货到账存证凭据 (高清预览)</span>
|
||||
</Space>
|
||||
}
|
||||
open={previewVoucherOpen}
|
||||
onCancel={() => setPreviewVoucherOpen(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewVoucherOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
if (!previewVoucherUrl) return
|
||||
const link = document.createElement('a')
|
||||
link.href = previewVoucherUrl
|
||||
link.download = `发货凭证_${Date.now()}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
message.success('凭据图片已下载')
|
||||
}}
|
||||
>
|
||||
下载凭证图
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||
borderColor: '#059669',
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (!previewVoucherUrl) return
|
||||
const copied = await copyVoucherDataUrlToClipboard(previewVoucherUrl)
|
||||
if (copied) {
|
||||
message.success('发货结果图片已复制到剪贴板!可直接在聊天窗粘贴 (Ctrl+V)')
|
||||
} else {
|
||||
message.info('请右键下方图片选择「复制图片」')
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制图片到剪贴板
|
||||
</Button>,
|
||||
]}
|
||||
width={420}
|
||||
destroyOnClose
|
||||
centered
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
{previewVoucherUrl ? (
|
||||
<img
|
||||
src={previewVoucherUrl}
|
||||
alt="发货凭证大图"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 520,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.25)',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: 40, color: '#94a3b8' }}>正在生成高清凭据图...</div>
|
||||
)}
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: '#64748b' }}>
|
||||
💡 提示:点击「复制图片到剪贴板」后,可直接在微信/QQ等客服聊天窗口按 <Text code>Ctrl + V</Text> 发送。
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
export type ProductImageInput = {
|
||||
sku?: string | null
|
||||
/** 上游缩略图 URL(乐玩/DLC);仅在无本地 SKU 图时作 image-proxy 回退 */
|
||||
name?: string | null
|
||||
/** 上游缩略图 URL(乐玩/DLC/0666);仅在无本地 SKU 图时作 image-proxy 回退 */
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
@@ -25,26 +26,50 @@ export function resolveProductImageBySku(sku?: string | null): string {
|
||||
/**
|
||||
* 解析展示用商品图:
|
||||
* 1. 有 sku → `/images/products/{sku}.webp`
|
||||
* 2. 其它外链 → image-proxy(运行时落盘到 data/uploads/products,仅兜底)
|
||||
* 2. 名称包含黑色ROCK/蓝色运动服 → 本地映射
|
||||
* 3. 其它外链 → image-proxy(运行时落盘到 data/uploads/products,仅兜底)
|
||||
*/
|
||||
export function resolveProductImage(input?: string | ProductImageInput | null): string {
|
||||
if (input == null) return ''
|
||||
|
||||
let sku = ''
|
||||
let name = ''
|
||||
let url = ''
|
||||
if (typeof input === 'string') {
|
||||
url = trim(input)
|
||||
} else {
|
||||
sku = trim(input.sku)
|
||||
name = trim(input.name)
|
||||
url = trim(input.url)
|
||||
}
|
||||
|
||||
// 1. 若有显式 SKU
|
||||
if (sku) {
|
||||
return resolveProductImageBySku(sku)
|
||||
}
|
||||
|
||||
if (!url) return ''
|
||||
if (url.startsWith('/')) return url
|
||||
// 2. 根据商品名称做模糊匹配(支持 Rock 两款商品)
|
||||
if (name) {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.includes('黑') || lower.includes('rock') || lower.includes('t恤')) {
|
||||
return '/images/products/rock_black_tshirt.webp'
|
||||
}
|
||||
if (lower.includes('蓝') || lower.includes('运动服')) {
|
||||
return '/images/products/rock_blue_sportswear.webp'
|
||||
}
|
||||
}
|
||||
|
||||
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(url)}`
|
||||
// 3. 根据 URL 特征做判断
|
||||
if (url) {
|
||||
if (url.includes('sku-27') || url.includes('rock_black')) {
|
||||
return '/images/products/rock_black_tshirt.webp'
|
||||
}
|
||||
if (url.includes('sku-21') || url.includes('rock_blue')) {
|
||||
return '/images/products/rock_blue_sportswear.webp'
|
||||
}
|
||||
if (url.startsWith('/')) return url
|
||||
return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
+215
-182
@@ -2,6 +2,18 @@ import type { DeliveryOrderInfo } from '../types'
|
||||
import { formatDateTime } from './time'
|
||||
import { resolveProductImage } from './productImage'
|
||||
|
||||
export type DeliveryVoucherData = {
|
||||
productName: string
|
||||
productSKU?: string
|
||||
productImage?: string
|
||||
roleName: string
|
||||
roleUID: string
|
||||
channel?: string
|
||||
currencyText?: string
|
||||
redeemedAt?: string
|
||||
voucherNo?: string
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
@@ -13,11 +25,12 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制高保真发货凭证卡片图片,返回 base64 PNG dataURL
|
||||
* 绘制媲美腾讯/网易官方顶级电竞质感的深海蓝发货凭据大图 (参考标杆设计)
|
||||
* 尺寸:640 x 960 (Retina 高清标准卡片)
|
||||
*/
|
||||
export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise<string> {
|
||||
const width = 1080
|
||||
const height = 1160
|
||||
export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise<string> {
|
||||
const width = 640
|
||||
const height = 960
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
@@ -26,187 +39,233 @@ export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise
|
||||
throw new Error('Canvas 2D context not available')
|
||||
}
|
||||
|
||||
// 1. 背景底色
|
||||
ctx.fillStyle = '#f8fafc'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
// 1. 背景:深海蓝科技渐变
|
||||
const bgGrad = ctx.createLinearGradient(0, 0, 0, height)
|
||||
bgGrad.addColorStop(0, '#0c2452') // 顶部科技蓝
|
||||
bgGrad.addColorStop(0.35, '#091b3e')
|
||||
bgGrad.addColorStop(0.7, '#071530')
|
||||
bgGrad.addColorStop(1, '#050e21') // 底部暗夜深蓝
|
||||
ctx.fillStyle = bgGrad
|
||||
fillRoundRect(ctx, 0, 0, width, height, 24)
|
||||
|
||||
// 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)
|
||||
const glowGrad = ctx.createRadialGradient(width / 2, 120, 10, width / 2, 120, 260)
|
||||
glowGrad.addColorStop(0, 'rgba(56, 189, 248, 0.22)')
|
||||
glowGrad.addColorStop(1, 'rgba(12, 36, 82, 0)')
|
||||
ctx.fillStyle = glowGrad
|
||||
ctx.fillRect(0, 0, width, 400)
|
||||
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')
|
||||
|
||||
// 2. 顶部主标:兑 换 成 功
|
||||
ctx.save()
|
||||
clipRoundRect(ctx, cardX, cardY, cardW, headerH, [cardRadius, cardRadius, 0, 0])
|
||||
ctx.fillStyle = grad
|
||||
ctx.fillRect(cardX, cardY, cardW, headerH)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.shadowColor = 'rgba(56, 189, 248, 0.5)'
|
||||
ctx.shadowBlur = 18
|
||||
ctx.font = 'bold 40px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('兑 换 成 功', width / 2, 85)
|
||||
ctx.restore()
|
||||
|
||||
// 顶部装饰圆点
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
|
||||
// 3. 核心圆形立绘徽章
|
||||
const badgeCenterX = width / 2
|
||||
const badgeCenterY = 230
|
||||
const badgeRadius = 96
|
||||
|
||||
// 3.1 外层发光光环
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(56, 189, 248, 0.45)'
|
||||
ctx.shadowBlur = 16
|
||||
ctx.strokeStyle = 'rgba(56, 189, 248, 0.35)'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath()
|
||||
ctx.arc(cardX + cardW - 60, cardY + 40, 120, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius + 4, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
// 平台徽标 / 标题
|
||||
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.strokeStyle = 'rgba(255, 255, 255, 0.85)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
// 4. 发货成功电子印章 (Stamp)
|
||||
drawSuccessStamp(ctx, cardX + cardW - 170, cardY + 140)
|
||||
// 3.2 徽章内部背景与立绘
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius - 1, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
|
||||
// 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)
|
||||
const badgeBg = ctx.createLinearGradient(
|
||||
badgeCenterX - badgeRadius,
|
||||
badgeCenterY - badgeRadius,
|
||||
badgeCenterX + badgeRadius,
|
||||
badgeCenterY + badgeRadius
|
||||
)
|
||||
badgeBg.addColorStop(0, '#15315b')
|
||||
badgeBg.addColorStop(1, '#091833')
|
||||
ctx.fillStyle = badgeBg
|
||||
ctx.fillRect(badgeCenterX - badgeRadius, badgeCenterY - badgeRadius, badgeRadius * 2, badgeRadius * 2)
|
||||
|
||||
// 尝试加载商品图片(优先 SKU 本地缓存)
|
||||
let productImgLoaded = false
|
||||
const productImgURL = resolveProductImage({ sku: order.product?.sku, url: order.product?.image })
|
||||
if (productImgURL) {
|
||||
// 绘制商品立绘图片
|
||||
let imgLoaded = false
|
||||
const imgURL = data.productImage || resolveProductImage({ sku: data.productSKU, name: data.productName })
|
||||
if (imgURL) {
|
||||
try {
|
||||
const img = await loadImage(productImgURL)
|
||||
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
|
||||
const img = await loadImage(imgURL)
|
||||
const drawSize = badgeRadius * 2 - 12
|
||||
ctx.drawImage(
|
||||
img,
|
||||
badgeCenterX - drawSize / 2,
|
||||
badgeCenterY - drawSize / 2,
|
||||
drawSize,
|
||||
drawSize
|
||||
)
|
||||
imgLoaded = true
|
||||
} catch {
|
||||
productImgLoaded = false
|
||||
imgLoaded = 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)
|
||||
if (!imgLoaded) {
|
||||
ctx.fillStyle = '#38bdf8'
|
||||
ctx.font = 'bold 44px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('🎁', badgeCenterX, badgeCenterY)
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
// 商品标签 (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'
|
||||
// 4. 商品标题与副标题
|
||||
const nameY = 368
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText('已发放: 1 件', textLeft, prodCardY + 168)
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(truncateString(data.productName || '和平精英道具', 18), width / 2, nameY)
|
||||
|
||||
// 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) : '已交付' },
|
||||
]
|
||||
// 副标(组合/附赠说明)
|
||||
const subText = data.currencyText ? `和平精英官方直发 · ${data.currencyText}` : '和平精英官方直充 · 官方到账保证'
|
||||
ctx.fillStyle = '#7dd3fc'
|
||||
ctx.font = '500 16px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(subText, width / 2, nameY + 32)
|
||||
|
||||
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'
|
||||
// 5. 优雅横向微光分割线
|
||||
const lineY = 432
|
||||
const lineGrad = ctx.createLinearGradient(80, lineY, width - 80, lineY)
|
||||
lineGrad.addColorStop(0, 'rgba(255, 255, 255, 0)')
|
||||
lineGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.22)')
|
||||
lineGrad.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
||||
ctx.strokeStyle = lineGrad
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cardX + 40, footerY)
|
||||
ctx.lineTo(cardX + cardW - 40, footerY)
|
||||
ctx.moveTo(80, lineY)
|
||||
ctx.lineTo(width - 80, lineY)
|
||||
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)
|
||||
// 6. 核心数据列表明细 (Key - Value 对齐)
|
||||
const listItems = [
|
||||
{ label: '游戏角色', value: data.roleName || '-' },
|
||||
{ label: '游戏区服', value: data.channel || '安卓-微信' },
|
||||
{ label: '兑换账号', value: data.roleUID || '-' },
|
||||
{ label: '兑换时间', value: data.redeemedAt || formatDateTime(new Date().toISOString()) },
|
||||
]
|
||||
if (data.voucherNo) {
|
||||
listItems.push({ label: '官方单号', value: data.voucherNo })
|
||||
}
|
||||
|
||||
let rowY = 475
|
||||
const rowHeight = 44
|
||||
const labelX = 110
|
||||
const valueX = width - 110
|
||||
|
||||
listItems.forEach((item) => {
|
||||
// Label (左侧淡银灰)
|
||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.72)'
|
||||
ctx.font = '18px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(item.label, labelX, rowY)
|
||||
|
||||
// Value (右侧高亮纯白加粗)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", monospace'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(truncateString(item.value, 26), valueX, rowY)
|
||||
|
||||
rowY += rowHeight
|
||||
})
|
||||
|
||||
// 7. 底部翡翠绿发光胶囊按钮 (Pill Badge)
|
||||
const pillW = 210
|
||||
const pillH = 52
|
||||
const pillX = (width - pillW) / 2
|
||||
const pillY = Math.max(rowY + 28, 740)
|
||||
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(16, 185, 129, 0.6)'
|
||||
ctx.shadowBlur = 22
|
||||
ctx.shadowOffsetY = 4
|
||||
|
||||
const pillGrad = ctx.createLinearGradient(pillX, pillY, pillX + pillW, pillY + pillH)
|
||||
pillGrad.addColorStop(0, '#10b981')
|
||||
pillGrad.addColorStop(1, '#059669')
|
||||
ctx.fillStyle = pillGrad
|
||||
fillRoundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2)
|
||||
ctx.restore()
|
||||
|
||||
// 胶囊文字
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('兑 换 成 功', width / 2, pillY + pillH / 2)
|
||||
|
||||
// 8. 底部微弱防伪存证标语
|
||||
ctx.fillStyle = 'rgba(148, 163, 184, 0.45)'
|
||||
ctx.font = '12px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('腾讯和平精英官方直充 · 真实到账存证凭据 · 防伪防篡改', width / 2, height - 36)
|
||||
|
||||
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) // 稍倾斜印章质感
|
||||
/**
|
||||
* 从订单对象快捷生成凭证
|
||||
*/
|
||||
export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise<string> {
|
||||
const isRock = order.product?.sku?.startsWith('rock_') || order.product?.name?.includes('ROCK')
|
||||
return generateVoucherDataURL({
|
||||
productName: order.product?.name || (isRock ? '黑色ROCK长袖T恤' : '和平精英道具'),
|
||||
productSKU: order.product?.sku,
|
||||
productImage: resolveProductImage({ sku: order.product?.sku, name: order.product?.name, url: order.product?.image }),
|
||||
roleName: order.role_name || '-',
|
||||
roleUID: order.game_uid || order.buyer_name || '-',
|
||||
channel: order.game_channel || (order.game_uid?.length === 10 ? '微信区' : '手Q / 微信区'),
|
||||
currencyText: isRock ? '幸运币 x 10' : undefined,
|
||||
redeemedAt: order.shipped_at ? formatDateTime(order.shipped_at) : formatDateTime(order.created_at),
|
||||
voucherNo: order.provider_order_no || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 外圈圆形边框
|
||||
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()
|
||||
/**
|
||||
* 将 DataURL 转换为 Blob 并复制到操作系统剪贴板(微信/QQ聊天框可直接 Ctrl+V 粘贴)
|
||||
*/
|
||||
export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(dataURL)
|
||||
const blob = await res.blob()
|
||||
if (navigator.clipboard && window.ClipboardItem) {
|
||||
const item = new ClipboardItem({ 'image/png': blob })
|
||||
await navigator.clipboard.write([item])
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to copy image via ClipboardItem', e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function fillRoundRect(
|
||||
@@ -222,32 +281,6 @@ function fillRoundRect(
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user