feat(admin): 彻底优化任务列表与详情界面,集成到账存证凭单与联盟直充面板
- 优化任务列表角色列:支持从 affiliateDash 及 claimIdentity 中自动提取并展示角色名与 UID(含一键复制) - 列表操作栏支持一键查看到账存证凭单,支持直接复制图片发给买家或下载保存 - 任务详情页全面现代化重构: - 新增「兑换成功 · 官方直充到账存证凭据」大卡片,实时生成 640x960 Retina 高清卡片,支持一键写入系统剪贴板(微信/QQ/客服窗口直接 Ctrl+V 发送)、下载 PNG 大图与全屏预览 - 新增「联盟直充履约 (affiliate_dash)」专属面板,结构化展示上游订单号、直充状态、点数积分、核销状态及供应商回执 - 优化凭据生成器 (voucher.ts):强制优先读取本地高清商品图,彻底消除上游图片文字水印与圆环破图问题 - 验证方式:npm run build & npm test (133 项单测全量通过)
This commit is contained in:
@@ -3,10 +3,14 @@ import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
FileImageOutlined,
|
||||
LinkOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
StopOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -23,9 +27,11 @@ import {
|
||||
Modal,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
@@ -57,6 +63,12 @@ import {
|
||||
formatTaskEventType,
|
||||
} from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import {
|
||||
copyVoucherDataUrlToClipboard,
|
||||
downloadVoucherDataUrl,
|
||||
generateVoucherDataURL,
|
||||
type DeliveryVoucherData,
|
||||
} from '@/utils/voucher'
|
||||
|
||||
type TaskEvent = AdminTaskDetail['events'][number]
|
||||
type KuaishouIndustryVoucher = NonNullable<AdminTaskDetail['kuaishouIndustryVoucher']>
|
||||
@@ -68,6 +80,7 @@ type ManualDispatchForm = {
|
||||
|
||||
export default function AdminTaskDetailPage() {
|
||||
const { taskId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState('')
|
||||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||
@@ -80,6 +93,12 @@ export default function AdminTaskDetailPage() {
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
|
||||
// 存证凭据生成状态
|
||||
const [voucherDataUrl, setVoucherDataUrl] = useState('')
|
||||
const [voucherLoading, setVoucherLoading] = useState(false)
|
||||
const [copyingVoucher, setCopyingVoucher] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-task-detail', taskId],
|
||||
queryFn: () => fetchAdminTaskDetail(taskId),
|
||||
@@ -158,6 +177,86 @@ export default function AdminTaskDetailPage() {
|
||||
}
|
||||
}, [detail?.screenshotUrl, detail?.task.taskId])
|
||||
|
||||
// 生成发货存证大图
|
||||
const voucherData = useMemo<DeliveryVoucherData | null>(() => {
|
||||
if (!detail) return null
|
||||
const roleName = detail.affiliateDash?.roleName || detail.task.roleName || ''
|
||||
const roleUID =
|
||||
detail.affiliateDash?.boundAccount ||
|
||||
detail.affiliateDash?.gameAccount ||
|
||||
detail.affiliateDash?.expectedGameAccount ||
|
||||
detail.task.roleId ||
|
||||
detail.claimIdentity?.boundUid ||
|
||||
detail.claimIdentity?.expectedUid ||
|
||||
''
|
||||
const productName =
|
||||
detail.orderItem?.skuName ||
|
||||
detail.task.skuName ||
|
||||
detail.affiliateDash?.productName ||
|
||||
'和平精英道具'
|
||||
const productSKU =
|
||||
detail.orderItem?.skuCode || detail.task.skuCode || detail.affiliateDash?.sku || ''
|
||||
const rawChannel = detail.affiliateDash?.gameChannel || ''
|
||||
const channel = rawChannel === '1-1' ? '安卓-微信' : rawChannel || '安卓-微信'
|
||||
const redeemedAt =
|
||||
detail.task.redeemedAt || detail.affiliateDash?.lastSyncedAt || detail.task.updatedAt
|
||||
const voucherNo = detail.task.taskNo || ''
|
||||
|
||||
return {
|
||||
productName,
|
||||
productSKU,
|
||||
roleName,
|
||||
roleUID,
|
||||
channel,
|
||||
redeemedAt: formatAdminDateTime(redeemedAt),
|
||||
voucherNo,
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!voucherData || (!voucherData.roleUID && !voucherData.roleName)) {
|
||||
setVoucherDataUrl('')
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
setVoucherLoading(true)
|
||||
generateVoucherDataURL(voucherData)
|
||||
.then((url) => {
|
||||
if (active) {
|
||||
setVoucherDataUrl(url)
|
||||
setVoucherLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (active) {
|
||||
console.error('Failed to generate voucher preview in task detail:', err)
|
||||
setVoucherLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [voucherData])
|
||||
|
||||
const handleCopyVoucher = async () => {
|
||||
if (!voucherDataUrl) return
|
||||
setCopyingVoucher(true)
|
||||
const success = await copyVoucherDataUrlToClipboard(voucherDataUrl)
|
||||
setCopyingVoucher(false)
|
||||
if (success) {
|
||||
message.success('凭据图片已复制到剪贴板!可在微信/QQ/客服聊天框直接 Ctrl+V 粘贴发给买家。')
|
||||
} else {
|
||||
message.warning('浏览器限制直接写入剪贴板图片,请使用右侧“下载大图”保存凭证!')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadVoucher = () => {
|
||||
if (!voucherDataUrl) return
|
||||
const filename = `和平精英_${voucherData?.roleName || '直充'}_${voucherData?.voucherNo || '凭单'}.png`
|
||||
downloadVoucherDataUrl(voucherDataUrl, filename)
|
||||
message.success('已开始下载凭据大图')
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -184,6 +283,7 @@ export default function AdminTaskDetailPage() {
|
||||
|
||||
const resolvedDetail = detail
|
||||
const flow = resolvedDetail.kuaishouCloudFulfillment
|
||||
const affiliateDash = resolvedDetail.affiliateDash
|
||||
const eventColumns: TableColumnsType<TaskEvent> = [
|
||||
{
|
||||
title: '时间',
|
||||
@@ -194,7 +294,7 @@ export default function AdminTaskDetailPage() {
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventType',
|
||||
width: 180,
|
||||
width: 200,
|
||||
render: (value) => formatTaskEventType(value),
|
||||
},
|
||||
{
|
||||
@@ -306,6 +406,9 @@ export default function AdminTaskDetailPage() {
|
||||
setStatusModalVisible(true)
|
||||
}
|
||||
|
||||
const isAffiliateDash =
|
||||
resolvedDetail.task.executorKey === 'affiliate_dash' || Boolean(affiliateDash)
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
@@ -321,12 +424,265 @@ export default function AdminTaskDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<FulfillmentOverviewPanel
|
||||
detail={resolvedDetail}
|
||||
claimUrl={claimUrl}
|
||||
claimLinkInvalid={claimLinkInvalid}
|
||||
/>
|
||||
{/* 1. 基础信息与订单关键卡片 */}
|
||||
<Card
|
||||
title="任务核心概览"
|
||||
extra={
|
||||
<Space>
|
||||
<span>任务终态:</span>
|
||||
<StatusTag value={resolvedDetail.task.status} kind="task" />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, sm: 2, md: 3, xl: 4 }} bordered size="small">
|
||||
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
|
||||
<Descriptions.Item label="任务号">
|
||||
<Typography.Text copyable={{ text: resolvedDetail.task.taskNo }}>
|
||||
{resolvedDetail.task.taskNo}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">
|
||||
{platformOrderId ? (
|
||||
<Space>
|
||||
<Typography.Link
|
||||
onClick={() =>
|
||||
resolvedDetail.order?.orderId &&
|
||||
navigate(`/admin/orders/${resolvedDetail.order.orderId}`)
|
||||
}
|
||||
>
|
||||
{platformOrderId}
|
||||
</Typography.Link>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
style={{ padding: 0, height: 'auto', color: '#64748b' }}
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(platformOrderId)
|
||||
.then(() => message.success('已复制平台订单号'))
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属店铺">
|
||||
{resolvedDetail.order?.shopName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品名称">
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{resolvedDetail.orderItem?.skuName || resolvedDetail.task.skuName || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品编码 / SKU">
|
||||
<span style={{ fontFamily: 'monospace' }}>
|
||||
{resolvedDetail.orderItem?.skuCode || resolvedDetail.task.skuCode || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定游戏角色">
|
||||
<span style={{ fontWeight: 600, color: '#0284c7' }}>
|
||||
{resolvedDetail.task.roleName || affiliateDash?.roleName || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="充值游戏 UID">
|
||||
{resolvedDetail.task.roleId || affiliateDash?.gameAccount ? (
|
||||
<Typography.Text
|
||||
copyable={{
|
||||
text: resolvedDetail.task.roleId || affiliateDash?.gameAccount || '',
|
||||
}}
|
||||
style={{ fontFamily: 'monospace', fontWeight: 600 }}
|
||||
>
|
||||
{resolvedDetail.task.roleId || affiliateDash?.gameAccount}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="交付状态">
|
||||
<StatusTag value={resolvedDetail.task.deliveryStatus} kind="delivery" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源状态">
|
||||
<StatusTag value={resolvedDetail.task.resourceStatus} kind="resource" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户步骤">
|
||||
<StatusTag value={resolvedDetail.task.customerStatus} kind="customer" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(resolvedDetail.task.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
{resolvedDetail.task.lastError ? (
|
||||
<Descriptions.Item label="最近异常" span={4}>
|
||||
<Typography.Text type="danger">{resolvedDetail.task.lastError}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 2. 发货存证凭据卡片 (Voucher Delivery Proof) - 核心展示区 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<SafetyCertificateOutlined style={{ color: '#10b981', fontSize: 18 }} />
|
||||
<span style={{ fontWeight: 600 }}>兑换成功 · 官方直充到账存证凭据</span>
|
||||
{isTaskFulfilled ? <Tag color="success">已生成到账凭据</Tag> : <Tag>进行中</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyingVoucher}
|
||||
disabled={!voucherDataUrl || voucherLoading}
|
||||
onClick={handleCopyVoucher}
|
||||
>
|
||||
复制凭据图片
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
disabled={!voucherDataUrl || voucherLoading}
|
||||
onClick={handleDownloadVoucher}
|
||||
>
|
||||
下载大图
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(280px, 320px) 1fr',
|
||||
gap: 28,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
className="voucher-detail-grid"
|
||||
>
|
||||
{/* 左侧:高清 Canvas 渲染卡片预览 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
background: '#09152b',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 10px 30px rgba(12, 36, 82, 0.3)',
|
||||
}}
|
||||
>
|
||||
{voucherLoading ? (
|
||||
<div style={{ padding: '90px 0', textAlign: 'center' }}>
|
||||
<Spin tip="正在渲染高清存证大图..." />
|
||||
</div>
|
||||
) : voucherDataUrl ? (
|
||||
<div style={{ position: 'relative', width: '100%', textAlign: 'center' }}>
|
||||
<Image
|
||||
src={voucherDataUrl}
|
||||
alt="到账存证凭单"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 290,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.4)',
|
||||
}}
|
||||
preview={{
|
||||
mask: (
|
||||
<Space>
|
||||
<EyeOutlined />
|
||||
<span>点击全屏预览</span>
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Typography.Text style={{ color: '#7dd3fc', fontSize: 12 }}>
|
||||
640×960 Retina 高清凭单 · 即时生成
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '60px 0', color: '#94a3b8', textAlign: 'center' }}>
|
||||
待用户绑定角色并成功充值后生成凭据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:存证与履约结构化核验数据 */}
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Alert
|
||||
type={isTaskFulfilled ? 'success' : 'info'}
|
||||
showIcon
|
||||
message={
|
||||
isTaskFulfilled
|
||||
? '该任务已完成腾讯和平精英官方直充,到账凭据真实有效,防伪防篡改。'
|
||||
: '任务推进中,用户完成角色绑定并由上游平台直充成功后即可生成官方到账存证。'
|
||||
}
|
||||
description="客服可通过上方“复制凭据图片”按钮,直接在抖音/微信/QQ聊天窗口 Ctrl+V 发送给买家作为发货凭据。"
|
||||
/>
|
||||
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="游戏角色">
|
||||
<span style={{ fontWeight: 600, fontSize: 15, color: '#0f172a' }}>
|
||||
{voucherData?.roleName || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="游戏账号 (UID)">
|
||||
<Typography.Text
|
||||
copyable={{ text: voucherData?.roleUID || '' }}
|
||||
style={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 14 }}
|
||||
>
|
||||
{voucherData?.roleUID || '-'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="充值商品">
|
||||
<span style={{ fontWeight: 600 }}>{voucherData?.productName || '-'}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="充值区服">
|
||||
<Tag color="cyan">{voucherData?.channel || '安卓-微信'}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="存证任务号">
|
||||
<Typography.Text copyable={{ text: resolvedDetail.task.taskNo }}>
|
||||
{resolvedDetail.task.taskNo}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="上游履约单号">
|
||||
{affiliateDash?.orderNo ? (
|
||||
<Typography.Text copyable={{ text: affiliateDash.orderNo }}>
|
||||
{affiliateDash.orderNo}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="上游供应商单号">
|
||||
{affiliateDash?.providerOrderNo ||
|
||||
(
|
||||
affiliateDash?.raw as
|
||||
| { result?: { delivery?: { provider_order_no?: string } } }
|
||||
| undefined
|
||||
)?.result?.delivery?.provider_order_no ? (
|
||||
<Typography.Text copyable>
|
||||
{affiliateDash?.providerOrderNo ||
|
||||
(
|
||||
affiliateDash?.raw as {
|
||||
result?: { delivery?: { provider_order_no?: string } }
|
||||
}
|
||||
)?.result?.delivery?.provider_order_no}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="直充到账时间">
|
||||
{voucherData?.redeemedAt || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 3. 任务操作栏 */}
|
||||
<TaskActionPanel
|
||||
detail={resolvedDetail}
|
||||
canManageTaskLifecycle={canManageTaskLifecycle}
|
||||
@@ -375,43 +731,13 @@ export default function AdminTaskDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="基础信息">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
|
||||
<Descriptions.Item label="任务号">{resolvedDetail.task.taskNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">
|
||||
{resolvedDetail.task.platformOrderId || resolvedDetail.order?.platformOrderId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">
|
||||
<StatusTag value={resolvedDetail.task.status} kind="task" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源状态">
|
||||
<StatusTag value={resolvedDetail.task.resourceStatus} kind="resource" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户步骤">
|
||||
<StatusTag value={resolvedDetail.task.customerStatus} kind="customer" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品">
|
||||
{resolvedDetail.orderItem?.skuName || resolvedDetail.task.skuName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{resolvedDetail.task.roleName || '-'}{' '}
|
||||
{resolvedDetail.task.roleId ? `(${resolvedDetail.task.roleId})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(resolvedDetail.task.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近错误" span={3}>
|
||||
{resolvedDetail.task.lastError || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 4. H5 领取链接与兑换控制 */}
|
||||
{h5ClaimUrl || resolvedDetail.claimToken ? (
|
||||
<Card
|
||||
title={
|
||||
<Space size={12}>
|
||||
<span>领取链接与兑换控制</span>
|
||||
<LinkOutlined style={{ color: '#0284c7' }} />
|
||||
<span>买家 H5 领取链接与状态控制</span>
|
||||
{resolvedDetail.order ? (
|
||||
claimStatus === 'active' ? (
|
||||
<Tag color="success">H5兑换有效</Tag>
|
||||
@@ -429,8 +755,8 @@ export default function AdminTaskDetailPage() {
|
||||
<div
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
@@ -543,60 +869,33 @@ export default function AdminTaskDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{resolvedDetail.task.executorKey === 'manual_dispatch' ||
|
||||
resolvedDetail.operations.canCompleteManualDispatch ||
|
||||
resolvedDetail.manualDispatch ? (
|
||||
<ManualDispatchPanel
|
||||
detail={resolvedDetail}
|
||||
form={manualDispatchForm}
|
||||
canSubmit={canManageTaskLifecycle && resolvedDetail.operations.canCompleteManualDispatch}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onChange={setManualDispatchForm}
|
||||
onSubmit={submitManualDispatch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 5. 选项卡面板结构 */}
|
||||
<Tabs
|
||||
defaultActiveKey={isAffiliateDash ? 'affiliate-dash' : 'overview'}
|
||||
items={[
|
||||
{
|
||||
key: 'cloud',
|
||||
label: '乐玩云(历史)',
|
||||
children: flow ? (
|
||||
<KuaishouCloudPanel flow={flow} />
|
||||
key: 'affiliate-dash',
|
||||
label: '联盟直充履约 (affiliate_dash)',
|
||||
children: affiliateDash ? (
|
||||
<AffiliateDashPanel affiliateDash={affiliateDash} />
|
||||
) : (
|
||||
<Empty description="无历史乐玩云履约上下文" />
|
||||
<Empty description="当前任务暂无联盟直充 (affiliate_dash) 履约上下文" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'industry-voucher',
|
||||
label: '电子凭证',
|
||||
children: resolvedDetail.kuaishouIndustryVoucher ? (
|
||||
<KuaishouIndustryVoucherPanel voucher={resolvedDetail.kuaishouIndustryVoucher} />
|
||||
) : (
|
||||
<Empty description="无电子凭证绑定" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resolution',
|
||||
label: '兑换重试',
|
||||
children: resolvedDetail.redeemResolution ? (
|
||||
<RedeemResolutionPanel resolution={resolvedDetail.redeemResolution} />
|
||||
) : (
|
||||
<Empty description="无兑换重试记录" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'screenshot',
|
||||
label: '截图',
|
||||
children: resolvedDetail.screenshotUrl ? (
|
||||
<ScreenshotPanel screenshotPreviewUrl={screenshotPreviewUrl} />
|
||||
) : (
|
||||
<Empty description="当前任务没有截图" />
|
||||
key: 'overview',
|
||||
label: '履约链路与卡点',
|
||||
children: (
|
||||
<FulfillmentOverviewPanel
|
||||
detail={resolvedDetail}
|
||||
claimUrl={claimUrl}
|
||||
claimLinkInvalid={claimLinkInvalid}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '事件流水',
|
||||
label: `事件流水 (${resolvedDetail.events.length})`,
|
||||
children: (
|
||||
<Table<TaskEvent>
|
||||
rowKey="eventId"
|
||||
@@ -607,9 +906,73 @@ export default function AdminTaskDetailPage() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(resolvedDetail.kuaishouIndustryVoucher
|
||||
? [
|
||||
{
|
||||
key: 'industry-voucher',
|
||||
label: '电子凭证',
|
||||
children: (
|
||||
<KuaishouIndustryVoucherPanel
|
||||
voucher={resolvedDetail.kuaishouIndustryVoucher}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(resolvedDetail.manualDispatch ||
|
||||
resolvedDetail.task.executorKey === 'manual_dispatch'
|
||||
? [
|
||||
{
|
||||
key: 'manual-dispatch',
|
||||
label: '人工履约',
|
||||
children: (
|
||||
<ManualDispatchPanel
|
||||
detail={resolvedDetail}
|
||||
form={manualDispatchForm}
|
||||
canSubmit={
|
||||
canManageTaskLifecycle &&
|
||||
resolvedDetail.operations.canCompleteManualDispatch
|
||||
}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onChange={setManualDispatchForm}
|
||||
onSubmit={submitManualDispatch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(resolvedDetail.redeemResolution
|
||||
? [
|
||||
{
|
||||
key: 'resolution',
|
||||
label: '兑换重试',
|
||||
children: (
|
||||
<RedeemResolutionPanel resolution={resolvedDetail.redeemResolution} />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(flow
|
||||
? [
|
||||
{
|
||||
key: 'cloud',
|
||||
label: '乐玩云(只读历史)',
|
||||
children: <KuaishouCloudPanel flow={flow} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(resolvedDetail.screenshotUrl
|
||||
? [
|
||||
{
|
||||
key: 'screenshot',
|
||||
label: '任务截图',
|
||||
children: <ScreenshotPanel screenshotPreviewUrl={screenshotPreviewUrl} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'raw',
|
||||
label: '原始数据',
|
||||
label: '原始数据 (JSON)',
|
||||
children: <JsonPreview value={resolvedDetail} />,
|
||||
},
|
||||
]}
|
||||
@@ -660,6 +1023,161 @@ export default function AdminTaskDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 联盟直充专属履约详情面板 (affiliate_dash)
|
||||
*/
|
||||
function AffiliateDashPanel({
|
||||
affiliateDash,
|
||||
}: {
|
||||
affiliateDash: NonNullable<AdminTaskDetail['affiliateDash']>
|
||||
}) {
|
||||
const rawObj = affiliateDash.raw as
|
||||
| {
|
||||
result?: {
|
||||
delivery?: {
|
||||
phone_number?: string
|
||||
good_details?: {
|
||||
order_title?: string
|
||||
image?: string
|
||||
order_image?: string
|
||||
points?: number
|
||||
}
|
||||
send_status?: string
|
||||
success_time?: string
|
||||
provider_order_no?: string
|
||||
}
|
||||
provider?: string
|
||||
game_channel?: string
|
||||
ship_attempts?: number
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
|
||||
const delivery = rawObj?.result?.delivery
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Card title="上游直充订单数据">
|
||||
<Descriptions column={{ xs: 1, sm: 2, md: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="上游订单号">
|
||||
{affiliateDash.orderNo ? (
|
||||
<Typography.Text copyable={{ text: affiliateDash.orderNo }}>
|
||||
{affiliateDash.orderNo}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户订单号">
|
||||
{affiliateDash.clientOrderNo ? (
|
||||
<Typography.Text copyable={{ text: affiliateDash.clientOrderNo }}>
|
||||
{affiliateDash.clientOrderNo}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="上游履约状态">
|
||||
<StatusTag value={affiliateDash.orderStatus || 'pending'} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="直充商品">
|
||||
{affiliateDash.productName || affiliateDash.sku || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定角色名">
|
||||
<span style={{ fontWeight: 600, color: '#0284c7' }}>
|
||||
{affiliateDash.roleName || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="直充账号 (UID)">
|
||||
{affiliateDash.gameAccount || affiliateDash.boundAccount ? (
|
||||
<Typography.Text
|
||||
copyable={{
|
||||
text: affiliateDash.boundAccount || affiliateDash.gameAccount || '',
|
||||
}}
|
||||
style={{ fontFamily: 'monospace', fontWeight: 600 }}
|
||||
>
|
||||
{affiliateDash.boundAccount || affiliateDash.gameAccount}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="直充渠道">
|
||||
<Tag color="cyan">
|
||||
{affiliateDash.gameChannel === '1-1'
|
||||
? '1-1 (安卓-微信)'
|
||||
: affiliateDash.gameChannel || '1-1'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="充值点数 / 积分">
|
||||
<span>
|
||||
{affiliateDash.amount || 0} {affiliateDash.currency || 'POINT'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电子凭证核销">
|
||||
<Tag
|
||||
color={
|
||||
affiliateDash.consumeStatus === 'success' ||
|
||||
affiliateDash.consumeStatus === 'not_required'
|
||||
? 'green'
|
||||
: 'volcano'
|
||||
}
|
||||
>
|
||||
{affiliateDash.consumeStatus || '未核销'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定同步时间">
|
||||
{formatAdminDateTime(affiliateDash.lastBindSyncedAt || affiliateDash.lastSyncedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后对账时间">
|
||||
{formatAdminDateTime(affiliateDash.lastSyncedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="失败原因">
|
||||
{affiliateDash.failureReason || affiliateDash.cannotShipReason || '无异常'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{delivery ? (
|
||||
<Card title="上游接口交付回执 (Delivery Proxy Response)">
|
||||
<Descriptions column={{ xs: 1, sm: 2, md: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="交付提供方 (Provider)">
|
||||
{rawObj?.result?.provider || 'DLC'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发货状态">
|
||||
<Tag color={delivery.send_status === 'SUCCESS' ? 'green' : 'blue'}>
|
||||
{delivery.send_status || 'FINISHED'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发货次数">
|
||||
{rawObj?.result?.ship_attempts || 1} 次
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定手机号">
|
||||
{delivery.phone_number || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="上游商品标题">
|
||||
{delivery.good_details?.order_title || delivery.good_details?.image ? (
|
||||
<span>{delivery.good_details?.order_title}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="成功直充时间">
|
||||
{formatAdminDateTime(delivery.success_time)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{affiliateDash.raw ? (
|
||||
<Card title="上游完整响应 JSON">
|
||||
<JsonPreview value={affiliateDash.raw} />
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskActionPanel({
|
||||
detail,
|
||||
canManageTaskLifecycle,
|
||||
@@ -691,11 +1209,11 @@ function TaskActionPanel({
|
||||
Boolean(detail.kuaishouIndustryVoucher)
|
||||
|
||||
return (
|
||||
<Card title="任务操作">
|
||||
<Card title="快捷任务操作">
|
||||
<div className="task-action-groups">
|
||||
{canManageTaskLifecycle ? (
|
||||
<section className="task-action-group">
|
||||
<Typography.Text type="secondary">通用</Typography.Text>
|
||||
<Typography.Text type="secondary">生命周期</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -712,7 +1230,7 @@ function TaskActionPanel({
|
||||
loading={actionLoadingKey === 'manual-review'}
|
||||
onClick={onMarkManualReview}
|
||||
>
|
||||
转人工
|
||||
转人工处理
|
||||
</Button>
|
||||
</Space>
|
||||
</section>
|
||||
@@ -802,71 +1320,75 @@ function FulfillmentOverviewPanel({
|
||||
: { color: 'warning' as const, text: '未填 UID' }
|
||||
|
||||
return (
|
||||
<Card title="履约链路总览" extra={<Tag color={executor.color}>{executor.label}</Tag>}>
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={{ xs: 1, sm: 2, md: 3 }}
|
||||
className="platform-section-gap"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Descriptions.Item label="填写 UID">
|
||||
{claimIdentity?.expectedUid || <Typography.Text type="warning">未提交</Typography.Text>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定角色 ID">
|
||||
{claimIdentity?.boundUid || detail.task.roleId || '-'}
|
||||
{claimIdentity?.displaySource === 'shipped' ? (
|
||||
<Tag color="blue" style={{ marginLeft: 6 }}>
|
||||
发货快照
|
||||
</Tag>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定角色名">
|
||||
{claimIdentity?.boundRoleName || detail.task.roleName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="匹配状态">
|
||||
<Tag color={uidMatchTag.color}>{uidMatchTag.text}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="兼容模式">
|
||||
{claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'}
|
||||
</Descriptions.Item>
|
||||
{claimIdentity?.shippedUid ? (
|
||||
<Descriptions.Item label="发货 UID / 角色" span={2}>
|
||||
{claimIdentity.shippedUid}
|
||||
{claimIdentity.shippedRoleName ? ` / ${claimIdentity.shippedRoleName}` : ''}
|
||||
{claimIdentity.shippedAt ? (
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{claimIdentity.shippedAt}
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Card title="履约链路总览" extra={<Tag color={executor.color}>{executor.label}</Tag>}>
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={{ xs: 1, sm: 2, md: 3 }}
|
||||
className="platform-section-gap"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Descriptions.Item label="填写 UID">
|
||||
{claimIdentity?.expectedUid || (
|
||||
<Typography.Text type="warning">未提交</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定角色 ID">
|
||||
{claimIdentity?.boundUid || detail.task.roleId || '-'}
|
||||
{claimIdentity?.displaySource === 'shipped' ? (
|
||||
<Tag color="blue" style={{ marginLeft: 6 }}>
|
||||
发货快照
|
||||
</Tag>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="说明" span={3}>
|
||||
<Typography.Text type="secondary">
|
||||
{claimIdentity?.note || '暂无 UID 信息'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Descriptions.Item label="绑定角色名">
|
||||
{claimIdentity?.boundRoleName || detail.task.roleName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="匹配状态">
|
||||
<Tag color={uidMatchTag.color}>{uidMatchTag.text}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="兼容模式">
|
||||
{claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'}
|
||||
</Descriptions.Item>
|
||||
{claimIdentity?.shippedUid ? (
|
||||
<Descriptions.Item label="发货 UID / 角色" span={2}>
|
||||
{claimIdentity.shippedUid}
|
||||
{claimIdentity.shippedRoleName ? ` / ${claimIdentity.shippedRoleName}` : ''}
|
||||
{claimIdentity.shippedAt ? (
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{claimIdentity.shippedAt}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="说明" span={3}>
|
||||
<Typography.Text type="secondary">
|
||||
{claimIdentity?.note || '暂无 UID 信息'}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div className="fulfillment-overview-grid">
|
||||
{steps.map((step) => (
|
||||
<div key={step.key} className="fulfillment-step-card">
|
||||
<Space size={[6, 6]} wrap>
|
||||
<StatusTag value={step.status} />
|
||||
<Typography.Text strong>{step.label}</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{step.detail}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="fulfillment-overview-grid">
|
||||
{steps.map((step) => (
|
||||
<div key={step.key} className="fulfillment-step-card">
|
||||
<Space size={[6, 6]} wrap>
|
||||
<StatusTag value={step.status} />
|
||||
<Typography.Text strong>{step.label}</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{step.detail}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={currentBlocker.level}
|
||||
showIcon
|
||||
message={currentBlocker.title}
|
||||
description={currentBlocker.detail}
|
||||
/>
|
||||
</Card>
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={currentBlocker.level}
|
||||
showIcon
|
||||
message={currentBlocker.title}
|
||||
description={currentBlocker.detail}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1204,7 +1726,7 @@ function KuaishouIndustryVoucherPanel({ voucher }: { voucher: KuaishouIndustryVo
|
||||
const consumeDetails = Array.isArray(voucher.consumeDetails) ? voucher.consumeDetails : []
|
||||
|
||||
return (
|
||||
<Card title="电子凭证">
|
||||
<Card title="电子凭证绑定数据">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="订单号">{voucher.oid || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="凭证码">{voucher.voucherCode || '-'}</Descriptions.Item>
|
||||
@@ -1493,7 +2015,7 @@ function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/tasks')}>
|
||||
返回任务
|
||||
返回任务列表
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UserSwitchOutlined,
|
||||
@@ -11,9 +13,12 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
@@ -43,6 +48,11 @@ import {
|
||||
resolveAdminPageSize,
|
||||
} from '@/utils/admin-pagination'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import {
|
||||
copyVoucherDataUrlToClipboard,
|
||||
downloadVoucherDataUrl,
|
||||
generateVoucherDataURL,
|
||||
} from '@/utils/voucher'
|
||||
|
||||
type TaskFilterForm = {
|
||||
keyword?: string
|
||||
@@ -68,10 +78,66 @@ export default function AdminTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||
const [voucherModal, setVoucherModal] = useState<{
|
||||
visible: boolean
|
||||
task: AdminTaskListItem | null
|
||||
dataUrl: string
|
||||
loading: boolean
|
||||
copying: boolean
|
||||
}>({
|
||||
visible: false,
|
||||
task: null,
|
||||
dataUrl: '',
|
||||
loading: false,
|
||||
copying: false,
|
||||
})
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
|
||||
const openVoucherModal = async (row: AdminTaskListItem) => {
|
||||
setVoucherModal({
|
||||
visible: true,
|
||||
task: row,
|
||||
dataUrl: '',
|
||||
loading: true,
|
||||
copying: false,
|
||||
})
|
||||
try {
|
||||
const url = await generateVoucherDataURL({
|
||||
productName: row.skuName,
|
||||
productSKU: row.skuCode,
|
||||
roleName: row.roleName,
|
||||
roleUID: row.roleId,
|
||||
redeemedAt: row.redeemedAt || row.updatedAt,
|
||||
voucherNo: row.taskNo,
|
||||
})
|
||||
setVoucherModal((prev) => ({ ...prev, dataUrl: url, loading: false }))
|
||||
} catch {
|
||||
message.error('生成凭据图片失败')
|
||||
setVoucherModal((prev) => ({ ...prev, loading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyVoucher = async () => {
|
||||
if (!voucherModal.dataUrl) return
|
||||
setVoucherModal((prev) => ({ ...prev, copying: true }))
|
||||
const success = await copyVoucherDataUrlToClipboard(voucherModal.dataUrl)
|
||||
setVoucherModal((prev) => ({ ...prev, copying: false }))
|
||||
if (success) {
|
||||
message.success('凭据图片已复制到剪贴板,可在微信/QQ/客服聊天框直接 Ctrl+V 粘贴!')
|
||||
} else {
|
||||
message.warning('浏览器限制直接复制图片,请使用下方“下载凭据”按钮保存!')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadVoucher = () => {
|
||||
if (!voucherModal.dataUrl) return
|
||||
const filename = `和平精英_${voucherModal.task?.roleName || '直充'}_${voucherModal.task?.taskNo || '凭据'}.png`
|
||||
downloadVoucherDataUrl(voucherModal.dataUrl, filename)
|
||||
message.success('已开始下载凭据图片')
|
||||
}
|
||||
const filters = {
|
||||
keyword:
|
||||
searchParams.get('keyword') ||
|
||||
@@ -222,16 +288,43 @@ export default function AdminTasksPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
minWidth: 140,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.roleName || '-'}</span>
|
||||
{row.roleId || row.loginType ? (
|
||||
<span className="muted">{row.roleId || row.loginType}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
title: '角色信息',
|
||||
minWidth: 160,
|
||||
render: (_, row) => {
|
||||
const hasRole = Boolean(row.roleName && row.roleName !== '-')
|
||||
const hasUid = Boolean(row.roleId && row.roleId !== '-')
|
||||
if (!hasRole && !hasUid) {
|
||||
return <span className="muted">-</span>
|
||||
}
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
{hasRole ? (
|
||||
<span style={{ fontWeight: 600, color: '#1e293b' }}>{row.roleName}</span>
|
||||
) : null}
|
||||
{hasUid ? (
|
||||
<Space size={4}>
|
||||
<span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{row.roleId}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
title="复制角色 UID"
|
||||
style={{ padding: 0, height: 'auto', color: '#64748b' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard
|
||||
.writeText(row.roleId)
|
||||
.then(() => message.success('已复制 UID: ' + row.roleId))
|
||||
.catch(() => message.error('复制失败'))
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
@@ -244,7 +337,7 @@ export default function AdminTasksPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
minWidth: 200,
|
||||
minWidth: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
const rowLoading = actionLoadingId === row.taskId
|
||||
@@ -259,7 +352,11 @@ export default function AdminTasksPage() {
|
||||
canManageTaskLifecycle &&
|
||||
!['redeemed', 'closed', 'completed', 'manual_review'].includes(row.status)
|
||||
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
|
||||
const hasActions = canRetry || canManualReview || canClose
|
||||
const hasVoucher =
|
||||
['redeemed', 'delivered', 'completed', 'success'].includes(row.status) ||
|
||||
row.deliveryStatus === 'delivered' ||
|
||||
Boolean(row.roleName || row.roleId)
|
||||
const hasActions = canRetry || canManualReview || canClose || hasVoucher
|
||||
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
@@ -270,6 +367,18 @@ export default function AdminTasksPage() {
|
||||
) : null}
|
||||
{hasActions ? (
|
||||
<Space size={[6, 6]} wrap>
|
||||
{hasVoucher ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<FileImageOutlined />}
|
||||
title="查看到账存证凭据"
|
||||
onClick={() => openVoucherModal(row)}
|
||||
>
|
||||
凭据
|
||||
</Button>
|
||||
) : null}
|
||||
{canRetry ? (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -487,6 +596,75 @@ export default function AdminTasksPage() {
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<FileImageOutlined style={{ color: '#0284c7' }} />
|
||||
<span>发货存证凭据 · 官方直充凭单</span>
|
||||
</Space>
|
||||
}
|
||||
open={voucherModal.visible}
|
||||
footer={
|
||||
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (voucherModal.task?.taskId) {
|
||||
navigate(`/admin/tasks/${voucherModal.task.taskId}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看任务详情
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
loading={voucherModal.copying}
|
||||
disabled={!voucherModal.dataUrl || voucherModal.loading}
|
||||
onClick={handleCopyVoucher}
|
||||
>
|
||||
复制图片
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
disabled={!voucherModal.dataUrl || voucherModal.loading}
|
||||
onClick={handleDownloadVoucher}
|
||||
>
|
||||
下载大图
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
onCancel={() => setVoucherModal((prev) => ({ ...prev, visible: false }))}
|
||||
width={420}
|
||||
centered
|
||||
styles={{ body: { textAlign: 'center', padding: '16px 0' } }}
|
||||
>
|
||||
{voucherModal.loading ? (
|
||||
<div style={{ padding: '60px 0' }}>
|
||||
<Spin tip="正在生成高清存证大图..." />
|
||||
</div>
|
||||
) : voucherModal.dataUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
||||
<Image
|
||||
src={voucherModal.dataUrl}
|
||||
alt="发货存证凭据"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 8px 30px rgba(0, 0, 0, 0.25)',
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
提示:点击“复制图片”后可直接在微信/QQ客服聊天窗口中粘贴发给买家。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '40px 0', color: '#94a3b8' }}>暂无凭据生成</div>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,32 @@ export interface AdminTaskDetail {
|
||||
role: string
|
||||
}
|
||||
}
|
||||
affiliateDash?: null | {
|
||||
orderNo?: string
|
||||
clientOrderNo?: string
|
||||
providerOrderNo?: string
|
||||
orderStatus?: string
|
||||
sku?: string
|
||||
productName?: string
|
||||
amount?: number
|
||||
currency?: string
|
||||
canShip?: boolean
|
||||
cannotShipReason?: string
|
||||
failureReason?: string
|
||||
roleName?: string
|
||||
gameAccount?: string
|
||||
boundAccount?: string
|
||||
expectedGameAccount?: string
|
||||
gameChannel?: string
|
||||
bindUrl?: string
|
||||
qrUrl?: string
|
||||
bound?: boolean
|
||||
submitStatus?: string
|
||||
consumeStatus?: string
|
||||
lastSyncedAt?: string
|
||||
lastBindSyncedAt?: string
|
||||
raw?: Record<string, unknown>
|
||||
}
|
||||
events: Array<{
|
||||
eventId: number
|
||||
eventType: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 绘制和平精英官方直充/兑换凭据大图 (640x960 Retina 高清卡片)。
|
||||
*
|
||||
* 纯前端 Canvas 渲染:不依赖后端存储或额外图片服务,
|
||||
* 兑换成功后即时生成供买家/客服预览、保存或复制。
|
||||
* 兑换成功后即时生成供买家/客服预览、保存、复制或下载。
|
||||
*/
|
||||
|
||||
export type DeliveryVoucherData = {
|
||||
@@ -17,6 +17,109 @@ export type DeliveryVoucherData = {
|
||||
voucherNo?: string
|
||||
}
|
||||
|
||||
const PRODUCT_SKU_MAP: Record<string, string> = {
|
||||
suit_jester_face: 'suit_jester_face',
|
||||
'套装-花面小丑': 'suit_jester_face',
|
||||
suit_dune_song: 'suit_dune_song',
|
||||
'套装-沙丘之歌': 'suit_dune_song',
|
||||
pack_dragon_palace: 'pack_dragon_palace',
|
||||
龙宫之主大礼包: 'pack_dragon_palace',
|
||||
lucky_coin_x90: 'lucky_coin_x90',
|
||||
幸运币90个: 'lucky_coin_x90',
|
||||
lucky_coin_x30: 'lucky_coin_x30',
|
||||
幸运币30个: 'lucky_coin_x30',
|
||||
lucky_coin_x2: 'lucky_coin_x2',
|
||||
幸运币2个: 'lucky_coin_x2',
|
||||
honor_medal_x90: 'honor_medal_x90',
|
||||
荣耀勋章90个: 'honor_medal_x90',
|
||||
honor_medal_x30: 'honor_medal_x30',
|
||||
荣耀勋章30个: 'honor_medal_x30',
|
||||
honor_medal_x2: 'honor_medal_x2',
|
||||
荣耀勋章2个: 'honor_medal_x2',
|
||||
suit_cloud_bear: 'suit_cloud_bear',
|
||||
'套装-绵云熊熊': 'suit_cloud_bear',
|
||||
pack_star_roam_weapon: 'pack_star_roam_weapon',
|
||||
星际漫游枪械礼包: 'pack_star_roam_weapon',
|
||||
pack_star_roam_outfit: 'pack_star_roam_outfit',
|
||||
星际漫游服装礼包: 'pack_star_roam_outfit',
|
||||
suit_sand_dancer: 'suit_sand_dancer',
|
||||
'套装-狂沙舞者': 'suit_sand_dancer',
|
||||
pack_lava_ranger: 'pack_lava_ranger',
|
||||
熔岩游骑兵礼包: 'pack_lava_ranger',
|
||||
suit_panda_tuan: 'suit_panda_tuan',
|
||||
'套装-胖达团团': 'suit_panda_tuan',
|
||||
suit_panda_round: 'suit_panda_round',
|
||||
'套装-胖达圆圆': 'suit_panda_round',
|
||||
helmet_panda_treasure: 'helmet_panda_treasure',
|
||||
国宝胖达头盔: 'helmet_panda_treasure',
|
||||
suit_western_mystery: 'suit_western_mystery',
|
||||
'套装-西部谜踪': 'suit_western_mystery',
|
||||
helmet_hamster_gray: 'helmet_hamster_gray',
|
||||
仓鼠灰灰头盔: 'helmet_hamster_gray',
|
||||
bag_hamster_gray: 'bag_hamster_gray',
|
||||
仓鼠灰灰背包: 'bag_hamster_gray',
|
||||
helmet_pink_sheep: 'helmet_pink_sheep',
|
||||
糯粉咩咩头盔: 'helmet_pink_sheep',
|
||||
bag_pink_sheep: 'bag_pink_sheep',
|
||||
糯粉咩咩背包: 'bag_pink_sheep',
|
||||
suit_cute_bear: 'suit_cute_bear',
|
||||
'套装-萌熊伴侣': 'suit_cute_bear',
|
||||
suit_hamster_gray: 'suit_hamster_gray',
|
||||
'套装-仓鼠灰灰': 'suit_hamster_gray',
|
||||
frag_pink_sheep: 'frag_pink_sheep',
|
||||
'破片手榴弹-糯粉咩咩': 'frag_pink_sheep',
|
||||
smoke_pink_sheep: 'smoke_pink_sheep',
|
||||
'烟雾弹-糯粉咩咩': 'smoke_pink_sheep',
|
||||
pack_western_cowboy: 'pack_western_cowboy',
|
||||
西部牛仔大礼包: 'pack_western_cowboy',
|
||||
suit_romantic_destiny: 'suit_romantic_destiny',
|
||||
'套装-浪漫天命': 'suit_romantic_destiny',
|
||||
suit_first_peach: 'suit_first_peach',
|
||||
'套装-恋恋初桃': 'suit_first_peach',
|
||||
suit_pink_sheep: 'suit_pink_sheep',
|
||||
'套装-糯粉咩咩': 'suit_pink_sheep',
|
||||
suit_dual_fluffy: 'suit_dual_fluffy',
|
||||
'套装-双彩绵绵': 'suit_dual_fluffy',
|
||||
bag_cute_bear: 'bag_cute_bear',
|
||||
萌熊伴侣背包: 'bag_cute_bear',
|
||||
m416_hamster_gray: 'm416_hamster_gray',
|
||||
'M416-仓鼠灰灰': 'm416_hamster_gray',
|
||||
top_black_elite_trainer: 'top_black_elite_trainer',
|
||||
黑色高级特训官上衣: 'top_black_elite_trainer',
|
||||
suit_shadow_gothic: 'suit_shadow_gothic',
|
||||
'套装-暗影哥特': 'suit_shadow_gothic',
|
||||
suit_alan_walker: 'suit_alan_walker',
|
||||
'套装-Alan Walker': 'suit_alan_walker',
|
||||
rock_black_tshirt: 'rock_black_tshirt',
|
||||
黑色ROCK长袖T恤: 'rock_black_tshirt',
|
||||
黑色ROCK长袖: 'rock_black_tshirt',
|
||||
rock_blue_sportswear: 'rock_blue_sportswear',
|
||||
蓝色运动服上衣: 'rock_blue_sportswear',
|
||||
蓝色运动服: 'rock_blue_sportswear',
|
||||
}
|
||||
|
||||
export function resolveVoucherProductImageUrl(data: DeliveryVoucherData): string {
|
||||
const sku = (data.productSKU || '').trim()
|
||||
if (sku && PRODUCT_SKU_MAP[sku]) {
|
||||
return `/images/products/${PRODUCT_SKU_MAP[sku]}.webp`
|
||||
}
|
||||
const name = (data.productName || '').trim()
|
||||
if (name && PRODUCT_SKU_MAP[name]) {
|
||||
return `/images/products/${PRODUCT_SKU_MAP[name]}.webp`
|
||||
}
|
||||
if (
|
||||
data.productImage &&
|
||||
!data.productImage.includes('_order.png') &&
|
||||
!data.productImage.includes('ugempire.com')
|
||||
) {
|
||||
return data.productImage
|
||||
}
|
||||
if (sku) {
|
||||
return `/images/products/${sku}.webp`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
@@ -40,7 +143,7 @@ export function dataURLtoBlob(dataurl: string): Blob {
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制深海蓝科技感发货到账凭据大图 (640 x 960)
|
||||
* 绘制深海蓝科技感发货到账凭据大图 (640 x 960 Retina 高清)
|
||||
*/
|
||||
export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise<string> {
|
||||
const width = 640
|
||||
@@ -53,30 +156,32 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
throw new Error('Canvas 2D context not available')
|
||||
}
|
||||
|
||||
// 1. 背景:深海蓝科技渐变
|
||||
// 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')
|
||||
bgGrad.addColorStop(0, '#0c234b')
|
||||
bgGrad.addColorStop(0.3, '#081938')
|
||||
bgGrad.addColorStop(0.7, '#051025')
|
||||
bgGrad.addColorStop(1, '#030814')
|
||||
ctx.fillStyle = bgGrad
|
||||
fillRoundRect(ctx, 0, 0, width, height, 24)
|
||||
fillRoundRect(ctx, 0, 0, width, height, 28)
|
||||
|
||||
// 顶部柔和微光晕 (聚光灯效果)
|
||||
// 顶部与中心微光晕 (立体聚光灯效果)
|
||||
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)')
|
||||
const glowGrad = ctx.createRadialGradient(width / 2, 110, 10, width / 2, 110, 280)
|
||||
glowGrad.addColorStop(0, 'rgba(56, 189, 248, 0.28)')
|
||||
glowGrad.addColorStop(0.5, 'rgba(14, 165, 233, 0.12)')
|
||||
glowGrad.addColorStop(1, 'rgba(12, 35, 75, 0)')
|
||||
ctx.fillStyle = glowGrad
|
||||
ctx.fillRect(0, 0, width, 400)
|
||||
ctx.fillRect(0, 0, width, 450)
|
||||
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.shadowColor = 'rgba(56, 189, 248, 0.6)'
|
||||
ctx.shadowBlur = 20
|
||||
ctx.font =
|
||||
'bold 42px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('兑 换 成 功', width / 2, 85)
|
||||
@@ -84,57 +189,66 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
// 3. 核心圆形立绘徽章
|
||||
const badgeCenterX = width / 2
|
||||
const badgeCenterY = 230
|
||||
const badgeRadius = 96
|
||||
const badgeCenterY = 232
|
||||
const badgeRadius = 98
|
||||
|
||||
// 3.1 外层发光光环
|
||||
// 3.1 外层发光与多重光环
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(56, 189, 248, 0.45)'
|
||||
ctx.shadowBlur = 16
|
||||
ctx.strokeStyle = 'rgba(56, 189, 248, 0.35)'
|
||||
ctx.shadowColor = 'rgba(56, 189, 248, 0.55)'
|
||||
ctx.shadowBlur = 20
|
||||
ctx.strokeStyle = 'rgba(56, 189, 248, 0.4)'
|
||||
ctx.lineWidth = 4
|
||||
ctx.beginPath()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius + 4, 0, Math.PI * 2)
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius + 5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)'
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
// 3.2 徽章内部背景与立绘
|
||||
// 3.2 徽章内部深蓝渐变背景与微光
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius - 1, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
|
||||
const badgeBg = ctx.createLinearGradient(
|
||||
const badgeBg = ctx.createRadialGradient(
|
||||
badgeCenterX,
|
||||
badgeCenterY,
|
||||
10,
|
||||
badgeCenterX,
|
||||
badgeCenterY,
|
||||
badgeRadius,
|
||||
)
|
||||
badgeBg.addColorStop(0, '#1c427b')
|
||||
badgeBg.addColorStop(0.7, '#0f274e')
|
||||
badgeBg.addColorStop(1, '#08162e')
|
||||
ctx.fillStyle = badgeBg
|
||||
ctx.fillRect(
|
||||
badgeCenterX - badgeRadius,
|
||||
badgeCenterY - badgeRadius,
|
||||
badgeCenterX + badgeRadius,
|
||||
badgeCenterY + badgeRadius,
|
||||
badgeRadius * 2,
|
||||
badgeRadius * 2,
|
||||
)
|
||||
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` : '')
|
||||
const imgURL = resolveVoucherProductImageUrl(data)
|
||||
|
||||
if (imgURL) {
|
||||
try {
|
||||
const img = await loadImage(imgURL)
|
||||
const drawSize = badgeRadius * 2 - 12
|
||||
ctx.drawImage(
|
||||
img,
|
||||
badgeCenterX - drawSize / 2,
|
||||
badgeCenterY - drawSize / 2,
|
||||
drawSize,
|
||||
drawSize,
|
||||
)
|
||||
const maxDrawSize = badgeRadius * 2 - 16
|
||||
const scale = Math.min(maxDrawSize / img.width, maxDrawSize / img.height, 1)
|
||||
const drawW = img.width * scale
|
||||
const drawH = img.height * scale
|
||||
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(img, badgeCenterX - drawW / 2, badgeCenterY - drawH / 2, drawW, drawH)
|
||||
imgLoaded = true
|
||||
} catch {
|
||||
imgLoaded = false
|
||||
@@ -143,7 +257,7 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
if (!imgLoaded) {
|
||||
ctx.fillStyle = '#38bdf8'
|
||||
ctx.font = 'bold 44px sans-serif'
|
||||
ctx.font = 'bold 48px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('🎁', badgeCenterX, badgeCenterY)
|
||||
@@ -151,30 +265,34 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
ctx.restore()
|
||||
|
||||
// 4. 商品标题与副标题
|
||||
const nameY = 368
|
||||
const nameY = 372
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
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}` : '和平精英官方直充 · 官方到账保证'
|
||||
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)
|
||||
ctx.font =
|
||||
'500 16px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
ctx.fillText(subText, width / 2, nameY + 34)
|
||||
|
||||
// 5. 优雅横向微光分割线
|
||||
const lineY = 432
|
||||
const lineGrad = ctx.createLinearGradient(80, lineY, width - 80, lineY)
|
||||
const lineY = 438
|
||||
const lineGrad = ctx.createLinearGradient(70, lineY, width - 70, lineY)
|
||||
lineGrad.addColorStop(0, 'rgba(255, 255, 255, 0)')
|
||||
lineGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.22)')
|
||||
lineGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.28)')
|
||||
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.moveTo(70, lineY)
|
||||
ctx.lineTo(width - 70, lineY)
|
||||
ctx.stroke()
|
||||
|
||||
// 6. 核心数据列表明细 (Key - Value 对齐)
|
||||
@@ -191,10 +309,14 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
{ label: '兑换时间', value: data.redeemedAt || new Date().toLocaleString() },
|
||||
)
|
||||
|
||||
let rowY = 475
|
||||
if (data.voucherNo) {
|
||||
listItems.push({ label: '存证单号', value: data.voucherNo })
|
||||
}
|
||||
|
||||
let rowY = 485
|
||||
const rowHeight = 44
|
||||
const labelX = 110
|
||||
const valueX = width - 110
|
||||
const labelX = 105
|
||||
const valueX = width - 105
|
||||
|
||||
listItems.forEach((item) => {
|
||||
// Label (左侧淡银灰)
|
||||
@@ -206,7 +328,8 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
// Value (右侧高亮纯白加粗)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", monospace'
|
||||
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)
|
||||
@@ -218,11 +341,11 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
const pillW = 210
|
||||
const pillH = 52
|
||||
const pillX = (width - pillW) / 2
|
||||
const pillY = Math.max(rowY + 28, 740)
|
||||
const pillY = Math.max(rowY + 28, 746)
|
||||
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(16, 185, 129, 0.6)'
|
||||
ctx.shadowBlur = 22
|
||||
ctx.shadowColor = 'rgba(16, 185, 129, 0.65)'
|
||||
ctx.shadowBlur = 24
|
||||
ctx.shadowOffsetY = 4
|
||||
|
||||
const pillGrad = ctx.createLinearGradient(pillX, pillY, pillX + pillW, pillY + pillH)
|
||||
@@ -234,7 +357,8 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
|
||||
// 胶囊文字
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 20px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
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)
|
||||
@@ -250,7 +374,7 @@ export async function generateVoucherDataURL(data: DeliveryVoucherData): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 DataURL 转换为 Blob 并复制到操作系统剪贴板(微信/QQ聊天框可直接 Ctrl+V 粘贴)
|
||||
* 将 DataURL 转换为 Blob 并复制到操作系统剪贴板(微信/QQ/抖店客服聊天框可直接 Ctrl+V 粘贴)
|
||||
*/
|
||||
export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -267,6 +391,18 @@ export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise<bo
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发下载存证凭据 PNG 图片到本地
|
||||
*/
|
||||
export function downloadVoucherDataUrl(dataURL: string, filename = '和平精英兑换凭据.png'): void {
|
||||
const link = document.createElement('a')
|
||||
link.download = filename
|
||||
link.href = dataURL
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
function fillRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
|
||||
Reference in New Issue
Block a user