All checks were successful
CI / test (push) Successful in 3m58s
评估列表:STAT_CHIPS 补 cancelled(已取消 4 条),使 全部18 = 各状态和; 页头 title '智能评估' 统一为 '评估列表'(与菜单一致)。 任务队列:统计条去掉派生的 unresolved(待处理)chip——它与 pending/assigned 重叠重复计数,保留真实四态后 全部8 = 待认领0+执行中1+已完成7+失败0。 tsc 0 错误 vitest 16 passed
169 lines
6.6 KiB
TypeScript
169 lines
6.6 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import {
|
||
Button, Empty, Space, Table, Tag, Tooltip, message,
|
||
} from 'antd'
|
||
import type { ColumnsType } from 'antd/es/table'
|
||
import { ReloadOutlined } from '@ant-design/icons'
|
||
import { intelligentEvalsApi, type TaskQueueItem, type TaskQueueStatus } from '../../api'
|
||
import { usePolling } from '../../hooks/usePolling'
|
||
import { colors, statusColors } from '../../tokens'
|
||
import { formatDateTime } from '../../utils/date'
|
||
import { EVAL_STATUS } from './status'
|
||
|
||
const STATUS_META: Record<TaskQueueStatus, { label: string; color: string }> = {
|
||
pending: { label: '待认领', color: 'orange' },
|
||
assigned: { label: '执行中', color: 'blue' },
|
||
completed: { label: '已完成', color: 'green' },
|
||
failed: { label: '失败', color: 'red' },
|
||
}
|
||
|
||
type FilterKey = 'all' | 'unresolved' | TaskQueueStatus
|
||
|
||
/**
|
||
* 任务队列监控(方案③可视化,与评估列表页统一 stat-chip + 单行表格)。
|
||
*
|
||
* 平台每 60s 扫描 executing 评估入队 + 触发 OpenClaw worker 执行。本组件
|
||
* 展示任务明细与状态分布,5s 轮询;统计条点击筛选。
|
||
*/
|
||
export default function TaskQueueMonitor() {
|
||
const [tasks, setTasks] = useState<TaskQueueItem[] | null>(null)
|
||
const [stats, setStats] = useState<{
|
||
pending: number; assigned: number; completed: number; failed: number; unresolved: number
|
||
} | null>(null)
|
||
const [filter, setFilter] = useState<FilterKey>('all')
|
||
const [page, setPage] = useState(1)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
// 一次拉取全部任务,筛选在前端完成(数据量小;unresolved 是 pending+assigned 并集,
|
||
// 后端 status 筛选无法一次表达,且 stats 含派生的 unresolved,不能直接求和当总数)。
|
||
const loadData = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const res = await intelligentEvalsApi.listTasks()
|
||
setTasks(res.data.tasks)
|
||
setStats(res.data.stats)
|
||
} catch {
|
||
message.error('加载任务队列失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Initial fetch (usePolling owns the 5s interval). 筛选/分页为前端状态。
|
||
useEffect(() => {
|
||
void loadData()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
// 筛选变化时回到第 1 页
|
||
useEffect(() => {
|
||
setPage(1)
|
||
}, [filter])
|
||
|
||
usePolling(() => { void loadData() }, 5000, true)
|
||
|
||
const allTasks = tasks ?? []
|
||
const visibleTasks = filter === 'all'
|
||
? allTasks
|
||
: filter === 'unresolved'
|
||
? allTasks.filter((t) => t.status === 'pending' || t.status === 'assigned')
|
||
: allTasks.filter((t) => t.status === filter)
|
||
|
||
// 全部任务 = 四态之和(不含派生的 unresolved);与后端 stats 对齐
|
||
const totalAll = stats ? stats.pending + stats.assigned + stats.completed + stats.failed : allTasks.length
|
||
|
||
// 统计条只展示真实四态(unresolved 是派生字段,与 pending/assigned 重叠会重复计数)
|
||
const chipMeta: { key: FilterKey; label: string; color?: string; get: () => number }[] = [
|
||
{ key: 'all', label: '全部任务', get: () => totalAll },
|
||
{ key: 'pending', label: '待认领', get: () => stats?.pending ?? 0 },
|
||
{ key: 'assigned', label: '执行中', color: colors.primary, get: () => stats?.assigned ?? 0 },
|
||
{ key: 'completed', label: '已完成', color: statusColors.completed, get: () => stats?.completed ?? 0 },
|
||
{ key: 'failed', label: '失败', color: statusColors.failed, get: () => stats?.failed ?? 0 },
|
||
]
|
||
|
||
const columns: ColumnsType<TaskQueueItem> = [
|
||
{
|
||
title: '入队时间', dataIndex: 'created_at', key: 'created_at', width: 170,
|
||
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
|
||
},
|
||
{
|
||
title: '评估', key: 'eval', width: 300, ellipsis: true,
|
||
render: (_, t) => (
|
||
<Space size={6}>
|
||
<span style={{ fontWeight: 500 }}>{t.eval_name ?? t.eval_id.slice(0, 8)}</span>
|
||
{t.eval_status && EVAL_STATUS[t.eval_status as keyof typeof EVAL_STATUS] && (
|
||
<Tag color={EVAL_STATUS[t.eval_status as keyof typeof EVAL_STATUS].color} style={{ margin: 0, fontSize: 11 }}>
|
||
{EVAL_STATUS[t.eval_status as keyof typeof EVAL_STATUS].label}
|
||
</Tag>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '队列状态', dataIndex: 'status', key: 'status', width: 100,
|
||
render: (s: TaskQueueStatus) => {
|
||
const meta = STATUS_META[s]
|
||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||
},
|
||
},
|
||
{
|
||
title: '优先级', dataIndex: 'priority', key: 'priority', width: 80,
|
||
render: (p: number) => <Tag>{p}</Tag>,
|
||
},
|
||
{
|
||
title: '原因', dataIndex: 'reason', key: 'reason', width: 240, ellipsis: true,
|
||
render: (r: string) => <Tooltip title={r}><span>{r}</span></Tooltip>,
|
||
},
|
||
{
|
||
title: '完成时间', dataIndex: 'completed_at', key: 'completed_at', width: 170,
|
||
render: (v: string | null, t) => {
|
||
if (t.status === 'failed' && t.error) {
|
||
return <Tooltip title={t.error}><span style={{ color: statusColors.failed }}>失败</span></Tooltip>
|
||
}
|
||
return v ? formatDateTime(v) : <span style={{ color: colors.textMuted }}>—</span>
|
||
},
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
<div className="stat-bar">
|
||
{chipMeta.map((c) => (
|
||
<div
|
||
key={c.key}
|
||
className={`stat-chip ${filter === c.key ? 'active' : ''}`}
|
||
onClick={() => setFilter(c.key)}
|
||
>
|
||
<div className="n" style={{ color: c.color ?? colors.text }}>{c.get()}</div>
|
||
<div className="l">{c.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ background: colors.bgContainer, borderRadius: 8, padding: 16, flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} />
|
||
</div>
|
||
<div style={{ height: 'calc(100% - 44px)', overflowY: 'auto' }}>
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
dataSource={visibleTasks}
|
||
columns={columns}
|
||
pagination={{
|
||
current: page,
|
||
pageSize: 10,
|
||
total: visibleTasks.length,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [10, 20, 50],
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
onChange: (p) => setPage(p),
|
||
}}
|
||
locale={{ emptyText: <Empty description="暂无任务" /> }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|