diff --git a/backend/agenteval/intelligent_eval/lifecycle.py b/backend/agenteval/intelligent_eval/lifecycle.py index 056036d..ee8db62 100644 --- a/backend/agenteval/intelligent_eval/lifecycle.py +++ b/backend/agenteval/intelligent_eval/lifecycle.py @@ -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: diff --git a/backend/agenteval/intelligent_eval/repository.py b/backend/agenteval/intelligent_eval/repository.py index 69ff4ce..7b28e71 100644 --- a/backend/agenteval/intelligent_eval/repository.py +++ b/backend/agenteval/intelligent_eval/repository.py @@ -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 diff --git a/backend/agenteval/web/routers/intelligent_evals.py b/backend/agenteval/web/routers/intelligent_evals.py index 5f1ac14..180138f 100644 --- a/backend/agenteval/web/routers/intelligent_evals.py +++ b/backend/agenteval/web/routers/intelligent_evals.py @@ -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, } diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index b86210a..9ccb85c 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -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: , component: () => }, { path: '/intelligent-evals', name: '智能评估', icon: , component: () => }, { path: '/intelligent-evals/tasks', name: '任务队列', icon: , component: () => }, - { path: '/intelligent-evals/detail', name: '评估详情', icon: , component: () => }, { path: '/models', name: '模型配置', icon: , component: () => }, { path: '/files', name: '原始文件', icon: , component: () => }, ] diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index c4362e0..56b61b0 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -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 + page?: number + page_size?: number + }>( '/intelligent-evals', { params }, ), diff --git a/frontend/web/src/components/intelligent_eval/EvalOverview.tsx b/frontend/web/src/components/intelligent_eval/EvalOverview.tsx index ac6ffe0..1c46be7 100644 --- a/frontend/web/src/components/intelligent_eval/EvalOverview.tsx +++ b/frontend/web/src/components/intelligent_eval/EvalOverview.tsx @@ -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' } diff --git a/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx b/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx index 3ca373d..9655dd4 100644 --- a/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx +++ b/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx @@ -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 = { - 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 = { + 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(null) - const [stats, setStats] = useState<{ pending: number; assigned: number; completed: number; failed: number; unresolved: number } | null>(null) - const [statusFilter, setStatusFilter] = useState('all') + const [stats, setStats] = useState<{ + pending: number; assigned: number; completed: number; failed: number; unresolved: number + } | null>(null) + const [filter, setFilter] = useState('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 = [ { - 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) => ( - - {name ?? t.eval_id.slice(0, 8)} - {t.eval_status && ( - {t.eval_status} + title: '评估', key: 'eval', width: 230, ellipsis: true, + render: (_, t) => ( + + {t.eval_name ?? t.eval_id.slice(0, 8)} + {t.eval_status && EVAL_STATUS_META[t.eval_status] && ( + + {EVAL_STATUS_META[t.eval_status].label} + )} ), }, { - title: '状态', dataIndex: 'status', key: 'status', width: 100, + title: '队列状态', dataIndex: 'status', key: 'status', width: 96, render: (s: TaskQueueStatus) => { const meta = STATUS_META[s] return {meta.label} }, }, { - title: '优先级', dataIndex: 'priority', key: 'priority', width: 80, + title: '优先级', dataIndex: 'priority', key: 'priority', width: 76, render: (p: number) => {p}, }, { - title: '原因', dataIndex: 'reason', key: 'reason', - render: (r: string) => ( - - - {r} - - - ), + title: '原因', dataIndex: 'reason', key: 'reason', ellipsis: true, + render: (r: string) => {r}, }, { - title: '认领方', dataIndex: 'assigned_cron_id', key: 'assigned_cron_id', width: 140, - render: (v: string | null, t) => { - if (!v) return — - return ( - - {v} - - ) - }, - }, - { - 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 失败 + return 失败 } - return v ? formatDateTime(v) : '—' + return v ? formatDateTime(v) : — }, }, ] return ( - - - - - - - - - - - setStatusFilter(v)} - style={{ width: 110 }} - options={[{ value: 'all', label: '全部状态' }, ...STATUS_OPTIONS]} - /> - } onClick={() => void loadData()} /> - + + + {chipMeta.map((c) => ( + setFilter(c.key)} + > + {c.get()} + {c.label} + + ))} - - - - 50 ? { pageSize: 50, showTotal: (t) => `共 ${t} 条` } : false} - locale={{ emptyText: }} - /> + + + } onClick={() => void loadData()} /> + + + 10 ? { pageSize: 10, showTotal: (t) => `共 ${t} 条` } : false} + locale={{ emptyText: }} + /> + ) diff --git a/frontend/web/src/index.css b/frontend/web/src/index.css index e4e2aac..34715bc 100644 --- a/frontend/web/src/index.css +++ b/frontend/web/src/index.css @@ -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 占剩余空间, diff --git a/frontend/web/src/pages/IntelligentEvalDetail.tsx b/frontend/web/src/pages/IntelligentEvalDetail.tsx deleted file mode 100644 index 70e1b96..0000000 --- a/frontend/web/src/pages/IntelligentEvalDetail.tsx +++ /dev/null @@ -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(null) - const [busy, setBusy] = useState(false) - const [rejectOpen, setRejectOpen] = useState(false) - const [feedback, setFeedback] = useState('') - const [activeTab, setActiveTab] = useState(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, 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 ( - - - - - - ) - } - - if (!detail) { - return ( - - - - - - ) - } - - 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: ( - - - - ), - }, - { - key: 'decision', - label: '决策过程', - children: , - }, - { - key: 'history', - label: '配置历史', - children: , - }, - ] - if (detail.status === 'completed') { - items.push({ - key: 'report', - label: '评估报告', - children: , - }) - } - - return ( - - - - {detail.name} - {meta.label} - - - {canApprove && ( - <> - setRejectOpen(true)}>打回 - 批准并执行 - > - )} - {canCancel && ( - - } loading={busy}>取消 - - )} - - - - - setActiveTab(k as EvalDetailTab)} - items={items} - /> - - - - setRejectOpen(false)} - onOk={submitReject} - okText="打回" - okButtonProps={{ danger: true, disabled: !feedback.trim(), loading: busy }} - > - setFeedback(e.target.value)} - placeholder="请填写打回原因与改进方向,OpenClaw 会据此重新规划" - /> - - - ) -} diff --git a/frontend/web/src/pages/IntelligentEvals.tsx b/frontend/web/src/pages/IntelligentEvals.tsx index 43e4a9d..962e38e 100644 --- a/frontend/web/src/pages/IntelligentEvals.tsx +++ b/frontend/web/src/pages/IntelligentEvals.tsx @@ -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: , closable: true, -} +type DetailTab = 'overview' | 'decision' | 'history' | 'report' + const TASKS_TAB: TabItem = { key: '/intelligent-evals/tasks', title: '任务队列', icon: , 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(null) + const [detailTab, setDetailTab] = useState('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() - 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 = {} @@ -112,45 +143,61 @@ export default function IntelligentEvalsPage() { const columns: ColumnsType = [ { - title: '名称', dataIndex: 'name', key: 'name', + title: '名称', dataIndex: 'name', key: 'name', width: 300, ellipsis: true, render: (name: string) => {name}, }, { - 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) => {targetName(id)}, }, { - 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 {meta.label} }, }, { - 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 ( + + + {ev.completed_sessions}/{ev.session_count} + + ) + }, }, { - 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) => ( - - - } onClick={() => openDetail(ev.id)} /> - - {ev.status === 'completed' && ( - - } onClick={() => openReport(ev.id)} /> - - )} - + openDetail(ev.id)}>查看 ), }, ] + const drawerItems: TabsProps['items'] = selected + ? [ + { + key: 'overview', + label: '概览', + children: ( + + + + ), + }, + { key: 'decision', label: '决策过程', children: }, + { key: 'history', label: '配置历史', children: }, + ...(selected.status === 'completed' ? [{ key: 'report' as const, label: '评估报告', children: }] : []), + ] + : [] + return ( - `共 ${t} 个`, - onChange: (p, ps) => { - setPage(p) - setPageSize(ps) - }, - }} - locale={{ emptyText: }} - /> + + {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 ( + setFilter(c.key)}> + {n} + {c.label} + + ) + })} + + + + ({ 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: }} + /> + + + + {EVAL_STATUS[selected.status]?.label ?? selected.status} + + {selected.status === 'pending_approval' && ( + void intelligentEvalsApi.approve(selected.id).then(() => { message.success('已批准,进入执行'); void reloadDetail() })}> + 批准并执行 + + )} + {(selected.status === 'pending_approval' || selected.status === 'executing') && ( + void intelligentEvalsApi.cancel(selected.id).then(() => { message.success('已取消'); void reloadDetail() })} + > + }>取消 + + )} + + )} + > + {selected == null ? ( + + ) : ( + + setDetailTab(k as DetailTab)} + items={drawerItems} + /> + + )} + + { 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', () => { diff --git a/frontend/web/src/read/intelligentEval.ts b/frontend/web/src/read/intelligentEval.ts index 9e6966f..4827511 100644 --- a/frontend/web/src/read/intelligentEval.ts +++ b/frontend/web/src/read/intelligentEval.ts @@ -9,7 +9,7 @@ export interface ReadSlot { } export interface IntelligentEvalReadState { - list: ReadSlot & { total: number } + list: ReadSlot & { total: number; stats: Record | null } detail: ReadSlot & { 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 | 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 | null + }> get: (id: string) => Promise } 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, diff --git a/frontend/web/src/read/useIntelligentEvalRead.test.tsx b/frontend/web/src/read/useIntelligentEvalRead.test.tsx index 87ed795..84c09a5 100644 --- a/frontend/web/src/read/useIntelligentEvalRead.test.tsx +++ b/frontend/web/src/read/useIntelligentEvalRead.test.tsx @@ -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() 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('不存在')), diff --git a/frontend/web/src/read/useIntelligentEvalRead.ts b/frontend/web/src/read/useIntelligentEvalRead.ts index 0020e29..1fd8e1e 100644 --- a/frontend/web/src/read/useIntelligentEvalRead.ts +++ b/frontend/web/src/read/useIntelligentEvalRead.ts @@ -23,6 +23,7 @@ export function useIntelligentEvalRead( adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter, page: number = 1, pageSize: number = 20, + status?: string, ): IntelligentEvalReadState & { reloadList: () => Promise; reloadDetail: () => Promise } { 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 diff --git a/frontend/web/src/stores/intelligentEvalNav.ts b/frontend/web/src/stores/intelligentEvalNav.ts deleted file mode 100644 index ed0c3d2..0000000 --- a/frontend/web/src/stores/intelligentEvalNav.ts +++ /dev/null @@ -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((set) => ({ - selectedEvalId: null, - detailTab: 'overview', - openDetail: (id, tab = 'overview') => set({ selectedEvalId: id, detailTab: tab }), -}))