fix(claim): 优化H5领取与绑定界面商品图展示并集成发货存证凭据生成
- 修复后端 affiliate_dash 领取详情接口漏传 imageUrl 字段的问题 - 优化前端 ClaimProductImage 占位逻辑,消除无图或失败时的空灰色方块 - 移植并集成 affiliate_dash 前端 Canvas 高清发货到账存证凭据(Retina 640x960) - 在兑换成功页面提供结果图预览、一键复制到剪贴板与下载凭据图片功能
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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;映射不到返回空串(前端展示占位)。 */
|
||||
|
||||
@@ -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 (
|
||||
<Card className="claim-content-card">
|
||||
@@ -275,10 +352,115 @@ export function AffiliateDashResultStep({ snapshot }: { snapshot: ClaimSnapshot
|
||||
<InfoTile label="绑定账号" value={boundAccount || '-'} />
|
||||
<InfoTile label="游戏角色" value={roleName || '-'} />
|
||||
<InfoTile label="游戏渠道" value={gameChannel || '-'} />
|
||||
<InfoTile label="结果时间" value={formatAdminDateTime(snapshot.task?.redeemedAt || null)} />
|
||||
<InfoTile label="结果时间" value={redeemedAt} />
|
||||
</div>
|
||||
|
||||
<ClaimProductItems product={snapshot.product} />
|
||||
|
||||
{success ? (
|
||||
<>
|
||||
<Divider style={{ margin: '20px 0 16px 0' }} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Space align="center" style={{ marginBottom: 12 }}>
|
||||
<SafetyCertificateOutlined style={{ color: '#10b981', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||
发货到账存证凭据 (高清结果图)
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{generating && !voucherUrl ? (
|
||||
<div style={{ padding: '28px 0' }}>
|
||||
<Spin tip="正在生成发货到账存证图..." />
|
||||
</div>
|
||||
) : voucherUrl ? (
|
||||
<div>
|
||||
<div style={{ margin: '0 auto 16px auto', maxWidth: 280 }}>
|
||||
<img
|
||||
src={voucherUrl}
|
||||
alt="发货凭证大图"
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 18px rgba(0, 0, 0, 0.12)',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<Space wrap size={10} style={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copying}
|
||||
onClick={handleCopyVoucher}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||
borderColor: '#059669',
|
||||
}}
|
||||
>
|
||||
复制结果图 (Ctrl+V)
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleDownloadVoucher}>
|
||||
下载凭证图
|
||||
</Button>
|
||||
<Button icon={<EyeOutlined />} onClick={() => setPreviewOpen(true)}>
|
||||
查看大图
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
💡 提示:点击「复制结果图」后可直接在微信/QQ等聊天窗口按 Ctrl+V / ⌘+V 粘贴发送
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
title="发货到账存证凭据 (高清大图)"
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button key="download" icon={<DownloadOutlined />} onClick={handleDownloadVoucher}>
|
||||
下载图片
|
||||
</Button>,
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copying}
|
||||
onClick={handleCopyVoucher}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||
borderColor: '#059669',
|
||||
}}
|
||||
>
|
||||
复制图片到剪贴板
|
||||
</Button>,
|
||||
]}
|
||||
width={400}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '10px 0' }}>
|
||||
<img
|
||||
src={voucherUrl}
|
||||
alt="发货凭证大图"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 520,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ export function ClaimHeaderCard({
|
||||
progressText: string
|
||||
expectedUid: string
|
||||
}) {
|
||||
const resolvedImageUrl =
|
||||
product?.imageUrl || (product?.skuCode ? `/images/products/${product.skuCode}.webp` : '')
|
||||
|
||||
return (
|
||||
<Card className="claim-header-card">
|
||||
<div className="claim-header-copy">
|
||||
@@ -26,7 +29,7 @@ export function ClaimHeaderCard({
|
||||
</div>
|
||||
|
||||
{/* 物品图放在文案块下方:步骤指示器是绝对定位在右上角的,窄屏时不会与它重叠 */}
|
||||
<ClaimProductImage imageUrl={product?.imageUrl || ''} title={product?.title || ''} />
|
||||
<ClaimProductImage imageUrl={resolvedImageUrl} title={product?.title || ''} />
|
||||
|
||||
<div className="claim-step-indicator">
|
||||
<div className="claim-step-dots" aria-label={`当前第 ${currentStep} 步`}>
|
||||
|
||||
@@ -47,13 +47,7 @@ export function ClaimProductImage({ imageUrl, title }: { imageUrl: string; title
|
||||
const [failedSrc, setFailedSrc] = useState('')
|
||||
|
||||
if (!src || failedSrc === src) {
|
||||
return (
|
||||
<div className="claim-product-image-wrap">
|
||||
<div className="claim-product-image claim-product-image-fallback" aria-label="物品图片">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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)
|
||||
*/
|
||||
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, '#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<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
|
||||
}
|
||||
|
||||
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) + '…'
|
||||
}
|
||||
Reference in New Issue
Block a user