feat(intelligent-eval): v4.2 UI — stat bar + single-line table + unified detail drawer
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:
sinohqb 2026-08-17 20:03:55 +08:00
parent a69fa8a797
commit 00ad929d68
15 changed files with 342 additions and 387 deletions

View File

@ -10,7 +10,7 @@
非法转换抛 IntelligentEvalTransitionError路由层映射为 409 非法转换抛 IntelligentEvalTransitionError路由层映射为 409
""" """
from typing import Any from typing import Any, Optional
from sqlmodel import Session from sqlmodel import Session
@ -218,10 +218,17 @@ def list_evals(session: Session) -> list[IntelligentEval]:
return IntelligentEvalRepository(session).list_all() return IntelligentEvalRepository(session).list_all()
def list_evals_page(session: Session, offset: int, limit: int) -> tuple[list[IntelligentEval], int]: def list_evals_page(
"""Return one page of evals (newest first) plus the total count.""" 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) 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: def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession:

View File

@ -101,20 +101,32 @@ class IntelligentEvalRepository:
statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc()) statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc())
return [self._from_db(r) for r in self.session.exec(statement).all()] return [self._from_db(r) for r in self.session.exec(statement).all()]
def list_page(self, offset: int, limit: int) -> list[IntelligentEval]: def list_page(
"""Return one page of evals (created_at desc) with the same ordering as ``list_all``.""" self, offset: int, limit: int, status: Optional[str] = None
statement = ( ) -> list[IntelligentEval]:
select(IntelligentEvalDB) """Return one page of evals (created_at desc), optionally filtered by status."""
.order_by(IntelligentEvalDB.created_at.desc()) statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc())
.offset(offset) if status:
.limit(limit) 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()] return [self._from_db(r) for r in self.session.exec(statement).all()]
def count(self) -> int: def count(self) -> int:
"""Total number of evaluations (for pagination metadata).""" """Total number of evaluations (for pagination metadata)."""
return self.session.exec(select(func.count()).select_from(IntelligentEvalDB)).one() 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]: def get(self, eval_id: str) -> Optional[IntelligentEval]:
db = self.session.get(IntelligentEvalDB, eval_id) db = self.session.get(IntelligentEvalDB, eval_id)
return self._from_db(db) if db else None return self._from_db(db) if db else None

View File

@ -97,12 +97,14 @@ async def create_eval(request: CreateEvalRequest, session: Session = Depends(get
async def list_evals( async def list_evals(
page: int | None = None, page: int | None = None,
page_size: int = 20, page_size: int = 20,
status: str | None = None,
session: Session = Depends(get_db), session: Session = Depends(get_db),
) -> dict: ) -> dict:
"""List intelligent evaluations, optionally paginated. """List intelligent evaluations, optionally paginated and filtered by status.
不传 ``page`` 时返回全部向后兼容 ``page`` 1 时按 不传 ``page`` 时返回全部向后兼容 ``page`` 1 时按
``created_at`` 倒序分页返回 ``total`` 供前端服务端分页 ``created_at`` 倒序分页返回 ``total`` + ``stats``各状态计数供前端
服务端分页与状态统计条
""" """
reader = IntelligentEvalReadModel(session) reader = IntelligentEvalReadModel(session)
if page is None: 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)]} return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]}
page_size = max(1, min(page_size, 100)) page_size = max(1, min(page_size, 100))
offset = (max(1, page) - 1) * page_size 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 { return {
"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)], "intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)],
"total": total, "total": total,
"stats": stats,
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
} }

View File

