Files
order_site/apps/frontend/src/pages/admin/AdminTasksPage.tsx
T

640 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}