From f458897ab5368c3e5daeff10d4d61615af643687 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Mon, 24 Aug 2026 05:51:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(read):=20=E5=89=8D=E7=AB=AF=E6=B3=9B?= =?UTF-8?q?=E5=8C=96=20ReadSlot=20=E8=B5=84=E6=BA=90=E6=8E=A5=E7=BC=9D?= =?UTF-8?q?=EF=BC=88Phase=204.18-21=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 src/read 从单一消费者扩展为通用资源接缝;drawer 标签页与 useCampaignReport / useFiles 统一消费;导出仪式下沉至工具函数。 - 新增 readResource.ts:ReadSlot 相位机(idle/loading/ refreshing/ready/error)、requestId 竞态守卫、静默刷新、 可注入 adapter;SelectedReadSlot 支持 list→detail 的 stale-response rejection。配套 characterization 测试。 - evalActivity.ts / useIntelligentEvalRead.ts 退化为 readResource 的特化;既有测试保持通过。 - DecisionProcess / ConfigSnapshots / TaskQueueMonitor 删除手写 fetch 样板(useState + try/catch + useEffect 三件套),改走 资源接缝;ExecutionProcess 的 5 个 fetch 槽同步收敛。 - 决策日志新鲜度归一:三种策略(父级 5s 详情轮询 + ExecutionProcess 独立 5s 轮询 + DecisionProcess 挂载取一次) 收敛为父级 evalActivity 单一供给,子组件消费同一份数据。 - useCampaignReport 删除私有 ReadPhase / ReadSlot 定义,改 import 自 readResource。 - ACTIVE_STATUSES 合并至 read/intelligentEval.ts 单一出口。 - 新增 utils/download.ts:downloadBlob / downloadJson 取代 api.ts 与 useFiles 中三处重复的 Blob 导出仪式(含 DOM 副作用迁出 接口定义出口)。 --- .../intelligent_eval/ConfigSnapshots.tsx | 40 ++--- .../intelligent_eval/DecisionProcess.tsx | 52 +++---- .../ExecutionProcess.test.tsx | 143 +++++++++-------- .../intelligent_eval/ExecutionProcess.tsx | 86 +++-------- .../intelligent_eval/TaskQueueMonitor.tsx | 44 ++---- frontend/web/src/hooks/useCampaignReport.ts | 84 +++------- frontend/web/src/hooks/useFiles.ts | 10 +- frontend/web/src/pages/IntelligentEvals.tsx | 35 ++++- frontend/web/src/read/evalActivity.test.ts | 52 +++++++ frontend/web/src/read/evalActivity.ts | 57 +++++++ frontend/web/src/read/intelligentEval.ts | 73 ++++----- frontend/web/src/read/readResource.test.tsx | 118 ++++++++++++++ frontend/web/src/read/readResource.ts | 144 ++++++++++++++++++ .../web/src/read/useIntelligentEvalRead.ts | 20 +-- frontend/web/src/utils/download.ts | 17 +++ 15 files changed, 608 insertions(+), 367 deletions(-) create mode 100644 frontend/web/src/read/evalActivity.test.ts create mode 100644 frontend/web/src/read/evalActivity.ts create mode 100644 frontend/web/src/read/readResource.test.tsx create mode 100644 frontend/web/src/read/readResource.ts create mode 100644 frontend/web/src/utils/download.ts diff --git a/frontend/web/src/components/intelligent_eval/ConfigSnapshots.tsx b/frontend/web/src/components/intelligent_eval/ConfigSnapshots.tsx index 8320f3b..07aff5d 100644 --- a/frontend/web/src/components/intelligent_eval/ConfigSnapshots.tsx +++ b/frontend/web/src/components/intelligent_eval/ConfigSnapshots.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Button, Card, Descriptions, Empty, Space, Table, Tag, message, } from 'antd' @@ -6,8 +6,10 @@ import type { ColumnsType } from 'antd/es/table' import { ArrowLeftOutlined, DiffOutlined, ReloadOutlined } from '@ant-design/icons' import SectionHeader from '../SectionHeader' import { intelligentEvalsApi, type ConfigSnapshot, type ConfigSnapshotComparison } from '../../api' +import { useReadResource } from '../../read/readResource' import { colors } from '../../tokens' import { formatDateTime } from '../../utils/date' +import { downloadJson } from '../../utils/download' const SNAPSHOT_TYPE_LABELS: Record = { created: { label: '创建', color: 'green' }, @@ -22,30 +24,17 @@ interface ConfigSnapshotsProps { } export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps) { - const [snapshots, setSnapshots] = useState(null) - const [loading, setLoading] = useState(false) + const { slot: snapshotsSlot, reload: reloadSnapshots } = useReadResource( + () => intelligentEvalsApi.listConfigSnapshots(evalId).then((res) => res.data.snapshots), + { key: evalId || null, fallbackError: '加载配置快照失败' }, + ) + const loading = snapshotsSlot.phase === 'loading' + const snapshots = snapshotsSlot.value const [selectedSnapshot, setSelectedSnapshot] = useState(null) const [compareMode, setCompareMode] = useState(false) const [selectedForCompare, setSelectedForCompare] = useState([]) const [comparison, setComparison] = useState(null) - const loadSnapshots = async () => { - setLoading(true) - try { - const res = await intelligentEvalsApi.listConfigSnapshots(evalId) - setSnapshots(res.data.snapshots) - } catch { - message.error('加载配置快照失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - if (evalId) void loadSnapshots() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [evalId]) - const handleViewDetail = async (snapshot: ConfigSnapshot) => { try { const res = await intelligentEvalsApi.getConfigSnapshot(evalId, snapshot.id) @@ -75,14 +64,7 @@ export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps } const handleExport = (snapshot: ConfigSnapshot) => { - const data = JSON.stringify(snapshot, null, 2) - const blob = new Blob([data], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `config-snapshot-${snapshot.id.slice(0, 8)}.json` - a.click() - URL.revokeObjectURL(url) + downloadJson(snapshot, `config-snapshot-${snapshot.id.slice(0, 8)}.json`) message.success('已导出快照') } @@ -295,7 +277,7 @@ export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps onBack={onBack} actions={( <> - + + )} diff --git a/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx b/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx index 0a79144..f2c6f93 100644 --- a/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx +++ b/frontend/web/src/components/intelligent_eval/ExecutionProcess.test.tsx @@ -1,22 +1,10 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionProgress, IntelligentEval, IntelligentEvalMessage, IntelligentEvalSession } from '../../api' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { DecisionLog, ExecutionProgress, IntelligentEval, IntelligentEvalMessage } from '../../api' +import type { EvalActivitySnapshot } from '../../read/evalActivity' +import type { ReadSlot } from '../../read/readResource' import ExecutionProcess from './ExecutionProcess' -const getExecutionProgress = vi.fn() -const listDecisionLogs = vi.fn() -const listTasks = vi.fn() -const listMessages = vi.fn() - -vi.mock('../../api', () => ({ - intelligentEvalsApi: { - getExecutionProgress: (...args: unknown[]) => getExecutionProgress(...args), - listDecisionLogs: (...args: unknown[]) => listDecisionLogs(...args), - listTasks: (...args: unknown[]) => listTasks(...args), - listMessages: (...args: unknown[]) => listMessages(...args), - }, -})) - const baseProgress: ExecutionProgress = { current_stage: 'executing', abnormal_outcome: null, @@ -67,8 +55,8 @@ function makeEval(overrides: Partial = {}): IntelligentEval { } } -function mockApi(progressOverrides: Partial = {}, logCount = 0) { - const logs = Array.from({ length: logCount }, (_, i) => ({ +function makeLogs(count: number): DecisionLog[] { + return Array.from({ length: count }, (_, i) => ({ id: `log-${i}`, eval_id: 'eval-1', decision_type: 'execute_session' as const, @@ -77,28 +65,22 @@ function mockApi(progressOverrides: Partial = {}, logCount = cron_id: 'cron-1', created_at: `2026-01-01T08:${String(i % 60).padStart(2, '0')}:00Z`, })) - getExecutionProgress.mockResolvedValue({ data: { ...baseProgress, ...progressOverrides } }) - listDecisionLogs.mockResolvedValue({ data: { logs } }) - listTasks.mockResolvedValue({ data: { tasks: [], stats: {} } }) - listMessages.mockResolvedValue({ data: { messages: [] } }) } -function makeRunningSession(id = 's-run'): IntelligentEvalSession { +function makeSnapshot(overrides: Partial = {}): EvalActivitySnapshot { return { - id, - eval_id: 'eval-1', - target_id: 't-1', - persona: { name: '新客户' }, - goal: '咨询价保', - dimension: null, - status: 'running', - verdict: null, - turn_count: 0, - created_at: '2026-01-01T09:00:00Z', - closed_at: null, + progress: baseProgress, + logs: [], + tasks: [], + messagesBySession: {}, + ...overrides, } } +function readySlot(value: EvalActivitySnapshot | null): ReadSlot { + return { phase: 'ready', value, error: null } +} + function makeMessage(i: number, role: string, content: string): IntelligentEvalMessage { return { id: `m-${i}`, @@ -110,55 +92,70 @@ function makeMessage(i: number, role: string, content: string): IntelligentEvalM } } -beforeEach(() => { - vi.clearAllMocks() -}) +const runningSession = { + id: 's-run', + eval_id: 'eval-1', + target_id: 't-1', + persona: { name: '新客户' }, + goal: '咨询价保', + dimension: null, + status: 'running' as const, + verdict: null, + turn_count: 0, + created_at: '2026-01-01T09:00:00Z', + closed_at: null, +} afterEach(() => { cleanup() }) -describe('ExecutionProcess', () => { - it('renders lifecycle timeline, next action and time distribution cards', async () => { - mockApi() - render() +describe('ExecutionProcess(注入读取槽的纯渲染)', () => { + it('renders lifecycle timeline, next action and time distribution cards', () => { + render() - await waitFor(() => expect(screen.getByText('0-1h')).toBeInTheDocument()) + expect(screen.getByText('0-1h')).toBeInTheDocument() expect(screen.getByText('1-2h')).toBeInTheDocument() expect(screen.getByText('规划')).toBeInTheDocument() expect(screen.getByText('审批')).toBeInTheDocument() expect(screen.getByText(/下一步:等待平台触发 worker 补足当前欠账 2 个会话/)).toBeInTheDocument() }) - it('shows blocker alert for abnormal outcomes', async () => { - mockApi({ abnormal_outcome: 'cancelled', blocker: '评估已取消', next_action: null }) - render() + it('shows blocker alert for abnormal outcomes', () => { + const snapshot = makeSnapshot({ + progress: { ...baseProgress, abnormal_outcome: 'cancelled', blocker: '评估已取消', next_action: null }, + }) + render() - await waitFor(() => expect(screen.getByText('评估已取消')).toBeInTheDocument()) + expect(screen.getByText('评估已取消')).toBeInTheDocument() }) - it('shows all activity entries with total count', async () => { - mockApi({}, 60) - render() + it('shows all activity entries with total count', () => { + render() - await waitFor(() => expect(screen.getByText(/共 \d+ 条活动/)).toBeInTheDocument()) + expect(screen.getByText(/共 \d+ 条活动/)).toBeInTheDocument() expect(screen.getByText(/决策·执行会话:时段欠账 59/)).toBeInTheDocument() expect(screen.getByText(/决策·执行会话:时段欠账 0/)).toBeInTheDocument() }) - it('scopes task loading to the current evaluation', async () => { - mockApi() - render() + it('shows a spinner before the first snapshot and an empty state on failure', () => { + const { unmount } = render( + , + ) + expect(screen.queryByText('执行过程数据不可用')).not.toBeInTheDocument() + unmount() - await waitFor(() => expect(listTasks).toHaveBeenCalledWith({ eval_id: 'eval-1', limit: 100 })) + render( + , + ) + expect(screen.getByText('执行过程数据不可用')).toBeInTheDocument() }) describe('进行中的会话卡片', () => { - it('renders live turn count and the latest 3 messages for running sessions', async () => { - mockApi() - listMessages.mockResolvedValue({ - data: { - messages: [ + it('renders live turn count and the latest 3 messages for running sessions', () => { + const snapshot = makeSnapshot({ + messagesBySession: { + 's-run': [ makeMessage(1, 'user', '最早的一条消息'), makeMessage(2, 'assistant', '较早的回复'), makeMessage(3, 'user', '我想问下价保怎么申请'), @@ -167,12 +164,10 @@ describe('ExecutionProcess', () => { ], }, }) - const running = makeRunningSession() - render() - - await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument()) - await waitFor(() => expect(listMessages).toHaveBeenCalledWith('eval-1', 's-run')) + const ev = makeEval({ sessions: [...(makeEval().sessions ?? []), runningSession] }) + render() + expect(screen.getByText('进行中的会话')).toBeInTheDocument() expect(screen.getAllByText('新客户').length).toBeGreaterThanOrEqual(1) expect(screen.getByText(/5 条消息/)).toBeInTheDocument() expect(screen.getByText(/我想问下价保怎么申请/)).toBeInTheDocument() @@ -185,21 +180,21 @@ describe('ExecutionProcess', () => { expect(screen.queryByText('较早的回复')).not.toBeInTheDocument() }) - it('shows next action hint when executing but no running session', async () => { - mockApi({ next_action: '等待平台触发 worker 补足当前欠账 2 个会话' }) - render() + it('shows next action hint when executing but no running session', () => { + render() - await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument()) + expect(screen.getByText('进行中的会话')).toBeInTheDocument() expect(screen.getByText(/当前没有进行中的会话:等待平台触发 worker 补足当前欠账 2 个会话/)).toBeInTheDocument() }) - it('shows placeholder text when evaluation has not started executing', async () => { - mockApi({ current_stage: 'planning', next_action: null }) - render() + it('shows placeholder text when evaluation has not started executing', () => { + const snapshot = makeSnapshot({ + progress: { ...baseProgress, current_stage: 'planning', next_action: null }, + }) + render() - await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument()) + expect(screen.getByText('进行中的会话')).toBeInTheDocument() expect(screen.getByText('评估尚未开始执行')).toBeInTheDocument() - expect(listMessages).not.toHaveBeenCalled() }) }) }) diff --git a/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx b/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx index 4bb043d..7599156 100644 --- a/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx +++ b/frontend/web/src/components/intelligent_eval/ExecutionProcess.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { Alert, Card, Empty, Spin, Tag, Timeline } from 'antd' import { CheckCircleOutlined, @@ -7,21 +7,12 @@ import { FileTextOutlined, PlayCircleOutlined, } from '@ant-design/icons' -import { - intelligentEvalsApi, - type DecisionLog, - type ExecutionProgress, - type IntelligentEval, - type IntelligentEvalMessage, - type IntelligentEvalSession, - type TaskQueueItem, -} from '../../api' +import type { DecisionLog, ExecutionProgress, IntelligentEval, IntelligentEvalMessage, IntelligentEvalSession, TaskQueueItem } from '../../api' +import type { EvalActivitySnapshot } from '../../read/evalActivity' +import type { ReadSlot } from '../../read/readResource' import { SESSION_STATUS, decisionTypeOf } from './status' import { colors, statusColors } from '../../tokens' import { formatDateTime } from '../../utils/date' -import { usePolling } from '../../hooks/usePolling' - -const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing']) const STAGE_CONFIG = [ { key: 'planning', title: '规划', icon: EditOutlined }, @@ -342,58 +333,18 @@ function LiveSessionsCard({ ) } -export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) { - const [progress, setProgress] = useState(null) - const [logs, setLogs] = useState([]) - const [tasks, setTasks] = useState([]) - const [messagesBySession, setMessagesBySession] = useState>({}) - const [loading, setLoading] = useState(true) - - // 依赖稳定的 id 键而非 ev.sessions 数组身份:父组件轮询会不断替换 ev, - // 若以数组为依赖,load 会每个轮询周期重建并触发重复全量加载。 - const runningKey = (ev.sessions ?? []) - .filter((s) => s.status === 'running') - .map((s) => s.id) - .sort() - .join('|') - - const load = useCallback(async () => { - try { - const [progressRes, logsRes, tasksRes] = await Promise.all([ - intelligentEvalsApi.getExecutionProgress(ev.id), - intelligentEvalsApi.listDecisionLogs(ev.id), - intelligentEvalsApi.listTasks({ eval_id: ev.id, limit: 100 }), - ]) - setProgress(progressRes.data) - setLogs(logsRes.data.logs) - setTasks(tasksRes.data.tasks) - - const runningIds = runningKey === '' ? [] : runningKey.split('|') - if (runningIds.length > 0) { - // N+1 API calls: one per running session. This is acceptable because: - // 1. Running sessions are typically few (1-3) at any time - // 2. Calls are parallelized with Promise.all - // 3. Adding a batch endpoint would increase backend complexity for minimal gain - const msgResults = await Promise.all(runningIds.map((sid) => intelligentEvalsApi.listMessages(ev.id, sid))) - const next: Record = {} - runningIds.forEach((sid, i) => { - next[sid] = msgResults[i].data.messages - }) - setMessagesBySession(next) - } else { - setMessagesBySession({}) - } - } finally { - setLoading(false) - } - }, [ev.id, runningKey]) - - useEffect(() => { - setLoading(true) - void load() - }, [load]) - - usePolling(() => { void load() }, 5000, ACTIVE_STATUSES.has(ev.status)) +export default function ExecutionProcess({ + ev, + activity, +}: { + ev: IntelligentEval + /** 父级读模块单一供给(见 read/evalActivity.ts);本组件只负责渲染。 */ + activity: ReadSlot +}) { + const snapshot = activity.value + const logs = snapshot?.logs ?? [] + const tasks = snapshot?.tasks ?? [] + const messagesBySession = snapshot?.messagesBySession ?? {} const activities = useMemo(() => { const all = [...sessionEvents(ev), ...taskEvents(tasks), ...decisionEvents(logs)] @@ -401,13 +352,14 @@ export default function ExecutionProcess({ ev }: { ev: IntelligentEval }) { return all }, [ev, tasks, logs]) - if (loading && progress == null) { + if (activity.phase === 'loading' && snapshot == null) { return
} - if (progress == null) { + if (snapshot == null) { return } + const progress = snapshot.progress const abnormal = progress.abnormal_outcome != null const runningSessions = (ev.sessions ?? []).filter((s) => s.status === 'running') diff --git a/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx b/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx index ff7f713..4cb9e93 100644 --- a/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx +++ b/frontend/web/src/components/intelligent_eval/TaskQueueMonitor.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react' import { - Button, Empty, Space, Table, Tag, Tooltip, message, + Button, Empty, Space, Table, Tag, Tooltip, } 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 { useReadResource } from '../../read/readResource' import { colors, statusColors } from '../../tokens' import { formatDateTime } from '../../utils/date' import { EVAL_STATUS } from './status' @@ -26,42 +26,24 @@ type FilterKey = 'all' | 'unresolved' | TaskQueueStatus * 展示任务明细与状态分布,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 [filter, setFilter] = useState('all') - const [page, setPage] = useState(1) - const [loading, setLoading] = useState(false) - // 一次拉取全部任务,筛选在前端完成(数据量小;unresolved 是 pending+assigned 并集, // 后端 status 筛选无法一次表达,且 stats 含派生的 unresolved,不能直接求和当总数)。 - const loadData = async () => { - setLoading(true) - try { - const res = await intelligentEvalsApi.listTasks() - setTasks(res.data.tasks) - setStats(res.data.stats) - } catch { - message.error('加载任务队列失败') - } finally { - setLoading(false) - } - } - - // Initial fetch (usePolling owns the 5s interval). 筛选/分页为前端状态。 - useEffect(() => { - void loadData() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + // 读取接缝内置 5s 轮询与静默刷新(轮询不再闪 loading)。 + const { slot, reload } = useReadResource( + () => intelligentEvalsApi.listTasks().then((res) => res.data), + { pollMs: 5000, fallbackError: '加载任务队列失败' }, + ) + const tasks = slot.value?.tasks ?? null + const stats = slot.value?.stats ?? null + const loading = slot.phase === 'loading' + const [filter, setFilter] = useState('all') + const [page, setPage] = useState(1) // 筛选变化时回到第 1 页 useEffect(() => { setPage(1) }, [filter]) - usePolling(() => { void loadData() }, 5000, true) - const allTasks = tasks ?? [] const visibleTasks = filter === 'all' ? allTasks @@ -142,7 +124,7 @@ export default function TaskQueueMonitor() {
-
{ - phase: ReadPhase - value: T - error: string | null -} - export interface CampaignReportSnapshot { report: CampaignReport runs: Run[] @@ -30,10 +33,7 @@ export interface CampaignReportSnapshot { interface CampaignReadState { list: ReadSlot - report: ReadSlot & { - selectedId: string | null - requestId: number - } + report: SelectedReadSlot } type CampaignReadAction = @@ -85,72 +85,36 @@ const initialState: CampaignReadState = { }, } -function requestSlot(slot: ReadSlot, silent: boolean | undefined): ReadSlot { - return { - ...slot, - phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', - error: null, - } -} - function reducer(state: CampaignReadState, action: CampaignReadAction): CampaignReadState { switch (action.type) { case 'list_requested': - return { ...state, list: requestSlot(state.list, action.silent) } + return { ...state, list: slotRequested(state.list, action.silent) } case 'list_succeeded': - return { ...state, list: { phase: 'ready', value: action.value, error: null } } + return { ...state, list: slotSucceeded(action.value) } case 'list_failed': - return { - ...state, - list: state.list.value.length > 0 - ? { ...state.list, phase: 'ready', error: null } - : { ...state.list, phase: 'error', error: action.error }, - } + return { ...state, list: slotFailed(state.list, action.error) } case 'report_cleared': + return { ...state, report: selectedSlotCleared(action.requestId) } + case 'report_requested': return { ...state, - report: { - phase: 'idle', value: null, error: null, selectedId: null, requestId: action.requestId, - }, + report: selectedSlotRequested(state.report, action.id, action.requestId, action.silent), } - case 'report_requested': { - const sameSelection = state.report.selectedId === action.id - const current = sameSelection - ? state.report - : { ...state.report, value: null, selectedId: action.id } - return { - ...state, - report: { - ...requestSlot(current, action.silent), - selectedId: action.id, - requestId: action.requestId, - }, - } - } case 'report_succeeded': - if (state.report.selectedId !== action.id || state.report.requestId !== action.requestId) return state + if (isStaleResponse(state.report, action.id, action.requestId)) return state return { ...state, report: { - phase: 'ready', value: action.value, error: null, + ...slotSucceeded(action.value), selectedId: action.id, requestId: action.requestId, }, } case 'report_failed': - if (state.report.selectedId !== action.id || state.report.requestId !== action.requestId) return state - return { - ...state, - report: state.report.value - ? { ...state.report, phase: 'ready', error: null } - : { ...state.report, phase: 'error', error: action.error }, - } + if (isStaleResponse(state.report, action.id, action.requestId)) return state + return { ...state, report: slotFailed(state.report, action.error) } } } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : '读取评估活动失败' -} - const isCampaignActive = (status: CampaignListItem['status'] | undefined) => ( status === 'planned' || status === 'running' ) @@ -178,7 +142,7 @@ export function useCampaignReport( try { dispatch({ type: 'list_succeeded', value: await adapter.list() }) } catch (error) { - dispatch({ type: 'list_failed', error: errorMessage(error) }) + dispatch({ type: 'list_failed', error: errorMessage(error, '读取评估活动失败') }) } }, [adapter]) @@ -189,7 +153,7 @@ export function useCampaignReport( const value = await adapter.report(campaignId) dispatch({ type: 'report_succeeded', id: campaignId, requestId, value }) } catch (error) { - dispatch({ type: 'report_failed', id: campaignId, requestId, error: errorMessage(error) }) + dispatch({ type: 'report_failed', id: campaignId, requestId, error: errorMessage(error, '读取评估活动失败') }) } }, [adapter]) diff --git a/frontend/web/src/hooks/useFiles.ts b/frontend/web/src/hooks/useFiles.ts index aad4b1b..d28514f 100644 --- a/frontend/web/src/hooks/useFiles.ts +++ b/frontend/web/src/hooks/useFiles.ts @@ -6,6 +6,7 @@ import { type FileUploadConfig, } from '../api' import { categoryContains, findCategory } from '../utils/fileTree' +import { downloadBlob } from '../utils/download' import { useOnTabActive } from './useOnTabActive' const DEFAULT_CONFIG: FileUploadConfig = { @@ -108,14 +109,7 @@ export function useFiles(tabPath?: string) { const downloadFile = useCallback(async (record: FileRecord) => { const response = await filesApi.download(record.id) - const url = URL.createObjectURL(response.data) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = record.original_name - document.body.appendChild(anchor) - anchor.click() - anchor.remove() - URL.revokeObjectURL(url) + downloadBlob(response.data, record.original_name) }, []) const selectedCategoryName = useMemo(() => { diff --git a/frontend/web/src/pages/IntelligentEvals.tsx b/frontend/web/src/pages/IntelligentEvals.tsx index eba9cdb..4508da0 100644 --- a/frontend/web/src/pages/IntelligentEvals.tsx +++ b/frontend/web/src/pages/IntelligentEvals.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Button, Drawer, Empty, Form, Input, InputNumber, Popconfirm, Progress, Select, Space, Spin, Table, Tabs, Tag, Tooltip, message, @@ -25,6 +25,8 @@ import { useTabStore, type TabItem } from '../stores/tabStore' import { colors, statusColors } from '../tokens' import { formatDateTime } from '../utils/date' import { useIntelligentEvalRead } from '../read/useIntelligentEvalRead' +import { isEvalActive } from '../read/intelligentEval' +import { useEvalActivityRead } from '../read/evalActivity' interface CreateFormValues { name: string @@ -82,6 +84,23 @@ export default function IntelligentEvalsPage() { const selected = selectedId != null && detail.value?.id === selectedId ? detail.value : null + // 抽屉子资源由父级读模块单一供给:执行过程 / 决策过程两个标签页消费 + // 同一份数据(活跃评估 5s 轮询,终态停止),不再有各自的取数循环。 + const runningIds = useMemo( + () => (selected?.sessions ?? []).filter((s) => s.status === 'running').map((s) => s.id).sort(), + [selected], + ) + const activity = useEvalActivityRead( + selectedId, + runningIds, + selected != null && isEvalActive(selected.status), + ) + const logsSlot = { + phase: activity.slot.phase, + value: activity.slot.value?.logs ?? null, + error: activity.slot.error, + } + const targetName = (id: string) => targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8) @@ -212,9 +231,19 @@ export default function IntelligentEvalsPage() { { key: 'execution', label: '执行过程', - children: , + children: , + }, + { + key: 'decision', + label: '决策过程', + children: ( + void activity.reload()} + /> + ), }, - { key: 'decision', label: '决策过程', children: }, { key: 'history', label: '配置历史', children: }, ...(selected.status === 'completed' ? [{ key: 'report' as const, label: '评估报告', children: }] : []), ] diff --git a/frontend/web/src/read/evalActivity.test.ts b/frontend/web/src/read/evalActivity.test.ts new file mode 100644 index 0000000..89e1d86 --- /dev/null +++ b/frontend/web/src/read/evalActivity.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { loadEvalActivity } from './evalActivity' + +const getExecutionProgress = vi.fn() +const listDecisionLogs = vi.fn() +const listTasks = vi.fn() +const listMessages = vi.fn() + +vi.mock('../api', () => ({ + intelligentEvalsApi: { + getExecutionProgress: (...args: unknown[]) => getExecutionProgress(...args), + listDecisionLogs: (...args: unknown[]) => listDecisionLogs(...args), + listTasks: (...args: unknown[]) => listTasks(...args), + listMessages: (...args: unknown[]) => listMessages(...args), + }, +})) + +beforeEach(() => { + vi.clearAllMocks() + getExecutionProgress.mockResolvedValue({ data: { current_stage: 'executing', slots: [] } }) + listDecisionLogs.mockResolvedValue({ data: { logs: [{ id: 'log-1' }] } }) + listTasks.mockResolvedValue({ data: { tasks: [], stats: {} } }) + listMessages.mockResolvedValue({ data: { messages: [] } }) +}) + +describe('loadEvalActivity(执行子资源一次取齐)', () => { + it('scopes every sub-query to the given evaluation', async () => { + await loadEvalActivity('eval-1', []) + + expect(getExecutionProgress).toHaveBeenCalledWith('eval-1') + expect(listDecisionLogs).toHaveBeenCalledWith('eval-1') + expect(listTasks).toHaveBeenCalledWith({ eval_id: 'eval-1', limit: 100 }) + expect(listMessages).not.toHaveBeenCalled() + }) + + it('fetches messages per running session and keys them by session id', async () => { + listMessages.mockImplementation((_evalId: string, sessionId: string) => ( + Promise.resolve({ data: { messages: [{ id: `m-${sessionId}` }] } }) + )) + + const snapshot = await loadEvalActivity('eval-1', ['s-1', 's-2']) + + expect(listMessages).toHaveBeenCalledWith('eval-1', 's-1') + expect(listMessages).toHaveBeenCalledWith('eval-1', 's-2') + expect(snapshot.messagesBySession).toEqual({ + 's-1': [{ id: 'm-s-1' }], + 's-2': [{ id: 'm-s-2' }], + }) + expect(snapshot.progress).toEqual({ current_stage: 'executing', slots: [] }) + expect(snapshot.logs).toEqual([{ id: 'log-1' }]) + }) +}) diff --git a/frontend/web/src/read/evalActivity.ts b/frontend/web/src/read/evalActivity.ts new file mode 100644 index 0000000..8236da6 --- /dev/null +++ b/frontend/web/src/read/evalActivity.ts @@ -0,0 +1,57 @@ +import { intelligentEvalsApi, type DecisionLog, type ExecutionProgress, type IntelligentEvalMessage, type TaskQueueItem } from '../api' +import { useReadResource, type ReadResource } from './readResource' + +/** + * 选中评估的执行子资源单一供给:进度、决策日志、任务、进行中会话消息 + * 一次取齐(执行过程 / 决策过程两个抽屉标签页共用同一份数据)。 + * 活跃评估 5s 轮询,终态后停止。 + */ + +export interface EvalActivitySnapshot { + progress: ExecutionProgress + logs: DecisionLog[] + tasks: TaskQueueItem[] + messagesBySession: Record +} + +export async function loadEvalActivity(evalId: string, runningSessionIds: string[]): Promise { + const [progressRes, logsRes, tasksRes] = await Promise.all([ + intelligentEvalsApi.getExecutionProgress(evalId), + intelligentEvalsApi.listDecisionLogs(evalId), + intelligentEvalsApi.listTasks({ eval_id: evalId, limit: 100 }), + ]) + // 进行中会话通常只有 1-3 个:逐会话并行取消息,不值得为此加批量端点。 + const msgResults = await Promise.all( + runningSessionIds.map((sessionId) => intelligentEvalsApi.listMessages(evalId, sessionId)), + ) + const messagesBySession: Record = {} + runningSessionIds.forEach((sessionId, i) => { + messagesBySession[sessionId] = msgResults[i].data.messages + }) + return { + progress: progressRes.data, + logs: logsRes.data.logs, + tasks: tasksRes.data.tasks, + messagesBySession, + } +} + +export interface EvalActivityAdapter { + load: (evalId: string, runningSessionIds: string[]) => Promise +} + +export const evalActivityAdapter: EvalActivityAdapter = { load: loadEvalActivity } + +export function useEvalActivityRead( + evalId: string | null, + runningSessionIds: string[], + active: boolean, + adapter: EvalActivityAdapter = evalActivityAdapter, +): ReadResource { + // 键含进行中会话集合:会话开/关时自动重取,无需手工失效。 + const key = evalId == null ? null : `${evalId}|${runningSessionIds.join(',')}` + return useReadResource( + () => adapter.load(evalId as string, runningSessionIds), + { key, pollMs: 5000, polling: active, fallbackError: '执行过程数据不可用' }, + ) +} diff --git a/frontend/web/src/read/intelligentEval.ts b/frontend/web/src/read/intelligentEval.ts index 4827511..29cddeb 100644 --- a/frontend/web/src/read/intelligentEval.ts +++ b/frontend/web/src/read/intelligentEval.ts @@ -1,19 +1,27 @@ import { intelligentEvalsApi, type IntelligentEval } from '../api' +import { + isStaleResponse, + selectedSlotCleared, + selectedSlotRequested, + slotFailed, + slotRequested, + slotSucceeded, + type ReadSlot, + type SelectedReadSlot, +} from './readResource' -export type ReadPhase = 'idle' | 'loading' | 'refreshing' | 'ready' | 'error' +export type { ReadPhase, ReadSlot } from './readResource' -export interface ReadSlot { - phase: ReadPhase - value: T - error: string | null +/** 活跃状态正源:轮询门控共用(useIntelligentEvalRead 与详情子资源)。 */ +export const INTELLIGENT_EVAL_ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing']) + +export function isEvalActive(status: string | undefined): boolean { + return status != null && INTELLIGENT_EVAL_ACTIVE_STATUSES.has(status) } export interface IntelligentEvalReadState { list: ReadSlot & { total: number; stats: Record | null } - detail: ReadSlot & { - selectedId: string | null - requestId: number - } + detail: SelectedReadSlot } export type IntelligentEvalReadAction = @@ -56,63 +64,36 @@ export const initialIntelligentEvalReadState: IntelligentEvalReadState = { detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 }, } -function requestPhase>(slot: S, silent: boolean | undefined): S { - return { ...slot, phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', error: null } -} - export function intelligentEvalReadReducer( state: IntelligentEvalReadState, action: IntelligentEvalReadAction, ): IntelligentEvalReadState { switch (action.type) { case 'list_requested': - return { ...state, list: requestPhase(state.list, action.silent) } + return { ...state, list: slotRequested(state.list, action.silent) } case 'list_succeeded': return { ...state, - list: { phase: 'ready', value: action.value, error: null, total: action.total, stats: action.stats }, + list: { ...slotSucceeded(action.value), total: action.total, stats: action.stats }, } case 'list_failed': - return { - ...state, - list: state.list.value.length > 0 - ? { ...state.list, phase: 'ready', error: null } - : { ...state.list, phase: 'error', error: action.error }, - } + return { ...state, list: slotFailed(state.list, action.error) } case 'detail_cleared': - return { - ...state, - detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: action.requestId }, - } - case 'detail_requested': { - const sameSelection = state.detail.selectedId === action.id - const current = sameSelection - ? state.detail - : { ...state.detail, value: null, selectedId: action.id } - return { - ...state, - detail: { ...requestPhase(current, action.silent), selectedId: action.id, requestId: action.requestId }, - } - } + return { ...state, detail: selectedSlotCleared(action.requestId) } + case 'detail_requested': + return { ...state, detail: selectedSlotRequested(state.detail, action.id, action.requestId, action.silent) } case 'detail_succeeded': - if (state.detail.selectedId !== action.id || state.detail.requestId !== action.requestId) return state + if (isStaleResponse(state.detail, action.id, action.requestId)) return state return { ...state, detail: { - phase: 'ready', - value: action.value, - error: null, + ...slotSucceeded(action.value), selectedId: action.id, requestId: action.requestId, }, } case 'detail_failed': - if (state.detail.selectedId !== action.id || state.detail.requestId !== action.requestId) return state - return { - ...state, - detail: state.detail.value - ? { ...state.detail, phase: 'ready', error: null } - : { ...state.detail, phase: 'error', error: action.error }, - } + if (isStaleResponse(state.detail, action.id, action.requestId)) return state + return { ...state, detail: slotFailed(state.detail, action.error) } } } diff --git a/frontend/web/src/read/readResource.test.tsx b/frontend/web/src/read/readResource.test.tsx new file mode 100644 index 0000000..2741384 --- /dev/null +++ b/frontend/web/src/read/readResource.test.tsx @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + idleSlot, + isStaleResponse, + selectedSlotCleared, + selectedSlotRequested, + slotFailed, + slotRequested, + slotSucceeded, + useReadResource, +} from './readResource' + +afterEach(() => { + vi.useRealTimers() +}) + +async function settle() { + await act(async () => { await Promise.resolve() }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} + +describe('slot phase machine', () => { + const ready = { ...slotSucceeded(['a']), total: 1 } + + it('silent refresh over ready data keeps the snapshot visible', () => { + expect(slotRequested(ready, true).phase).toBe('refreshing') + expect(slotRequested(ready, true).value).toEqual(['a']) + expect(slotRequested(idleSlot([]), true).phase).toBe('loading') + }) + + it('failure retains a non-empty snapshot and surfaces errors otherwise', () => { + expect(slotFailed(ready, '网络错误')).toEqual({ ...ready, phase: 'ready', error: null }) + expect(slotFailed(idleSlot([]), '网络错误').phase).toBe('error') + expect(slotFailed(idleSlot(null), '不存在').error).toBe('不存在') + }) +}) + +describe('selected slot race guard', () => { + it('resets the value when the selection changes and drops stale responses', () => { + const first = selectedSlotRequested(selectedSlotCleared(0), 'a', 1, false) + const second = selectedSlotRequested(first, 'b', 2, false) + expect(second.value).toBeNull() + expect(second.selectedId).toBe('b') + expect(isStaleResponse(second, 'a', 1)).toBe(true) + expect(isStaleResponse(second, 'b', 2)).toBe(false) + }) +}) + +describe('useReadResource', () => { + it('loads on mount, keeps the snapshot on silent failure, and polls while gated on', async () => { + vi.useFakeTimers() + const fetcher = vi.fn() + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error('网络错误')) + const { result } = renderHook(() => useReadResource(fetcher, { pollMs: 5000 })) + await settle() + expect(result.current.slot).toEqual({ phase: 'ready', value: { ok: true }, error: null }) + + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + await settle() + expect(fetcher).toHaveBeenCalledTimes(2) + expect(result.current.slot.phase).toBe('ready') + expect(result.current.slot.value).toEqual({ ok: true }) + }) + + it('stops polling when the gate flips false', async () => { + vi.useFakeTimers() + const fetcher = vi.fn().mockResolvedValue('x') + const { rerender } = renderHook( + ({ polling }) => useReadResource(fetcher, { pollMs: 5000, polling }), + { initialProps: { polling: true } }, + ) + await settle() + expect(fetcher).toHaveBeenCalledTimes(1) + + rerender({ polling: false }) + await act(async () => { await vi.advanceTimersByTimeAsync(15000) }) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('drops a stale response after the key changes', async () => { + const first = deferred() + const second = deferred() + const fetchers: Record Promise> = { + a: () => first.promise, + b: () => second.promise, + } + const { result, rerender } = renderHook( + ({ key }) => useReadResource(() => fetchers[key](), { key }), + { initialProps: { key: 'a' } }, + ) + rerender({ key: 'b' }) + await act(async () => { second.resolve('B') }) + expect(result.current.slot.value).toBe('B') + + await act(async () => { first.resolve('A') }) + expect(result.current.slot.value).toBe('B') + }) + + it('clears the slot when the key becomes null', async () => { + const fetcher = vi.fn().mockResolvedValue('x') + const { result, rerender } = renderHook( + ({ key }) => useReadResource(fetcher, { key }), + { initialProps: { key: 'a' as string | null } }, + ) + await settle() + expect(result.current.slot.phase).toBe('ready') + + rerender({ key: null }) + expect(result.current.slot).toEqual({ phase: 'idle', value: null, error: null }) + }) +}) diff --git a/frontend/web/src/read/readResource.ts b/frontend/web/src/read/readResource.ts new file mode 100644 index 0000000..2a8894b --- /dev/null +++ b/frontend/web/src/read/readResource.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react' +import { usePolling } from '../hooks/usePolling' + +/** + * 通用资源读取接缝:相位机(idle/loading/refreshing/ready/error)、 + * 竞态守卫、静默刷新、轮询门控全部内置。领域读模块(如 + * intelligentEval.ts)用 slotRequested/slotSucceeded/slotFailed 组装 + * 自己的 reducer;简单组件直接用 useReadResource 一个 hook 取数。 + */ + +export type ReadPhase = 'idle' | 'loading' | 'refreshing' | 'ready' | 'error' + +export interface ReadSlot { + phase: ReadPhase + value: T + error: string | null +} + +export function idleSlot(value: T): ReadSlot { + return { phase: 'idle', value, error: null } +} + +function hasData(value: unknown): boolean { + return Array.isArray(value) ? value.length > 0 : value != null +} + +/** 请求相位:ready 数据之上的静默刷新保持旧快照可见(refreshing)。 */ +export function slotRequested>(slot: S, silent: boolean | undefined): S { + return { ...slot, phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', error: null } +} + +export function slotSucceeded(value: T): ReadSlot { + return { phase: 'ready', value, error: null } +} + +/** 失败相位:仍有可用快照时保留数据不报错,否则落 error。 */ +export function slotFailed>(slot: S, error: string): S { + const next = hasData(slot.value) + ? { ...slot, phase: 'ready', error: null } + : { ...slot, phase: 'error', error } + return next as S +} + +/** 选中型槽(列表选中一条取详情):携带选中 id 与请求序号做竞态守卫。 */ +export interface SelectedReadSlot extends ReadSlot { + selectedId: string | null + requestId: number +} + +export function selectedSlotCleared(requestId: number): SelectedReadSlot { + return { phase: 'idle', value: null, error: null, selectedId: null, requestId } +} + +export function selectedSlotRequested( + slot: SelectedReadSlot, + id: string, + requestId: number, + silent: boolean | undefined, +): SelectedReadSlot { + const base = slot.selectedId === id ? slot : { ...slot, value: null, selectedId: id } + return { ...slotRequested(base, silent), selectedId: id, requestId } +} + +/** 迟到响应守卫:选中已切换或序号过期时返回 true,reducer 应原样返回。 */ +export function isStaleResponse(slot: SelectedReadSlot, id: string, requestId: number): boolean { + return slot.selectedId !== id || slot.requestId !== requestId +} + +export function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback +} + +export interface UseReadResourceOptions { + /** 依赖该键取数;键变化重新取数;null 表示不取数并清空槽。 */ + key?: string | null + /** 轮询间隔(毫秒);0 或不传表示不轮询。 */ + pollMs?: number + /** 轮询门控:为 false 时暂停轮询(默认 true)。 */ + polling?: boolean + /** 非 Error 异常时的兜底错误文案。 */ + fallbackError?: string +} + +export interface ReadResource { + slot: ReadSlot + reload: (silent?: boolean) => Promise +} + +type ResourceAction = + | { type: 'requested'; silent?: boolean } + | { type: 'succeeded'; value: T } + | { type: 'failed'; error: string } + | { type: 'cleared' } + +function resourceReducer(slot: ReadSlot, action: ResourceAction): ReadSlot { + switch (action.type) { + case 'requested': + return slotRequested(slot, action.silent) + case 'succeeded': + return slotSucceeded(action.value) + case 'failed': + return slotFailed(slot, action.error) + case 'cleared': + return idleSlot(null) + } +} + +export function useReadResource(fetcher: () => Promise, options: UseReadResourceOptions = {}): ReadResource { + const { key = '', pollMs = 0, polling = true, fallbackError = '加载数据失败' } = options + const [slot, dispatch] = useReducer(resourceReducer, null, idleSlot) + + const fetcherRef = useRef(fetcher) + fetcherRef.current = fetcher + const requestId = useRef(0) + + const reload = useCallback(async (silent = false) => { + const id = ++requestId.current + dispatch({ type: 'requested', silent }) + try { + const value = await fetcherRef.current() + if (id === requestId.current) dispatch({ type: 'succeeded', value }) + } catch (error) { + if (id === requestId.current) dispatch({ type: 'failed', error: errorMessage(error, fallbackError) }) + } + }, [fallbackError]) + + useEffect(() => { + if (key == null) { + requestId.current += 1 + dispatch({ type: 'cleared' }) + return + } + void reload() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, reload]) + + usePolling( + () => { if (key != null) void reload(true) }, + pollMs, + pollMs > 0 && polling && key != null, + ) + + return { slot, reload } +} diff --git a/frontend/web/src/read/useIntelligentEvalRead.ts b/frontend/web/src/read/useIntelligentEvalRead.ts index 1fd8e1e..1d2aebf 100644 --- a/frontend/web/src/read/useIntelligentEvalRead.ts +++ b/frontend/web/src/read/useIntelligentEvalRead.ts @@ -3,21 +3,13 @@ import { intelligentEvalReadAdapter, intelligentEvalReadReducer, initialIntelligentEvalReadState, + isEvalActive, type IntelligentEvalReadAdapter, type IntelligentEvalReadState, } from './intelligentEval' +import { errorMessage } from './readResource' import { usePolling } from '../hooks/usePolling' -const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing']) - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : '读取智能评估失败' -} - -function isActive(status: string | undefined): boolean { - return status != null && ACTIVE_STATUSES.has(status) -} - export function useIntelligentEvalRead( selectedId: string | null, adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter, @@ -34,7 +26,7 @@ export function useIntelligentEvalRead( 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) }) + dispatch({ type: 'list_failed', error: errorMessage(error, '读取智能评估失败') }) } }, [adapter, page, pageSize, status]) @@ -45,7 +37,7 @@ export function useIntelligentEvalRead( const value = await adapter.get(id) dispatch({ type: 'detail_succeeded', id, requestId, value }) } catch (error) { - dispatch({ type: 'detail_failed', id, requestId, error: errorMessage(error) }) + dispatch({ type: 'detail_failed', id, requestId, error: errorMessage(error, '读取智能评估失败') }) } }, [adapter]) @@ -61,8 +53,8 @@ export function useIntelligentEvalRead( void loadDetail(selectedId) }, [loadDetail, selectedId]) - const listActive = state.list.value.some((item) => isActive(item.status)) - const detailActive = isActive(state.detail.value?.status) + const listActive = state.list.value.some((item) => isEvalActive(item.status)) + const detailActive = isEvalActive(state.detail.value?.status) usePolling(() => { void loadList(true) }, 5000, listActive) usePolling( diff --git a/frontend/web/src/utils/download.ts b/frontend/web/src/utils/download.ts new file mode 100644 index 0000000..74084b4 --- /dev/null +++ b/frontend/web/src/utils/download.ts @@ -0,0 +1,17 @@ +/** + * Blob 下载仪式单一出口:创建对象 URL → 触发锚点点击 → 回收。 + * api.ts 的响应式下载与各组件的 JSON 导出都走这里,不再各自手写 DOM 副作用。 + */ + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function downloadJson(data: unknown, filename: string): void { + downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), filename) +}