feat(intelligent-eval): v4.2 UI — stat bar + single-line table + unified detail drawer
All checks were successful
CI / test (push) Successful in 3m58s
All checks were successful
CI / test (push) Successful in 3m58s
评估列表页(v4.2): - 顶部状态统计条(stat-chip 点击筛选,后端列表接口新增 stats 状态分布 + status 筛选参数) - 单行表格(名称/评测对象 ellipsis 省略)+ 会话进度迷你进度条 + 最后一列醒目查看按钮 + 整行可点击 - 详情/报告合一单层抽屉(1000px,Tabs:概览/决策过程/配置历史/报告), 审批/取消在抽屉头部;移除独立详情页 /intelligent-evals/detail 与 intelligentEvalNav store 任务队列页: - 与列表页统一 stat-chip 统计条 + 单行表格(评估状态小标、失败原因 tooltip) 清理:删除 IntelligentEvalDetail.tsx / intelligentEvalNav.ts;App.tsx 移除 detail 路由。任务队列保持独立菜单页。tsc 0 错误 vitest 19 passed 895 passed
This commit is contained in:
parent
a69fa8a797
commit
00ad929d68
@ -10,7 +10,7 @@
|
||||
非法转换抛 IntelligentEvalTransitionError,路由层映射为 409。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
@ -218,10 +218,17 @@ def list_evals(session: Session) -> list[IntelligentEval]:
|
||||
return IntelligentEvalRepository(session).list_all()
|
||||
|
||||
|
||||
def list_evals_page(session: Session, offset: int, limit: int) -> tuple[list[IntelligentEval], int]:
|
||||
"""Return one page of evals (newest first) plus the total count."""
|
||||
def list_evals_page(
|
||||
session: Session, offset: int, limit: int, status: Optional[str] = None
|
||||
) -> tuple[list[IntelligentEval], int, dict[str, int]]:
|
||||
"""Return one page of evals (newest first), total count, and per-status counts."""
|
||||
repo = IntelligentEvalRepository(session)
|
||||
return repo.list_page(offset, limit), repo.count()
|
||||
return repo.list_page(offset, limit, status), repo.count(), repo.count_by_status()
|
||||
|
||||
|
||||
def eval_status_counts(session: Session) -> dict[str, int]:
|
||||
"""Count evaluations per status (for the list page stat bar)."""
|
||||
return IntelligentEvalRepository(session).count_by_status()
|
||||
|
||||
|
||||
def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession:
|
||||
|
||||
@ -101,20 +101,32 @@ class IntelligentEvalRepository:
|
||||
statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc())
|
||||
return [self._from_db(r) for r in self.session.exec(statement).all()]
|
||||
|
||||
def list_page(self, offset: int, limit: int) -> list[IntelligentEval]:
|
||||
"""Return one page of evals (created_at desc) with the same ordering as ``list_all``."""
|
||||
statement = (
|
||||
select(IntelligentEvalDB)
|
||||
.order_by(IntelligentEvalDB.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
def list_page(
|
||||
self, offset: int, limit: int, status: Optional[str] = None
|
||||
) -> list[IntelligentEval]:
|
||||
"""Return one page of evals (created_at desc), optionally filtered by status."""
|
||||
statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc())
|
||||
if status:
|
||||
statement = statement.where(IntelligentEvalDB.status == status)
|
||||
statement = statement.offset(offset).limit(limit)
|
||||
return [self._from_db(r) for r in self.session.exec(statement).all()]
|
||||
|
||||
def count(self) -> int:
|
||||
"""Total number of evaluations (for pagination metadata)."""
|
||||
return self.session.exec(select(func.count()).select_from(IntelligentEvalDB)).one()
|
||||
|
||||
def count_by_status(self) -> dict[str, int]:
|
||||
"""Count evaluations per status (for the list page stat bar)."""
|
||||
rows = self.session.exec(
|
||||
select(IntelligentEvalDB.status, func.count(IntelligentEvalDB.id)).group_by(
|
||||
IntelligentEvalDB.status
|
||||
)
|
||||
).all()
|
||||
stats = {status.value: 0 for status in IntelligentEvalStatus}
|
||||
for status, cnt in rows:
|
||||
stats[status] = cnt
|
||||
return stats
|
||||
|
||||
def get(self, eval_id: str) -> Optional[IntelligentEval]:
|
||||
db = self.session.get(IntelligentEvalDB, eval_id)
|
||||
return self._from_db(db) if db else None
|
||||
|
||||
@ -97,12 +97,14 @@ async def create_eval(request: CreateEvalRequest, session: Session = Depends(get
|
||||
async def list_evals(
|
||||
page: int | None = None,
|
||||
page_size: int = 20,
|
||||
status: str | None = None,
|
||||
session: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""List intelligent evaluations, optionally paginated.
|
||||
"""List intelligent evaluations, optionally paginated and filtered by status.
|
||||
|
||||
不传 ``page`` 时返回全部(向后兼容);传 ``page``(从 1 起)时按
|
||||
``created_at`` 倒序分页,返回 ``total`` 供前端服务端分页。
|
||||
``created_at`` 倒序分页,返回 ``total`` + ``stats``(各状态计数)供前端
|
||||
服务端分页与状态统计条。
|
||||
"""
|
||||
reader = IntelligentEvalReadModel(session)
|
||||
if page is None:
|
||||
@ -110,10 +112,11 @@ async def list_evals(
|
||||
return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]}
|
||||
page_size = max(1, min(page_size, 100))
|
||||
offset = (max(1, page) - 1) * page_size
|
||||
evals, total = lifecycle.list_evals_page(session, offset, page_size)
|
||||
evals, total, stats = lifecycle.list_evals_page(session, offset, page_size, status)
|
||||
return {
|
||||
"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)],
|
||||
"total": total,
|
||||
"stats": stats,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
@ -40,7 +40,6 @@ const FilesPage = lazy(() => import('./pages/Files'))
|
||||
const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs'))
|
||||
const IntelligentEvalsPage = lazy(() => import('./pages/IntelligentEvals'))
|
||||
const IntelligentEvalTasksPage = lazy(() => import('./pages/IntelligentEvalTasks'))
|
||||
const IntelligentEvalDetailPage = lazy(() => import('./pages/IntelligentEvalDetail'))
|
||||
|
||||
function PageLoader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@ -71,7 +70,6 @@ const routeConfigs: RouteConfig[] = [
|
||||
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
|
||||
{ path: '/intelligent-evals', name: '智能评估', icon: <BulbOutlined />, component: () => <PageLoader><IntelligentEvalsPage /></PageLoader> },
|
||||
{ path: '/intelligent-evals/tasks', name: '任务队列', icon: <UnorderedListOutlined />, component: () => <PageLoader><IntelligentEvalTasksPage /></PageLoader> },
|
||||
{ path: '/intelligent-evals/detail', name: '评估详情', icon: <FileTextOutlined />, component: () => <PageLoader><IntelligentEvalDetailPage /></PageLoader> },
|
||||
{ path: '/models', name: '模型配置', icon: <CloudServerOutlined />, component: () => <PageLoader><ModelConfigsPage /></PageLoader> },
|
||||
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
|
||||
]
|
||||
|
||||
@ -785,8 +785,14 @@ export interface TaskQueueList {
|
||||
}
|
||||
|
||||
export const intelligentEvalsApi = {
|
||||
list: (params?: { page?: number; page_size?: number }) =>
|
||||
api.get<{ intelligent_evals: IntelligentEval[]; total?: number; page?: number; page_size?: number }>(
|
||||
list: (params?: { page?: number; page_size?: number; status?: string }) =>
|
||||
api.get<{
|
||||
intelligent_evals: IntelligentEval[]
|
||||
total?: number
|
||||
stats?: Record<string, number>
|
||||
page?: number
|
||||
page_size?: number
|
||||
}>(
|
||||
'/intelligent-evals',
|
||||
{ params },
|
||||
),
|
||||
|
||||
@ -64,7 +64,7 @@ interface EvalOverviewProps {
|
||||
|
||||
/**
|
||||
* 评估详情"概览"子视图(纯展示):基本信息、用户输入、粗计划、会话进度。
|
||||
* 审批/取消等动作由详情页(IntelligentEvalDetail)统一管理。
|
||||
* 审批/取消等动作由评估列表页的详情抽屉头部统一管理。
|
||||
*/
|
||||
export default function EvalOverview({ ev, targetName }: EvalOverviewProps) {
|
||||
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }
|
||||
|
||||
@ -1,44 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Empty, Select, Space, Statistic, Table, Tag, Tooltip, message,
|
||||
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 { colors } from '../../tokens'
|
||||
import { formatDateTime } from '../../utils/date'
|
||||
|
||||
const STATUS_META: Record<TaskQueueStatus, { label: string; color: string }> = {
|
||||
pending: { label: '待处理', color: 'orange' },
|
||||
pending: { label: '待认领', color: 'orange' },
|
||||
assigned: { label: '执行中', color: 'blue' },
|
||||
completed: { label: '已完成', color: 'green' },
|
||||
failed: { label: '失败', color: 'red' },
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(STATUS_META) as TaskQueueStatus[]).map((s) => ({
|
||||
value: s,
|
||||
label: STATUS_META[s].label,
|
||||
}))
|
||||
const EVAL_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
executing: { label: '执行中', color: 'blue' },
|
||||
completed: { label: '已完成', color: 'green' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
failed: { label: '失败', color: 'red' },
|
||||
}
|
||||
|
||||
type FilterKey = 'all' | 'unresolved' | TaskQueueStatus
|
||||
|
||||
/**
|
||||
* 任务队列监控(方案③可视化)。
|
||||
* 任务队列监控(方案③可视化,与评估列表页统一 stat-chip + 单行表格)。
|
||||
*
|
||||
* 方案③的"定时触发"(scan loop 每 60s 扫描入队 + 触发 OpenClaw worker)
|
||||
* 此前只有 Worker 消费端 API,无可查看的列表。这里展示任务队列明细与
|
||||
* 状态分布,5s 轮询,让平台侧的定时触发对用户可见。
|
||||
* 平台每 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 [statusFilter, setStatusFilter] = useState<TaskQueueStatus | 'all'>('all')
|
||||
const [stats, setStats] = useState<{
|
||||
pending: number; assigned: number; completed: number; failed: number; unresolved: number
|
||||
} | null>(null)
|
||||
const [filter, setFilter] = useState<FilterKey>('all')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await intelligentEvalsApi.listTasks(
|
||||
statusFilter === 'all' ? undefined : { status: statusFilter },
|
||||
filter === 'all' || filter === 'unresolved' ? undefined : { status: filter },
|
||||
)
|
||||
setTasks(res.data.tasks)
|
||||
setStats(res.data.stats)
|
||||
@ -49,111 +54,97 @@ export default function TaskQueueMonitor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch (usePolling owns the 5s interval + visibility pause).
|
||||
// Initial fetch + reload on filter change (usePolling owns the 5s interval).
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter])
|
||||
}, [filter])
|
||||
|
||||
usePolling(() => { void loadData() }, 5000, true)
|
||||
|
||||
const chipMeta: { key: FilterKey; label: string; color?: string; get: () => number }[] = [
|
||||
{ key: 'all', label: '全部任务', get: () => (stats ? Object.values(stats).reduce((a, b) => a + b, 0) : (tasks?.length ?? 0)) },
|
||||
{ key: 'unresolved', label: '待处理', color: colors.warning, get: () => stats?.unresolved ?? 0 },
|
||||
{ key: 'pending', label: '待认领', get: () => stats?.pending ?? 0 },
|
||||
{ key: 'assigned', label: '执行中', color: colors.primary, get: () => stats?.assigned ?? 0 },
|
||||
{ key: 'completed', label: '已完成', color: '#52c41a', get: () => stats?.completed ?? 0 },
|
||||
{ key: 'failed', label: '失败', color: '#ff4d4f', get: () => stats?.failed ?? 0 },
|
||||
]
|
||||
|
||||
const columns: ColumnsType<TaskQueueItem> = [
|
||||
{
|
||||
title: '入队时间', dataIndex: 'created_at', key: 'created_at', width: 170,
|
||||
title: '入队时间', dataIndex: 'created_at', key: 'created_at', width: 165,
|
||||
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
|
||||
},
|
||||
{
|
||||
title: '评估', dataIndex: 'eval_name', key: 'eval_name',
|
||||
render: (name: string | null, t) => (
|
||||
<Space size={6} direction="vertical" style={{ gap: 2 }}>
|
||||
<span style={{ fontWeight: 500 }}>{name ?? t.eval_id.slice(0, 8)}</span>
|
||||
{t.eval_status && (
|
||||
<span style={{ fontSize: 12, color: colors.textSecondary }}>{t.eval_status}</span>
|
||||
title: '评估', key: 'eval', width: 230, 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_META[t.eval_status] && (
|
||||
<Tag color={EVAL_STATUS_META[t.eval_status].color} style={{ margin: 0, fontSize: 11 }}>
|
||||
{EVAL_STATUS_META[t.eval_status].label}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
title: '队列状态', dataIndex: 'status', key: 'status', width: 96,
|
||||
render: (s: TaskQueueStatus) => {
|
||||
const meta = STATUS_META[s]
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '优先级', dataIndex: 'priority', key: 'priority', width: 80,
|
||||
title: '优先级', dataIndex: 'priority', key: 'priority', width: 76,
|
||||
render: (p: number) => <Tag>{p}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '原因', dataIndex: 'reason', key: 'reason',
|
||||
render: (r: string) => (
|
||||
<Tooltip title={r}>
|
||||
<span style={{ display: 'inline-block', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
title: '原因', dataIndex: 'reason', key: 'reason', ellipsis: true,
|
||||
render: (r: string) => <Tooltip title={r}><span>{r}</span></Tooltip>,
|
||||
},
|
||||
{
|
||||
title: '认领方', dataIndex: 'assigned_cron_id', key: 'assigned_cron_id', width: 140,
|
||||
render: (v: string | null, t) => {
|
||||
if (!v) return <span style={{ color: colors.textSecondary }}>—</span>
|
||||
return (
|
||||
<Tooltip title={t.assigned_at ? `认领于 ${formatDateTime(t.assigned_at)}` : undefined}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '完成时间', dataIndex: 'completed_at', key: 'completed_at', width: 170,
|
||||
title: '完成时间', dataIndex: 'completed_at', key: 'completed_at', width: 165,
|
||||
render: (v: string | null, t) => {
|
||||
if (t.status === 'failed' && t.error) {
|
||||
return <Tooltip title={t.error}><span style={{ color: statusColors.failed }}>失败</span></Tooltip>
|
||||
return <Tooltip title={t.error}><span style={{ color: '#ff4d4f' }}>失败</span></Tooltip>
|
||||
}
|
||||
return v ? formatDateTime(v) : '—'
|
||||
return v ? formatDateTime(v) : <span style={{ color: colors.textMuted }}>—</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space size={16} wrap>
|
||||
<Statistic title="待处理" value={stats?.unresolved ?? 0} valueStyle={{ color: colors.warning }} />
|
||||
<Statistic title="待认领" value={stats?.pending ?? 0} />
|
||||
<Statistic title="执行中" value={stats?.assigned ?? 0} valueStyle={{ color: colors.primary }} />
|
||||
<Statistic title="已完成" value={stats?.completed ?? 0} valueStyle={{ color: statusColors.completed }} />
|
||||
<Statistic title="失败" value={stats?.failed ?? 0} valueStyle={{ color: statusColors.failed }} />
|
||||
</Space>
|
||||
<Space>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v)}
|
||||
style={{ width: 110 }}
|
||||
options={[{ value: 'all', label: '全部状态' }, ...STATUS_OPTIONS]}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} />
|
||||
</Space>
|
||||
<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>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="平台每 60 秒扫描 executing 评估并入队,有任务时通过 docker exec 触发 OpenClaw worker 执行(取代外部 Channel 的方案③)。"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={tasks ?? []}
|
||||
columns={columns}
|
||||
pagination={(tasks?.length ?? 0) > 50 ? { pageSize: 50, showTotal: (t) => `共 ${t} 条` } : false}
|
||||
locale={{ emptyText: <Empty description="暂无任务" /> }}
|
||||
/>
|
||||
<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"
|
||||
size="middle"
|
||||
loading={loading}
|
||||
dataSource={tasks ?? []}
|
||||
columns={columns}
|
||||
pagination={(tasks?.length ?? 0) > 10 ? { pageSize: 10, showTotal: (t) => `共 ${t} 条` } : false}
|
||||
locale={{ emptyText: <Empty description="暂无任务" /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -64,6 +64,39 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 状态统计条(评估列表 / 任务队列页共用):一排可点击的状态计数卡 */
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-chip {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
min-width: 110px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.stat-chip:hover {
|
||||
border-color: #1677ff;
|
||||
}
|
||||
.stat-chip.active {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 1px #1677ff inset;
|
||||
}
|
||||
.stat-chip .n {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.stat-chip .l {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/*
|
||||
* intelligent-detail-tabs:评估详情页的 Tabs 需承载内容面板(概览/决策过程/
|
||||
* 配置历史/报告)。建立高度链:Tabs 撑满父容器,content-holder 占剩余空间,
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Empty, Input, Modal, Popconfirm, Space, Spin, Tabs, Tag, message,
|
||||
} from 'antd'
|
||||
import type { TabsProps } from 'antd'
|
||||
import { StopOutlined } from '@ant-design/icons'
|
||||
import { intelligentEvalsApi, targetsApi, type IntelligentEval } from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import EvalOverview from '../components/intelligent_eval/EvalOverview'
|
||||
import DecisionProcess from '../components/intelligent_eval/DecisionProcess'
|
||||
import ConfigSnapshots from '../components/intelligent_eval/ConfigSnapshots'
|
||||
import EvalReport from '../components/intelligent_eval/EvalReport'
|
||||
import { EVAL_STATUS } from '../components/intelligent_eval/status'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { useIntelligentEvalNav, type EvalDetailTab } from '../stores/intelligentEvalNav'
|
||||
import { colors } from '../tokens'
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing'])
|
||||
|
||||
function isActive(status: string | undefined): boolean {
|
||||
return status != null && ACTIVE_STATUSES.has(status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估详情独立页(keep-alive 多页模式下的子 tab 页)。
|
||||
*
|
||||
* 由评估列表页点击"详情/报告"进入:选中评估 id 通过 intelligentEvalNav store
|
||||
* 传递,本页读取并加载,用页内 Tabs 承载概览/决策过程/配置历史/报告子视图,
|
||||
* 取代原列表页内的 Drawer 嵌套。
|
||||
*/
|
||||
export default function IntelligentEvalDetailPage() {
|
||||
const evalId = useIntelligentEvalNav((s) => s.selectedEvalId)
|
||||
const initialTab = useIntelligentEvalNav((s) => s.detailTab)
|
||||
const [detail, setDetail] = useState<IntelligentEval | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [rejectOpen, setRejectOpen] = useState(false)
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<EvalDetailTab>(initialTab)
|
||||
const { data: targets } = useResource(
|
||||
() => targetsApi.list().then((r) => r.data),
|
||||
{ tabPath: '/intelligent-evals/detail' },
|
||||
)
|
||||
|
||||
const load = async () => {
|
||||
if (!evalId) return
|
||||
try {
|
||||
const res = await intelligentEvalsApi.get(evalId)
|
||||
setDetail(res.data)
|
||||
} catch {
|
||||
message.error('加载评估详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (evalId) {
|
||||
setActiveTab(initialTab)
|
||||
void load()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [evalId, initialTab])
|
||||
|
||||
usePolling(() => { void load() }, 5000, evalId != null && isActive(detail?.status))
|
||||
|
||||
const runAction = async (fn: () => Promise<unknown>, okMsg: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await fn()
|
||||
message.success(okMsg)
|
||||
await load()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const approve = () => runAction(() => intelligentEvalsApi.approve(evalId!), '已批准,进入执行')
|
||||
const cancel = () => runAction(() => intelligentEvalsApi.cancel(evalId!), '已取消')
|
||||
const submitReject = () => runAction(async () => {
|
||||
await intelligentEvalsApi.reject(evalId!, feedback)
|
||||
setRejectOpen(false)
|
||||
setFeedback('')
|
||||
}, '已打回,等待重新规划')
|
||||
|
||||
if (!evalId) {
|
||||
return (
|
||||
<PageWrapper title="评估详情" inline fullHeight>
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Empty description="从「智能评估 → 评估列表」点击某条评估进入详情" />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageWrapper title="评估详情" inline fullHeight>
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = EVAL_STATUS[detail.status] ?? { label: detail.status, color: 'default' }
|
||||
const targetName = (id: string) =>
|
||||
targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8)
|
||||
const canApprove = detail.status === 'pending_approval'
|
||||
const canCancel = detail.status === 'pending_approval' || detail.status === 'executing'
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<EvalOverview ev={detail} targetName={targetName(detail.target_id)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'decision',
|
||||
label: '决策过程',
|
||||
children: <DecisionProcess evalId={evalId} />,
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: '配置历史',
|
||||
children: <ConfigSnapshots evalId={evalId} />,
|
||||
},
|
||||
]
|
||||
if (detail.status === 'completed') {
|
||||
items.push({
|
||||
key: 'report',
|
||||
label: '评估报告',
|
||||
children: <EvalReport ev={detail} />,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper title="评估详情" inline fullHeight>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||||
padding: '8px 16px', background: colors.bgContainer, borderBottom: `1px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: colors.text }}>{detail.name}</span>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Space>
|
||||
{canApprove && (
|
||||
<>
|
||||
<Button danger loading={busy} onClick={() => setRejectOpen(true)}>打回</Button>
|
||||
<Button type="primary" loading={busy} onClick={approve}>批准并执行</Button>
|
||||
</>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Popconfirm title="取消该智能评估?" onConfirm={cancel}>
|
||||
<Button danger icon={<StopOutlined />} loading={busy}>取消</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, padding: '0 16px', background: colors.bgLayout }}>
|
||||
<Tabs
|
||||
className="intelligent-detail-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => setActiveTab(k as EvalDetailTab)}
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="打回计划"
|
||||
open={rejectOpen}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={submitReject}
|
||||
okText="打回"
|
||||
okButtonProps={{ danger: true, disabled: !feedback.trim(), loading: busy }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="请填写打回原因与改进方向,OpenClaw 会据此重新规划"
|
||||
/>
|
||||
</Modal>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
@ -1,21 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button, Empty, Form, Input, InputNumber, Select, Space, Table, Tag, Tooltip, message,
|
||||
Button, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Progress, Select, Space, Spin, Table, Tabs, Tag, message,
|
||||
} from 'antd'
|
||||
import type { TabsProps } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
PlusOutlined, ReloadOutlined, EyeOutlined, FileTextOutlined, UnorderedListOutlined,
|
||||
PlusOutlined, ReloadOutlined, StopOutlined, UnorderedListOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import FormDrawer from '../components/FormDrawer'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import EvalOverview from '../components/intelligent_eval/EvalOverview'
|
||||
import DecisionProcess from '../components/intelligent_eval/DecisionProcess'
|
||||
import ConfigSnapshots from '../components/intelligent_eval/ConfigSnapshots'
|
||||
import EvalReport from '../components/intelligent_eval/EvalReport'
|
||||
import { EVAL_STATUS } from '../components/intelligent_eval/status'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import {
|
||||
intelligentEvalsApi, targetsApi,
|
||||
type CreateIntelligentEvalPayload, type IntelligentEval, type Target,
|
||||
} from '../api'
|
||||
import { useIntelligentEvalNav, type EvalDetailTab } from '../stores/intelligentEvalNav'
|
||||
import { useTabStore, type TabItem } from '../stores/tabStore'
|
||||
import { colors } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
@ -31,47 +35,74 @@ interface CreateFormValues {
|
||||
time_window_hours: number
|
||||
}
|
||||
|
||||
const DETAIL_TAB: TabItem = {
|
||||
key: '/intelligent-evals/detail', title: '评估详情', icon: <EyeOutlined />, closable: true,
|
||||
}
|
||||
type DetailTab = 'overview' | 'decision' | 'history' | 'report'
|
||||
|
||||
const TASKS_TAB: TabItem = {
|
||||
key: '/intelligent-evals/tasks', title: '任务队列', icon: <UnorderedListOutlined />, closable: true,
|
||||
}
|
||||
|
||||
// 状态统计条:全部 + 5 个主要状态(cancelled 不单列)
|
||||
const STAT_CHIPS: { key: string; label: string; color?: string }[] = [
|
||||
{ key: 'all', label: '全部评估' },
|
||||
{ key: 'planning', label: '规划中' },
|
||||
{ key: 'pending_approval', label: '待审批' },
|
||||
{ key: 'executing', label: '执行中' },
|
||||
{ key: 'completed', label: '已完成', color: '#52c41a' },
|
||||
{ key: 'failed', label: '失败', color: '#ff4d4f' },
|
||||
]
|
||||
|
||||
export default function IntelligentEvalsPage() {
|
||||
const navigate = useNavigate()
|
||||
const openTab = useTabStore((s) => s.openTab)
|
||||
const openDetailNav = useIntelligentEvalNav((s) => s.openDetail)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [form] = Form.useForm<CreateFormValues>()
|
||||
|
||||
const { list, reloadList } = useIntelligentEvalRead(null, undefined, page, pageSize)
|
||||
const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(
|
||||
selectedId,
|
||||
undefined,
|
||||
page,
|
||||
pageSize,
|
||||
statusFilter === 'all' ? undefined : statusFilter,
|
||||
)
|
||||
const evals = list.value
|
||||
const loading = list.phase === 'loading'
|
||||
const stats = list.stats
|
||||
const { data: targets } = useResource(
|
||||
() => targetsApi.list().then((r) => r.data),
|
||||
{ tabPath: '/intelligent-evals' },
|
||||
)
|
||||
|
||||
const selected = selectedId != null && detail.value?.id === selectedId ? detail.value : null
|
||||
|
||||
const targetName = (id: string) =>
|
||||
targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8)
|
||||
|
||||
const openDetail = (id: string, tab: EvalDetailTab = 'overview') => {
|
||||
openDetailNav(id, tab)
|
||||
openTab(DETAIL_TAB)
|
||||
navigate('/intelligent-evals/detail')
|
||||
const openDetail = (id: string, tab: DetailTab = 'overview') => {
|
||||
setSelectedId(id)
|
||||
setDetailTab(tab)
|
||||
}
|
||||
|
||||
const openReport = (id: string) => openDetail(id, 'report')
|
||||
const closeDrawer = () => {
|
||||
setSelectedId(null)
|
||||
void reloadList()
|
||||
}
|
||||
|
||||
const openTasks = () => {
|
||||
openTab(TASKS_TAB)
|
||||
navigate('/intelligent-evals/tasks')
|
||||
}
|
||||
|
||||
const setFilter = (key: string) => {
|
||||
setStatusFilter(key)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
let seeds: Record<string, unknown> = {}
|
||||
@ -112,45 +143,61 @@ export default function IntelligentEvalsPage() {
|
||||
|
||||
const columns: ColumnsType<IntelligentEval> = [
|
||||
{
|
||||
title: '名称', dataIndex: 'name', key: 'name',
|
||||
title: '名称', dataIndex: 'name', key: 'name', width: 300, ellipsis: true,
|
||||
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
|
||||
},
|
||||
{
|
||||
title: '评测对象', dataIndex: 'target_id', key: 'target', width: 180,
|
||||
render: (id: string) => targetName(id),
|
||||
title: '评测对象', dataIndex: 'target_id', key: 'target', width: 180, ellipsis: true,
|
||||
render: (id: string) => <span style={{ fontSize: 13, color: colors.textSecondary }}>{targetName(id)}</span>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 110,
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 96,
|
||||
render: (status: IntelligentEval['status']) => {
|
||||
const meta = EVAL_STATUS[status] ?? { label: status, color: 'default' }
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '会话进度', key: 'progress', width: 120,
|
||||
render: (_, ev) => `${ev.completed_sessions}/${ev.session_count}`,
|
||||
title: '会话进度', key: 'progress', width: 170,
|
||||
render: (_, ev) => {
|
||||
const pct = ev.session_count ? Math.round((ev.completed_sessions / ev.session_count) * 100) : 0
|
||||
return (
|
||||
<Space size={6}>
|
||||
<Progress percent={pct} size="small" style={{ width: 90 }} />
|
||||
<span style={{ fontSize: 12, color: colors.textSecondary }}>{ev.completed_sessions}/{ev.session_count}</span>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170,
|
||||
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160,
|
||||
render: (v: string | null) => (v ? formatDateTime(v) : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 110, fixed: 'right' as const,
|
||||
title: '操作', key: 'action', width: 88, fixed: 'right' as const,
|
||||
render: (_, ev) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情">
|
||||
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => openDetail(ev.id)} />
|
||||
</Tooltip>
|
||||
{ev.status === 'completed' && (
|
||||
<Tooltip title="查看报告">
|
||||
<Button size="small" type="text" icon={<FileTextOutlined />} onClick={() => openReport(ev.id)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
<Button size="small" onClick={() => openDetail(ev.id)}>查看</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const drawerItems: TabsProps['items'] = selected
|
||||
? [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<EvalOverview ev={selected} targetName={targetName(selected.target_id)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'decision', label: '决策过程', children: <DecisionProcess evalId={selected.id} /> },
|
||||
{ key: 'history', label: '配置历史', children: <ConfigSnapshots evalId={selected.id} /> },
|
||||
...(selected.status === 'completed' ? [{ key: 'report' as const, label: '评估报告', children: <EvalReport ev={selected} /> }] : []),
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
title="智能评估"
|
||||
@ -170,27 +217,86 @@ export default function IntelligentEvalsPage() {
|
||||
}
|
||||
>
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={evals ?? []}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: list.total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (t) => `共 ${t} 个`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="还没有智能评估" /> }}
|
||||
/>
|
||||
<div className="stat-bar">
|
||||
{STAT_CHIPS.map((c) => {
|
||||
const n = c.key === 'all'
|
||||
? (stats ? Object.values(stats).reduce((a, b) => a + b, 0) : list.total)
|
||||
: (stats?.[c.key] ?? 0)
|
||||
return (
|
||||
<div key={c.key} className={`stat-chip ${statusFilter === c.key ? 'active' : ''}`} onClick={() => setFilter(c.key)}>
|
||||
<div className="n" style={{ color: c.color ?? colors.text }}>{n}</div>
|
||||
<div className="l">{c.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ background: colors.bgContainer, borderRadius: 8, padding: 16 }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={evals ?? []}
|
||||
columns={columns}
|
||||
onRow={(record) => ({ onClick: () => openDetail(record.id), style: { cursor: 'pointer' } })}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: list.total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (t) => `共 ${t} 个`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="该状态下暂无评估" /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={selected?.name ?? '评估详情'}
|
||||
open={selectedId != null}
|
||||
onClose={closeDrawer}
|
||||
width={1000}
|
||||
destroyOnClose
|
||||
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
|
||||
extra={selected && (
|
||||
<Space>
|
||||
<Tag color={EVAL_STATUS[selected.status]?.color ?? 'default'}>
|
||||
{EVAL_STATUS[selected.status]?.label ?? selected.status}
|
||||
</Tag>
|
||||
{selected.status === 'pending_approval' && (
|
||||
<Button type="primary" onClick={() => void intelligentEvalsApi.approve(selected.id).then(() => { message.success('已批准,进入执行'); void reloadDetail() })}>
|
||||
批准并执行
|
||||
</Button>
|
||||
)}
|
||||
{(selected.status === 'pending_approval' || selected.status === 'executing') && (
|
||||
<Popconfirm
|
||||
title="取消该智能评估?"
|
||||
onConfirm={() => void intelligentEvalsApi.cancel(selected.id).then(() => { message.success('已取消'); void reloadDetail() })}
|
||||
>
|
||||
<Button danger icon={<StopOutlined />}>取消</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{selected == null ? (
|
||||
<div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, padding: '0 16px', background: colors.bgLayout }}>
|
||||
<Tabs
|
||||
className="intelligent-detail-tabs"
|
||||
activeKey={detailTab}
|
||||
onChange={(k) => setDetailTab(k as DetailTab)}
|
||||
items={drawerItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<FormDrawer
|
||||
title="新建智能评估"
|
||||
open={createOpen}
|
||||
|
||||
@ -16,21 +16,22 @@ describe('intelligent evaluation read state', () => {
|
||||
type: 'list_succeeded',
|
||||
value: [evaluation],
|
||||
total: 42,
|
||||
stats: { planning: 1 },
|
||||
})
|
||||
expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 42 })
|
||||
expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 42, stats: { planning: 1 } })
|
||||
})
|
||||
|
||||
it('keeps the last list snapshot during silent refresh and failure', () => {
|
||||
const ready: IntelligentEvalReadState = {
|
||||
...initialIntelligentEvalReadState,
|
||||
list: { phase: 'ready', value: [evaluation], error: null, total: 1 },
|
||||
list: { phase: 'ready', value: [evaluation], error: null, total: 1, stats: null },
|
||||
}
|
||||
const refreshing = intelligentEvalReadReducer(ready, { type: 'list_requested', silent: true })
|
||||
expect(refreshing.list.phase).toBe('refreshing')
|
||||
expect(refreshing.list.total).toBe(1)
|
||||
|
||||
const afterFailure = intelligentEvalReadReducer(refreshing, { type: 'list_failed', error: '网络错误' })
|
||||
expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 1 })
|
||||
expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 1, stats: null })
|
||||
})
|
||||
|
||||
it('surfaces an initial detail failure without an existing snapshot', () => {
|
||||
|
||||
@ -9,7 +9,7 @@ export interface ReadSlot<T> {
|
||||
}
|
||||
|
||||
export interface IntelligentEvalReadState {
|
||||
list: ReadSlot<IntelligentEval[]> & { total: number }
|
||||
list: ReadSlot<IntelligentEval[]> & { total: number; stats: Record<string, number> | null }
|
||||
detail: ReadSlot<IntelligentEval | null> & {
|
||||
selectedId: string | null
|
||||
requestId: number
|
||||
@ -18,7 +18,7 @@ export interface IntelligentEvalReadState {
|
||||
|
||||
export type IntelligentEvalReadAction =
|
||||
| { type: 'list_requested'; silent?: boolean }
|
||||
| { type: 'list_succeeded'; value: IntelligentEval[]; total: number }
|
||||
| { type: 'list_succeeded'; value: IntelligentEval[]; total: number; stats: Record<string, number> | null }
|
||||
| { type: 'list_failed'; error: string }
|
||||
| { type: 'detail_cleared'; requestId: number }
|
||||
| { type: 'detail_requested'; id: string; requestId: number; silent?: boolean }
|
||||
@ -26,24 +26,33 @@ export type IntelligentEvalReadAction =
|
||||
| { type: 'detail_failed'; id: string; requestId: number; error: string }
|
||||
|
||||
export interface IntelligentEvalReadAdapter {
|
||||
list: (page?: number, pageSize?: number) => Promise<{ items: IntelligentEval[]; total: number }>
|
||||
list: (page?: number, pageSize?: number, status?: string) => Promise<{
|
||||
items: IntelligentEval[]
|
||||
total: number
|
||||
stats: Record<string, number> | null
|
||||
}>
|
||||
get: (id: string) => Promise<IntelligentEval>
|
||||
}
|
||||
|
||||
export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = {
|
||||
list: (page, pageSize) =>
|
||||
list: (page, pageSize, status) =>
|
||||
intelligentEvalsApi
|
||||
.list(page != null ? { page, page_size: pageSize ?? 20 } : undefined)
|
||||
.list(
|
||||
page != null
|
||||
? { page, page_size: pageSize ?? 20, ...(status ? { status } : {}) }
|
||||
: undefined,
|
||||
)
|
||||
.then((response) => ({
|
||||
items: response.data.intelligent_evals,
|
||||
// 兼容不分页响应:无 total 时以本页长度为总数
|
||||
total: response.data.total ?? response.data.intelligent_evals.length,
|
||||
stats: response.data.stats ?? null,
|
||||
})),
|
||||
get: (id) => intelligentEvalsApi.get(id).then((response) => response.data),
|
||||
}
|
||||
|
||||
export const initialIntelligentEvalReadState: IntelligentEvalReadState = {
|
||||
list: { phase: 'idle', value: [], error: null, total: 0 },
|
||||
list: { phase: 'idle', value: [], error: null, total: 0, stats: null },
|
||||
detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 },
|
||||
}
|
||||
|
||||
@ -59,7 +68,10 @@ export function intelligentEvalReadReducer(
|
||||
case 'list_requested':
|
||||
return { ...state, list: requestPhase(state.list, action.silent) }
|
||||
case 'list_succeeded':
|
||||
return { ...state, list: { phase: 'ready', value: action.value, error: null, total: action.total } }
|
||||
return {
|
||||
...state,
|
||||
list: { phase: 'ready', value: action.value, error: null, total: action.total, stats: action.stats },
|
||||
}
|
||||
case 'list_failed':
|
||||
return {
|
||||
...state,
|
||||
|
||||
@ -28,7 +28,7 @@ describe('useIntelligentEvalRead', () => {
|
||||
it('refreshes active evaluations every five seconds and stops at terminal state', async () => {
|
||||
vi.useFakeTimers()
|
||||
const adapter: IntelligentEvalReadAdapter = {
|
||||
list: vi.fn().mockResolvedValue({ items: [evaluation], total: 1 }),
|
||||
list: vi.fn().mockResolvedValue({ items: [evaluation], total: 1, stats: null }),
|
||||
get: vi.fn().mockResolvedValue(evaluation),
|
||||
}
|
||||
const { result } = renderHook(() => useIntelligentEvalRead(null, adapter))
|
||||
@ -41,7 +41,7 @@ describe('useIntelligentEvalRead', () => {
|
||||
expect(adapter.list).toHaveBeenCalledTimes(2)
|
||||
|
||||
const terminal = { ...evaluation, status: 'completed' } as IntelligentEval
|
||||
vi.mocked(adapter.list).mockResolvedValue({ items: [terminal], total: 1 })
|
||||
vi.mocked(adapter.list).mockResolvedValue({ items: [terminal], total: 1, stats: null })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
await settle()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000) })
|
||||
@ -68,7 +68,7 @@ describe('useIntelligentEvalRead', () => {
|
||||
|
||||
it('passes page/pageSize to the adapter and reloads on change', async () => {
|
||||
const adapter: IntelligentEvalReadAdapter = {
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0, stats: null }),
|
||||
get: vi.fn().mockResolvedValue(evaluation),
|
||||
}
|
||||
const { rerender } = renderHook(
|
||||
@ -76,11 +76,11 @@ describe('useIntelligentEvalRead', () => {
|
||||
{ initialProps: { page: 1 } },
|
||||
)
|
||||
await settle()
|
||||
expect(adapter.list).toHaveBeenCalledWith(1, 20)
|
||||
expect(adapter.list).toHaveBeenCalledWith(1, 20, undefined)
|
||||
|
||||
rerender({ page: 2 })
|
||||
await settle()
|
||||
expect(adapter.list).toHaveBeenCalledWith(2, 20)
|
||||
expect(adapter.list).toHaveBeenCalledWith(2, 20, undefined)
|
||||
})
|
||||
|
||||
it('clears the old detail and ignores a response for the previous selection', async () => {
|
||||
@ -88,7 +88,7 @@ describe('useIntelligentEvalRead', () => {
|
||||
const second = deferred<IntelligentEval>()
|
||||
const secondEvaluation = { ...evaluation, id: 'eval-2', name: '评估二' } as IntelligentEval
|
||||
const adapter: IntelligentEvalReadAdapter = {
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0, stats: null }),
|
||||
get: vi.fn((id: string) => (id === 'eval-1' ? first.promise : second.promise)),
|
||||
}
|
||||
const { result, rerender } = renderHook(
|
||||
@ -109,7 +109,7 @@ describe('useIntelligentEvalRead', () => {
|
||||
|
||||
it('surfaces failure for a newly selected detail instead of retaining the old snapshot', async () => {
|
||||
const adapter: IntelligentEvalReadAdapter = {
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0, stats: null }),
|
||||
get: vi.fn()
|
||||
.mockResolvedValueOnce(evaluation)
|
||||
.mockRejectedValueOnce(new Error('不存在')),
|
||||
|
||||
@ -23,6 +23,7 @@ export function useIntelligentEvalRead(
|
||||
adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
status?: string,
|
||||
): IntelligentEvalReadState & { reloadList: () => Promise<void>; reloadDetail: () => Promise<void> } {
|
||||
const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState)
|
||||
const detailRequestId = useRef(0)
|
||||
@ -30,12 +31,12 @@ export function useIntelligentEvalRead(
|
||||
const loadList = useCallback(async (silent = false) => {
|
||||
dispatch({ type: 'list_requested', silent })
|
||||
try {
|
||||
const { items, total } = await adapter.list(page, pageSize)
|
||||
dispatch({ type: 'list_succeeded', value: items, total })
|
||||
const { items, total, stats } = await adapter.list(page, pageSize, status)
|
||||
dispatch({ type: 'list_succeeded', value: items, total, stats })
|
||||
} catch (error) {
|
||||
dispatch({ type: 'list_failed', error: errorMessage(error) })
|
||||
}
|
||||
}, [adapter, page, pageSize])
|
||||
}, [adapter, page, pageSize, status])
|
||||
|
||||
const loadDetail = useCallback(async (id: string, silent = false) => {
|
||||
const requestId = ++detailRequestId.current
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type EvalDetailTab = 'overview' | 'decision' | 'history' | 'report'
|
||||
|
||||
interface IntelligentEvalNav {
|
||||
/** 当前详情页展示的评估 id(由评估列表页进入时设置)。 */
|
||||
selectedEvalId: string | null
|
||||
/** 详情页初始激活的子视图 tab。 */
|
||||
detailTab: EvalDetailTab
|
||||
openDetail: (id: string, tab?: EvalDetailTab) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估列表 → 详情页 之间的选中状态桥(keep-alive 多页模式)。
|
||||
* 详情页是独立 tab(/intelligent-evals/detail),与列表页同时挂载,
|
||||
* 选中哪个评估由这里传递;列表页导航前调用 openDetail 再 openTab + navigate。
|
||||
*/
|
||||
export const useIntelligentEvalNav = create<IntelligentEvalNav>((set) => ({
|
||||
selectedEvalId: null,
|
||||
detailTab: 'overview',
|
||||
openDetail: (id, tab = 'overview') => set({ selectedEvalId: id, detailTab: tab }),
|
||||
}))
|
||||
Loading…
Reference in New Issue
Block a user