import type { DeliveryOrderInfo } from '../types' import { formatDateTime } from './time' import { resolveProductImage } from './productImage' function loadImage(src: string): Promise { 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 { 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) // 尝试加载商品图片(优先 SKU 本地缓存) let productImgLoaded = false const productImgURL = resolveProductImage({ sku: order.product?.sku, url: order.product?.image }) if (productImgURL) { 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 } 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 件', 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) + '…' }