diff --git a/backend/internal/service/provider_rock.go b/backend/internal/service/provider_rock.go index 8a15650..13f46ac 100644 --- a/backend/internal/service/provider_rock.go +++ b/backend/internal/service/provider_rock.go @@ -231,15 +231,6 @@ func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder currencyName = token.TargetCurrency } - patch := map[string]interface{}{ - "provider": ProviderRock, - "provider_order_no": fulfillIDStr, - "fulfill_id": token.UpstreamFulfillID, - "skin": skinName, - "currency": currencyName, - "currency_quantity": token.TargetCurrencyQuantity, - } - roleName := token.ActualRoleName if roleName == "" { roleName = resultDataString(order.ResultData, "role_name") @@ -249,6 +240,18 @@ func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder channel = resultDataString(order.ResultData, "game_channel") } + patch := map[string]interface{}{ + "provider": ProviderRock, + "provider_order_no": fulfillIDStr, + "fulfill_id": token.UpstreamFulfillID, + "skin": skinName, + "currency": currencyName, + "currency_quantity": token.TargetCurrencyQuantity, + "role_name": roleName, + "game_channel": channel, + "game_uid": expectedUID, + } + return &ProviderSubmitResult{ ProviderOrderNo: fulfillIDStr, NextStatus: model.OrderStatusDelivered, diff --git a/frontend/public/images/products/rock_black_tshirt.png b/frontend/public/images/products/rock_black_tshirt.png new file mode 100644 index 0000000..0cf2c63 Binary files /dev/null and b/frontend/public/images/products/rock_black_tshirt.png differ diff --git a/frontend/public/images/products/rock_black_tshirt.webp b/frontend/public/images/products/rock_black_tshirt.webp new file mode 100644 index 0000000..0cf2c63 Binary files /dev/null and b/frontend/public/images/products/rock_black_tshirt.webp differ diff --git a/frontend/public/images/products/rock_blue_sportswear.png b/frontend/public/images/products/rock_blue_sportswear.png new file mode 100644 index 0000000..e057107 Binary files /dev/null and b/frontend/public/images/products/rock_blue_sportswear.png differ diff --git a/frontend/public/images/products/rock_blue_sportswear.webp b/frontend/public/images/products/rock_blue_sportswear.webp new file mode 100644 index 0000000..e057107 Binary files /dev/null and b/frontend/public/images/products/rock_blue_sportswear.webp differ diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 118ab0a..a5ab441 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -431,7 +431,7 @@ export default function Delivery() {
{(() => { - const imgSrc = resolveProductImage({ sku: order.product?.sku, url: order.product?.image }) + const imgSrc = resolveProductImage({ sku: order.product?.sku, name: order.product?.name, url: order.product?.image }) if (!imgSrc) { return (
@@ -445,7 +445,13 @@ export default function Delivery() { width={72} height={72} alt={order.product?.name || '商品'} - style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }} + style={{ + objectFit: 'contain', + background: '#f8fafc', + borderRadius: 8, + border: '1px solid #e2e8f0', + padding: 4, + }} fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='72' height='72'%3E%3Crect fill='%23e2e8f0' width='72' height='72'/%3E%3C/svg%3E" /> ) @@ -492,7 +498,9 @@ export default function Delivery() { {order.role_name || '-'} - {order.game_channel || '-'} + + {order.game_channel || (order.game_uid?.length === 10 ? '微信区' : '手Q / 微信区')} + {formatDateTime(order.created_at)} {order.shipped_at ? formatDateTime(order.shipped_at) : '已完成'} diff --git a/frontend/src/pages/PlatformRock.tsx b/frontend/src/pages/PlatformRock.tsx index 6f6d96b..926bb5c 100644 --- a/frontend/src/pages/PlatformRock.tsx +++ b/frontend/src/pages/PlatformRock.tsx @@ -23,7 +23,10 @@ import { import { CheckCircleOutlined, ControlOutlined, + CopyOutlined, + DownloadOutlined, ExclamationCircleOutlined, + EyeOutlined, FireOutlined, KeyOutlined, ReloadOutlined, @@ -36,6 +39,8 @@ import { import type { ColumnsType } from 'antd/es/table' import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api' import { PageHeader } from '../components/PageHeader' +import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '../utils/voucher' +import { formatDateTime } from '../utils/time' const { Text } = Typography @@ -49,6 +54,11 @@ export default function PlatformRock() { const [purchaseModalOpen, setPurchaseModalOpen] = useState(false) const [purchaseCount, setPurchaseCount] = useState(50) + // 凭证图片操作状态 + const [copyingVoucherId, setCopyingVoucherId] = useState(null) + const [previewVoucherUrl, setPreviewVoucherUrl] = useState('') + const [previewVoucherOpen, setPreviewVoucherOpen] = useState(false) + // Token 列表与筛选 const [tokens, setTokens] = useState([]) const [totalTokens, setTotalTokens] = useState(0) @@ -208,6 +218,68 @@ export default function PlatformRock() { } } + // 复制/生成发货凭证图片 + const handleCopyVoucher = async (record: RockTokenItem) => { + setCopyingVoucherId(record.id) + try { + const isRockBlue = record.target_skin?.includes('蓝') + const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt' + const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤') + const channel = record.actual_channel || record.region || '安卓-微信' + const timeStr = record.redeemed_at || record.bound_at || record.created_at + + const dataURL = await generateVoucherDataURL({ + productName, + productSKU: sku, + roleName: record.actual_role_name || '-', + roleUID: record.expected_uid || record.actual_role_id || '-', + channel, + currencyText: '幸运币 x 10', + redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()), + voucherNo: record.upstream_fulfill_id ? String(record.upstream_fulfill_id) : (record.assigned_order_no || undefined), + }) + + const copied = await copyVoucherDataUrlToClipboard(dataURL) + if (copied) { + message.success('发货结果图片已复制到剪贴板!可直接在微信/QQ聊天框粘贴 (Ctrl+V)') + } else { + setPreviewVoucherUrl(dataURL) + setPreviewVoucherOpen(true) + message.info('已生成凭据图片,已为您打开预览窗口,支持直接保存或右键复制') + } + } catch (e) { + message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误')) + } finally { + setCopyingVoucherId(null) + } + } + + const handlePreviewVoucher = async (record: RockTokenItem) => { + try { + const isRockBlue = record.target_skin?.includes('蓝') + const sku = isRockBlue ? 'rock_blue_sportswear' : 'rock_black_tshirt' + const productName = record.target_skin || (isRockBlue ? '蓝色运动服上衣' : '黑色ROCK长袖T恤') + const channel = record.actual_channel || record.region || '安卓-微信' + const timeStr = record.redeemed_at || record.bound_at || record.created_at + + const dataURL = await generateVoucherDataURL({ + productName, + productSKU: sku, + roleName: record.actual_role_name || '-', + roleUID: record.expected_uid || record.actual_role_id || '-', + channel, + currencyText: '幸运币 x 10', + redeemedAt: timeStr ? formatDateTime(timeStr) : formatDateTime(new Date().toISOString()), + voucherNo: record.upstream_fulfill_id ? String(record.upstream_fulfill_id) : (record.assigned_order_no || undefined), + }) + + setPreviewVoucherUrl(dataURL) + setPreviewVoucherOpen(true) + } catch (e) { + message.error('生成凭证失败: ' + (e instanceof Error ? e.message : '未知错误')) + } + } + const columns: ColumnsType = [ { title: 'ID / 提取码', @@ -333,24 +405,59 @@ export default function PlatformRock() { }, }, { - title: '官方发货凭证 (FulfillId)', - dataIndex: 'upstream_fulfill_id', - width: 180, - render: (fulfillId, record) => - fulfillId ? ( - - - {fulfillId} - -
- - {record.target_skin || '和平精英道具'} + title: '发货结果图片', + key: 'voucher', + width: 200, + render: (_, record) => { + if (record.status !== 'redeemed' && !record.upstream_fulfill_id) { + return - + } + const isCopying = copyingVoucherId === record.id + return ( + + + + +
+ {/* 发货结果图片高清预览弹窗 */} + + + 发货到账存证凭据 (高清预览) + + } + open={previewVoucherOpen} + onCancel={() => setPreviewVoucherOpen(false)} + footer={[ + , + , + , + ]} + width={420} + destroyOnClose + centered + > +
+ {previewVoucherUrl ? ( + 发货凭证大图 + ) : ( +
正在生成高清凭据图...
+ )} +
+ 💡 提示:点击「复制图片到剪贴板」后,可直接在微信/QQ等客服聊天窗口按 Ctrl + V 发送。 +
+
+
) } diff --git a/frontend/src/utils/productImage.ts b/frontend/src/utils/productImage.ts index 9ca49a1..69a3c51 100644 --- a/frontend/src/utils/productImage.ts +++ b/frontend/src/utils/productImage.ts @@ -7,7 +7,8 @@ export type ProductImageInput = { sku?: string | null - /** 上游缩略图 URL(乐玩/DLC);仅在无本地 SKU 图时作 image-proxy 回退 */ + name?: string | null + /** 上游缩略图 URL(乐玩/DLC/0666);仅在无本地 SKU 图时作 image-proxy 回退 */ url?: string | null } @@ -25,26 +26,50 @@ export function resolveProductImageBySku(sku?: string | null): string { /** * 解析展示用商品图: * 1. 有 sku → `/images/products/{sku}.webp` - * 2. 其它外链 → image-proxy(运行时落盘到 data/uploads/products,仅兜底) + * 2. 名称包含黑色ROCK/蓝色运动服 → 本地映射 + * 3. 其它外链 → image-proxy(运行时落盘到 data/uploads/products,仅兜底) */ export function resolveProductImage(input?: string | ProductImageInput | null): string { if (input == null) return '' let sku = '' + let name = '' let url = '' if (typeof input === 'string') { url = trim(input) } else { sku = trim(input.sku) + name = trim(input.name) url = trim(input.url) } + // 1. 若有显式 SKU if (sku) { return resolveProductImageBySku(sku) } - if (!url) return '' - if (url.startsWith('/')) return url + // 2. 根据商品名称做模糊匹配(支持 Rock 两款商品) + if (name) { + const lower = name.toLowerCase() + if (lower.includes('黑') || lower.includes('rock') || lower.includes('t恤')) { + return '/images/products/rock_black_tshirt.webp' + } + if (lower.includes('蓝') || lower.includes('运动服')) { + return '/images/products/rock_blue_sportswear.webp' + } + } - return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(url)}` + // 3. 根据 URL 特征做判断 + if (url) { + if (url.includes('sku-27') || url.includes('rock_black')) { + return '/images/products/rock_black_tshirt.webp' + } + if (url.includes('sku-21') || url.includes('rock_blue')) { + return '/images/products/rock_blue_sportswear.webp' + } + if (url.startsWith('/')) return url + return `/api/delivery/v1/image-proxy?url=${encodeURIComponent(url)}` + } + + return '' } diff --git a/frontend/src/utils/voucher.ts b/frontend/src/utils/voucher.ts index 16acb14..8b562fd 100644 --- a/frontend/src/utils/voucher.ts +++ b/frontend/src/utils/voucher.ts @@ -2,6 +2,18 @@ import type { DeliveryOrderInfo } from '../types' import { formatDateTime } from './time' import { resolveProductImage } from './productImage' +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 { return new Promise((resolve, reject) => { const img = new Image() @@ -13,11 +25,12 @@ function loadImage(src: string): Promise { } /** - * 绘制高保真发货凭证卡片图片,返回 base64 PNG dataURL + * 绘制媲美腾讯/网易官方顶级电竞质感的深海蓝发货凭据大图 (参考标杆设计) + * 尺寸:640 x 960 (Retina 高清标准卡片) */ -export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise { - const width = 1080 - const height = 1160 +export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise { + const width = 640 + const height = 960 const canvas = document.createElement('canvas') canvas.width = width canvas.height = height @@ -26,187 +39,233 @@ export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise throw new Error('Canvas 2D context not available') } - // 1. 背景底色 - ctx.fillStyle = '#f8fafc' - ctx.fillRect(0, 0, width, height) + // 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) - // 2. 主卡片容器 - const cardX = 40 - const cardY = 40 - const cardW = width - 80 - const cardH = height - 80 - const cardRadius = 24 - - // 卡片阴影与白色背景 + // 顶部柔和微光晕 (聚光灯效果) ctx.save() - ctx.fillStyle = '#ffffff' - ctx.shadowColor = 'rgba(15, 23, 42, 0.08)' - ctx.shadowBlur = 32 - ctx.shadowOffsetY = 16 - fillRoundRect(ctx, cardX, cardY, cardW, cardH, cardRadius) + 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() - // 3. 顶部 Header (深色渐变) - const headerH = 220 - const grad = ctx.createLinearGradient(cardX, cardY, cardX + cardW, cardY + headerH) - grad.addColorStop(0, '#0f172a') - grad.addColorStop(1, '#1e293b') - + // 2. 顶部主标:兑 换 成 功 ctx.save() - clipRoundRect(ctx, cardX, cardY, cardW, headerH, [cardRadius, cardRadius, 0, 0]) - ctx.fillStyle = grad - ctx.fillRect(cardX, cardY, cardW, headerH) + 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() - // 顶部装饰圆点 - ctx.fillStyle = 'rgba(255, 255, 255, 0.05)' + // 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(cardX + cardW - 60, cardY + 40, 120, 0, Math.PI * 2) - ctx.fill() + ctx.arc(badgeCenterX, badgeCenterY, badgeRadius + 4, 0, Math.PI * 2) + ctx.stroke() - // 平台徽标 / 标题 - ctx.fillStyle = '#38bdf8' - ctx.font = 'bold 24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText('GAME FULFILLMENT · OFFICIAL RECEIPT', cardX + 48, cardY + 60) - - ctx.fillStyle = '#ffffff' - ctx.font = 'bold 44px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText('游戏道具发货电子凭证', cardX + 48, cardY + 124) - - ctx.fillStyle = '#94a3b8' - ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText('官方直充已完成 · 售后维权与电子核销依据', cardX + 48, cardY + 168) + 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() - // 4. 发货成功电子印章 (Stamp) - drawSuccessStamp(ctx, cardX + cardW - 170, cardY + 140) + // 3.2 徽章内部背景与立绘 + ctx.save() + ctx.beginPath() + ctx.arc(badgeCenterX, badgeCenterY, badgeRadius - 1, 0, Math.PI * 2) + ctx.clip() - // 5. 商品展示卡片 - const prodCardY = cardY + headerH + 32 - const prodCardH = 210 - ctx.fillStyle = '#f8fafc' - fillRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16) - ctx.strokeStyle = '#e2e8f0' - ctx.lineWidth = 1 - strokeRoundRect(ctx, cardX + 40, prodCardY, cardW - 80, prodCardH, 16) + 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) - // 尝试加载商品图片(优先 SKU 本地缓存) - let productImgLoaded = false - const productImgURL = resolveProductImage({ sku: order.product?.sku, url: order.product?.image }) - if (productImgURL) { + // 绘制商品立绘图片 + let imgLoaded = false + const imgURL = data.productImage || resolveProductImage({ sku: data.productSKU, name: data.productName }) + if (imgURL) { try { - const img = await loadImage(productImgURL) - const imgSize = 146 - const imgX = cardX + 64 - const imgY = prodCardY + 32 - ctx.save() - clipRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12) - ctx.drawImage(img, imgX, imgY, imgSize, imgSize) - ctx.restore() - ctx.strokeStyle = '#cbd5e1' - strokeRoundRect(ctx, imgX, imgY, imgSize, imgSize, 12) - productImgLoaded = true + const img = await loadImage(imgURL) + const drawSize = badgeRadius * 2 - 12 + ctx.drawImage( + img, + badgeCenterX - drawSize / 2, + badgeCenterY - drawSize / 2, + drawSize, + drawSize + ) + imgLoaded = true } catch { - productImgLoaded = false + imgLoaded = false } } - // 商品文字信息 - const textLeft = productImgLoaded ? cardX + 240 : cardX + 72 - ctx.fillStyle = '#0f172a' - ctx.font = 'bold 36px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(truncateString(order.product?.name || '游戏道具商品', 20), textLeft, prodCardY + 70) + if (!imgLoaded) { + ctx.fillStyle = '#38bdf8' + ctx.font = 'bold 44px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('🎁', badgeCenterX, badgeCenterY) + } + ctx.restore() - // 商品标签 (SKU & 游戏) - ctx.fillStyle = '#64748b' - ctx.font = '24px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - const skuText = `SKU: ${order.product?.sku || '-'}` - const gameText = order.product?.game ? ` · ${order.product.game}` : '' - ctx.fillText(skuText + gameText, textLeft, prodCardY + 120) - - // 交付状态与数量 - ctx.fillStyle = '#16a34a' + // 4. 商品标题与副标题 + const nameY = 368 + ctx.fillStyle = '#ffffff' ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText('已发放: 1 件', textLeft, prodCardY + 168) + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(truncateString(data.productName || '和平精英道具', 18), width / 2, nameY) - // 6. 核心凭证字段明细表 - const tableY = prodCardY + prodCardH + 32 - const tableW = cardW - 80 - const fields = [ - { label: '游戏账号 UID', value: order.game_uid || '-', highlight: true }, - { label: '游戏角色名', value: order.role_name || '-', highlight: true }, - { label: '区服信息', value: order.game_channel || '-' }, - { label: '下单时间', value: formatDateTime(order.created_at) }, - { label: '交付时间', value: order.shipped_at ? formatDateTime(order.shipped_at) : '已交付' }, - ] + // 副标(组合/附赠说明) + 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) - let curY = tableY - const rowHeight = 72 - fields.forEach((f, idx) => { - ctx.fillStyle = idx % 2 === 0 ? '#f8fafc' : '#ffffff' - fillRoundRect(ctx, cardX + 40, curY, tableW, rowHeight, 8) - - // 标签文字 - ctx.fillStyle = '#64748b' - ctx.font = '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText(f.label, cardX + 68, curY + 46) - - // 数值文字 - ctx.fillStyle = f.highlight ? '#0f172a' : '#334155' - ctx.font = f.highlight - ? 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - : '26px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - const valText = truncateString(f.value, 36) - ctx.fillText(valText, cardX + 320, curY + 46) - - curY += rowHeight + 8 - }) - - // 7. 底部防伪与存证声明 - const footerY = curY + 24 - ctx.strokeStyle = '#e2e8f0' + // 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(cardX + 40, footerY) - ctx.lineTo(cardX + cardW - 40, footerY) + ctx.moveTo(80, lineY) + ctx.lineTo(width - 80, lineY) ctx.stroke() - ctx.fillStyle = '#94a3b8' - ctx.font = '22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.fillText('注:此电子凭证为系统自动发货存证,道具已成功充值至指定角色,请登录游戏查收。', cardX + 48, footerY + 40) - ctx.fillText(`凭据生成时间:${formatDateTime(new Date().toISOString())}`, cardX + 48, footerY + 76) + // 6. 核心数据列表明细 (Key - Value 对齐) + const listItems = [ + { label: '游戏角色', value: data.roleName || '-' }, + { label: '游戏区服', value: data.channel || '安卓-微信' }, + { label: '兑换账号', value: data.roleUID || '-' }, + { label: '兑换时间', value: data.redeemedAt || formatDateTime(new Date().toISOString()) }, + ] + if (data.voucherNo) { + listItems.push({ label: '官方单号', value: data.voucherNo }) + } + + 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') } -// 辅助:绘制发货成功的印章 -function drawSuccessStamp(ctx: CanvasRenderingContext2D, x: number, y: number) { - ctx.save() - ctx.translate(x, y) - ctx.rotate((-14 * Math.PI) / 180) // 稍倾斜印章质感 +/** + * 从订单对象快捷生成凭证 + */ +export async function generateDeliveryVoucher(order: DeliveryOrderInfo): Promise { + const isRock = order.product?.sku?.startsWith('rock_') || order.product?.name?.includes('ROCK') + return generateVoucherDataURL({ + productName: order.product?.name || (isRock ? '黑色ROCK长袖T恤' : '和平精英道具'), + productSKU: order.product?.sku, + productImage: resolveProductImage({ sku: order.product?.sku, name: order.product?.name, url: order.product?.image }), + roleName: order.role_name || '-', + roleUID: order.game_uid || order.buyer_name || '-', + channel: order.game_channel || (order.game_uid?.length === 10 ? '微信区' : '手Q / 微信区'), + currencyText: isRock ? '幸运币 x 10' : undefined, + redeemedAt: order.shipped_at ? formatDateTime(order.shipped_at) : formatDateTime(order.created_at), + voucherNo: order.provider_order_no || undefined, + }) +} - // 外圈圆形边框 - ctx.strokeStyle = '#22c55e' - ctx.lineWidth = 4 - ctx.beginPath() - ctx.arc(0, 0, 68, 0, Math.PI * 2) - ctx.stroke() - - // 内圈虚线 - ctx.setLineDash([4, 4]) - ctx.lineWidth = 1.5 - ctx.beginPath() - ctx.arc(0, 0, 60, 0, Math.PI * 2) - ctx.stroke() - - // 印章文字 - ctx.fillStyle = '#22c55e' - ctx.font = 'bold 22px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText('已发货完成', 0, -12) - - ctx.font = 'bold 15px sans-serif' - ctx.fillText('VERIFIED', 0, 16) - ctx.restore() +/** + * 将 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 } function fillRoundRect( @@ -222,32 +281,6 @@ function fillRoundRect( ctx.fill() } -function strokeRoundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number | number[] -) { - ctx.beginPath() - roundRectPath(ctx, x, y, width, height, radius) - ctx.stroke() -} - -function clipRoundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number | number[] -) { - ctx.beginPath() - roundRectPath(ctx, x, y, width, height, radius) - ctx.clip() -} - function roundRectPath( ctx: CanvasRenderingContext2D, x: number,