Files
order_site/apps/frontend/src/pages/admin/AdminTasksPage.tsx
T
yml2213 122144a2b7 feat(task): 任务详情与列表页增加H5订单号领取链接及兑换有效性控制
- 后端 AdminTaskDetail 视图补充关联订单的 claimStatus、claimStatusReason 等状态字段
- 任务详情页升级“领取链接”卡片:支持固定 H5 链接展示、状态标签(有效/暂停/关闭),并支持一键复制及【暂停/关闭/恢复启用】弹窗操作
- 任务详情页保留历史 Token 链接作为辅助对比展示
- 任务列表页在平台订单号旁增加一键复制 H5 领取链接的快捷按钮
- 验证方式:前后端类型检查、全量单元测试(后端 674、前端 132)及前后端全量构建均通过
2026-10-10 18:38:49 +08:00

653 lines
19 KiB
TypeScript

import {
CloseCircleOutlined,
CopyOutlined,
ReloadOutlined,
SearchOutlined,
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 { useEffect, 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,
retryAdminTask,
} from '@/services/admin'
import type { AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { adminOrderExecutorOptions, adminTaskStatusOptions } from '@/utils/admin-options'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminTablePagination,
resolveAdminPageSize,
} from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
type TaskFilterForm = {
keyword?: string
executorKey?: string
status?: string
dateRange?: unknown
}
type TaskListAction = 'retry' | '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 [form] = Form.useForm<TaskFilterForm>()
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')
const canCloseTasks = hasAdminRole('support')
const filters = {
keyword:
searchParams.get('keyword') ||
searchParams.get('taskNo') ||
searchParams.get('platformOrderId') ||
searchParams.get('skuCode') ||
searchParams.get('roleId') ||
'',
executorKey: searchParams.get('executorKey') || '',
status: searchParams.get('status') || '',
dateFrom: searchParams.get('dateFrom') || '',
dateTo: searchParams.get('dateTo') || '',
}
const queryParams = useMemo(
() => ({ page, pageSize, ...filters }),
[
filters.dateFrom,
filters.dateTo,
filters.executorKey,
filters.keyword,
filters.status,
page,
pageSize,
],
)
const query = useQuery({
queryKey: ['admin-tasks', queryParams],
queryFn: () => fetchAdminTasks(queryParams),
})
const data = query.data?.data
useEffect(() => {
form.setFieldsValue({
keyword: filters.keyword || undefined,
executorKey: filters.executorKey || undefined,
status: filters.status || undefined,
dateRange:
filters.dateFrom && filters.dateTo
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
: undefined,
})
}, [filters.dateFrom, filters.dateTo, filters.executorKey, filters.keyword, filters.status, form])
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>
<Space size={4}>
<span className="muted">{row.platformOrderId}</span>
{row.platformOrderId ? (
<Button
type="text"
size="small"
icon={<CopyOutlined />}
title="复制 H5 领取链接"
style={{ padding: 0, height: 'auto', color: '#64748b' }}
onClick={(e) => {
e.stopPropagation()
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(row.platformOrderId)}`
navigator.clipboard
.writeText(h5Url)
.then(() => message.success('已复制 H5 领取链接: ' + h5Url))
.catch(() => message.error('复制失败,请手动复制'))
}}
/>
) : null}
</Space>
</div>
),
},
{
title: '商品',
minWidth: 200,
render: (_, row) => {
const executor = resolveTaskExecutorDisplay(row.executorKey)
return (
<div className="cell-stack">
<span>{row.skuName || '-'}</span>
{row.skuCode && row.skuCode !== row.skuName ? (
<span className="muted">{row.skuCode}</span>
) : null}
<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' &&
row.executorKey !== 'kuaishou_ct_assisted' &&
row.executorKey !== 'kuaishou_feifei' &&
['retry_pending', 'manual_review'].includes(row.status)
const canManualReview =
canManageTaskLifecycle &&
!['redeemed', 'closed', 'completed', 'manual_review'].includes(row.status)
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
const hasActions = canRetry || 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}
{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 === '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() {
form.setFieldsValue({
keyword: undefined,
executorKey: undefined,
status: undefined,
dateRange: undefined,
})
setSearchParams(cleanParams({ page: 1, pageSize }))
}
return (
<section className="page-stack">
<PageHeader title="任务" description="跟踪履约任务状态、客户步骤和资源准备进度。" />
<Card>
<Form<TaskFilterForm>
form={form}
layout="inline"
onFinish={applyFilters}
className="filter-form"
>
<Form.Item name="keyword">
<Input
allowClear
placeholder="任务号 / 订单号 / 商品标题 / SKU / 角色"
style={{ width: 330 }}
/>
</Form.Item>
<Form.Item name="executorKey">
<Select
allowClear
placeholder="履约渠道"
options={adminOrderExecutorOptions}
style={{ minWidth: 150 }}
/>
</Form.Item>
<Form.Item name="status">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="任务状态"
options={adminTaskStatusOptions}
style={{ minWidth: 170 }}
/>
</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 resolveTaskExecutorDisplay(value: unknown) {
const executorKey = String(value || '').trim()
if (executorKey === 'affiliate_dash') {
return { key: executorKey, label: '联盟发货', color: 'green' }
}
if (executorKey === 'kuaishou_ct_assisted') {
return { key: executorKey, label: '乐玩云(已下线)', color: 'default' }
}
if (executorKey === 'kuaishou-industry') {
return { key: executorKey, label: '行业凭证', color: 'cyan' }
}
if (executorKey === 'kuaishou_feifei') {
return { key: executorKey, label: '飞飞(已下线)', color: 'default' }
}
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
}