Files
order_site/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx
T
yml2213 3f2bb3f09a fix(admin): 修复恢复启用订单兑换时 orderId 闭包失效及 H5 页面重试机制
- 修复任务详情页和订单详情页中 statusMutation 闭包提前捕获空 orderId 导致恢复启用请求未发出的问题,改为动态从 query.data 解析最新的 orderId
- 订单详情页统一切换为 App.useApp 提供的 modal/message 实例,保证异步弹窗与提示稳定渲染
- H5 买家领取页面在暂停或关闭等异常状态卡片中增加【重新检测状态】按钮,客服恢复启用后买家可一键重新加载最新状态
- 验证方式:前端类型检查(0 error)、单测 132 项全通及前端 Vite 重新构建成功
2026-10-10 18:46:49 +08:00

1500 lines
51 KiB
TypeScript

import {
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
CopyOutlined,
LinkOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Descriptions,
Divider,
Empty,
Image,
Input,
Modal,
Skeleton,
Space,
Table,
Tabs,
Tag,
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'
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 { 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 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])
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 eventColumns: TableColumnsType<TaskEvent> = [
{
title: '时间',
dataIndex: 'createdAt',
width: 180,
render: (value) => formatAdminDateTime(value),
},
{
title: '事件',
dataIndex: 'eventType',
width: 180,
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)
}
return (
<section className="page-stack">
<PageHeader
title="任务详情"
description={resolvedDetail.task.taskNo}
extra={
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => query.refetch()}>
刷新
</Button>
<BackButton />
</Space>
}
/>
<FulfillmentOverviewPanel
detail={resolvedDetail}
claimUrl={claimUrl}
claimLinkInvalid={claimLinkInvalid}
/>
<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} 吗?关闭后不会自动继续推进。`,
)
}
/>
<Card title="基础信息">
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
<Descriptions.Item label="任务号">{resolvedDetail.task.taskNo}</Descriptions.Item>
<Descriptions.Item label="平台订单号">
{resolvedDetail.task.platformOrderId || resolvedDetail.order?.platformOrderId || '-'}
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<StatusTag value={resolvedDetail.task.status} kind="task" />
</Descriptions.Item>
<Descriptions.Item label="资源状态">
<StatusTag value={resolvedDetail.task.resourceStatus} kind="resource" />
</Descriptions.Item>
<Descriptions.Item label="客户步骤">
<StatusTag value={resolvedDetail.task.customerStatus} kind="customer" />
</Descriptions.Item>
<Descriptions.Item label="商品">
{resolvedDetail.orderItem?.skuName || resolvedDetail.task.skuName || '-'}
</Descriptions.Item>
<Descriptions.Item label="角色">
{resolvedDetail.task.roleName || '-'}{' '}
{resolvedDetail.task.roleId ? `(${resolvedDetail.task.roleId})` : ''}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatAdminDateTime(resolvedDetail.task.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label="最近错误" span={3}>
{resolvedDetail.task.lastError || '-'}
</Descriptions.Item>
</Descriptions>
</Card>
{h5ClaimUrl || resolvedDetail.claimToken ? (
<Card
title={
<Space size={12}>
<span>领取链接与兑换控制</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: 12,
borderRadius: 6,
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}
{resolvedDetail.task.executorKey === 'manual_dispatch' ||
resolvedDetail.operations.canCompleteManualDispatch ||
resolvedDetail.manualDispatch ? (
<ManualDispatchPanel
detail={resolvedDetail}
form={manualDispatchForm}
canSubmit={canManageTaskLifecycle && resolvedDetail.operations.canCompleteManualDispatch}
actionLoadingKey={actionLoadingKey}
onChange={setManualDispatchForm}
onSubmit={submitManualDispatch}
/>
) : null}
<Tabs
items={[
{
key: 'cloud',
label: '乐玩云(历史)',
children: flow ? (
<KuaishouCloudPanel flow={flow} />
) : (
<Empty description="无历史乐玩云履约上下文" />
),
},
{
key: 'industry-voucher',
label: '电子凭证',
children: resolvedDetail.kuaishouIndustryVoucher ? (
<KuaishouIndustryVoucherPanel voucher={resolvedDetail.kuaishouIndustryVoucher} />
) : (
<Empty description="无电子凭证绑定" />
),
},
{
key: 'resolution',
label: '兑换重试',
children: resolvedDetail.redeemResolution ? (
<RedeemResolutionPanel resolution={resolvedDetail.redeemResolution} />
) : (
<Empty description="无兑换重试记录" />
),
},
{
key: 'screenshot',
label: '截图',
children: resolvedDetail.screenshotUrl ? (
<ScreenshotPanel screenshotPreviewUrl={screenshotPreviewUrl} />
) : (
<Empty description="当前任务没有截图" />
),
},
{
key: 'events',
label: '事件流水',
children: (
<Table<TaskEvent>
rowKey="eventId"
pagination={false}
columns={eventColumns}
dataSource={resolvedDetail.events}
scroll={{ x: 760 }}
/>
),
},
{
key: 'raw',
label: '原始数据',
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>
)
}
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 (
<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>
)
}
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?.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>
)
}