diff --git a/apps/frontend/src/pages/claim/claim-shared.tsx b/apps/frontend/src/pages/claim/claim-shared.tsx
index 05511cfc..23539bbf 100644
--- a/apps/frontend/src/pages/claim/claim-shared.tsx
+++ b/apps/frontend/src/pages/claim/claim-shared.tsx
@@ -47,13 +47,7 @@ export function ClaimProductImage({ imageUrl, title }: { imageUrl: string; title
const [failedSrc, setFailedSrc] = useState('')
if (!src || failedSrc === src) {
- return (
-
- )
+ return null
}
return (
diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css
index c143170a..cf773882 100644
--- a/apps/frontend/src/styles/client.css
+++ b/apps/frontend/src/styles/client.css
@@ -442,12 +442,14 @@
}
.claim-product-image {
- width: min(220px, 62%);
+ width: min(160px, 48%);
aspect-ratio: 1 / 1;
border-radius: 16px;
- object-fit: cover;
- background: #f1f5f9;
+ object-fit: contain;
+ background: #f8fafc;
+ padding: 8px;
border: 1px solid rgba(148, 163, 184, 0.24);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.claim-product-image-fallback {
diff --git a/apps/frontend/src/utils/voucher.ts b/apps/frontend/src/utils/voucher.ts
new file mode 100644
index 00000000..4fbe9474
--- /dev/null
+++ b/apps/frontend/src/utils/voucher.ts
@@ -0,0 +1,309 @@
+/**
+ * 绘制和平精英官方直充/兑换凭据大图 (640x960 Retina 高清卡片)。
+ *
+ * 纯前端 Canvas 渲染:不依赖后端存储或额外图片服务,
+ * 兑换成功后即时生成供买家/客服预览、保存或复制。
+ */
+
+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
{
+ 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
+ })
+}
+
+export 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 })
+}
+
+/**
+ * 绘制深海蓝科技感发货到账凭据大图 (640 x 960)
+ */
+export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise {
+ const width = 640
+ const height = 960
+ 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. 背景:深海蓝科技渐变
+ 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)
+
+ // 顶部柔和微光晕 (聚光灯效果)
+ ctx.save()
+ 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()
+
+ // 2. 顶部主标:兑 换 成 功
+ ctx.save()
+ 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()
+
+ // 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(badgeCenterX, badgeCenterY, badgeRadius + 4, 0, Math.PI * 2)
+ ctx.stroke()
+
+ 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()
+
+ // 3.2 徽章内部背景与立绘
+ ctx.save()
+ ctx.beginPath()
+ ctx.arc(badgeCenterX, badgeCenterY, badgeRadius - 1, 0, Math.PI * 2)
+ ctx.clip()
+
+ 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)
+
+ // 绘制商品立绘图片
+ let imgLoaded = false
+ const imgURL = data.productImage || (data.productSKU ? `/images/products/${data.productSKU}.webp` : '')
+ if (imgURL) {
+ try {
+ const img = await loadImage(imgURL)
+ const drawSize = badgeRadius * 2 - 12
+ ctx.drawImage(
+ img,
+ badgeCenterX - drawSize / 2,
+ badgeCenterY - drawSize / 2,
+ drawSize,
+ drawSize,
+ )
+ imgLoaded = true
+ } catch {
+ imgLoaded = false
+ }
+ }
+
+ if (!imgLoaded) {
+ ctx.fillStyle = '#38bdf8'
+ ctx.font = 'bold 44px sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText('🎁', badgeCenterX, badgeCenterY)
+ }
+ ctx.restore()
+
+ // 4. 商品标题与副标题
+ const nameY = 368
+ ctx.fillStyle = '#ffffff'
+ ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(truncateString(data.productName || '和平精英道具', 18), width / 2, nameY)
+
+ // 副标(组合/附赠说明)
+ 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)
+
+ // 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(80, lineY)
+ ctx.lineTo(width - 80, lineY)
+ ctx.stroke()
+
+ // 6. 核心数据列表明细 (Key - Value 对齐)
+ const listItems: Array<{ label: string; value: string }> = []
+
+ const cleanRole = (data.roleName || '').trim()
+ if (cleanRole && cleanRole !== '-') {
+ listItems.push({ label: '游戏角色', value: cleanRole })
+ }
+
+ listItems.push(
+ { label: '游戏区服', value: data.channel || '安卓-微信' },
+ { label: '兑换账号', value: data.roleUID || '-' },
+ { label: '兑换时间', value: data.redeemedAt || new Date().toLocaleString() },
+ )
+
+ 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')
+}
+
+/**
+ * 将 DataURL 转换为 Blob 并复制到操作系统剪贴板(微信/QQ聊天框可直接 Ctrl+V 粘贴)
+ */
+export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise {
+ 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(
+ 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 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) + '…'
+}