feat: 优化发货交付页面与凭据生成,支持一键复制结果图片及官方商品图片本地缓存
- 交付界面重构:隐藏内部订单号、流水号与发货渠道,展示聚焦于买家游戏UID、角色名与交付时间 - 发货结果凭据生成:采用 Canvas 生成高清发货存证图,优化存证说明文案与自适应排版 - 一键复制图片:支持直接将结果凭证图片写入系统剪贴板,方便客服在聊天窗口快捷粘贴 - 官方图片本地化缓存:全量下载乐玩商品官方主图并建立本地持久化路由,规避外部源站跨域及破损问题 - 修复乐玩渠道角色UID提取与履约状态幂等回填逻辑
This commit is contained in:
@@ -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)}`
|
||||
}
|
||||
@@ -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) + '…'
|
||||
}
|
||||
Reference in New Issue
Block a user