- 统一 prettier 代码风格; - 为 AdminTaskDetailPage 与 AdminTasksPage 中未捕获的 Promise 添加 void 标记; - 验证方式:npm --prefix apps/frontend run check 全流程通过。
464 lines
15 KiB
TypeScript
464 lines
15 KiB
TypeScript
/**
|
||
* 绘制和平精英官方直充/兑换凭据大图 (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) + '…'
|
||
}
|