Files
order_site/apps/frontend/src/utils/voucher.ts
T
yml2213 9ce29964c7 style(frontend): 格式化前端代码并修复 eslint 异步操作未标记告警
- 统一 prettier 代码风格;
- 为 AdminTaskDetailPage 与 AdminTasksPage 中未捕获的 Promise 添加 void 标记;
- 验证方式:npm --prefix apps/frontend run check 全流程通过。
2026-10-11 15:20:54 +08:00

464 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 绘制和平精英官方直充/兑换凭据大图 (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<string, string> = {
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<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 Retina 高清)
*/
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, '#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<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
}
/**
* 触发下载存证凭据 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) + '…'
}