| 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> |
| ) |
| } |
|
|