fix(claim): 优化H5领取与绑定界面商品图展示并集成发货存证凭据生成

- 修复后端 affiliate_dash 领取详情接口漏传 imageUrl 字段的问题
- 优化前端 ClaimProductImage 占位逻辑,消除无图或失败时的空灰色方块
- 移植并集成 affiliate_dash 前端 Canvas 高清发货到账存证凭据(Retina 640x960)
- 在兑换成功页面提供结果图预览、一键复制到剪贴板与下载凭据图片功能
This commit is contained in:
yml2213
2026-10-10 20:55:02 +08:00
parent c137741215
commit 215f3351ba
7 changed files with 514 additions and 15 deletions
+309
View File
@@ -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<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
})
}
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<string> {
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<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(
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) + '…'
}