@ -40,7 +40,6 @@ const FilesPage = lazy(() => import('./pages/Files'))
const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs')) const ModelConfigsPage = lazy(() => import('./pages/ModelConfigs'))
const IntelligentEvalsPage = lazy(() => import('./pages/IntelligentEvals')) const IntelligentEvalsPage = lazy(() => import('./pages/IntelligentEvals'))
const IntelligentEvalTasksPage = lazy(() => import('./pages/IntelligentEvalTasks')) const IntelligentEvalTasksPage = lazy(() => import('./pages/IntelligentEvalTasks'))
const IntelligentEvalDetailPage = lazy(() => import('./pages/IntelligentEvalDetail'))
function PageLoader({ children }: { children: ReactNode }) { function PageLoader({ children }: { children: ReactNode }) {
return ( return (
@ -71,7 +70,6 @@ const routeConfigs: RouteConfig[] = [
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> }, { path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
{ path: '/intelligent-evals', name: '智能评估', icon: <BulbOutlined />, component: () => <PageLoader><IntelligentEvalsPage /></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/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: '/models', name: '模型配置', icon: <CloudServerOutlined />, component: () => <PageLoader><ModelConfigsPage /></PageLoader> },
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> }, { path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
] ]

View File

@ -785,8 +785,14 @@ export interface TaskQueueList {
} }
export const intelligentEvalsApi = { export const intelligentEvalsApi = {
list: (params?: { page?: number; page_size?: number }) => list: (params?: { page?: number; page_size?: number; status?: string }) =>
api.get<{ intelligent_evals: IntelligentEval[]; total?: number; page?: number; page_size?: number }>( api.get<{
intelligent_evals: IntelligentEval[]
total?: number
stats?: Record<string, number>
page?: number
page_size?: number
}>(
'/intelligent-evals', '/intelligent-evals',
{ params }, { params },
), ),

View File

@ -64,7 +64,7 @@ interface EvalOverviewProps {
/** /**
* "概览" * "概览"
* /IntelligentEvalDetail * /
*/ */
export default function EvalOverview({ ev, targetName }: EvalOverviewProps) { export default function EvalOverview({ ev, targetName }: EvalOverviewProps) {
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' } const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }

View File

@ -1,44 +1,49 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Alert, Button, Empty, Select, Space, Statistic, Table, Tag, Tooltip, message, Button, Empty, Space, Table, Tag, Tooltip, message,
} from 'antd' } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { ReloadOutlined } from '@ant-design/icons' import { ReloadOutlined } from '@ant-design/icons'
import { intelligentEvalsApi, type TaskQueueItem, type TaskQueueStatus } from '../../api' import { intelligentEvalsApi, type TaskQueueItem, type TaskQueueStatus } from '../../api'
import { usePolling } from '../../hooks/usePolling' import { usePolling } from '../../hooks/usePolling'
import { colors, statusColors } from '../../tokens' import { colors } from '../../tokens'
import { formatDateTime } from '../../utils/date' import { formatDateTime } from '../../utils/date'
const STATUS_META: Record<TaskQueueStatus, { label: string; color: string }> = { const STATUS_META: Record<TaskQueueStatus, { label: string; color: string }> = {
pending: { label: '待处理', color: 'orange' }, pending: { label: '待认领', color: 'orange' },
assigned: { label: '执行中', color: 'blue' }, assigned: { label: '执行中', color: 'blue' },
completed: { label: '已完成', color: 'green' }, completed: { label: '已完成', color: 'green' },
failed: { label: '失败', color: 'red' }, failed: { label: '失败', color: 'red' },
} }
const STATUS_OPTIONS = (Object.keys(STATUS_META) as TaskQueueStatus[]).map((s) => ({ const EVAL_STATUS_META: Record<string, { label: string; color: string }> = {
value: s, executing: { label: '执行中', color: 'blue' },
label: STATUS_META[s].label, completed: { label: '已完成', color: 'green' },
})) cancelled: { label: '已取消', color: 'default' },
failed: { label: '失败', color: 'red' },
}
type FilterKey = 'all' | 'unresolved' | TaskQueueStatus
/** /**
* * stat-chip +
* *
* "定时触发"scan loop 60s + OpenClaw worker * 60s executing + OpenClaw worker
* Worker API * 5s
* 5s
*/ */
export default function TaskQueueMonitor() { export default function TaskQueueMonitor() {
const [tasks, setTasks] = useState<TaskQueueItem[] | null>(null) const [tasks, setTasks] = useState<TaskQueueItem[] | null>(null)
const [stats, setStats] = useState<{ pending: number; assigned: number; completed: number; failed: number; unresolved: number } | null>(null) const [stats, setStats] = useState<{
const [statusFilter, setStatusFilter] = useState<TaskQueueStatus | 'all'>('all') pending: number; assigned: number; completed: number; failed: number; unresolved: number
} | null>(null)
const [filter, setFilter] = useState<FilterKey>('all')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadData = async () => { const loadData = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await intelligentEvalsApi.listTasks( const res = await intelligentEvalsApi.listTasks(
statusFilter === 'all' ? undefined : { status: statusFilter }, filter === 'all' || filter === 'unresolved' ? undefined : { status: filter },
) )
setTasks(res.data.tasks) setTasks(res.data.tasks)
setStats(res.data.stats) setStats(res.data.stats)
@ -49,112 +54,98 @@ 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(() => { useEffect(() => {
void loadData() void loadData()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter]) }, [filter])
usePolling(() => { void loadData() }, 5000, true) 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> = [ 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) : '—'), render: (v: string | null) => (v ? formatDateTime(v) : '—'),
}, },
{ {
title: '评估', dataIndex: 'eval_name', key: 'eval_name', title: '评估', key: 'eval', width: 230, ellipsis: true,
render: (name: string | null, t) => ( render: (_, t) => (
<Space size={6} direction="vertical" style={{ gap: 2 }}> <Space size={6}>
<span style={{ fontWeight: 500 }}>{name ?? t.eval_id.slice(0, 8)}</span> <span style={{ fontWeight: 500 }}>{t.eval_name ?? t.eval_id.slice(0, 8)}</span>
{t.eval_status && ( {t.eval_status && EVAL_STATUS_META[t.eval_status] && (
<span style={{ fontSize: 12, color: colors.textSecondary }}>{t.eval_status}</span> <Tag color={EVAL_STATUS_META[t.eval_status].color} style={{ margin: 0, fontSize: 11 }}>
{EVAL_STATUS_META[t.eval_status].label}
</Tag>
)} )}
</Space> </Space>
), ),
}, },
{ {
title: '状态', dataIndex: 'status', key: 'status', width: 100, title: '队列状态', dataIndex: 'status', key: 'status', width: 96,
render: (s: TaskQueueStatus) => { render: (s: TaskQueueStatus) => {
const meta = STATUS_META[s] const meta = STATUS_META[s]
return <Tag color={meta.color}>{meta.label}</Tag> 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>, render: (p: number) => <Tag>{p}</Tag>,
}, },
{ {
title: '原因', dataIndex: 'reason', key: 'reason', title: '原因', dataIndex: 'reason', key: 'reason', ellipsis: true,
render: (r: string) => ( render: (r: string) => <Tooltip title={r}><span>{r}</span></Tooltip>,
<Tooltip title={r}>
<span style={{ display: 'inline-block', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r}
</span>
</Tooltip>
),
}, },
{ {
title: '认领方', dataIndex: 'assigned_cron_id', key: 'assigned_cron_id', width: 140, title: '完成时间', dataIndex: 'completed_at', key: 'completed_at', width: 165,
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,
render: (v: string | null, t) => { render: (v: string | null, t) => {
if (t.status === 'failed' && t.error) { 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 ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}> <div className="stat-bar">
<Space size={16} wrap> {chipMeta.map((c) => (
<Statistic title="待处理" value={stats?.unresolved ?? 0} valueStyle={{ color: colors.warning }} /> <div
<Statistic title="待认领" value={stats?.pending ?? 0} /> key={c.key}
<Statistic title="执行中" value={stats?.assigned ?? 0} valueStyle={{ color: colors.primary }} /> className={`stat-chip ${filter === c.key ? 'active' : ''}`}
<Statistic title="已完成" value={stats?.completed ?? 0} valueStyle={{ color: statusColors.completed }} /> onClick={() => setFilter(c.key)}
<Statistic title="失败" value={stats?.failed ?? 0} valueStyle={{ color: statusColors.failed }} /> >
</Space> <div className="n" style={{ color: c.color ?? colors.text }}>{c.get()}</div>
<Space> <div className="l">{c.label}</div>
<Select </div>
value={statusFilter} ))}
onChange={(v) => setStatusFilter(v)}
style={{ width: 110 }}
options={[{ value: 'all', label: '全部状态' }, ...STATUS_OPTIONS]}
/>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} />
</Space>
</div> </div>
<Alert <div style={{ background: colors.bgContainer, borderRadius: 8, padding: 16, flex: 1, minHeight: 0, overflow: 'hidden' }}>
type="info" <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
showIcon <Button icon={<ReloadOutlined />} onClick={() => void loadData()} />
message="平台每 60 秒扫描 executing 评估并入队,有任务时通过 docker exec 触发 OpenClaw worker 执行(取代外部 Channel 的方案③)。" </div>
style={{ fontSize: 12 }} <div style={{ height: 'calc(100% - 44px)', overflowY: 'auto' }}>
/>
<div style={{ flex: 1, overflowY: 'auto' }}>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="middle"
loading={loading} loading={loading}
dataSource={tasks ?? []} dataSource={tasks ?? []}
columns={columns} columns={columns}
pagination={(tasks?.length ?? 0) > 50 ? { pageSize: 50, showTotal: (t) => `${t}` } : false} pagination={(tasks?.length ?? 0) > 10 ? { pageSize: 10, showTotal: (t) => `${t}` } : false}
locale={{ emptyText: <Empty description="暂无任务" /> }} locale={{ emptyText: <Empty description="暂无任务" /> }}
/> />
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -64,6 +64,39 @@ body {
display: none !important; 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 需承载内容面板概览/决策过程/ * intelligent-detail-tabs评估详情页的 Tabs 需承载内容面板概览/决策过程/
* 配置历史/报告建立高度链Tabs 撑满父容器content-holder 占剩余空间 * 配置历史/报告建立高度链Tabs 撑满父容器content-holder 占剩余空间

View File

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

View File

@ -1,21 +1,25 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { 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' } from 'antd'
import type { TabsProps } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { import {
PlusOutlined, ReloadOutlined, EyeOutlined, FileTextOutlined, UnorderedListOutlined, PlusOutlined, ReloadOutlined, StopOutlined, UnorderedListOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import FormDrawer from '../components/FormDrawer' import FormDrawer from '../components/FormDrawer'
import PageWrapper from '../components/PageWrapper' 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 { EVAL_STATUS } from '../components/intelligent_eval/status'
import { useResource } from '../hooks/useResource' import { useResource } from '../hooks/useResource'
import { import {
intelligentEvalsApi, targetsApi, intelligentEvalsApi, targetsApi,
type CreateIntelligentEvalPayload, type IntelligentEval, type Target, type CreateIntelligentEvalPayload, type IntelligentEval, type Target,
} from '../api' } from '../api'
import { useIntelligentEvalNav, type EvalDetailTab } from '../stores/intelligentEvalNav'
import { useTabStore, type TabItem } from '../stores/tabStore' import { useTabStore, type TabItem } from '../stores/tabStore'
import { colors } from '../tokens' import { colors } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
@ -31,47 +35,74 @@ interface CreateFormValues {
time_window_hours: number time_window_hours: number
} }
const DETAIL_TAB: TabItem = { type DetailTab = 'overview' | 'decision' | 'history' | 'report'
key: '/intelligent-evals/detail', title: '评估详情', icon: <EyeOutlined />, closable: true,
}
const TASKS_TAB: TabItem = { const TASKS_TAB: TabItem = {
key: '/intelligent-evals/tasks', title: '任务队列', icon: <UnorderedListOutlined />, closable: true, 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() { export default function IntelligentEvalsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const openTab = useTabStore((s) => s.openTab) 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 [createOpen, setCreateOpen] = useState(false)
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [form] = Form.useForm<CreateFormValues>() 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 evals = list.value
const loading = list.phase === 'loading' const loading = list.phase === 'loading'
const stats = list.stats
const { data: targets } = useResource( const { data: targets } = useResource(
() => targetsApi.list().then((r) => r.data), () => targetsApi.list().then((r) => r.data),
{ tabPath: '/intelligent-evals' }, { tabPath: '/intelligent-evals' },
) )
const selected = selectedId != null && detail.value?.id === selectedId ? detail.value : null
const targetName = (id: string) => const targetName = (id: string) =>
targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8) targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8)
const openDetail = (id: string, tab: EvalDetailTab = 'overview') => { const openDetail = (id: string, tab: DetailTab = 'overview') => {
openDetailNav(id, tab) setSelectedId(id)
openTab(DETAIL_TAB) setDetailTab(tab)
navigate('/intelligent-evals/detail')
} }
const openReport = (id: string) => openDetail(id, 'report') const closeDrawer = () => {
setSelectedId(null)
void reloadList()
}
const openTasks = () => { const openTasks = () => {
openTab(TASKS_TAB) openTab(TASKS_TAB)
navigate('/intelligent-evals/tasks') navigate('/intelligent-evals/tasks')
} }
const setFilter = (key: string) => {
setStatusFilter(key)
setPage(1)
}
const submitCreate = async () => { const submitCreate = async () => {
const values = await form.validateFields() const values = await form.validateFields()
let seeds: Record<string, unknown> = {} let seeds: Record<string, unknown> = {}
@ -112,45 +143,61 @@ export default function IntelligentEvalsPage() {
const columns: ColumnsType<IntelligentEval> = [ 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>, render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
}, },
{ {
title: '评测对象', dataIndex: 'target_id', key: 'target', width: 180, title: '评测对象', dataIndex: 'target_id', key: 'target', width: 180, ellipsis: true,
render: (id: string) => targetName(id), 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']) => { render: (status: IntelligentEval['status']) => {
const meta = EVAL_STATUS[status] ?? { label: status, color: 'default' } const meta = EVAL_STATUS[status] ?? { label: status, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag> return <Tag color={meta.color}>{meta.label}</Tag>
}, },
}, },
{ {
title: '会话进度', key: 'progress', width: 120, title: '会话进度', key: 'progress', width: 170,
render: (_, ev) => `${ev.completed_sessions}/${ev.session_count}`, 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) : '—'), 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) => ( render: (_, ev) => (
<Space size={4}> <Button size="small" onClick={() => openDetail(ev.id)}></Button>
<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>
), ),
}, },
] ]
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 ( return (
<PageWrapper <PageWrapper
title="智能评估" title="智能评估"
@ -170,11 +217,27 @@ export default function IntelligentEvalsPage() {
} }
> >
<div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}> <div style={{ height: '100%', overflowY: 'auto', padding: '0 16px 16px' }}>
<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 <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
dataSource={evals ?? []} dataSource={evals ?? []}
columns={columns} columns={columns}
onRow={(record) => ({ onClick: () => openDetail(record.id), style: { cursor: 'pointer' } })}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@ -187,9 +250,52 @@ export default function IntelligentEvalsPage() {
setPageSize(ps) setPageSize(ps)
}, },
}} }}
locale={{ emptyText: <Empty description="还没有智能评估" /> }} locale={{ emptyText: <Empty description="该状态下暂无评估" /> }}
/> />
</div> </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 <FormDrawer
title="新建智能评估" title="新建智能评估"

View File

@ -16,21 +16,22 @@ describe('intelligent evaluation read state', () => {
type: 'list_succeeded', type: 'list_succeeded',
value: [evaluation], value: [evaluation],
total: 42, 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', () => { it('keeps the last list snapshot during silent refresh and failure', () => {
const ready: IntelligentEvalReadState = { const ready: IntelligentEvalReadState = {
...initialIntelligentEvalReadState, ...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 }) const refreshing = intelligentEvalReadReducer(ready, { type: 'list_requested', silent: true })
expect(refreshing.list.phase).toBe('refreshing') expect(refreshing.list.phase).toBe('refreshing')
expect(refreshing.list.total).toBe(1) expect(refreshing.list.total).toBe(1)
const afterFailure = intelligentEvalReadReducer(refreshing, { type: 'list_failed', error: '网络错误' }) 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', () => { it('surfaces an initial detail failure without an existing snapshot', () => {

View File

@ -9,7 +9,7 @@ export interface ReadSlot<T> {
} }
export interface IntelligentEvalReadState { export interface IntelligentEvalReadState {
list: ReadSlot<IntelligentEval[]> & { total: number } list: ReadSlot<IntelligentEval[]> & { total: number; stats: Record<string, number> | null }
detail: ReadSlot<IntelligentEval | null> & { detail: ReadSlot<IntelligentEval | null> & {
selectedId: string | null selectedId: string | null
requestId: number requestId: number
@ -18,7 +18,7 @@ export interface IntelligentEvalReadState {
export type IntelligentEvalReadAction = export type IntelligentEvalReadAction =
| { type: 'list_requested'; silent?: boolean } | { 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: 'list_failed'; error: string }
| { type: 'detail_cleared'; requestId: number } | { type: 'detail_cleared'; requestId: number }
| { type: 'detail_requested'; id: string; requestId: number; silent?: boolean } | { 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 } | { type: 'detail_failed'; id: string; requestId: number; error: string }
export interface IntelligentEvalReadAdapter { 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> get: (id: string) => Promise<IntelligentEval>
} }
export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = { export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = {
list: (page, pageSize) => list: (page, pageSize, status) =>
intelligentEvalsApi intelligentEvalsApi
.list(page != null ? { page, page_size: pageSize ?? 20 } : undefined) .list(
page != null
? { page, page_size: pageSize ?? 20, ...(status ? { status } : {}) }
: undefined,
)
.then((response) => ({ .then((response) => ({
items: response.data.intelligent_evals, items: response.data.intelligent_evals,
// 兼容不分页响应:无 total 时以本页长度为总数 // 兼容不分页响应:无 total 时以本页长度为总数
total: response.data.total ?? response.data.intelligent_evals.length, total: response.data.total ?? response.data.intelligent_evals.length,
stats: response.data.stats ?? null,
})), })),
get: (id) => intelligentEvalsApi.get(id).then((response) => response.data), get: (id) => intelligentEvalsApi.get(id).then((response) => response.data),
} }
export const initialIntelligentEvalReadState: IntelligentEvalReadState = { 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 }, detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 },
} }
@ -59,7 +68,10 @@ export function intelligentEvalReadReducer(
case 'list_requested': case 'list_requested':
return { ...state, list: requestPhase(state.list, action.silent) } return { ...state, list: requestPhase(state.list, action.silent) }
case 'list_succeeded': 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': case 'list_failed':
return { return {
...state, ...state,

View File

@ -28,7 +28,7 @@ describe('useIntelligentEvalRead', () => {
it('refreshes active evaluations every five seconds and stops at terminal state', async () => { it('refreshes active evaluations every five seconds and stops at terminal state', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const adapter: IntelligentEvalReadAdapter = { 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), get: vi.fn().mockResolvedValue(evaluation),
} }
const { result } = renderHook(() => useIntelligentEvalRead(null, adapter)) const { result } = renderHook(() => useIntelligentEvalRead(null, adapter))
@ -41,7 +41,7 @@ describe('useIntelligentEvalRead', () => {
expect(adapter.list).toHaveBeenCalledTimes(2) expect(adapter.list).toHaveBeenCalledTimes(2)
const terminal = { ...evaluation, status: 'completed' } as IntelligentEval 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 act(async () => { await vi.advanceTimersByTimeAsync(5000) })
await settle() await settle()
await act(async () => { await vi.advanceTimersByTimeAsync(10000) }) 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 () => { it('passes page/pageSize to the adapter and reloads on change', async () => {
const adapter: IntelligentEvalReadAdapter = { const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue({ items: [], total: 0 }), list: vi.fn().mockResolvedValue({ items: [], total: 0, stats: null }),
get: vi.fn().mockResolvedValue(evaluation), get: vi.fn().mockResolvedValue(evaluation),
} }
const { rerender } = renderHook( const { rerender } = renderHook(
@ -76,11 +76,11 @@ describe('useIntelligentEvalRead', () => {
{ initialProps: { page: 1 } }, { initialProps: { page: 1 } },
) )
await settle() await settle()
expect(adapter.list).toHaveBeenCalledWith(1, 20) expect(adapter.list).toHaveBeenCalledWith(1, 20, undefined)
rerender({ page: 2 }) rerender({ page: 2 })
await settle() 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 () => { 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 second = deferred<IntelligentEval>()
const secondEvaluation = { ...evaluation, id: 'eval-2', name: '评估二' } as IntelligentEval const secondEvaluation = { ...evaluation, id: 'eval-2', name: '评估二' } as IntelligentEval
const adapter: IntelligentEvalReadAdapter = { 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)), get: vi.fn((id: string) => (id === 'eval-1' ? first.promise : second.promise)),
} }
const { result, rerender } = renderHook( 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 () => { it('surfaces failure for a newly selected detail instead of retaining the old snapshot', async () => {
const adapter: IntelligentEvalReadAdapter = { const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue({ items: [], total: 0 }), list: vi.fn().mockResolvedValue({ items: [], total: 0, stats: null }),
get: vi.fn() get: vi.fn()
.mockResolvedValueOnce(evaluation) .mockResolvedValueOnce(evaluation)
.mockRejectedValueOnce(new Error('不存在')), .mockRejectedValueOnce(new Error('不存在')),

View File

@ -23,6 +23,7 @@ export function useIntelligentEvalRead(
adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter, adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter,
page: number = 1, page: number = 1,
pageSize: number = 20, pageSize: number = 20,
status?: string,
): IntelligentEvalReadState & { reloadList: () => Promise<void>; reloadDetail: () => Promise<void> } { ): IntelligentEvalReadState & { reloadList: () => Promise<void>; reloadDetail: () => Promise<void> } {
const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState) const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState)
const detailRequestId = useRef(0) const detailRequestId = useRef(0)
@ -30,12 +31,12 @@ export function useIntelligentEvalRead(
const loadList = useCallback(async (silent = false) => { const loadList = useCallback(async (silent = false) => {
dispatch({ type: 'list_requested', silent }) dispatch({ type: 'list_requested', silent })
try { try {
const { items, total } = await adapter.list(page, pageSize) const { items, total, stats } = await adapter.list(page, pageSize, status)
dispatch({ type: 'list_succeeded', value: items, total }) dispatch({ type: 'list_succeeded', value: items, total, stats })
} catch (error) { } catch (error) {
dispatch({ type: 'list_failed', error: errorMessage(error) }) dispatch({ type: 'list_failed', error: errorMessage(error) })
} }
}, [adapter, page, pageSize]) }, [adapter, page, pageSize, status])
const loadDetail = useCallback(async (id: string, silent = false) => { const loadDetail = useCallback(async (id: string, silent = false) => {
const requestId = ++detailRequestId.current const requestId = ++detailRequestId.current

View File

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