640 lines
19 KiB
TypeScript
640 lines
19 KiB
TypeScript
import {
|
||
CloseCircleOutlined,
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
SwapOutlined,
|
||
UserSwitchOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
Alert,
|
||
App,
|
||
Button,
|
||
Card,
|
||
Form,
|
||
Input,
|
||
Select,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
} from 'antd'
|
||
import type { TableColumnsType } from 'antd'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { useMemo, useState } from 'react'
|
||
import { useNavigate, useSearchParams } from 'react-router'
|
||
|
||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||
import PageHeader from '@/components/admin/PageHeader'
|
||
import StatusTag from '@/components/admin/StatusTag'
|
||
import dayjs from '@/lib/dayjs'
|
||
import {
|
||
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 {
|
||
ADMIN_DEFAULT_PAGE_SIZE,
|
||
buildAdminTablePagination,
|
||
resolveAdminPageSize,
|
||
} from '@/utils/admin-pagination'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
|
||
type TaskFilterForm = {
|
||
status?: string
|
||
platformOrderId?: string
|
||
taskNo?: string
|
||
skuCode?: string
|
||
roleId?: string
|
||
dateRange?: unknown
|
||
}
|
||
|
||
type TaskListAction = 'retry' | 'rebind_role' | 'manual_review' | 'close'
|
||
type TaskListBlocker = {
|
||
label: string
|
||
color: string
|
||
level: 'normal' | 'error' | 'done'
|
||
detail: string
|
||
/** 列表副文案:完成态默认隐藏,异常/进行中显示 */
|
||
showDetail: boolean
|
||
}
|
||
|
||
export default function AdminTasksPage() {
|
||
const navigate = useNavigate()
|
||
const { message, modal } = App.useApp()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||
const page = Number(searchParams.get('page') || 1) || 1
|
||
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
|
||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||
// 与详情页一致:客服可换绑协助处理(canOperateAssistedTask)
|
||
const canRebindRole = hasAdminRole('support')
|
||
const canCloseTasks = hasAdminRole('support')
|
||
const filters = {
|
||
status: searchParams.get('status') || '',
|
||
platformOrderId: searchParams.get('platformOrderId') || '',
|
||
taskNo: searchParams.get('taskNo') || '',
|
||
skuCode: searchParams.get('skuCode') || '',
|
||
roleId: searchParams.get('roleId') || '',
|
||
dateFrom: searchParams.get('dateFrom') || '',
|
||
dateTo: searchParams.get('dateTo') || '',
|
||
}
|
||
|
||
const queryParams = useMemo(
|
||
() => ({ page, pageSize, ...filters }),
|
||
[
|
||
filters.dateFrom,
|
||
filters.dateTo,
|
||
filters.platformOrderId,
|
||
filters.roleId,
|
||
filters.skuCode,
|
||
filters.status,
|
||
filters.taskNo,
|
||
page,
|
||
pageSize,
|
||
],
|
||
)
|
||
const query = useQuery({
|
||
queryKey: ['admin-tasks', queryParams],
|
||
queryFn: () => fetchAdminTasks(queryParams),
|
||
})
|
||
const data = query.data?.data
|
||
|
||
const columns: TableColumnsType<AdminTaskListItem> = [
|
||
{
|
||
title: '任务 / 订单',
|
||
minWidth: 210,
|
||
render: (_, row) => (
|
||
<div className="cell-stack">
|
||
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
|
||
{row.taskNo}
|
||
</Typography.Link>
|
||
<span className="muted">{row.platformOrderId}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '商品',
|
||
minWidth: 200,
|
||
render: (_, row) => {
|
||
const executor = resolveTaskExecutorDisplay(row.executorKey)
|
||
|
||
return (
|
||
<div className="cell-stack">
|
||
<span>{row.skuName || '-'}</span>
|
||
<span className="muted">{row.skuCode || '-'}</span>
|
||
<Tag className="task-list-executor-tag" color={executor.color} title={executor.key}>
|
||
{executor.label}
|
||
</Tag>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '当前卡点',
|
||
minWidth: 200,
|
||
render: (_, row) => {
|
||
const blocker = resolveTaskListBlocker(row)
|
||
|
||
return (
|
||
<div className="cell-stack">
|
||
<Tag className="task-list-blocker-tag" color={blocker.color} title={blocker.detail}>
|
||
{blocker.label}
|
||
</Tag>
|
||
{blocker.showDetail ? (
|
||
<Typography.Text
|
||
className="task-list-blocker-detail"
|
||
type={blocker.level === 'error' ? 'danger' : 'secondary'}
|
||
title={blocker.detail}
|
||
ellipsis
|
||
>
|
||
{blocker.detail}
|
||
</Typography.Text>
|
||
) : null}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '状态',
|
||
minWidth: 120,
|
||
render: (_, row) => (
|
||
<Tooltip
|
||
placement="topLeft"
|
||
title={
|
||
<div className="task-list-status-tooltip">
|
||
<div className="task-list-status-tooltip-row">
|
||
<span>交付</span>
|
||
<StatusTag value={row.deliveryStatus} kind="delivery" />
|
||
</div>
|
||
<div className="task-list-status-tooltip-row">
|
||
<span>资源</span>
|
||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||
</div>
|
||
<div className="task-list-status-tooltip-row">
|
||
<span>客户</span>
|
||
<StatusTag value={row.customerStatus} kind="customer" />
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
<span className="task-list-status-main">
|
||
<StatusTag value={row.status} kind="task" />
|
||
</span>
|
||
</Tooltip>
|
||
),
|
||
},
|
||
{
|
||
title: '角色',
|
||
minWidth: 140,
|
||
render: (_, row) => (
|
||
<div className="cell-stack">
|
||
<span>{row.roleName || '-'}</span>
|
||
{row.roleId || row.loginType ? (
|
||
<span className="muted">{row.roleId || row.loginType}</span>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '更新时间',
|
||
minWidth: 160,
|
||
render: (_, row) => (
|
||
<span title={`创建 ${formatAdminDateTime(row.createdAt)}`}>
|
||
{formatAdminDateTime(row.updatedAt)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
minWidth: 200,
|
||
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, canRebindRole)
|
||
const canManualReview =
|
||
canManageTaskLifecycle &&
|
||
!['redeemed', 'closed', 'completed', 'manual_review'].includes(row.status)
|
||
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
|
||
const hasActions = canRetry || canRebind || canManualReview || canClose
|
||
|
||
return (
|
||
<div className="cell-stack">
|
||
{row.lastError ? (
|
||
<Typography.Text type="danger" title={row.lastError} ellipsis>
|
||
{row.lastError}
|
||
</Typography.Text>
|
||
) : null}
|
||
{hasActions ? (
|
||
<Space size={[6, 6]} wrap>
|
||
{canRetry ? (
|
||
<Button
|
||
size="small"
|
||
danger
|
||
ghost
|
||
icon={<ReloadOutlined />}
|
||
disabled={actionRunning}
|
||
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}
|
||
{canManualReview ? (
|
||
<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>
|
||
) : (
|
||
<span className="muted">—</span>
|
||
)}
|
||
</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) {
|
||
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() {
|
||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||
}
|
||
|
||
return (
|
||
<section className="page-stack">
|
||
<PageHeader title="任务" description="跟踪履约任务状态、客户步骤和资源准备进度。" />
|
||
|
||
<Card>
|
||
<Form<TaskFilterForm>
|
||
layout="inline"
|
||
initialValues={{
|
||
...filters,
|
||
dateRange:
|
||
filters.dateFrom && filters.dateTo
|
||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||
: undefined,
|
||
}}
|
||
onFinish={applyFilters}
|
||
className="filter-form"
|
||
>
|
||
<Form.Item name="status">
|
||
<Select
|
||
allowClear
|
||
placeholder="任务状态"
|
||
options={adminTaskStatusOptions}
|
||
style={{ minWidth: 170 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="platformOrderId">
|
||
<Input allowClear placeholder="平台订单号" />
|
||
</Form.Item>
|
||
<Form.Item name="taskNo">
|
||
<Input allowClear placeholder="任务号" />
|
||
</Form.Item>
|
||
<Form.Item name="roleId">
|
||
<Input allowClear placeholder="角色 ID" />
|
||
</Form.Item>
|
||
<Form.Item name="skuCode">
|
||
<Input allowClear placeholder="商品名 / SKU" />
|
||
</Form.Item>
|
||
<Form.Item name="dateRange">
|
||
<AdminRangePicker />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||
查询
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</Card>
|
||
|
||
{lastClaimUrl ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="最近可用领取链接"
|
||
description={
|
||
<Typography.Text copyable={{ text: lastClaimUrl }}>{lastClaimUrl}</Typography.Text>
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
<Card>
|
||
<Table<AdminTaskListItem>
|
||
rowKey="taskId"
|
||
loading={query.isLoading || query.isFetching}
|
||
columns={columns}
|
||
dataSource={data?.items || []}
|
||
scroll={{ x: 1280 }}
|
||
size="middle"
|
||
rowClassName={(row) =>
|
||
resolveTaskListBlocker(row).level === 'error' ? 'task-list-row-error' : ''
|
||
}
|
||
pagination={buildAdminTablePagination({
|
||
current: data?.pagination.page || page,
|
||
pageSize: data?.pagination.pageSize || pageSize,
|
||
total: data?.pagination.total || 0,
|
||
onChange: (nextPage, nextPageSize) => {
|
||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||
},
|
||
})}
|
||
/>
|
||
</Card>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function canRebindListTask(item: AdminTaskListItem, canRebindRole: boolean) {
|
||
return (
|
||
canRebindRole &&
|
||
item.executorKey === 'kuaishou_ct_assisted' &&
|
||
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
|
||
)
|
||
}
|
||
|
||
function resolveTaskExecutorDisplay(value: unknown) {
|
||
const executorKey = String(value || '').trim()
|
||
if (executorKey === 'kuaishou_ct_assisted') {
|
||
return { key: executorKey, label: '乐玩云', color: 'blue' }
|
||
}
|
||
if (executorKey === 'kuaishou-industry') {
|
||
return { key: executorKey, label: '行业凭证', color: 'cyan' }
|
||
}
|
||
if (executorKey === 'kuaishou_feifei') {
|
||
return { key: executorKey, label: '飞飞', color: 'purple' }
|
||
}
|
||
if (executorKey === 'manual_dispatch') {
|
||
return { key: executorKey, label: '人工履约', color: 'orange' }
|
||
}
|
||
return {
|
||
key: executorKey,
|
||
label: executorKey || '未分配',
|
||
color: 'default',
|
||
}
|
||
}
|
||
|
||
function resolveTaskListBlocker(item: AdminTaskListItem): TaskListBlocker {
|
||
const taskStatus = normalizeTaskListStatus(item.status)
|
||
const deliveryStatus = normalizeTaskListStatus(item.deliveryStatus)
|
||
const resourceStatus = normalizeTaskListStatus(item.resourceStatus)
|
||
const customerStatus = normalizeTaskListStatus(item.customerStatus)
|
||
const errorText = item.lastError || item.resultMessage
|
||
|
||
if (
|
||
['completed', 'redeemed'].includes(taskStatus) ||
|
||
['delivered', 'completed', 'success', 'consumed'].includes(deliveryStatus)
|
||
) {
|
||
return {
|
||
label: '已完成',
|
||
color: 'green',
|
||
level: 'done',
|
||
detail: item.resultMessage || '履约链路已完成。',
|
||
showDetail: false,
|
||
}
|
||
}
|
||
|
||
if (taskStatus === 'closed') {
|
||
return {
|
||
label: '已关闭',
|
||
color: 'default',
|
||
level: 'done',
|
||
detail: item.resultMessage || item.lastError || '任务已关闭,不会继续自动推进。',
|
||
showDetail: false,
|
||
}
|
||
}
|
||
|
||
if (
|
||
item.lastError ||
|
||
['manual_review', 'retry_pending', 'failed'].includes(taskStatus) ||
|
||
['resource_exception', 'binding_exception', 'failed'].includes(resourceStatus) ||
|
||
['customer_exception', 'failed'].includes(customerStatus) ||
|
||
['failed'].includes(deliveryStatus)
|
||
) {
|
||
return {
|
||
label: '需处理',
|
||
color: 'red',
|
||
level: 'error',
|
||
detail: errorText || '需要进入详情页按履约层级检查并重试。',
|
||
showDetail: true,
|
||
}
|
||
}
|
||
|
||
if (item.executorKey === 'manual_dispatch') {
|
||
return {
|
||
label: '人工履约',
|
||
color: 'orange',
|
||
level: 'normal',
|
||
detail: item.resultMessage || '等待人工回写履约结果。',
|
||
showDetail: true,
|
||
}
|
||
}
|
||
|
||
if (['pending_config'].includes(resourceStatus) || !item.executorKey) {
|
||
return {
|
||
label: '待配置',
|
||
color: 'gold',
|
||
level: 'normal',
|
||
detail: item.resultMessage || '商品还没有命中明确的履约配置。',
|
||
showDetail: true,
|
||
}
|
||
}
|
||
|
||
if (
|
||
[
|
||
'not_started',
|
||
'pending',
|
||
'pending_prepare',
|
||
'pending_binding',
|
||
'pending_binding_prepare',
|
||
].includes(resourceStatus)
|
||
) {
|
||
return {
|
||
label: '等资源',
|
||
color: 'gold',
|
||
level: 'normal',
|
||
detail: item.resultMessage || '等待准备履约资源,可在详情页触发对应动作。',
|
||
showDetail: true,
|
||
}
|
||
}
|
||
|
||
if (
|
||
['waiting_customer', 'waiting_binding', 'waiting_user_claim', 'user_binding'].includes(
|
||
customerStatus,
|
||
)
|
||
) {
|
||
return {
|
||
label: '等客户',
|
||
color: 'blue',
|
||
level: 'normal',
|
||
detail: item.roleConfirmedAt
|
||
? '客户已提交角色信息,等待平台履约收口。'
|
||
: '等待客户打开链接、绑定或提交信息。',
|
||
showDetail: true,
|
||
}
|
||
}
|
||
|
||
if (deliveryStatus && !['pending', 'not_started'].includes(deliveryStatus)) {
|
||
return {
|
||
label: '平台处理中',
|
||
color: 'cyan',
|
||
level: 'normal',
|
||
detail: item.resultMessage || '平台履约执行中,可在详情页查看各层进度。',
|
||
showDetail: true,
|
||
}
|
||
}
|
||
|
||
return {
|
||
label: '进行中',
|
||
color: 'blue',
|
||
level: 'normal',
|
||
detail: item.resultMessage || '当前没有明确异常,等待任务自动推进。',
|
||
showDetail: Boolean(item.resultMessage),
|
||
}
|
||
}
|
||
|
||
function normalizeTaskListStatus(value: unknown) {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
}
|
||
|
||
function cleanParams(params: Record<string, unknown>) {
|
||
const next = new URLSearchParams()
|
||
Object.entries(params).forEach(([key, value]) => {
|
||
const normalized = String(value ?? '').trim()
|
||
if (normalized) {
|
||
next.set(key, normalized)
|
||
}
|
||
})
|
||
return next
|
||
}
|