/** * 绘制和平精英官方直充/兑换凭据大图 (640x960 Retina 高清卡片)。 * * 纯前端 Canvas 渲染:不依赖后端存储或额外图片服务, * 兑换成功后即时生成供买家/客服预览、保存、复制或下载。 */ export type DeliveryVoucherData = { productName: string productSKU?: string productImage?: string roleName: string roleUID: string channel?: string currencyText?: string redeemedAt?: string voucherNo?: string } const PRODUCT_SKU_MAP: Record = { suit_jester_face: 'suit_jester_face', '套装-花面小丑': 'suit_jester_face', suit_dune_song: 'suit_dune_song', '套装-沙丘之歌': 'suit_dune_song', pack_dragon_palace: 'pack_dragon_palace', 龙宫之主大礼包: 'pack_dragon_palace', lucky_coin_x90: 'lucky_coin_x90', 幸运币90个: 'lucky_coin_x90', lucky_coin_x30: 'lucky_coin_x30', 幸运币30个: 'lucky_coin_x30', lucky_coin_x2: 'lucky_coin_x2', 幸运币2个: 'lucky_coin_x2', honor_medal_x90: 'honor_medal_x90', 荣耀勋章90个: 'honor_medal_x90', honor_medal_x30: 'honor_medal_x30', 荣耀勋章30个: 'honor_medal_x30', honor_medal_x2: 'honor_medal_x2', 荣耀勋章2个: 'honor_medal_x2', suit_cloud_bear: 'suit_cloud_bear', '套装-绵云熊熊': 'suit_cloud_bear', pack_star_roam_weapon: 'pack_star_roam_weapon', 星际漫游枪械礼包: 'pack_star_roam_weapon', pack_star_roam_outfit: 'pack_star_roam_outfit', 星际漫游服装礼包: 'pack_star_roam_outfit', suit_sand_dancer: 'suit_sand_dancer', '套装-狂沙舞者': 'suit_sand_dancer', pack_lava_ranger: 'pack_lava_ranger', 熔岩游骑兵礼包: 'pack_lava_ranger', suit_panda_tuan: 'suit_panda_tuan', '套装-胖达团团': 'suit_panda_tuan', suit_panda_round: 'suit_panda_round', '套装-胖达圆圆': 'suit_panda_round', helmet_panda_treasure: 'helmet_panda_treasure', 国宝胖达头盔: 'helmet_panda_treasure', suit_western_mystery: 'suit_western_mystery', '套装-西部谜踪': 'suit_western_mystery', helmet_hamster_gray: 'helmet_hamster_gray', 仓鼠灰灰头盔: 'helmet_hamster_gray', bag_hamster_gray: 'bag_hamster_gray', 仓鼠灰灰背包: 'bag_hamster_gray', helmet_pink_sheep: 'helmet_pink_sheep', 糯粉咩咩头盔: 'helmet_pink_sheep', bag_pink_sheep: 'bag_pink_sheep', 糯粉咩咩背包: 'bag_pink_sheep', suit_cute_bear: 'suit_cute_bear', '套装-萌熊伴侣': 'suit_cute_bear', suit_hamster_gray: 'suit_hamster_gray', '套装-仓鼠灰灰': 'suit_hamster_gray', frag_pink_sheep: 'frag_pink_sheep', '破片手榴弹-糯粉咩咩': 'frag_pink_sheep', smoke_pink_sheep: 'smoke_pink_sheep', '烟雾弹-糯粉咩咩': 'smoke_pink_sheep', pack_western_cowboy: 'pack_western_cowboy', 西部牛仔大礼包: 'pack_western_cowboy', suit_romantic_destiny: 'suit_romantic_destiny', '套装-浪漫天命': 'suit_romantic_destiny', suit_first_peach: 'suit_first_peach', '套装-恋恋初桃': 'suit_first_peach', suit_pink_sheep: 'suit_pink_sheep', '套装-糯粉咩咩': 'suit_pink_sheep', suit_dual_fluffy: 'suit_dual_fluffy', '套装-双彩绵绵': 'suit_dual_fluffy', bag_cute_bear: 'bag_cute_bear', 萌熊伴侣背包: 'bag_cute_bear', m416_hamster_gray: 'm416_hamster_gray', 'M416-仓鼠灰灰': 'm416_hamster_gray', top_black_elite_trainer: 'top_black_elite_trainer', 黑色高级特训官上衣: 'top_black_elite_trainer', suit_shadow_gothic: 'suit_shadow_gothic', '套装-暗影哥特': 'suit_shadow_gothic', suit_alan_walker: 'suit_alan_walker', '套装-Alan Walker': 'suit_alan_walker', rock_black_tshirt: 'rock_black_tshirt', 黑色ROCK长袖T恤: 'rock_black_tshirt', 黑色ROCK长袖: 'rock_black_tshirt', rock_blue_sportswear: 'rock_blue_sportswear', 蓝色运动服上衣: 'rock_blue_sportswear', 蓝色运动服: 'rock_blue_sportswear', } /** * 格式化和平精英官方/DLC 渠道代码 (area-plat) 为用户易读的中文区服名称 * * 腾讯大区 (area):1=微信, 2=手Q * 操作系统 (plat):1=安卓, 0=苹果 (iOS) */ export function formatGameChannelLabel(channel?: string): string { const c = String(channel || '').trim() if (c === '1-1') return '安卓-微信' if (c === '1-0') return '苹果-微信' if (c === '2-1') return '安卓-手Q' if (c === '2-0') return '苹果-手Q' if (c === '1' || c.toLowerCase() === 'wx' || c.toLowerCase() === 'weixin') return '微信区' if (c === '2' || c.toLowerCase() === 'qq') return '手Q区' return c || '安卓-微信' } export function resolveVoucherProductImageUrl(data: DeliveryVoucherData): string { const sku = (data.productSKU || '').trim() if (sku && PRODUCT_SKU_MAP[sku]) { return `/images/products/${PRODUCT_SKU_MAP[sku]}.webp` } const name = (data.productName || '').trim() if (name && PRODUCT_SKU_MAP[name]) { return `/images/products/${PRODUCT_SKU_MAP[name]}.webp` } if ( data.productImage && !data.productImage.includes('_order.png') && !data.productImage.includes('ugempire.com') ) { return data.productImage } if (sku) { return `/images/products/${sku}.webp` } return '' } 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 Retina 高清) */ 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, '#0c234b') bgGrad.addColorStop(0.3, '#081938') bgGrad.addColorStop(0.7, '#051025') bgGrad.addColorStop(1, '#030814') ctx.fillStyle = bgGrad fillRoundRect(ctx, 0, 0, width, height, 28) // 顶部与中心微光晕 (立体聚光灯效果) ctx.save() const glowGrad = ctx.createRadialGradient(width / 2, 110, 10, width / 2, 110, 280) glowGrad.addColorStop(0, 'rgba(56, 189, 248, 0.28)') glowGrad.addColorStop(0.5, 'rgba(14, 165, 233, 0.12)') glowGrad.addColorStop(1, 'rgba(12, 35, 75, 0)') ctx.fillStyle = glowGrad ctx.fillRect(0, 0, width, 450) ctx.restore() // 2. 顶部主标:兑 换 成 功 ctx.save() ctx.fillStyle = '#ffffff' ctx.shadowColor = 'rgba(56, 189, 248, 0.6)' ctx.shadowBlur = 20 ctx.font = 'bold 42px -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 = 232 const badgeRadius = 98 // 3.1 外层发光与多重光环 ctx.save() ctx.shadowColor = 'rgba(56, 189, 248, 0.55)' ctx.shadowBlur = 20 ctx.strokeStyle = 'rgba(56, 189, 248, 0.4)' ctx.lineWidth = 4 ctx.beginPath() ctx.arc(badgeCenterX, badgeCenterY, badgeRadius + 5, 0, Math.PI * 2) ctx.stroke() ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)' 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.createRadialGradient( badgeCenterX, badgeCenterY, 10, badgeCenterX, badgeCenterY, badgeRadius, ) badgeBg.addColorStop(0, '#1c427b') badgeBg.addColorStop(0.7, '#0f274e') badgeBg.addColorStop(1, '#08162e') ctx.fillStyle = badgeBg ctx.fillRect( badgeCenterX - badgeRadius, badgeCenterY - badgeRadius, badgeRadius * 2, badgeRadius * 2, ) // 绘制商品立绘图片(确保使用纯净高清资源) let imgLoaded = false const imgURL = resolveVoucherProductImageUrl(data) if (imgURL) { try { const img = await loadImage(imgURL) const maxDrawSize = badgeRadius * 2 - 16 const scale = Math.min(maxDrawSize / img.width, maxDrawSize / img.height, 1) const drawW = img.width * scale const drawH = img.height * scale ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' ctx.drawImage(img, badgeCenterX - drawW / 2, badgeCenterY - drawH / 2, drawW, drawH) imgLoaded = true } catch { imgLoaded = false } } if (!imgLoaded) { ctx.fillStyle = '#38bdf8' ctx.font = 'bold 48px sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText('🎁', badgeCenterX, badgeCenterY) } ctx.restore() // 4. 商品标题与副标题 const nameY = 372 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 + 34) // 5. 优雅横向微光分割线 const lineY = 438 const lineGrad = ctx.createLinearGradient(70, lineY, width - 70, lineY) lineGrad.addColorStop(0, 'rgba(255, 255, 255, 0)') lineGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.28)') lineGrad.addColorStop(1, 'rgba(255, 255, 255, 0)') ctx.strokeStyle = lineGrad ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(70, lineY) ctx.lineTo(width - 70, 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: formatGameChannelLabel(data.channel) }, { label: '兑换账号', value: data.roleUID || '-' }, { label: '兑换时间', value: data.redeemedAt || new Date().toLocaleString() }, ) if (data.voucherNo) { listItems.push({ label: '存证单号', value: data.voucherNo }) } let rowY = 485 const rowHeight = 44 const labelX = 105 const valueX = width - 105 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, 746) ctx.save() ctx.shadowColor = 'rgba(16, 185, 129, 0.65)' ctx.shadowBlur = 24 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 } /** * 触发下载存证凭据 PNG 图片到本地 */ export function downloadVoucherDataUrl(dataURL: string, filename = '和平精英兑换凭据.png'): void { const link = document.createElement('a') link.download = filename link.href = dataURL document.body.appendChild(link) link.click() document.body.removeChild(link) } 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) + '…' }