Files
order_site/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx
T
yml2213 7542d547bf feat: 新增客服备用金台账与后台用户中文显示名
- 新增备用金账户、流水、支出、对账与权限
- 后台用户支持中文显示名并同步备用金姓名
- 审计、登录记录、任务及工单操作人优先展示中文名
- 补充备用金与人工支出方案文档
2026-10-18 13:55:24 +08:00

2018 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
DownloadOutlined,
EyeOutlined,
FileImageOutlined,
LinkOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
StopOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Descriptions,
Divider,
Empty,
Image,
Input,
Modal,
Skeleton,
Space,
Spin,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import {
closeAdminTask,
completeAdminTaskManualDispatch,
consumeAdminTaskKuaishouIndustryVoucher,
fetchAdminTaskDetail,
fetchAdminTaskScreenshot,
markAdminTaskManualReview,
resendAdminTaskKuaishouIndustryVoucherCode,
retryAdminTask,
updateAdminOrderClaimStatus,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import {
formatKuaishouRoleInfoLabel,
formatRedeemOutcomeLabel,
formatRedeemResolutionStatus,
formatTaskEventPayload,
formatTaskEventType,
} from '@/utils/admin-display'
import { formatAdminDateTime } from '@/utils/admin-time'
import {
copyVoucherDataUrlToClipboard,
downloadVoucherDataUrl,
formatGameChannelLabel,
generateVoucherDataURL,
type DeliveryVoucherData,
} from '@/utils/voucher'
type TaskEvent = AdminTaskDetail['events'][number]
type KuaishouIndustryVoucher = NonNullable<AdminTaskDetail['kuaishouIndustryVoucher']>
type ManualDispatchForm = {
deliveryReference: string
deliveredCredential: string
resultMessage: string
}
export default function AdminTaskDetailPage() {
const { taskId = '' } = useParams()
const navigate = useNavigate()
const { message, modal } = App.useApp()
const [actionLoadingKey, setActionLoadingKey] = useState('')
const [lastClaimUrl, setLastClaimUrl] = useState('')
const [screenshotPreviewUrl, setScreenshotPreviewUrl] = useState('')
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
const [manualDispatchForm, setManualDispatchForm] = useState<ManualDispatchForm>({
deliveryReference: '',
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),
enabled: Boolean(taskId),
})
const detail = query.data?.data
const orderId = detail?.order?.orderId ? String(detail.order.orderId) : ''
const statusMutation = useMutation({
mutationFn: async (data: { status: 'active' | 'paused' | 'closed'; reason?: string }) => {
const resolvedOrderId = query.data?.data?.order?.orderId || detail?.order?.orderId
if (!resolvedOrderId) throw new Error('当前任务无关联订单信息')
return updateAdminOrderClaimStatus(resolvedOrderId, data)
},
onSuccess: () => {
message.success('订单兑换状态已更新')
setStatusModalVisible(false)
setStatusReason('')
void query.refetch()
},
onError: (err: unknown) => {
message.error(err instanceof Error ? err.message : '更新状态失败')
},
})
const canManageTaskLifecycle = hasAdminRole('operator')
const canOperateIndustryVoucher = hasAdminRole('support')
const canCloseTasks = hasAdminRole('support')
const claimTokenStatus = String(detail?.claimToken?.status || '').trim()
const isExternalClaimLink = claimTokenStatus.toLowerCase() === 'external'
const claimUrl = useMemo(() => {
const tokenStatus = String(detail?.claimToken?.status || '').trim()
const taskStatus = String(detail?.task.status || '').trim()
if (!isUsableClaimLinkStatus(tokenStatus) || ['closed', 'expired'].includes(taskStatus)) {
return ''
}
return lastClaimUrl || detail?.claimToken?.claimUrl || ''
}, [detail?.claimToken?.claimUrl, detail?.claimToken?.status, detail?.task.status, lastClaimUrl])
const claimLinkInvalid = Boolean(
detail?.claimToken?.claimUrl && claimTokenStatus && !isUsableClaimLinkStatus(claimTokenStatus),
)
useEffect(() => {
if (!detail) return
setManualDispatchForm({
deliveryReference: detail.manualDispatch?.deliveryReference || '',
deliveredCredential: detail.manualDispatch?.deliveredCredential || '',
resultMessage: detail.manualDispatch?.resultMessage || detail.task.resultMessage || '',
})
}, [detail])
useEffect(() => {
let objectUrl = ''
setScreenshotPreviewUrl('')
if (!detail?.screenshotUrl) {
return undefined
}
fetchAdminTaskScreenshot(detail.task.taskId)
.then((blob) => {
objectUrl = URL.createObjectURL(blob)
setScreenshotPreviewUrl(objectUrl)
})
.catch(() => {
setScreenshotPreviewUrl('')
})
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [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 = formatGameChannelLabel(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">
<PageHeader title="任务详情" extra={<BackButton />} />
<Card>
<Skeleton active paragraph={{ rows: 10 }} />
</Card>
</section>
)
}
if (query.error || !detail) {
return (
<section className="page-stack">
<PageHeader title="任务详情" extra={<BackButton />} />
<Alert
type="error"
showIcon
message={query.error instanceof Error ? query.error.message : '任务不存在或读取失败'}
/>
</section>
)
}
const resolvedDetail = detail
const flow = resolvedDetail.kuaishouCloudFulfillment
const affiliateDash = resolvedDetail.affiliateDash
const eventColumns: TableColumnsType<TaskEvent> = [
{
title: '时间',
dataIndex: 'createdAt',
width: 180,
render: (value) => formatAdminDateTime(value),
},
{
title: '事件',
dataIndex: 'eventType',
width: 200,
render: (value) => formatTaskEventType(value),
},
{
title: '摘要',
render: (_, row) => formatTaskEventPayload(row.payload),
},
]
async function copyClaimUrl() {
if (!claimUrl) return
await navigator.clipboard.writeText(claimUrl)
message.success('领取链接已复制')
}
function runTaskAction(
actionKey: string,
action: () => Promise<{ data: AdminTaskActionResponse }>,
successMessage: string,
confirmText: string,
) {
modal.confirm({
title: '确认操作',
content: confirmText,
okText: '继续执行',
cancelText: '取消',
centered: true,
onOk: async () => {
setActionLoadingKey(actionKey)
try {
const response = await action()
if (response.data.claimUrl) {
setLastClaimUrl(response.data.claimUrl)
}
message.success(successMessage)
await query.refetch()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
} finally {
setActionLoadingKey('')
}
},
})
}
function submitManualDispatch(outcome: 'delivered' | 'failed') {
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
const confirmText =
outcome === 'failed'
? `确认把任务 ${resolvedDetail.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
: `确认把任务 ${resolvedDetail.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
runTaskAction(
`manual-${outcome}`,
() =>
completeAdminTaskManualDispatch(resolvedDetail.task.taskId, {
outcome,
resultMessage: manualDispatchForm.resultMessage,
deliveryReference: manualDispatchForm.deliveryReference,
deliveredCredential: manualDispatchForm.deliveredCredential,
}),
actionLabel,
confirmText,
)
}
const platformOrderId =
resolvedDetail.order?.platformOrderId || resolvedDetail.task.platformOrderId || ''
const h5ClaimUrl = platformOrderId
? `${window.location.origin}/h5/${encodeURIComponent(platformOrderId)}`
: ''
const claimStatus = resolvedDetail.order?.claimStatus || 'active'
const claimStatusReason = resolvedDetail.order?.claimStatusReason || ''
const isTaskFulfilled =
['completed', 'redeemed', 'delivered', 'success'].includes(resolvedDetail.task.status) ||
resolvedDetail.task.deliveryStatus === 'delivered'
const isOrderRefundedOrCancelled =
resolvedDetail.order?.payStatus === 'refunded' ||
resolvedDetail.order?.orderStatus === 'cancelled'
const isOrderCompleted = resolvedDetail.order?.orderStatus === 'completed'
const canPauseOrClose = !isTaskFulfilled && !isOrderCompleted && !isOrderRefundedOrCancelled
const handleCopyH5Link = () => {
if (!h5ClaimUrl) return
navigator.clipboard
.writeText(h5ClaimUrl)
.then(() => message.success('已复制 H5 领取链接: ' + h5ClaimUrl))
.catch(() => message.error('复制失败,请手动复制'))
}
const openStatusModal = (status: 'active' | 'paused' | 'closed') => {
if (!orderId) {
message.warning('当前任务无关联订单 ID,无法更改兑换状态')
return
}
if (status === 'active') {
modal.confirm({
title: '恢复启用订单兑换',
content: '恢复后,买家可正常进入 H5 页面提交 UID 领取,确认恢复吗?',
onOk: () =>
statusMutation.mutateAsync({
status: 'active',
reason: '客服在任务详情中恢复启用',
}),
})
return
}
setTargetStatus(status)
setStatusReason('')
setStatusModalVisible(true)
}
const isAffiliateDash =
resolvedDetail.task.executorKey === 'affiliate_dash' || Boolean(affiliateDash)
return (
<section className="page-stack">
<PageHeader
title="任务详情"
description={resolvedDetail.task.taskNo}
extra={
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => query.refetch()}>
刷新
</Button>
<BackButton />
</Space>
}
/>
{/* 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={() => {
void 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}
canOperateIndustryVoucher={canOperateIndustryVoucher}
canCloseTasks={canCloseTasks}
actionLoadingKey={actionLoadingKey}
onRetry={() =>
runTaskAction(
'retry',
() => retryAdminTask(resolvedDetail.task.taskId),
'任务已重试',
`确认重试任务 ${resolvedDetail.task.taskNo} 吗?`,
)
}
onResendIndustryVoucher={() =>
runTaskAction(
'resend-industry-voucher',
() => resendAdminTaskKuaishouIndustryVoucherCode(resolvedDetail.task.taskId),
'电子凭证发码回调已重发',
`确认重新发送任务 ${resolvedDetail.task.taskNo} 的电子凭证发码回调吗?`,
)
}
onConsumeIndustryVoucher={() =>
runTaskAction(
'consume-industry-voucher',
() => consumeAdminTaskKuaishouIndustryVoucher(resolvedDetail.task.taskId),
'电子凭证已执行核销',
`确认立即核销任务 ${resolvedDetail.task.taskNo} 绑定的电子凭证吗?`,
)
}
onMarkManualReview={() =>
runTaskAction(
'manual-review',
() => markAdminTaskManualReview(resolvedDetail.task.taskId),
'任务已转人工处理',
`确认将任务 ${resolvedDetail.task.taskNo} 转为人工处理吗?`,
)
}
onCloseTask={() =>
runTaskAction(
'close',
() => closeAdminTask(resolvedDetail.task.taskId),
'任务已关闭',
`确认关闭任务 ${resolvedDetail.task.taskNo} 吗?关闭后不会自动继续推进。`,
)
}
/>
{/* 4. H5 领取链接与兑换控制 */}
{h5ClaimUrl || resolvedDetail.claimToken ? (
<Card
title={
<Space size={12}>
<LinkOutlined style={{ color: '#0284c7' }} />
<span>买家 H5 领取链接与状态控制</span>
{resolvedDetail.order ? (
claimStatus === 'active' ? (
<Tag color="success">H5兑换有效</Tag>
) : claimStatus === 'paused' ? (
<Tag color="warning">H5兑换已暂停</Tag>
) : (
<Tag color="error">H5兑换已关闭</Tag>
)
) : null}
</Space>
}
>
<Space direction="vertical" size={16} className="full-width">
{h5ClaimUrl ? (
<div
style={{
background: '#fafafa',
padding: 14,
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<Space direction="vertical" size={8} className="full-width">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space>
<Tag color="blue">固定 H5 链接</Tag>
<Typography.Text strong copyable={{ text: h5ClaimUrl }}>
{h5ClaimUrl}
</Typography.Text>
</Space>
<Space wrap>
<Button
type="primary"
ghost
icon={<CopyOutlined />}
onClick={handleCopyH5Link}
>
复制 H5 链接
</Button>
{orderId ? (
<>
{canPauseOrClose && claimStatus !== 'paused' && (
<Button
danger
style={{ color: '#fa8c16', borderColor: '#ffa39e' }}
icon={<PauseCircleOutlined />}
onClick={() => openStatusModal('paused')}
>
暂停兑换
</Button>
)}
{canPauseOrClose && claimStatus !== 'closed' && (
<Button
danger
icon={<StopOutlined />}
onClick={() => openStatusModal('closed')}
>
关闭兑换
</Button>
)}
{claimStatus !== 'active' && (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => openStatusModal('active')}
>
恢复启用
</Button>
)}
</>
) : null}
</Space>
</div>
{claimStatusReason ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
状态备注:{claimStatusReason}
</Typography.Text>
) : null}
</Space>
</div>
) : null}
{resolvedDetail.claimToken ? (
<div>
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginBottom: 6 }}
>
历史/备用 Token 链接:
</Typography.Text>
<Space wrap align="center">
{claimUrl ? (
<Typography.Text copyable={{ text: claimUrl }}>{claimUrl}</Typography.Text>
) : claimLinkInvalid ? (
<Typography.Text type="secondary">链接已失效</Typography.Text>
) : (
<Typography.Text type="secondary">暂无可用链接</Typography.Text>
)}
<StatusTag value={resolvedDetail.claimToken.status} />
{isExternalClaimLink ? (
<Typography.Text type="secondary">外部交付链接</Typography.Text>
) : (
<Typography.Text type="secondary">
{resolvedDetail.claimToken.expiredAt
? `到期:${formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}`
: '长期有效'}
</Typography.Text>
)}
<Button
icon={<CopyOutlined />}
size="small"
disabled={!claimUrl}
onClick={copyClaimUrl}
>
复制 Token 链接
</Button>
</Space>
</div>
) : null}
</Space>
</Card>
) : null}
{/* 5. 选项卡面板结构 */}
<Tabs
defaultActiveKey={isAffiliateDash ? 'affiliate-dash' : 'overview'}
items={[
{
key: 'affiliate-dash',
label: '联盟直充履约 (affiliate_dash)',
children: affiliateDash ? (
<AffiliateDashPanel affiliateDash={affiliateDash} />
) : (
<Empty description="当前任务暂无联盟直充 (affiliate_dash) 履约上下文" />
),
},
{
key: 'overview',
label: '履约链路与卡点',
children: (
<FulfillmentOverviewPanel
detail={resolvedDetail}
claimUrl={claimUrl}
claimLinkInvalid={claimLinkInvalid}
/>
),
},
{
key: 'events',
label: `事件流水 (${resolvedDetail.events.length})`,
children: (
<Table<TaskEvent>
rowKey="eventId"
pagination={false}
columns={eventColumns}
dataSource={resolvedDetail.events}
scroll={{ x: 760 }}
/>
),
},
...(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: '原始数据 (JSON)',
children: <JsonPreview value={resolvedDetail} />,
},
]}
/>
<Modal
title={targetStatus === 'paused' ? '暂停订单兑换' : '关闭订单兑换'}
open={statusModalVisible}
confirmLoading={statusMutation.isPending}
onOk={() =>
statusMutation.mutateAsync({
status: targetStatus,
reason: statusReason.trim() || undefined,
})
}
onCancel={() => setStatusModalVisible(false)}
okText="确认变更"
cancelText="取消"
centered
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
<Alert
type={targetStatus === 'paused' ? 'warning' : 'error'}
showIcon
message={
targetStatus === 'paused'
? '暂停后,买家访问 H5 链接将提示“订单兑换已暂停”,无法提交 UID。可在售后处理完成后恢复启用。'
: '关闭后,买家访问 H5 链接将提示“订单兑换已关闭”,完全阻断兑换。通常用于退款完成场景。'
}
/>
<div>
<Typography.Text type="secondary">原因 / 备注(可选):</Typography.Text>
<Input.TextArea
rows={3}
placeholder={
targetStatus === 'paused'
? '例如:买家申请退款中 / 售后核实'
: '例如:买家已全额退款'
}
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
style={{ marginTop: 6 }}
/>
</div>
</div>
</Modal>
</section>
)
}
/**
* 联盟直充专属履约详情面板 (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,
canOperateIndustryVoucher,
canCloseTasks,
actionLoadingKey,
onRetry,
onResendIndustryVoucher,
onConsumeIndustryVoucher,
onMarkManualReview,
onCloseTask,
}: {
detail: AdminTaskDetail
canManageTaskLifecycle: boolean
canOperateIndustryVoucher: boolean
canCloseTasks: boolean
actionLoadingKey: string
onRetry: () => void
onResendIndustryVoucher: () => void
onConsumeIndustryVoucher: () => void
onMarkManualReview: () => void
onCloseTask: () => void
}) {
const operations = detail.operations
const loading = Boolean(actionLoadingKey)
const hasIndustryVoucherActions =
operations.canResendKuaishouIndustryVoucherCode ||
operations.canConsumeKuaishouIndustryVoucher ||
Boolean(detail.kuaishouIndustryVoucher)
return (
<Card title="快捷任务操作">
<div className="task-action-groups">
{canManageTaskLifecycle ? (
<section className="task-action-group">
<Typography.Text type="secondary">生命周期</Typography.Text>
<Space wrap>
<Button
type="primary"
icon={<ReloadOutlined />}
disabled={loading || !operations.canRetry}
loading={actionLoadingKey === 'retry'}
onClick={onRetry}
>
重试任务
</Button>
<Button
icon={<UserSwitchOutlined />}
disabled={loading || !operations.canMarkManualReview}
loading={actionLoadingKey === 'manual-review'}
onClick={onMarkManualReview}
>
转人工处理
</Button>
</Space>
</section>
) : null}
{canOperateIndustryVoucher && hasIndustryVoucherActions ? (
<section className="task-action-group">
<Typography.Text type="secondary">电子凭证</Typography.Text>
<Space wrap>
<Button
icon={<ReloadOutlined />}
disabled={loading || !operations.canResendKuaishouIndustryVoucherCode}
loading={actionLoadingKey === 'resend-industry-voucher'}
onClick={onResendIndustryVoucher}
>
重发发码
</Button>
<Button
type="primary"
ghost
icon={<CheckCircleOutlined />}
disabled={loading || !operations.canConsumeKuaishouIndustryVoucher}
loading={actionLoadingKey === 'consume-industry-voucher'}
onClick={onConsumeIndustryVoucher}
>
核销凭证
</Button>
</Space>
</section>
) : null}
{canCloseTasks ? (
<section className="task-action-group">
<Typography.Text type="secondary">收口</Typography.Text>
<Button
danger
icon={<CloseCircleOutlined />}
disabled={loading || !operations.canClose}
loading={actionLoadingKey === 'close'}
onClick={onCloseTask}
>
关闭任务
</Button>
</section>
) : null}
{!canManageTaskLifecycle && !canOperateIndustryVoucher && !canCloseTasks ? (
<Typography.Text type="secondary">当前账号没有可执行的任务操作</Typography.Text>
) : null}
</div>
{loading ? (
<Typography.Text className="task-action-hint" type="secondary">
操作执行中,请稍候。
</Typography.Text>
) : null}
</Card>
)
}
function isUsableClaimLinkStatus(value: unknown) {
const normalized = String(value || '')
.trim()
.toLowerCase()
return normalized === 'active' || normalized === 'external'
}
function FulfillmentOverviewPanel({
detail,
claimUrl,
claimLinkInvalid,
}: {
detail: AdminTaskDetail
claimUrl: string
claimLinkInvalid: boolean
}) {
const executor = resolveTaskExecutorDisplay(detail.task.executorKey)
const steps = buildFulfillmentOverviewSteps(detail, claimUrl, claimLinkInvalid)
const claimIdentity = detail.claimIdentity
const currentBlocker = resolveTaskBlocker(detail.task, claimUrl, claimLinkInvalid, claimIdentity)
const uidMatchTag =
claimIdentity?.uidMatched === true
? { color: 'success' as const, text: 'UID 已匹配' }
: claimIdentity?.uidMatched === false
? { color: 'error' as const, text: 'UID 不一致' }
: claimIdentity?.ready
? { color: 'processing' as const, text: '已填 UID' }
: { color: 'warning' as const, text: '未填 UID' }
return (
<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>
<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>
<Alert
className="platform-section-gap"
type={currentBlocker.level}
showIcon
message={currentBlocker.title}
description={currentBlocker.detail}
/>
</Card>
</Space>
)
}
function buildFulfillmentOverviewSteps(
detail: AdminTaskDetail,
claimUrl: string,
claimLinkInvalid: boolean,
) {
const task = detail.task
const executor = resolveTaskExecutorDisplay(task.executorKey)
const voucher = detail.kuaishouIndustryVoucher
const manualDispatch = detail.manualDispatch
const claimStatus = claimUrl
? 'success'
: claimLinkInvalid
? 'failed'
: detail.claimToken?.status || 'pending'
return [
{
key: 'source',
label: '订单来源',
status: detail.order?.payStatus || task.status,
detail: `${detail.order?.provider || '-'} / ${detail.order?.platform || '-'} / ${task.platformOrderId || '-'}`,
},
{
key: 'product',
label: '商品解析',
status: detail.orderItem || task.skuName ? 'success' : 'pending_config',
detail: detail.orderItem?.skuName || task.skuName || task.skuCode || '未命中履约商品',
},
{
key: 'planner',
label: '履约计划',
status: task.executorKey ? 'success' : 'pending_config',
detail: executor.label,
},
{
key: 'prepare',
label: '任务准备',
status: task.resourceStatus || task.status,
detail: resolveTaskPrepareSummary(detail),
},
{
key: 'delivery',
label: '平台执行',
status: resolveDeliveryStepStatus(detail),
detail: resolveDeliveryStepSummary(detail),
},
{
key: 'voucher',
label: '电子凭证',
status: voucher ? voucher.sendCallbackStatus || voucher.status : 'skipped',
detail: voucher
? `发码 ${voucher.sendCallbackStatus || '-'} / 凭证 ${voucher.status || '-'}`
: '当前任务未绑定电子凭证',
},
{
key: 'claim',
label: '交付链接',
status: task.executorKey === 'manual_dispatch' ? 'skipped' : claimStatus,
detail:
task.executorKey === 'manual_dispatch'
? '人工履约不需要客户领取链接'
: claimUrl
? '已有可用链接'
: claimLinkInvalid
? '链接已失效'
: '等待生成或同步链接',
},
{
key: 'finish',
label: '履约收口',
status: manualDispatch?.outcome || task.deliveryStatus || task.status,
detail: task.resultMessage || task.lastError || '等待履约完成',
},
]
}
function resolveTaskBlocker(
task: AdminTaskDetail['task'],
claimUrl: string,
claimLinkInvalid: boolean,
claimIdentity?: AdminTaskDetail['claimIdentity'],
): { level: 'success' | 'info' | 'warning' | 'error'; title: string; detail: string } {
if (
['completed', 'redeemed', 'delivered'].includes(task.status) ||
task.deliveryStatus === 'delivered'
) {
return {
level: 'success',
title: '履约已完成',
detail: task.resultMessage || '任务已经收口。',
}
}
if (['manual_review', 'retry_pending', 'failed'].includes(task.status)) {
return {
level: 'error',
title: '当前卡在异常处理',
detail: task.lastError || task.resultMessage || '需要人工检查后重试或收口。',
}
}
if (task.executorKey === 'kuaishou_ct_assisted' && claimIdentity && !claimIdentity.ready) {
return {
level: 'warning',
title: '等待用户填写 UID',
detail: '旧单或新单均需用户打开领取页提交游戏 UID,否则禁止自动发货。',
}
}
if (task.executorKey === 'kuaishou_ct_assisted' && claimIdentity?.uidMatched === false) {
return {
level: 'error',
title: 'UID 与绑定角色不一致',
detail: `填写 ${claimIdentity.expectedUid || '-'},绑定 ${claimIdentity.boundUid || '-'},请用户换绑或更正 UID。`,
}
}
if (claimLinkInvalid) {
return {
level: 'warning',
title: '领取链接不可用',
detail: '链接状态不是 active,可重新生成或检查平台交付链接。',
}
}
if (!claimUrl && task.executorKey !== 'manual_dispatch') {
return {
level: 'warning',
title: '等待交付链接',
detail: '任务尚未拿到可交付链接,先检查任务准备和平台执行状态。',
}
}
if (task.customerStatus === 'waiting_customer') {
return {
level: 'info',
title: '等待客户操作',
detail: '资源已准备,客户还没有完成绑定或领取动作。',
}
}
return {
level: 'info',
title: '履约推进中',
detail: task.lastError || '当前任务没有明确异常,可按链路状态继续观察。',
}
}
function resolveTaskPrepareSummary(detail: AdminTaskDetail) {
const flow = detail.kuaishouCloudFulfillment
if (flow) {
return flow.binding.bindPreparedAt
? `绑定资源 ${flow.binding.vnPhone || flow.binding.vnId || '-'}`
: '等待准备 Cloud 资源'
}
if (detail.task.executorKey === 'manual_dispatch') {
return '人工履约任务无需自动准备资源'
}
return detail.task.resultMessage || detail.task.lastError || '等待执行器准备'
}
function resolveDeliveryStepStatus(detail: AdminTaskDetail) {
const flow = detail.kuaishouCloudFulfillment
if (flow) {
return flow.dispatch.status || detail.task.deliveryStatus || detail.task.status
}
if (detail.manualDispatch) {
return detail.manualDispatch.outcome || detail.task.deliveryStatus
}
return detail.task.deliveryStatus || detail.task.status
}
function resolveDeliveryStepSummary(detail: AdminTaskDetail) {
const flow = detail.kuaishouCloudFulfillment
if (flow) {
if (flow.dispatch.dispatchAt) {
return `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
}
return flow.dispatch.note || '等待平台发货执行'
}
if (detail.manualDispatch) {
return (
detail.manualDispatch.resultMessage ||
detail.manualDispatch.deliveryReference ||
'人工履约已回写'
)
}
return detail.task.resultMessage || detail.task.lastError || '等待执行器推进'
}
function resolveTaskExecutorDisplay(value: unknown) {
const executorKey = String(value || '').trim()
if (executorKey === 'affiliate_dash') {
return { label: '联盟发货', color: 'green' }
}
if (executorKey === 'kuaishou_ct_assisted') {
return { label: '乐玩云(已下线)', color: 'default' }
}
if (executorKey === 'kuaishou-industry') {
return { label: '行业电子凭证', color: 'cyan' }
}
if (executorKey === 'kuaishou_feifei') {
return { label: '飞飞(已下线)', color: 'default' }
}
if (executorKey === 'manual_dispatch') {
return { label: '人工履约', color: 'orange' }
}
return { label: executorKey || '未分配执行器', color: 'default' }
}
function ManualDispatchPanel({
detail,
form,
canSubmit,
actionLoadingKey,
onChange,
onSubmit,
}: {
detail: AdminTaskDetail
form: ManualDispatchForm
canSubmit: boolean
actionLoadingKey: string
onChange: (form: ManualDispatchForm) => void
onSubmit: (outcome: 'delivered' | 'failed') => void
}) {
return (
<Card
title="人工履约"
extra={
canSubmit ? (
<Space>
<Button
type="primary"
icon={<CheckCircleOutlined />}
disabled={Boolean(actionLoadingKey)}
loading={actionLoadingKey === 'manual-delivered'}
onClick={() => onSubmit('delivered')}
>
人工完成
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
disabled={Boolean(actionLoadingKey)}
loading={actionLoadingKey === 'manual-failed'}
onClick={() => onSubmit('failed')}
>
履约失败
</Button>
</Space>
) : null
}
>
<div className="manual-dispatch-grid">
<div>
<Typography.Text type="secondary">履约单号 / 回执</Typography.Text>
<Input
value={form.deliveryReference}
disabled={!canSubmit || Boolean(actionLoadingKey)}
placeholder="例如快递单号、平台消息回执号"
onChange={(event) => onChange({ ...form, deliveryReference: event.target.value })}
/>
</div>
<div>
<Typography.Text type="secondary">发放凭据</Typography.Text>
<Input.TextArea
value={form.deliveredCredential}
disabled={!canSubmit || Boolean(actionLoadingKey)}
placeholder="可填写人工发送的卡密、链接或关键信息"
rows={3}
onChange={(event) => onChange({ ...form, deliveredCredential: event.target.value })}
/>
</div>
<div>
<Typography.Text type="secondary">处理备注</Typography.Text>
<Input.TextArea
value={form.resultMessage}
disabled={!canSubmit || Boolean(actionLoadingKey)}
placeholder="说明实际履约结果,失败时建议写清原因"
rows={3}
onChange={(event) => onChange({ ...form, resultMessage: event.target.value })}
/>
</div>
</div>
<Alert
className="platform-section-gap"
type="info"
showIcon
message="回写后会直接更新任务终态,不再走旧式领取链接流程。"
/>
{detail.manualDispatch ? (
<Descriptions
className="platform-section-gap"
column={{ xs: 1, md: 2, xl: 3 }}
bordered
size="small"
>
<Descriptions.Item label="处理结果">
<StatusTag value={detail.manualDispatch.outcome || detail.task.deliveryStatus} />
</Descriptions.Item>
<Descriptions.Item label="处理时间">
{formatAdminDateTime(detail.manualDispatch.completedAt)}
</Descriptions.Item>
<Descriptions.Item label="处理人">
{detail.manualDispatch.completedBy?.displayName ||
detail.manualDispatch.completedBy?.username ||
'-'}{' '}
/ {detail.manualDispatch.completedBy?.role || '-'}
</Descriptions.Item>
<Descriptions.Item label="履约单号">
{detail.manualDispatch.deliveryReference || '-'}
</Descriptions.Item>
<Descriptions.Item label="发放凭据">
{detail.manualDispatch.deliveredCredential || '-'}
</Descriptions.Item>
<Descriptions.Item label="备注">
{detail.manualDispatch.resultMessage || '-'}
</Descriptions.Item>
</Descriptions>
) : null}
</Card>
)
}
function KuaishouIndustryVoucherPanel({ voucher }: { voucher: KuaishouIndustryVoucher }) {
const consumeDetails = Array.isArray(voucher.consumeDetails) ? voucher.consumeDetails : []
return (
<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>
<Descriptions.Item label="序号">{voucher.unitIndex || '-'}</Descriptions.Item>
<Descriptions.Item label="凭证状态">
<StatusTag value={voucher.status} />
</Descriptions.Item>
<Descriptions.Item label="发码回调">
<StatusTag value={voucher.sendCallbackStatus} />
</Descriptions.Item>
<Descriptions.Item label="发码次数">{voucher.sendCallbackAttemptCount}</Descriptions.Item>
<Descriptions.Item label="发码时间">
{formatAdminDateTime(voucher.sendCallbackSentAt)}
</Descriptions.Item>
<Descriptions.Item label="核销流水">{voucher.consumeSerialNum || '-'}</Descriptions.Item>
<Descriptions.Item label="核销时间">
{formatAdminDateTime(voucher.consumedAt)}
</Descriptions.Item>
<Descriptions.Item label="最近异常" span={3}>
{voucher.sendCallbackLastError || '-'}
</Descriptions.Item>
</Descriptions>
{voucher.sendCallbackLastError ? (
<Alert
className="platform-section-gap"
type="warning"
showIcon
message="电子凭证发码回调存在异常"
description={voucher.sendCallbackLastError}
/>
) : null}
{consumeDetails.length > 0 ? (
<Table<Record<string, unknown>>
className="platform-section-gap"
rowKey={(_, index) => String(index ?? 0)}
pagination={false}
dataSource={consumeDetails}
scroll={{ x: 760 }}
columns={[
{
title: '核销明细',
render: (_, row) => <JsonPreview value={row} />,
},
]}
/>
) : null}
</Card>
)
}
function KuaishouCloudPanel({
flow,
}: {
flow: NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
}) {
const roleInfoEntries = flow.role.rawInfo
? Object.entries(flow.role.rawInfo).filter(
([, value]) => value !== null && value !== undefined && String(value).trim() !== '',
)
: []
const checklist = [
{
key: 'ticket',
label: '凭证确认',
status: flow.ticket.status || 'pending',
detail: flow.ticket.verifiedAt
? `已于 ${formatAdminDateTime(flow.ticket.verifiedAt)} 确认`
: '等待电子凭证同步确认',
},
{
key: 'bind',
label: '绑定资源',
status: flow.binding.prepareStatus || 'pending',
detail: flow.binding.bindPreparedAt
? `绑定资源已准备,VN ${flow.binding.vnId || '-'}`
: '等待准备 Cloud 绑定资源',
},
{
key: 'role',
label: '角色识别',
status: flow.role.status || 'pending',
detail:
flow.role.name || flow.role.rid
? `${flow.role.name || '-'} / ${flow.role.rid || '-'}`
: '客户绑定后刷新角色信息',
},
{
key: 'dispatch',
label: '发货执行',
status: flow.dispatch.status || 'pending',
detail: flow.dispatch.dispatchAt
? `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
: '等待客服确认绑定并发货',
},
{
key: 'return',
label: '退还号码',
status: flow.returnNumber.status || 'pending',
detail: flow.returnNumber.returnedAt
? `已于 ${formatAdminDateTime(flow.returnNumber.returnedAt)} 退号`
: '发货后执行退还号码',
},
{
key: 'consume',
label: '电子凭证核销',
status: flow.consume.status || 'pending',
detail: flow.consume.consumedAt
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 核销(链接交付或履约收口)`
: flow.consume.errorMessage || '等待 91 交付链接时核销,或发货收口时补核销',
},
]
return (
<section className="kuaishou-cloud-stack">
<Card title="乐玩云履约(只读历史)">
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
<Descriptions.Item label="来源账号">
{flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'}
</Descriptions.Item>
<Descriptions.Item label="虚拟号">
{flow.binding.vnPhone || flow.binding.vnId || '-'}
</Descriptions.Item>
<Descriptions.Item label="绑定状态">
<StatusTag value={flow.binding.prepareStatus} />
</Descriptions.Item>
<Descriptions.Item label="角色">
{flow.role.name || '-'} {flow.role.rid ? `(${flow.role.rid})` : ''}
</Descriptions.Item>
<Descriptions.Item label="卡券">
<StatusTag value={flow.ticket.status} />
</Descriptions.Item>
<Descriptions.Item label="发货">
<StatusTag value={flow.dispatch.status} kind="delivery" />
</Descriptions.Item>
<Descriptions.Item label="核销">
<StatusTag value={flow.consume.status} />
</Descriptions.Item>
<Descriptions.Item label="退号">
<StatusTag value={flow.returnNumber.status} />
</Descriptions.Item>
<Descriptions.Item label="绑定链接" span={3}>
{flow.binding.bindUrl ? (
<Typography.Text copyable={{ text: flow.binding.bindUrl }}>
{flow.binding.bindUrl}
</Typography.Text>
) : (
'-'
)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="流程检查">
<div className="task-flow-grid">
{checklist.map((item) => (
<div key={item.key} className="task-flow-item">
<Space>
<StatusTag value={item.status} />
<Typography.Text strong>{item.label}</Typography.Text>
</Space>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
</div>
))}
</div>
</Card>
{roleInfoEntries.length > 0 ? (
<Card title="角色原始字段">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
{roleInfoEntries.map(([key, value]) => (
<Descriptions.Item key={key} label={formatKuaishouRoleInfoLabel(key)}>
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</Descriptions.Item>
))}
</Descriptions>
</Card>
) : null}
{flow.rebind.history.length > 0 ? (
<Card title="换绑记录">
<Table
rowKey={(row) => `${row.attempt}-${row.requestedAt || ''}`}
pagination={false}
dataSource={flow.rebind.history}
scroll={{ x: 860 }}
columns={[
{ title: '次数', dataIndex: 'attempt', width: 80 },
{
title: '时间',
dataIndex: 'requestedAt',
width: 180,
render: (value) => formatAdminDateTime(value),
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (value) => (
<Tag color={value === 'success' ? 'green' : value === 'failed' ? 'red' : 'blue'}>
{String(value || '-')}
</Tag>
),
},
{
title: '旧角色',
render: (_, row) =>
`${row.oldBinding.roleName || '-'} / ${row.oldBinding.roleId || '-'}`,
},
{
title: '旧虚拟号',
render: (_, row) =>
row.oldBinding.vnPhone || row.oldBinding.vnId || row.oldBinding.vnKey || '-',
},
{
title: '异常',
dataIndex: 'errorMessage',
render: (value) => value || '-',
},
]}
/>
</Card>
) : null}
</section>
)
}
function RedeemResolutionPanel({
resolution,
}: {
resolution: NonNullable<AdminTaskDetail['redeemResolution']>
}) {
return (
<Card title="兑换重试链路">
<Descriptions column={{ xs: 1, md: 2, xl: 4 }} bordered size="small">
<Descriptions.Item label="处理结果">
{formatRedeemResolutionStatus(resolution.status)}
</Descriptions.Item>
<Descriptions.Item label="任务状态">{resolution.taskStatus || '-'}</Descriptions.Item>
<Descriptions.Item label="替换次数">{resolution.replacementCount}</Descriptions.Item>
<Descriptions.Item label="完成时间">
{formatAdminDateTime(resolution.finishedAt)}
</Descriptions.Item>
</Descriptions>
<Table
className="platform-section-gap"
rowKey={(row) => `${row.attempt}-${row.codeMasked}`}
pagination={false}
dataSource={resolution.attempts}
scroll={{ x: 760 }}
columns={[
{ title: '次数', dataIndex: 'attempt', width: 80 },
{ title: '凭据', dataIndex: 'codeMasked', minWidth: 160 },
{ title: '类型', dataIndex: 'credentialType', minWidth: 120 },
{
title: '结果',
dataIndex: 'outcome',
minWidth: 160,
render: (value) => formatRedeemOutcomeLabel(value),
},
{ title: '代码', dataIndex: 'resultCode', minWidth: 120 },
{ title: '说明', dataIndex: 'resultMessage', minWidth: 220 },
]}
/>
</Card>
)
}
function ScreenshotPanel({ screenshotPreviewUrl }: { screenshotPreviewUrl: string }) {
return (
<Card title="任务截图">
{screenshotPreviewUrl ? (
<div className="task-screenshot-preview">
<Image src={screenshotPreviewUrl} alt="任务截图" />
</div>
) : (
<Empty description="截图加载中或当前不可读取" />
)}
</Card>
)
}
function BackButton() {
const navigate = useNavigate()
return (
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/tasks')}>
返回任务列表
</Button>
)
}