diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index e9ce5e50..213739d3 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -139,7 +139,7 @@ export async function getAdminOrders( } } -function groupRowsByOrderId(rows: T[]): Map { +function groupRowsByOrderId(rows: T[]): Map { const grouped = new Map() for (const row of rows) { // pg BIGINT 常以 string 返回;统一 Number 键,避免列表挂不上 order_items →「商品 0」。 @@ -431,6 +431,7 @@ export async function getAdminTaskDetail( kuaishouCloudFulfillment, kuaishouIndustryVoucher, manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), + affiliateDash: taskContext.affiliateDash || null, events: taskEvents.map(mapAdminTaskEvent), operations: { canRetry: diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.ts b/apps/backend/src/services/admin/admin-task-read-helpers.ts index 9083014c..b70296c6 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-task-read-helpers.ts @@ -285,9 +285,53 @@ function getTaskRetryCount(task: TaskRow | null | undefined): number { } function resolveTaskDisplayRoleName(task: TaskRow): string { - return String(task.kuaishou_role_name || task.role_name || '').trim() + if (task.kuaishou_role_name || task.role_name) { + return String(task.kuaishou_role_name || task.role_name).trim() + } + const context = safeParseJson(task.context_json) + const affiliateDash = context.affiliateDash + if (affiliateDash && typeof affiliateDash === 'object') { + const dash = affiliateDash as Record + const name = String(dash.roleName || dash.boundRoleName || dash.game_account_role_name || '').trim() + if (name) return name + } + const kuaishouCloud = context.kuaishouCloudFulfillment + if (kuaishouCloud && typeof kuaishouCloud === 'object') { + const cloud = kuaishouCloud as Record + const roleObj = cloud.role as Record | undefined + if (roleObj?.name) return String(roleObj.name).trim() + } + return '' } function resolveTaskDisplayRoleId(task: TaskRow): string { - return String(task.kuaishou_role_id || task.role_id || '').trim() + if (task.kuaishou_role_id || task.role_id) { + return String(task.kuaishou_role_id || task.role_id).trim() + } + const context = safeParseJson(task.context_json) + const affiliateDash = context.affiliateDash + if (affiliateDash && typeof affiliateDash === 'object') { + const dash = affiliateDash as Record + const id = String( + dash.boundAccount || + dash.gameAccount || + dash.expectedGameAccount || + dash.game_uid || + dash.game_account || + '', + ).trim() + if (id) return id + } + const claimIdentity = context.claimIdentity + if (claimIdentity && typeof claimIdentity === 'object') { + const id = String((claimIdentity as Record).expectedUid || '').trim() + if (id) return id + } + const kuaishouCloud = context.kuaishouCloudFulfillment + if (kuaishouCloud && typeof kuaishouCloud === 'object') { + const cloud = kuaishouCloud as Record + const roleObj = cloud.role as Record | undefined + if (roleObj?.rid) return String(roleObj.rid).trim() + } + return '' } diff --git a/apps/backend/src/types/admin/task-detail.ts b/apps/backend/src/types/admin/task-detail.ts index b2f232cb..8e83965d 100644 --- a/apps/backend/src/types/admin/task-detail.ts +++ b/apps/backend/src/types/admin/task-detail.ts @@ -20,6 +20,7 @@ export type AdminTaskDetailView = { kuaishouCloudFulfillment: JsonObject | null kuaishouIndustryVoucher: JsonObject | null manualDispatch: JsonObject | null + affiliateDash: JsonObject | null events: JsonObject[] operations: JsonObject } diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index 10c1c917..d7c12758 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -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 @@ -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(() => { + 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 (
@@ -184,6 +283,7 @@ export default function AdminTaskDetailPage() { const resolvedDetail = detail const flow = resolvedDetail.kuaishouCloudFulfillment + const affiliateDash = resolvedDetail.affiliateDash const eventColumns: TableColumnsType = [ { 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 (
- + {/* 1. 基础信息与订单关键卡片 */} + + 任务终态: + + + } + > + + {resolvedDetail.task.taskId} + + + {resolvedDetail.task.taskNo} + + + + {platformOrderId ? ( + + + resolvedDetail.order?.orderId && + navigate(`/admin/orders/${resolvedDetail.order.orderId}`) + } + > + {platformOrderId} + + + + + } + > +
+ {/* 左侧:高清 Canvas 渲染卡片预览 */} +
+ {voucherLoading ? ( +
+ +
+ ) : voucherDataUrl ? ( +
+ 到账存证凭单 + + 点击全屏预览 + + ), + }} + /> +
+ + 640×960 Retina 高清凭单 · 即时生成 + +
+
+ ) : ( +
+ 待用户绑定角色并成功充值后生成凭据 +
+ )} +
+ + {/* 右侧:存证与履约结构化核验数据 */} + + + + + + + {voucherData?.roleName || '-'} + + + + + {voucherData?.roleUID || '-'} + + + + {voucherData?.productName || '-'} + + + {voucherData?.channel || '安卓-微信'} + + + + {resolvedDetail.task.taskNo} + + + + {affiliateDash?.orderNo ? ( + + {affiliateDash.orderNo} + + ) : ( + '-' + )} + + + {affiliateDash?.providerOrderNo || + ( + affiliateDash?.raw as + | { result?: { delivery?: { provider_order_no?: string } } } + | undefined + )?.result?.delivery?.provider_order_no ? ( + + {affiliateDash?.providerOrderNo || + ( + affiliateDash?.raw as { + result?: { delivery?: { provider_order_no?: string } } + } + )?.result?.delivery?.provider_order_no} + + ) : ( + '-' + )} + + + {voucherData?.redeemedAt || '-'} + + + +
+
+ + {/* 3. 任务操作栏 */} - - - {resolvedDetail.task.taskId} - {resolvedDetail.task.taskNo} - - {resolvedDetail.task.platformOrderId || resolvedDetail.order?.platformOrderId || '-'} - - - - - - - - - - - - {resolvedDetail.orderItem?.skuName || resolvedDetail.task.skuName || '-'} - - - {resolvedDetail.task.roleName || '-'}{' '} - {resolvedDetail.task.roleId ? `(${resolvedDetail.task.roleId})` : ''} - - - {formatAdminDateTime(resolvedDetail.task.updatedAt)} - - - {resolvedDetail.task.lastError || '-'} - - - - + {/* 4. H5 领取链接与兑换控制 */} {h5ClaimUrl || resolvedDetail.claimToken ? ( - 领取链接与兑换控制 + + 买家 H5 领取链接与状态控制 {resolvedDetail.order ? ( claimStatus === 'active' ? ( H5兑换有效 @@ -429,8 +755,8 @@ export default function AdminTaskDetailPage() {
@@ -543,60 +869,33 @@ export default function AdminTaskDetailPage() { ) : null} - {resolvedDetail.task.executorKey === 'manual_dispatch' || - resolvedDetail.operations.canCompleteManualDispatch || - resolvedDetail.manualDispatch ? ( - - ) : null} - + {/* 5. 选项卡面板结构 */} + key: 'affiliate-dash', + label: '联盟直充履约 (affiliate_dash)', + children: affiliateDash ? ( + ) : ( - + ), }, { - key: 'industry-voucher', - label: '电子凭证', - children: resolvedDetail.kuaishouIndustryVoucher ? ( - - ) : ( - - ), - }, - { - key: 'resolution', - label: '兑换重试', - children: resolvedDetail.redeemResolution ? ( - - ) : ( - - ), - }, - { - key: 'screenshot', - label: '截图', - children: resolvedDetail.screenshotUrl ? ( - - ) : ( - + key: 'overview', + label: '履约链路与卡点', + children: ( + ), }, { key: 'events', - label: '事件流水', + label: `事件流水 (${resolvedDetail.events.length})`, children: ( rowKey="eventId" @@ -607,9 +906,73 @@ export default function AdminTaskDetailPage() { /> ), }, + ...(resolvedDetail.kuaishouIndustryVoucher + ? [ + { + key: 'industry-voucher', + label: '电子凭证', + children: ( + + ), + }, + ] + : []), + ...(resolvedDetail.manualDispatch || + resolvedDetail.task.executorKey === 'manual_dispatch' + ? [ + { + key: 'manual-dispatch', + label: '人工履约', + children: ( + + ), + }, + ] + : []), + ...(resolvedDetail.redeemResolution + ? [ + { + key: 'resolution', + label: '兑换重试', + children: ( + + ), + }, + ] + : []), + ...(flow + ? [ + { + key: 'cloud', + label: '乐玩云(只读历史)', + children: , + }, + ] + : []), + ...(resolvedDetail.screenshotUrl + ? [ + { + key: 'screenshot', + label: '任务截图', + children: , + }, + ] + : []), { key: 'raw', - label: '原始数据', + label: '原始数据 (JSON)', children: , }, ]} @@ -660,6 +1023,161 @@ export default function AdminTaskDetailPage() { ) } +/** + * 联盟直充专属履约详情面板 (affiliate_dash) + */ +function AffiliateDashPanel({ + affiliateDash, +}: { + affiliateDash: NonNullable +}) { + 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 ( + + + + + {affiliateDash.orderNo ? ( + + {affiliateDash.orderNo} + + ) : ( + '-' + )} + + + {affiliateDash.clientOrderNo ? ( + + {affiliateDash.clientOrderNo} + + ) : ( + '-' + )} + + + + + + {affiliateDash.productName || affiliateDash.sku || '-'} + + + + {affiliateDash.roleName || '-'} + + + + {affiliateDash.gameAccount || affiliateDash.boundAccount ? ( + + {affiliateDash.boundAccount || affiliateDash.gameAccount} + + ) : ( + '-' + )} + + + + {affiliateDash.gameChannel === '1-1' + ? '1-1 (安卓-微信)' + : affiliateDash.gameChannel || '1-1'} + + + + + {affiliateDash.amount || 0} {affiliateDash.currency || 'POINT'} + + + + + {affiliateDash.consumeStatus || '未核销'} + + + + {formatAdminDateTime(affiliateDash.lastBindSyncedAt || affiliateDash.lastSyncedAt)} + + + {formatAdminDateTime(affiliateDash.lastSyncedAt)} + + + {affiliateDash.failureReason || affiliateDash.cannotShipReason || '无异常'} + + + + + {delivery ? ( + + + + {rawObj?.result?.provider || 'DLC'} + + + + {delivery.send_status || 'FINISHED'} + + + + {rawObj?.result?.ship_attempts || 1} 次 + + + {delivery.phone_number || '-'} + + + {delivery.good_details?.order_title || delivery.good_details?.image ? ( + {delivery.good_details?.order_title} + ) : ( + '-' + )} + + + {formatAdminDateTime(delivery.success_time)} + + + + ) : null} + + {affiliateDash.raw ? ( + + + + ) : null} + + ) +} + function TaskActionPanel({ detail, canManageTaskLifecycle, @@ -691,11 +1209,11 @@ function TaskActionPanel({ Boolean(detail.kuaishouIndustryVoucher) return ( - +
{canManageTaskLifecycle ? (
- 通用 + 生命周期
@@ -802,71 +1320,75 @@ function FulfillmentOverviewPanel({ : { color: 'warning' as const, text: '未填 UID' } return ( - {executor.label}}> - - - {claimIdentity?.expectedUid || 未提交} - - - {claimIdentity?.boundUid || detail.task.roleId || '-'} - {claimIdentity?.displaySource === 'shipped' ? ( - - 发货快照 - - ) : null} - - - {claimIdentity?.boundRoleName || detail.task.roleName || '-'} - - - {uidMatchTag.text} - - - {claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'} - - {claimIdentity?.shippedUid ? ( - - {claimIdentity.shippedUid} - {claimIdentity.shippedRoleName ? ` / ${claimIdentity.shippedRoleName}` : ''} - {claimIdentity.shippedAt ? ( - - {claimIdentity.shippedAt} - + + {executor.label}}> + + + {claimIdentity?.expectedUid || ( + 未提交 + )} + + + {claimIdentity?.boundUid || detail.task.roleId || '-'} + {claimIdentity?.displaySource === 'shipped' ? ( + + 发货快照 + ) : null} - ) : null} - - - {claimIdentity?.note || '暂无 UID 信息'} - - - + + {claimIdentity?.boundRoleName || detail.task.roleName || '-'} + + + {uidMatchTag.text} + + + {claimIdentity?.compatibilityMode === 'legacy_no_uid' ? '旧单无 UID' : 'UID 闸门'} + + {claimIdentity?.shippedUid ? ( + + {claimIdentity.shippedUid} + {claimIdentity.shippedRoleName ? ` / ${claimIdentity.shippedRoleName}` : ''} + {claimIdentity.shippedAt ? ( + + {claimIdentity.shippedAt} + + ) : null} + + ) : null} + + + {claimIdentity?.note || '暂无 UID 信息'} + + + -
- {steps.map((step) => ( -
- - - {step.label} - - {step.detail} -
- ))} -
+
+ {steps.map((step) => ( +
+ + + {step.label} + + {step.detail} +
+ ))} +
- -
+ + + ) } @@ -1204,7 +1726,7 @@ function KuaishouIndustryVoucherPanel({ voucher }: { voucher: KuaishouIndustryVo const consumeDetails = Array.isArray(voucher.consumeDetails) ? voucher.consumeDetails : [] return ( - + {voucher.oid || '-'} {voucher.voucherCode || '-'} @@ -1493,7 +2015,7 @@ function BackButton() { const navigate = useNavigate() return ( ) } diff --git a/apps/frontend/src/pages/admin/AdminTasksPage.tsx b/apps/frontend/src/pages/admin/AdminTasksPage.tsx index 23d41e89..93927961 100644 --- a/apps/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTasksPage.tsx @@ -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(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) => ( -
- {row.roleName || '-'} - {row.roleId || row.loginType ? ( - {row.roleId || row.loginType} - ) : null} -
- ), + title: '角色信息', + minWidth: 160, + render: (_, row) => { + const hasRole = Boolean(row.roleName && row.roleName !== '-') + const hasUid = Boolean(row.roleId && row.roleId !== '-') + if (!hasRole && !hasUid) { + return - + } + return ( +
+ {hasRole ? ( + {row.roleName} + ) : null} + {hasUid ? ( + + + {row.roleId} + +
+ ) + }, }, { 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 (
@@ -270,6 +367,18 @@ export default function AdminTasksPage() { ) : null} {hasActions ? ( + {hasVoucher ? ( + + ) : null} {canRetry ? ( + + + + + + } + onCancel={() => setVoucherModal((prev) => ({ ...prev, visible: false }))} + width={420} + centered + styles={{ body: { textAlign: 'center', padding: '16px 0' } }} + > + {voucherModal.loading ? ( +
+ +
+ ) : voucherModal.dataUrl ? ( +
+ 发货存证凭据 + + 提示:点击“复制图片”后可直接在微信/QQ客服聊天窗口中粘贴发给买家。 + +
+ ) : ( +
暂无凭据生成
+ )} +
) } diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index fe3086e1..4cff77eb 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -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 + } events: Array<{ eventId: number eventType: string diff --git a/apps/frontend/src/utils/voucher.ts b/apps/frontend/src/utils/voucher.ts index 4fbe9474..5848e434 100644 --- a/apps/frontend/src/utils/voucher.ts +++ b/apps/frontend/src/utils/voucher.ts @@ -2,7 +2,7 @@ * 绘制和平精英官方直充/兑换凭据大图 (640x960 Retina 高清卡片)。 * * 纯前端 Canvas 渲染:不依赖后端存储或额外图片服务, - * 兑换成功后即时生成供买家/客服预览、保存或复制。 + * 兑换成功后即时生成供买家/客服预览、保存、复制或下载。 */ export type DeliveryVoucherData = { @@ -17,6 +17,109 @@ export type DeliveryVoucherData = { voucherNo?: string } +const PRODUCT_SKU_MAP: Record = { + 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 { 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 { 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 { try { @@ -267,6 +391,18 @@ export async function copyVoucherDataUrlToClipboard(dataURL: string): Promise