XHS / frontend /src /pages /TaskDetailPage.tsx
Trae Bot
Upload Spider_XHS project
c481f8a
import { ArrowLeftOutlined, ReloadOutlined, StopOutlined, ApiOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Descriptions, Space, Tag, Tabs, Typography, Popconfirm, message } from 'antd'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import type { ApiError } from '../lib/api'
import JsonViewer from '../components/JsonViewer'
import { getTask, getTaskResult, retryTask, cancelTask, markTaskRpa, type TaskRecord, type TaskResultResponse } from '../lib/tasks'
export default function TaskDetailPage() {
const navigate = useNavigate()
const params = useParams<{ id: string }>()
const id = useMemo(() => params.id ?? '', [params.id])
const [task, setTask] = useState<TaskRecord | null>(null)
const [result, setResult] = useState<TaskResultResponse | null>(null)
const [notReadyBody, setNotReadyBody] = useState<unknown>(null)
const [loading, setLoading] = useState(false)
const [polling, setPolling] = useState(false)
const [error, setError] = useState<ApiError | null>(null)
const controllerRef = useRef<AbortController | null>(null)
const [actionLoading, setActionLoading] = useState(false)
const refresh = useCallback(async () => {
if (!id) return
setLoading(true)
setError(null)
try {
const taskRes = await getTask(id)
setTask(taskRes)
} catch (e) {
setError(e as ApiError)
} finally {
setLoading(false)
}
}, [id])
const handleRetry = async () => {
if (!id) return
setActionLoading(true)
try {
await retryTask(id)
message.success('任务已重新排队')
await refresh()
startPolling()
} catch (e) {
message.error(`重试失败: ${(e as ApiError).message}`)
} finally {
setActionLoading(false)
}
}
const handleCancel = async () => {
if (!id) return
setActionLoading(true)
try {
await cancelTask(id)
message.success('任务已取消')
await refresh()
stopPolling()
} catch (e) {
message.error(`取消失败: ${(e as ApiError).message}`)
} finally {
setActionLoading(false)
}
}
const handleMarkRpa = async () => {
if (!id) return
setActionLoading(true)
try {
await markTaskRpa(id)
message.success('已转为等待 RPA')
await refresh()
stopPolling()
} catch (e) {
message.error(`转 RPA 失败: ${(e as ApiError).message}`)
} finally {
setActionLoading(false)
}
}
useEffect(() => {
void refresh()
}, [refresh])
function stopPolling() {
controllerRef.current?.abort()
controllerRef.current = null
setPolling(false)
}
async function sleep(ms: number, signal?: AbortSignal) {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
function onAbort() {
window.clearTimeout(timer)
reject(new DOMException('Aborted', 'AbortError'))
}
if (signal) {
if (signal.aborted) return onAbort()
signal.addEventListener('abort', onAbort, { once: true })
}
})
}
const startPolling = useCallback(async () => {
if (!id) return
stopPolling()
setPolling(true)
setError(null)
setResult(null)
setNotReadyBody(null)
const controller = new AbortController()
controllerRef.current = controller
const signal = controller.signal
try {
while (true) {
const res = await getTaskResult(id, signal)
if (res.ready) {
setResult(res.data)
setNotReadyBody(null)
setPolling(false)
controllerRef.current = null
return
}
setNotReadyBody(res.body)
await sleep(1500, signal)
}
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
setError(e as ApiError)
setPolling(false)
controllerRef.current = null
}
}, [id])
useEffect(() => {
if (!id) return undefined
void startPolling()
return () => stopPolling()
}, [id, startPolling])
function formatTs(value: number | null | undefined) {
if (!value) return '-'
const d = new Date(value * 1000)
return Number.isFinite(d.getTime()) ? d.toLocaleString() : '-'
}
return (
<div style={{ display: 'grid', gap: 16 }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/tasks')}>
返回
</Button>
<Typography.Title level={3} style={{ margin: 0 }}>
Task: {id}
</Typography.Title>
</Space>
{error ? (
<Alert
type="error"
showIcon
message={error.status ? `HTTP ${error.status}` : '请求失败'}
description={error.message}
/>
) : null}
<Card
title="任务信息"
loading={loading}
extra={
<Space>
<Button onClick={refresh} disabled={loading || actionLoading}>
刷新
</Button>
<Button type="primary" onClick={startPolling} loading={polling} disabled={actionLoading}>
重新轮询结果
</Button>
<Button onClick={stopPolling} disabled={!polling || actionLoading}>
停止轮询
</Button>
{task?.status === 'failed' && (
<Popconfirm title="确定要重试此任务吗?" onConfirm={handleRetry}>
<Button icon={<ReloadOutlined />} loading={actionLoading}>重试任务</Button>
</Popconfirm>
)}
{task?.status === 'failed' && (
<Popconfirm title="确定转为等待 RPA 吗?" onConfirm={handleMarkRpa}>
<Button icon={<ApiOutlined />} loading={actionLoading}>转为 RPA</Button>
</Popconfirm>
)}
{(task?.status === 'queued' || task?.status === 'running' || task?.status === 'retrying' || task?.status === 'fallback_running') && (
<Popconfirm title="确定要取消此任务吗?" onConfirm={handleCancel}>
<Button danger icon={<StopOutlined />} loading={actionLoading}>取消任务</Button>
</Popconfirm>
)}
</Space>
}
>
{task ? (
<div style={{ display: 'grid', gap: 16 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="id">{task.id}</Descriptions.Item>
<Descriptions.Item label="status">
<Tag>{task.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="task_type">{task.task_type}</Descriptions.Item>
<Descriptions.Item label="engine">{task.engine || '-'}</Descriptions.Item>
<Descriptions.Item label="target" span={2}>
{task.target || '-'}
</Descriptions.Item>
<Descriptions.Item label="created">{formatTs(task.created)}</Descriptions.Item>
<Descriptions.Item label="started">{formatTs(task.started)}</Descriptions.Item>
<Descriptions.Item label="finished">{formatTs(task.finished)}</Descriptions.Item>
<Descriptions.Item label="retry_count">{task.retry_count}</Descriptions.Item>
</Descriptions>
<div style={{ display: 'grid', gap: 8 }}>
<Typography.Text strong>payload</Typography.Text>
<JsonViewer value={task.payload} height={260} />
</div>
<div style={{ display: 'grid', gap: 8 }}>
<Typography.Text strong>error</Typography.Text>
<JsonViewer value={task.error ?? null} height={220} />
</div>
</div>
) : (
<Typography.Text type="secondary">无数据</Typography.Text>
)}
</Card>
<Card
title="任务结果"
extra={
<Space>
{polling ? <Tag color="processing">轮询中</Tag> : null}
{!polling && result ? <Tag color="success">已就绪</Tag> : null}
{!polling && !result && notReadyBody ? <Tag color="default">未就绪</Tag> : null}
</Space>
}
>
{notReadyBody ? (
<div style={{ display: 'grid', gap: 12 }}>
<Alert type="info" showIcon message="未就绪,继续轮询中" />
<JsonViewer value={notReadyBody} height={220} defaultExpandAll={false} />
</div>
) : null}
{result ? (
<Tabs
items={[
{
key: 'raw',
label: 'raw',
children: <JsonViewer value={result.raw} height={360} />,
},
{
key: 'normalized',
label: 'normalized',
children: <JsonViewer value={result.normalized} height={360} />,
},
{
key: 'meta',
label: 'meta',
children: <JsonViewer value={result.meta} height={360} />,
},
]}
/>
) : null}
{!result && !notReadyBody ? <Typography.Text type="secondary">暂无</Typography.Text> : null}
</Card>
</div>
)
}