补齐任务操作能力
This commit is contained in:
@@ -1,4 +1,15 @@
|
|||||||
import { ArrowLeftOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RollbackOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
UserSwitchOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
App,
|
App,
|
||||||
@@ -6,36 +17,123 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Empty,
|
Empty,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
|
|
||||||
import JsonPreview from '@/components/admin/JsonPreview'
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
import PageHeader from '@/components/admin/PageHeader'
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
import StatusTag from '@/components/admin/StatusTag'
|
import StatusTag from '@/components/admin/StatusTag'
|
||||||
import { fetchAdminTaskDetail } from '@/services/admin'
|
import {
|
||||||
import type { AdminTaskDetail } from '@/types/admin'
|
closeAdminTask,
|
||||||
import { formatTaskEventPayload, formatTaskEventType } from '@/utils/admin-display'
|
completeAdminTaskManualDispatch,
|
||||||
|
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||||
|
fetchAdminTaskDetail,
|
||||||
|
fetchAdminTaskScreenshot,
|
||||||
|
markAdminTaskManualReview,
|
||||||
|
prepareAdminTaskKuaishouCloudFulfillment,
|
||||||
|
rebindAdminTaskKuaishouCloudRole,
|
||||||
|
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||||
|
retryAdminTask,
|
||||||
|
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||||
|
} 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 { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
type TaskEvent = AdminTaskDetail['events'][number]
|
type TaskEvent = AdminTaskDetail['events'][number]
|
||||||
|
type ManualDispatchForm = {
|
||||||
|
deliveryReference: string
|
||||||
|
deliveredCredential: string
|
||||||
|
resultMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminTaskDetailPage() {
|
export default function AdminTaskDetailPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { taskId = '' } = useParams()
|
const { taskId = '' } = useParams()
|
||||||
const { message } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
|
const [actionLoadingKey, setActionLoadingKey] = useState('')
|
||||||
|
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||||
|
const [screenshotPreviewUrl, setScreenshotPreviewUrl] = useState('')
|
||||||
|
const [manualDispatchForm, setManualDispatchForm] = useState<ManualDispatchForm>({
|
||||||
|
deliveryReference: '',
|
||||||
|
deliveredCredential: '',
|
||||||
|
resultMessage: '',
|
||||||
|
})
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['admin-task-detail', taskId],
|
queryKey: ['admin-task-detail', taskId],
|
||||||
queryFn: () => fetchAdminTaskDetail(taskId),
|
queryFn: () => fetchAdminTaskDetail(taskId),
|
||||||
enabled: Boolean(taskId),
|
enabled: Boolean(taskId),
|
||||||
})
|
})
|
||||||
const detail = query.data?.data
|
const detail = query.data?.data
|
||||||
|
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||||
|
const canCloseTasks = hasAdminRole('support')
|
||||||
|
const claimUrl = useMemo(() => {
|
||||||
|
const tokenStatus = String(detail?.claimToken?.status || '').trim()
|
||||||
|
const taskStatus = String(detail?.task.status || '').trim()
|
||||||
|
|
||||||
|
if (tokenStatus !== 'active' || ['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 &&
|
||||||
|
String(detail.claimToken.status || '').trim() &&
|
||||||
|
String(detail.claimToken.status || '').trim() !== 'active',
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
if (query.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -83,12 +181,62 @@ export default function AdminTaskDetailPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
async function copyClaimUrl() {
|
async function copyClaimUrl() {
|
||||||
const claimUrl = resolvedDetail.claimToken?.claimUrl || ''
|
|
||||||
if (!claimUrl) return
|
if (!claimUrl) return
|
||||||
await navigator.clipboard.writeText(claimUrl)
|
await navigator.clipboard.writeText(claimUrl)
|
||||||
message.success('领取链接已复制')
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -104,6 +252,69 @@ export default function AdminTaskDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TaskActionPanel
|
||||||
|
detail={resolvedDetail}
|
||||||
|
canManageTaskLifecycle={canManageTaskLifecycle}
|
||||||
|
canCloseTasks={canCloseTasks}
|
||||||
|
actionLoadingKey={actionLoadingKey}
|
||||||
|
onRetry={() =>
|
||||||
|
runTaskAction(
|
||||||
|
'retry',
|
||||||
|
() => retryAdminTask(resolvedDetail.task.taskId),
|
||||||
|
'任务已重试',
|
||||||
|
`确认重试任务 ${resolvedDetail.task.taskNo} 吗?`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRebindRole={() =>
|
||||||
|
runTaskAction(
|
||||||
|
'rebind-role',
|
||||||
|
() => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId),
|
||||||
|
'角色换绑资源已准备完成',
|
||||||
|
`确认为任务 ${resolvedDetail.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPrepareKuaishouCloud={() =>
|
||||||
|
runTaskAction(
|
||||||
|
'prepare-kuaishou-cloud',
|
||||||
|
() => prepareAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||||
|
'绑定资源已准备完成',
|
||||||
|
`确认开始为任务 ${resolvedDetail.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDispatchKuaishouCloud={() =>
|
||||||
|
runTaskAction(
|
||||||
|
'dispatch-kuaishou-cloud',
|
||||||
|
() => dispatchAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||||
|
'已完成绑定确认并发货',
|
||||||
|
`确认客户已经完成绑定,并立即为任务 ${resolvedDetail.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onReturnKuaishouCloud={() =>
|
||||||
|
runTaskAction(
|
||||||
|
'return-kuaishou-cloud',
|
||||||
|
() => returnNumberAdminTaskKuaishouCloudFulfillment(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="基础信息">
|
<Card title="基础信息">
|
||||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||||
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
|
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
|
||||||
@@ -138,15 +349,19 @@ export default function AdminTaskDetailPage() {
|
|||||||
{resolvedDetail.claimToken ? (
|
{resolvedDetail.claimToken ? (
|
||||||
<Card title="领取链接">
|
<Card title="领取链接">
|
||||||
<Space direction="vertical" size={8} className="full-width">
|
<Space direction="vertical" size={8} className="full-width">
|
||||||
<Typography.Text copyable={{ text: resolvedDetail.claimToken.claimUrl }}>
|
{claimUrl ? (
|
||||||
{resolvedDetail.claimToken.claimUrl}
|
<Typography.Text copyable={{ text: claimUrl }}>{claimUrl}</Typography.Text>
|
||||||
</Typography.Text>
|
) : claimLinkInvalid ? (
|
||||||
|
<Typography.Text type="secondary">领取链接已失效</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无可用领取链接</Typography.Text>
|
||||||
|
)}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<StatusTag value={resolvedDetail.claimToken.status} />
|
<StatusTag value={resolvedDetail.claimToken.status} />
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
到期:{formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}
|
到期:{formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Button icon={<CopyOutlined />} onClick={copyClaimUrl}>
|
<Button icon={<CopyOutlined />} disabled={!claimUrl} onClick={copyClaimUrl}>
|
||||||
复制链接
|
复制链接
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -154,12 +369,59 @@ export default function AdminTaskDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : 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
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'cloud',
|
key: 'cloud',
|
||||||
label: '快手 Cloud',
|
label: '快手 Cloud',
|
||||||
children: flow ? <KuaishouCloudPanel flow={flow} /> : <Empty description="无快手 Cloud 履约上下文" />,
|
children: flow ? (
|
||||||
|
<KuaishouCloudPanel
|
||||||
|
flow={flow}
|
||||||
|
canRefresh={resolvedDetail.operations.canRefreshKuaishouCloudRoleInfo}
|
||||||
|
actionLoadingKey={actionLoadingKey}
|
||||||
|
onRefreshRole={() =>
|
||||||
|
runTaskAction(
|
||||||
|
'refresh-role',
|
||||||
|
() => refreshAdminTaskKuaishouCloudRoleInfo(resolvedDetail.task.taskId),
|
||||||
|
'角色信息已刷新',
|
||||||
|
`确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="无快手 Cloud 履约上下文" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
key: 'events',
|
||||||
@@ -185,44 +447,485 @@ export default function AdminTaskDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KuaishouCloudPanel({ flow }: { flow: NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']> }) {
|
function TaskActionPanel({
|
||||||
|
detail,
|
||||||
|
canManageTaskLifecycle,
|
||||||
|
canCloseTasks,
|
||||||
|
actionLoadingKey,
|
||||||
|
onRetry,
|
||||||
|
onRebindRole,
|
||||||
|
onPrepareKuaishouCloud,
|
||||||
|
onDispatchKuaishouCloud,
|
||||||
|
onReturnKuaishouCloud,
|
||||||
|
onMarkManualReview,
|
||||||
|
onCloseTask,
|
||||||
|
}: {
|
||||||
|
detail: AdminTaskDetail
|
||||||
|
canManageTaskLifecycle: boolean
|
||||||
|
canCloseTasks: boolean
|
||||||
|
actionLoadingKey: string
|
||||||
|
onRetry: () => void
|
||||||
|
onRebindRole: () => void
|
||||||
|
onPrepareKuaishouCloud: () => void
|
||||||
|
onDispatchKuaishouCloud: () => void
|
||||||
|
onReturnKuaishouCloud: () => void
|
||||||
|
onMarkManualReview: () => void
|
||||||
|
onCloseTask: () => void
|
||||||
|
}) {
|
||||||
|
const operations = detail.operations
|
||||||
|
const loading = Boolean(actionLoadingKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card title="任务操作">
|
||||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
<Space wrap>
|
||||||
<Descriptions.Item label="来源账号">
|
{canManageTaskLifecycle ? (
|
||||||
{flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'}
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
disabled={loading || !operations.canRetry}
|
||||||
|
loading={actionLoadingKey === 'retry'}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
重试任务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{operations.canRebindKuaishouCloudRole ? (
|
||||||
|
<Button
|
||||||
|
icon={<SwapOutlined />}
|
||||||
|
disabled={loading}
|
||||||
|
loading={actionLoadingKey === 'rebind-role'}
|
||||||
|
onClick={onRebindRole}
|
||||||
|
>
|
||||||
|
换绑角色
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
ghost
|
||||||
|
icon={<ToolOutlined />}
|
||||||
|
disabled={loading}
|
||||||
|
loading={actionLoadingKey === 'prepare-kuaishou-cloud'}
|
||||||
|
onClick={onPrepareKuaishouCloud}
|
||||||
|
>
|
||||||
|
准备绑定资源
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
disabled={loading}
|
||||||
|
loading={actionLoadingKey === 'dispatch-kuaishou-cloud'}
|
||||||
|
onClick={onDispatchKuaishouCloud}
|
||||||
|
>
|
||||||
|
确认绑定完成并发货
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageTaskLifecycle && operations.canReturnKuaishouCloudFulfillment ? (
|
||||||
|
<Button
|
||||||
|
icon={<RollbackOutlined />}
|
||||||
|
disabled={loading}
|
||||||
|
loading={actionLoadingKey === 'return-kuaishou-cloud'}
|
||||||
|
onClick={onReturnKuaishouCloud}
|
||||||
|
>
|
||||||
|
退还号码
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageTaskLifecycle ? (
|
||||||
|
<Button
|
||||||
|
icon={<UserSwitchOutlined />}
|
||||||
|
disabled={loading || !operations.canMarkManualReview}
|
||||||
|
loading={actionLoadingKey === 'manual-review'}
|
||||||
|
onClick={onMarkManualReview}
|
||||||
|
>
|
||||||
|
转人工
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canCloseTasks ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
disabled={loading || !operations.canClose}
|
||||||
|
loading={actionLoadingKey === 'close'}
|
||||||
|
onClick={onCloseTask}
|
||||||
|
>
|
||||||
|
关闭任务
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!canManageTaskLifecycle && !canCloseTasks && !operations.canRebindKuaishouCloudRole ? (
|
||||||
|
<Typography.Text type="secondary">当前账号没有可执行的任务操作</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
{loading ? (
|
||||||
|
<Typography.Text className="task-action-hint" type="secondary">
|
||||||
|
操作执行中,请稍候。
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 KuaishouCloudPanel({
|
||||||
|
flow,
|
||||||
|
canRefresh,
|
||||||
|
actionLoadingKey,
|
||||||
|
onRefreshRole,
|
||||||
|
}: {
|
||||||
|
flow: NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||||
|
canRefresh: boolean
|
||||||
|
actionLoadingKey: string
|
||||||
|
onRefreshRole: () => void
|
||||||
|
}) {
|
||||||
|
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 || '等待退号后核销收口',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="kuaishou-cloud-stack">
|
||||||
|
<Card
|
||||||
|
title="快手 Cloud 履约"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
disabled={Boolean(actionLoadingKey) || !canRefresh}
|
||||||
|
loading={actionLoadingKey === 'refresh-role'}
|
||||||
|
onClick={onRefreshRole}
|
||||||
|
>
|
||||||
|
刷新角色
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<Descriptions.Item label="虚拟号">
|
<Descriptions.Item label="任务状态">{resolution.taskStatus || '-'}</Descriptions.Item>
|
||||||
{flow.binding.vnPhone || flow.binding.vnId || '-'}
|
<Descriptions.Item label="替换次数">{resolution.replacementCount}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
<Descriptions.Item label="完成时间">
|
||||||
<Descriptions.Item label="绑定状态">
|
{formatAdminDateTime(resolution.finishedAt)}
|
||||||
<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.Item>
|
||||||
</Descriptions>
|
</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>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
import {
|
||||||
import { Button, Card, Form, Input, Select, Space, Table, Typography } from 'antd'
|
CloseCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
UserSwitchOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { Alert, App, Button, Card, DatePicker, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import dayjs from 'dayjs'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router'
|
import { useNavigate, useSearchParams } from 'react-router'
|
||||||
|
|
||||||
import PageHeader from '@/components/admin/PageHeader'
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
import StatusTag from '@/components/admin/StatusTag'
|
import StatusTag from '@/components/admin/StatusTag'
|
||||||
import { fetchAdminTasks } from '@/services/admin'
|
import {
|
||||||
import type { AdminTaskListItem } from '@/types/admin'
|
closeAdminTask,
|
||||||
|
fetchAdminTasks,
|
||||||
|
markAdminTaskManualReview,
|
||||||
|
rebindAdminTaskKuaishouCloudRole,
|
||||||
|
retryAdminTask,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type { AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
|
||||||
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
@@ -18,24 +32,36 @@ type TaskFilterForm = {
|
|||||||
taskNo?: string
|
taskNo?: string
|
||||||
skuCode?: string
|
skuCode?: string
|
||||||
roleId?: string
|
roleId?: string
|
||||||
|
dateRange?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskListAction = 'retry' | 'rebind_role' | 'manual_review' | 'close'
|
||||||
|
|
||||||
export default function AdminTasksPage() {
|
export default function AdminTasksPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { message, modal } = App.useApp()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||||
|
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||||
const page = Number(searchParams.get('page') || 1) || 1
|
const page = Number(searchParams.get('page') || 1) || 1
|
||||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||||
|
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||||
|
const canCloseTasks = hasAdminRole('support')
|
||||||
const filters = {
|
const filters = {
|
||||||
status: searchParams.get('status') || '',
|
status: searchParams.get('status') || '',
|
||||||
platformOrderId: searchParams.get('platformOrderId') || '',
|
platformOrderId: searchParams.get('platformOrderId') || '',
|
||||||
taskNo: searchParams.get('taskNo') || '',
|
taskNo: searchParams.get('taskNo') || '',
|
||||||
skuCode: searchParams.get('skuCode') || '',
|
skuCode: searchParams.get('skuCode') || '',
|
||||||
roleId: searchParams.get('roleId') || '',
|
roleId: searchParams.get('roleId') || '',
|
||||||
|
dateFrom: searchParams.get('dateFrom') || '',
|
||||||
|
dateTo: searchParams.get('dateTo') || '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({ page, pageSize, ...filters }),
|
() => ({ page, pageSize, ...filters }),
|
||||||
[
|
[
|
||||||
|
filters.dateFrom,
|
||||||
|
filters.dateTo,
|
||||||
filters.platformOrderId,
|
filters.platformOrderId,
|
||||||
filters.roleId,
|
filters.roleId,
|
||||||
filters.skuCode,
|
filters.skuCode,
|
||||||
@@ -96,19 +122,172 @@ export default function AdminTasksPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间 / 异常',
|
title: '时间',
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div className="cell-stack">
|
<div className="cell-stack">
|
||||||
<span>{formatAdminDateTime(row.updatedAt)}</span>
|
<span>{formatAdminDateTime(row.updatedAt)}</span>
|
||||||
<span className="muted">{row.lastError || '-'}</span>
|
<span className="muted">创建 {formatAdminDateTime(row.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '异常 / 操作',
|
||||||
|
minWidth: 260,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => {
|
||||||
|
const rowLoading = actionLoadingId === row.taskId
|
||||||
|
const actionRunning = actionLoadingId !== null
|
||||||
|
const canRetry =
|
||||||
|
canManageTaskLifecycle &&
|
||||||
|
row.executorKey !== 'manual_dispatch' &&
|
||||||
|
['retry_pending', 'manual_review'].includes(row.status)
|
||||||
|
const canRebind = canRebindListTask(row, canManageTaskLifecycle)
|
||||||
|
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
|
||||||
|
const hasActions = canManageTaskLifecycle || canRebind || canClose
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text type={row.lastError ? 'danger' : 'secondary'} ellipsis>
|
||||||
|
{row.lastError || '当前无异常'}
|
||||||
|
</Typography.Text>
|
||||||
|
{hasActions ? (
|
||||||
|
<Space size={[6, 6]} wrap>
|
||||||
|
{canManageTaskLifecycle ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
ghost
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
disabled={actionRunning || !canRetry}
|
||||||
|
loading={rowLoading}
|
||||||
|
onClick={() => handleActionCommand('retry', row)}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canRebind ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<SwapOutlined />}
|
||||||
|
disabled={actionRunning}
|
||||||
|
loading={rowLoading}
|
||||||
|
onClick={() => handleActionCommand('rebind_role', row)}
|
||||||
|
>
|
||||||
|
换绑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageTaskLifecycle ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<UserSwitchOutlined />}
|
||||||
|
disabled={actionRunning}
|
||||||
|
loading={rowLoading}
|
||||||
|
onClick={() => handleActionCommand('manual_review', row)}
|
||||||
|
>
|
||||||
|
转人工
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canClose ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
disabled={actionRunning}
|
||||||
|
loading={rowLoading}
|
||||||
|
onClick={() => handleActionCommand('close', row)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function handleActionCommand(actionKey: TaskListAction, item: AdminTaskListItem) {
|
||||||
|
const actionConfig = resolveTaskListAction(actionKey, item)
|
||||||
|
|
||||||
|
modal.confirm({
|
||||||
|
title: '确认操作',
|
||||||
|
content: actionConfig.confirmText,
|
||||||
|
okText: '继续执行',
|
||||||
|
cancelText: '取消',
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
setActionLoadingId(item.taskId)
|
||||||
|
try {
|
||||||
|
const response = await actionConfig.action()
|
||||||
|
if (response.data.claimUrl) {
|
||||||
|
setLastClaimUrl(response.data.claimUrl)
|
||||||
|
}
|
||||||
|
message.success(actionConfig.successMessage)
|
||||||
|
await query.refetch()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
} finally {
|
||||||
|
setActionLoadingId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskListAction(actionKey: TaskListAction, item: AdminTaskListItem): {
|
||||||
|
action: () => Promise<{ data: AdminTaskActionResponse }>
|
||||||
|
confirmText: string
|
||||||
|
successMessage: string
|
||||||
|
} {
|
||||||
|
if (actionKey === 'retry') {
|
||||||
|
return {
|
||||||
|
action: () => retryAdminTask(item.taskId),
|
||||||
|
confirmText: `确认重试任务 ${item.taskNo} 吗?`,
|
||||||
|
successMessage: '任务已重试',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionKey === 'rebind_role') {
|
||||||
|
return {
|
||||||
|
action: () => rebindAdminTaskKuaishouCloudRole(item.taskId),
|
||||||
|
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||||
|
successMessage: '角色换绑资源已准备完成',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionKey === 'manual_review') {
|
||||||
|
return {
|
||||||
|
action: () => markAdminTaskManualReview(item.taskId),
|
||||||
|
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
|
||||||
|
successMessage: '任务已转人工处理',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: () => closeAdminTask(item.taskId),
|
||||||
|
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||||
|
successMessage: '任务已关闭',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters(values: TaskFilterForm) {
|
function applyFilters(values: TaskFilterForm) {
|
||||||
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
|
const { dateRange, ...restValues } = values
|
||||||
|
const [dateFrom, dateTo] = Array.isArray(dateRange)
|
||||||
|
? dateRange.map((item: { format?: (pattern: string) => string }) =>
|
||||||
|
item?.format ? item.format('YYYY-MM-DD') : '',
|
||||||
|
)
|
||||||
|
: ['', '']
|
||||||
|
|
||||||
|
setSearchParams(
|
||||||
|
cleanParams({
|
||||||
|
...restValues,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
page: 1,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
@@ -122,7 +301,13 @@ export default function AdminTasksPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<Form<TaskFilterForm>
|
<Form<TaskFilterForm>
|
||||||
layout="inline"
|
layout="inline"
|
||||||
initialValues={filters}
|
initialValues={{
|
||||||
|
...filters,
|
||||||
|
dateRange:
|
||||||
|
filters.dateFrom && filters.dateTo
|
||||||
|
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
onFinish={applyFilters}
|
onFinish={applyFilters}
|
||||||
className="filter-form"
|
className="filter-form"
|
||||||
>
|
>
|
||||||
@@ -143,6 +328,12 @@ export default function AdminTasksPage() {
|
|||||||
<Form.Item name="roleId">
|
<Form.Item name="roleId">
|
||||||
<Input allowClear placeholder="角色 ID" />
|
<Input allowClear placeholder="角色 ID" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="skuCode">
|
||||||
|
<Input allowClear placeholder="商品名 / SKU" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="dateRange">
|
||||||
|
<DatePicker.RangePicker />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||||
@@ -156,13 +347,24 @@ export default function AdminTasksPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{lastClaimUrl ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="最近可用领取链接"
|
||||||
|
description={
|
||||||
|
<Typography.Text copyable={{ text: lastClaimUrl }}>{lastClaimUrl}</Typography.Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Table<AdminTaskListItem>
|
<Table<AdminTaskListItem>
|
||||||
rowKey="taskId"
|
rowKey="taskId"
|
||||||
loading={query.isLoading || query.isFetching}
|
loading={query.isLoading || query.isFetching}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items || []}
|
dataSource={data?.items || []}
|
||||||
scroll={{ x: 1080 }}
|
scroll={{ x: 1240 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: data?.pagination.page || page,
|
current: data?.pagination.page || page,
|
||||||
pageSize: data?.pagination.pageSize || pageSize,
|
pageSize: data?.pagination.pageSize || pageSize,
|
||||||
@@ -179,6 +381,14 @@ export default function AdminTasksPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRebindListTask(item: AdminTaskListItem, canManageTaskLifecycle: boolean) {
|
||||||
|
return (
|
||||||
|
canManageTaskLifecycle &&
|
||||||
|
item.executorKey === 'kuaishou_ct_assisted' &&
|
||||||
|
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function cleanParams(params: Record<string, unknown>) {
|
function cleanParams(params: Record<string, unknown>) {
|
||||||
const next = new URLSearchParams()
|
const next = new URLSearchParams()
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
|||||||
@@ -305,6 +305,60 @@ select {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-action-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-dispatch-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-dispatch-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kuaishou-cloud-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-flow-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-flow-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #edf0f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-screenshot-preview {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 260px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #edf0f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-screenshot-preview .ant-image,
|
||||||
|
.task-screenshot-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-page {
|
.empty-page {
|
||||||
min-height: 50vh;
|
min-height: 50vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -948,6 +1002,11 @@ select {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-dispatch-grid,
|
||||||
|
.task-flow-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.users-action-form .ant-form-item {
|
.users-action-form .ant-form-item {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user