- 新增备用金账户、流水、支出、对账与权限 - 后台用户支持中文显示名并同步备用金姓名 - 审计、登录记录、任务及工单操作人优先展示中文名 - 补充备用金与人工支出方案文档
2018 lines
70 KiB
TypeScript
2018 lines
70 KiB
TypeScript
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>
|
||
)
|
||
}
|