fix(claim): 优化H5领取与绑定界面商品图展示并集成发货存证凭据生成

- 修复后端 affiliate_dash 领取详情接口漏传 imageUrl 字段的问题
- 优化前端 ClaimProductImage 占位逻辑,消除无图或失败时的空灰色方块
- 移植并集成 affiliate_dash 前端 Canvas 高清发货到账存证凭据(Retina 640x960)
- 在兑换成功页面提供结果图预览、一键复制到剪贴板与下载凭据图片功能
This commit is contained in:
yml2213
2026-10-10 20:55:02 +08:00
parent c137741215
commit 215f3351ba
7 changed files with 514 additions and 15 deletions
@@ -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 (