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 (
+
+
+ }
+ loading={isCopying}
+ onClick={() => handleCopyVoucher(record)}
+ style={{
+ background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
+ borderColor: '#059669',
+ boxShadow: '0 1px 2px rgba(16, 185, 129, 0.2)',
+ fontSize: 12,
+ fontWeight: 500,
+ }}
+ >
+ 复制结果图
+
+
+ }
+ onClick={() => handlePreviewVoucher(record)}
+ />
+
+
+
+
+ {record.target_skin || '黑色ROCK长袖T恤'}
+ {record.upstream_fulfill_id && (
+
+ {record.upstream_fulfill_id}
+
+ )}
- ) : (
- -
- ),
+ )
+ },
},
{
title: '操作',
@@ -948,6 +1055,82 @@ export default function PlatformRock() {
+ {/* 发货结果图片高清预览弹窗 */}
+
+
+ 发货到账存证凭据 (高清预览)
+
+ }
+ open={previewVoucherOpen}
+ onCancel={() => setPreviewVoucherOpen(false)}
+ footer={[
+ ,
+ }
+ onClick={() => {
+ if (!previewVoucherUrl) return
+ const link = document.createElement('a')
+ link.href = previewVoucherUrl
+ link.download = `发货凭证_${Date.now()}.png`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ message.success('凭据图片已下载')
+ }}
+ >
+ 下载凭证图
+ ,
+ }
+ style={{
+ background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
+ borderColor: '#059669',
+ }}
+ onClick={async () => {
+ if (!previewVoucherUrl) return
+ const copied = await copyVoucherDataUrlToClipboard(previewVoucherUrl)
+ if (copied) {
+ message.success('发货结果图片已复制到剪贴板!可直接在聊天窗粘贴 (Ctrl+V)')
+ } else {
+ message.info('请右键下方图片选择「复制图片」')
+ }
+ }}
+ >
+ 复制图片到剪贴板
+ ,
+ ]}
+ 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,