diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index fd11f79b..f39d24f9 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -271,6 +271,7 @@ function buildAffiliateDashClaimDetailPayload({ const product = { title: productTitle, skuCode: String(orderItem.sku_code || '').trim(), + imageUrl: resolveClaimProductImageUrl(orderItem), quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), isBundle: false, items: [ diff --git a/apps/backend/src/services/platforms/affiliate-dash/product-image.ts b/apps/backend/src/services/platforms/affiliate-dash/product-image.ts index f72813e2..a5e6bb18 100644 --- a/apps/backend/src/services/platforms/affiliate-dash/product-image.ts +++ b/apps/backend/src/services/platforms/affiliate-dash/product-image.ts @@ -20,13 +20,21 @@ export const PRODUCT_IMAGE_PUBLIC_PREFIX = '/images/products' /** 图片后缀:38 个 slug 全部有 webp,统一用它(体积小、移动端浏览器都支持)。 */ export const PRODUCT_IMAGE_EXTENSION = 'webp' +const KNOWN_SLUGS = new Set(Object.values(AFFILIATE_DASH_SKU_MAPPING)) + /** 内部 SKU → 图片文件名(英文 sku);映射不到返回空串。 */ export function resolveProductImageSlug(internalSku: unknown): string { const key = String(internalSku ?? '').trim() if (!key) { return '' } - return String(AFFILIATE_DASH_SKU_MAPPING[key] || '').trim() + if (AFFILIATE_DASH_SKU_MAPPING[key]) { + return AFFILIATE_DASH_SKU_MAPPING[key] + } + if (KNOWN_SLUGS.has(key)) { + return key + } + return '' } /** 内部 SKU → 图片 URL;映射不到返回空串(前端展示占位)。 */ diff --git a/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx b/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx index aab405c6..3b8835b2 100644 --- a/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx +++ b/apps/frontend/src/pages/claim/ClaimAffiliateDashSteps.tsx @@ -1,7 +1,17 @@ -import { CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons' -import { Alert, Button, Card, Typography } from 'antd' +import { + CheckCircleOutlined, + CopyOutlined, + DownloadOutlined, + ExclamationCircleOutlined, + EyeOutlined, + SafetyCertificateOutlined, +} from '@ant-design/icons' +import { Alert, Button, Card, Divider, Modal, Space, Spin, Typography } from 'antd' +import { useEffect, useState } from 'react' import { formatAdminDateTime } from '@/utils/admin-time' +import { showSuccess } from '@/lib/feedback' import type { ClaimAffiliateDashFlowInfo } from '@/types/claim' +import { generateVoucherDataURL, copyVoucherDataUrlToClipboard } from '@/utils/voucher' import { normalizeUid } from './claim-snapshot' import type { ClaimSnapshot } from './claim-snapshot' import { ClaimProductItems, InfoTile, UidEditRow } from './claim-shared' @@ -255,6 +265,73 @@ export function AffiliateDashResultStep({ snapshot }: { snapshot: ClaimSnapshot const boundAccount = String( affiliateDash?.boundAccount || affiliateDash?.gameAccount || '', ).trim() + const productName = snapshot.product?.title || affiliateDash?.productName || '和平精英道具' + const productSKU = snapshot.product?.skuCode || affiliateDash?.sku + const redeemedAt = formatAdminDateTime(snapshot.task?.redeemedAt || null) + + const [voucherUrl, setVoucherUrl] = useState('') + const [generating, setGenerating] = useState(false) + const [copying, setCopying] = useState(false) + const [previewOpen, setPreviewOpen] = useState(false) + + useEffect(() => { + if (!success) return + let cancelled = false + setGenerating(true) + generateVoucherDataURL({ + productName, + productSKU, + productImage: snapshot.product?.imageUrl, + roleName: roleName && roleName !== '-' ? roleName : '', + roleUID: boundAccount || '-', + channel: gameChannel || (boundAccount.length === 10 ? '微信区' : '手Q / 微信区'), + currencyText: productSKU?.startsWith('rock_') ? '幸运币 x 10' : undefined, + redeemedAt, + }) + .then((url) => { + if (!cancelled) { + setVoucherUrl(url) + } + }) + .catch((err) => { + console.error('Failed to generate voucher:', err) + }) + .finally(() => { + if (!cancelled) setGenerating(false) + }) + return () => { + cancelled = true + } + }, [success, productName, productSKU, snapshot.product?.imageUrl, roleName, boundAccount, gameChannel, redeemedAt]) + + const handleCopyVoucher = async () => { + if (!voucherUrl) return + setCopying(true) + try { + const copied = await copyVoucherDataUrlToClipboard(voucherUrl) + if (copied) { + showSuccess('发货结果图片已复制到剪贴板!可直接在聊天窗口粘贴 (Ctrl+V / ⌘+V)') + } else { + setPreviewOpen(true) + showSuccess('已为您打开高清凭据预览,可长按或右键复制图片') + } + } catch { + setPreviewOpen(true) + } finally { + setCopying(false) + } + } + + const handleDownloadVoucher = () => { + if (!voucherUrl) return + const link = document.createElement('a') + link.href = voucherUrl + link.download = `发货凭据_${boundAccount || 'order'}_${Date.now()}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + showSuccess('凭据图片已开始下载') + } return ( @@ -275,10 +352,115 @@ export function AffiliateDashResultStep({ snapshot }: { snapshot: ClaimSnapshot - + + + {success ? ( + <> + +
+ + + + 发货到账存证凭据 (高清结果图) + + + + {generating && !voucherUrl ? ( +
+ +
+ ) : voucherUrl ? ( +
+
+ 发货凭证大图 setPreviewOpen(true)} + /> +
+ + + + + +
+ + 💡 提示:点击「复制结果图」后可直接在微信/QQ等聊天窗口按 Ctrl+V / ⌘+V 粘贴发送 + +
+
+ ) : null} +
+ + setPreviewOpen(false)} + title="发货到账存证凭据 (高清大图)" + footer={[ + , + , + , + ]} + width={400} + centered + destroyOnClose + > +
+ 发货凭证大图 +
+
+ + ) : null}
) } diff --git a/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx b/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx index a90bf769..c69bccd1 100644 --- a/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx +++ b/apps/frontend/src/pages/claim/ClaimHeaderCard.tsx @@ -15,6 +15,9 @@ export function ClaimHeaderCard({ progressText: string expectedUid: string }) { + const resolvedImageUrl = + product?.imageUrl || (product?.skuCode ? `/images/products/${product.skuCode}.webp` : '') + return (
@@ -26,7 +29,7 @@ export function ClaimHeaderCard({
{/* 物品图放在文案块下方:步骤指示器是绝对定位在右上角的,窄屏时不会与它重叠 */} - +
diff --git a/apps/frontend/src/pages/claim/claim-shared.tsx b/apps/frontend/src/pages/claim/claim-shared.tsx index 05511cfc..23539bbf 100644 --- a/apps/frontend/src/pages/claim/claim-shared.tsx +++ b/apps/frontend/src/pages/claim/claim-shared.tsx @@ -47,13 +47,7 @@ export function ClaimProductImage({ imageUrl, title }: { imageUrl: string; title const [failedSrc, setFailedSrc] = useState('') if (!src || failedSrc === src) { - return ( -
-
- -
-
- ) + return null } return ( diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index c143170a..cf773882 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -442,12 +442,14 @@ } .claim-product-image { - width: min(220px, 62%); + width: min(160px, 48%); aspect-ratio: 1 / 1; border-radius: 16px; - object-fit: cover; - background: #f1f5f9; + object-fit: contain; + background: #f8fafc; + padding: 8px; border: 1px solid rgba(148, 163, 184, 0.24); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } .claim-product-image-fallback { diff --git a/apps/frontend/src/utils/voucher.ts b/apps/frontend/src/utils/voucher.ts new file mode 100644 index 00000000..4fbe9474 --- /dev/null +++ b/apps/frontend/src/utils/voucher.ts @@ -0,0 +1,309 @@ +/** + * 绘制和平精英官方直充/兑换凭据大图 (640x960 Retina 高清卡片)。 + * + * 纯前端 Canvas 渲染:不依赖后端存储或额外图片服务, + * 兑换成功后即时生成供买家/客服预览、保存或复制。 + */ + +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() + 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) + */ +export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise { + 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, '#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) + + // 顶部柔和微光晕 (聚光灯效果) + 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)') + ctx.fillStyle = glowGrad + ctx.fillRect(0, 0, width, 400) + 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.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('兑 换 成 功', width / 2, 85) + ctx.restore() + + // 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(badgeCenterX, badgeCenterY, badgeRadius + 4, 0, Math.PI * 2) + ctx.stroke() + + 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() + + // 3.2 徽章内部背景与立绘 + ctx.save() + ctx.beginPath() + ctx.arc(badgeCenterX, badgeCenterY, badgeRadius - 1, 0, Math.PI * 2) + ctx.clip() + + 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) + + // 绘制商品立绘图片 + let imgLoaded = false + const imgURL = data.productImage || (data.productSKU ? `/images/products/${data.productSKU}.webp` : '') + if (imgURL) { + try { + const img = await loadImage(imgURL) + const drawSize = badgeRadius * 2 - 12 + ctx.drawImage( + img, + badgeCenterX - drawSize / 2, + badgeCenterY - drawSize / 2, + drawSize, + drawSize, + ) + imgLoaded = true + } catch { + imgLoaded = false + } + } + + if (!imgLoaded) { + ctx.fillStyle = '#38bdf8' + ctx.font = 'bold 44px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('🎁', badgeCenterX, badgeCenterY) + } + ctx.restore() + + // 4. 商品标题与副标题 + const nameY = 368 + 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 + 32) + + // 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(80, lineY) + ctx.lineTo(width - 80, 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: data.channel || '安卓-微信' }, + { label: '兑换账号', value: data.roleUID || '-' }, + { label: '兑换时间', value: data.redeemedAt || new Date().toLocaleString() }, + ) + + 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') +} + +/** + * 将 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( + 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) + '…' +}