feat(admin): 彻底优化任务列表与详情界面,集成到账存证凭单与联盟直充面板
- 优化任务列表角色列:支持从 affiliateDash 及 claimIdentity 中自动提取并展示角色名与 UID(含一键复制) - 列表操作栏支持一键查看到账存证凭单,支持直接复制图片发给买家或下载保存 - 任务详情页全面现代化重构: - 新增「兑换成功 · 官方直充到账存证凭据」大卡片,实时生成 640x960 Retina 高清卡片,支持一键写入系统剪贴板(微信/QQ/客服窗口直接 Ctrl+V 发送)、下载 PNG 大图与全屏预览 - 新增「联盟直充履约 (affiliate_dash)」专属面板,结构化展示上游订单号、直充状态、点数积分、核销状态及供应商回执 - 优化凭据生成器 (voucher.ts):强制优先读取本地高清商品图,彻底消除上游图片文字水印与圆环破图问题 - 验证方式:npm run build & npm test (133 项单测全量通过)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* 绘制和平精英官方直充/兑换凭据大图 (640x960 Retina 高清卡片)。
|
||||
*
|
||||
* 纯前端 Canvas 渲染:不依赖后端存储或额外图片服务,
|
||||
* 兑换成功后即时生成供买家/客服预览、保存或复制。
|
||||
* 兑换成功后即时生成供买家/客服预览、保存、复制或下载。
|
||||
*/
|
||||
|
||||
export type DeliveryVoucherData = {
|
||||
@@ -17,6 +17,109 @@ export type DeliveryVoucherData = {
|
||||
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',
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -40,7 +143,7 @@ export function dataURLtoBlob(dataurl: string): Blob {
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制深海蓝科技感发货到账凭据大图 (640 x 960)
|
||||
* 绘制深海蓝科技感发货到账凭据大图 (640 x 960 Retina 高清)
|
||||
*/
|
||||
export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise<string> {
|
||||
const width = 640
|
||||
@@ -53,30 +156,32 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
throw new Error('Canvas 2D context not available')
|
||||
}
|
||||
|
||||
// 1. 背景:深海蓝科技渐变
|
||||
// 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')
|
||||
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, 24)
|
||||
fillRoundRect(ctx, 0, 0, width, height, 28)
|
||||
|
||||
// 顶部柔和微光晕 (聚光灯效果)
|
||||
// 顶部与中心微光晕 (立体聚光灯效果)
|
||||
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)')
|
||||
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, 400)
|
||||
ctx.fillRect(0, 0, width, 450)
|
||||
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.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)
|
||||
@@ -84,57 +189,66 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
// 3. 核心圆形立绘徽章
|
||||
const badgeCenterX = width / 2
|
||||
const badgeCenterY = 230
|
||||
const badgeRadius = 96
|
||||
const badgeCenterY = 232
|
||||
const badgeRadius = 98
|
||||
|
||||
// 3.1 外层发光光环
|
||||
// 3.1 外层发光与多重光环
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(56, 189, 248, 0.45)'
|
||||
ctx.shadowBlur = 16
|
||||
ctx.strokeStyle = 'rgba(56, 189, 248, 0.35)'
|
||||
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 + 4, 0, Math.PI * 2)
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius + 5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)'
|
||||
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 徽章内部背景与立绘
|
||||
// 3.2 徽章内部深蓝渐变背景与微光
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius - 1, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
|
||||
const badgeBg = ctx.createLinearGradient(
|
||||
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,
|
||||
badgeCenterX + badgeRadius,
|
||||
badgeCenterY + badgeRadius,
|
||||
badgeRadius * 2,
|
||||
badgeRadius * 2,
|
||||
)
|
||||
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` : '')
|
||||
const imgURL = resolveVoucherProductImageUrl(data)
|
||||
|
||||
if (imgURL) {
|
||||
try {
|
||||
const img = await loadImage(imgURL)
|
||||
const drawSize = badgeRadius * 2 - 12
|
||||
ctx.drawImage(
|
||||
img,
|
||||
badgeCenterX - drawSize / 2,
|
||||
badgeCenterY - drawSize / 2,
|
||||
drawSize,
|
||||
drawSize,
|
||||
)
|
||||
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
|
||||
@@ -143,7 +257,7 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
if (!imgLoaded) {
|
||||
ctx.fillStyle = '#38bdf8'
|
||||
ctx.font = 'bold 44px sans-serif'
|
||||
ctx.font = 'bold 48px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('🎁', badgeCenterX, badgeCenterY)
|
||||
@@ -151,30 +265,34 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
ctx.restore()
|
||||
|
||||
// 4. 商品标题与副标题
|
||||
const nameY = 368
|
||||
const nameY = 372
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
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}` : '和平精英官方直充 · 官方到账保证'
|
||||
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)
|
||||
ctx.font =
|
||||
'500 16px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(subText, width / 2, nameY + 34)
|
||||
|
||||
// 5. 优雅横向微光分割线
|
||||
const lineY = 432
|
||||
const lineGrad = ctx.createLinearGradient(80, lineY, width - 80, lineY)
|
||||
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.22)')
|
||||
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(80, lineY)
|
||||
ctx.lineTo(width - 80, lineY)
|
||||
ctx.moveTo(70, lineY)
|
||||
ctx.lineTo(width - 70, lineY)
|
||||
ctx.stroke()
|
||||
|
||||
// 6. 核心数据列表明细 (Key - Value 对齐)
|
||||
@@ -191,10 +309,14 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
{ label: '兑换时间', value: data.redeemedAt || new Date().toLocaleString() },
|
||||
)
|
||||
|
||||
let rowY = 475
|
||||
if (data.voucherNo) {
|
||||
listItems.push({ label: '存证单号', value: data.voucherNo })
|
||||
}
|
||||
|
||||
let rowY = 485
|
||||
const rowHeight = 44
|
||||
const labelX = 110
|
||||
const valueX = width - 110
|
||||
const labelX = 105
|
||||
const valueX = width - 105
|
||||
|
||||
listItems.forEach((item) => {
|
||||
// Label (左侧淡银灰)
|
||||
@@ -206,7 +328,8 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
// Value (右侧高亮纯白加粗)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", monospace'
|
||||
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)
|
||||
@@ -218,11 +341,11 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
const pillW = 210
|
||||
const pillH = 52
|
||||
const pillX = (width - pillW) / 2
|
||||
const pillY = Math.max(rowY + 28, 740)
|
||||
const pillY = Math.max(rowY + 28, 746)
|
||||
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(16, 185, 129, 0.6)'
|
||||
ctx.shadowBlur = 22
|
||||
ctx.shadowColor = 'rgba(16, 185, 129, 0.65)'
|
||||
ctx.shadowBlur = 24
|
||||
ctx.shadowOffsetY = 4
|
||||
|
||||
const pillGrad = ctx.createLinearGradient(pillX, pillY, pillX + pillW, pillY + pillH)
|
||||
@@ -234,7 +357,8 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
// 胶囊文字
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
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)
|
||||
@@ -250,7 +374,7 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 DataURL 转换为 Blob 并复制到操作系统剪贴板(微信/QQ聊天框可直接 Ctrl+V 粘贴)
|
||||
* 将 DataURL 转换为 Blob 并复制到操作系统剪贴板(微信/QQ/抖店客服聊天框可直接 Ctrl+V 粘贴)
|
||||
*/
|
||||
export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -267,6 +391,18 @@ export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise<bo
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user