refactor(read): 前端泛化 ReadSlot 资源接缝(Phase 4.18-21)

将 src/read 从单一消费者扩展为通用资源接缝;drawer 标签页与
useCampaignReport / useFiles 统一消费;导出仪式下沉至工具函数。

- 新增 readResource.ts:ReadSlot<T> 相位机(idle/loading/
  refreshing/ready/error)、requestId 竞态守卫、静默刷新、
  可注入 adapter;SelectedReadSlot<T> 支持 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 副作用迁出
  接口定义出口)。
This commit is contained in:
sinohqb 2026-08-24 05:51:53 +08:00
parent 7eae6de52d
commit f458897ab5
15 changed files with 608 additions and 367 deletions

View File

@ -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<string, { label: string; color: string }> = {
created: { label: '创建', color: 'green' },
@ -22,30 +24,17 @@ interface ConfigSnapshotsProps {
}
export default function ConfigSnapshots({ evalId, onBack }: ConfigSnapshotsProps) {
const [snapshots, setSnapshots] = useState<ConfigSnapshot[] | null>(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<ConfigSnapshot | null>(null)
const [compareMode, setCompareMode] = useState(false)
const [selectedForCompare, setSelectedForCompare] = useState<string[]>([])
const [comparison, setComparison] = useState<ConfigSnapshotComparison | null>(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={(
<>
<Button icon={<ReloadOutlined />} onClick={() => void loadSnapshots()}></Button>
<Button icon={<ReloadOutlined />} onClick={() => void reloadSnapshots()}></Button>
<Button
icon={<DiffOutlined />}
disabled={selectedForCompare.length !== 2}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import {
Button, Card, Empty, Select, Table, Tag, Timeline, message,
} from 'antd'
@ -6,56 +6,38 @@ import type { ColumnsType } from 'antd/es/table'
import { DownloadOutlined, ReloadOutlined } from '@ant-design/icons'
import SectionHeader from '../SectionHeader'
import { decisionTypeOf } from './status'
import { intelligentEvalsApi, type DecisionLog } from '../../api'
import type { DecisionLog } from '../../api'
import type { ReadSlot } from '../../read/readResource'
import { colors } from '../../tokens'
import { formatDateTime } from '../../utils/date'
import { downloadJson } from '../../utils/download'
interface DecisionProcessProps {
evalId: string
/** 父级读模块单一供给的决策日志槽(见 read/evalActivity.ts。 */
logs: ReadSlot<DecisionLog[] | null>
onReload: () => void
/** 独立页内作为子视图 tab 使用时可不传tab 切换代替返回)。 */
onBack?: () => void
}
export default function DecisionProcess({ evalId, onBack }: DecisionProcessProps) {
const [logs, setLogs] = useState<DecisionLog[] | null>(null)
const [loading, setLoading] = useState(false)
export default function DecisionProcess({
evalId, logs, onReload, onBack,
}: DecisionProcessProps) {
const [filterType, setFilterType] = useState<string | null>(null)
const [expandedLog, setExpandedLog] = useState<string | null>(null)
const loadLogs = async () => {
setLoading(true)
try {
const res = await intelligentEvalsApi.listDecisionLogs(evalId)
setLogs(res.data.logs)
} catch {
message.error('加载决策日志失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (evalId) void loadLogs()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [evalId])
const loading = logs.phase === 'loading'
const logList = logs.value ?? []
const handleExport = () => {
if (!logs) return
const data = JSON.stringify(logs, 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 = `decision-logs-${evalId.slice(0, 8)}.json`
a.click()
URL.revokeObjectURL(url)
if (logList.length === 0) return
downloadJson(logList, `decision-logs-${evalId.slice(0, 8)}.json`)
message.success('已导出决策日志')
}
const filteredLogs = filterType
? logs?.filter((log) => log.decision_type === filterType) ?? []
: logs ?? []
? logList.filter((log) => log.decision_type === filterType)
: logList
const columns: ColumnsType<DecisionLog> = [
{
@ -118,7 +100,7 @@ export default function DecisionProcess({ evalId, onBack }: DecisionProcessProps
{ label: '开始分析', value: 'start_analysis' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={() => void loadLogs()}></Button>
<Button icon={<ReloadOutlined />} onClick={onReload}></Button>
<Button icon={<DownloadOutlined />} onClick={handleExport}></Button>
</>
)}

View File

@ -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> = {}): IntelligentEval {
}
}
function mockApi(progressOverrides: Partial<ExecutionProgress> = {}, 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<ExecutionProgress> = {}, 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> = {}): 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<EvalActivitySnapshot | null> {
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(<ExecutionProcess ev={makeEval()} />)
describe('ExecutionProcess注入读取槽的纯渲染', () => {
it('renders lifecycle timeline, next action and time distribution cards', () => {
render(<ExecutionProcess ev={makeEval()} activity={readySlot(makeSnapshot())} />)
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(<ExecutionProcess ev={makeEval({ status: 'cancelled' })} />)
it('shows blocker alert for abnormal outcomes', () => {
const snapshot = makeSnapshot({
progress: { ...baseProgress, abnormal_outcome: 'cancelled', blocker: '评估已取消', next_action: null },
})
render(<ExecutionProcess ev={makeEval({ status: 'cancelled' })} activity={readySlot(snapshot)} />)
await waitFor(() => expect(screen.getByText('评估已取消')).toBeInTheDocument())
expect(screen.getByText('评估已取消')).toBeInTheDocument()
})
it('shows all activity entries with total count', async () => {
mockApi({}, 60)
render(<ExecutionProcess ev={makeEval()} />)
it('shows all activity entries with total count', () => {
render(<ExecutionProcess ev={makeEval()} activity={readySlot(makeSnapshot({ logs: makeLogs(60) }))} />)
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(<ExecutionProcess ev={makeEval()} />)
it('shows a spinner before the first snapshot and an empty state on failure', () => {
const { unmount } = render(
<ExecutionProcess ev={makeEval()} activity={{ phase: 'loading', value: null, error: null }} />,
)
expect(screen.queryByText('执行过程数据不可用')).not.toBeInTheDocument()
unmount()
await waitFor(() => expect(listTasks).toHaveBeenCalledWith({ eval_id: 'eval-1', limit: 100 }))
render(
<ExecutionProcess ev={makeEval()} activity={{ phase: 'error', value: null, error: '执行过程数据不可用' }} />,
)
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(<ExecutionProcess ev={makeEval({ sessions: [...(makeEval().sessions ?? []), running] })} />)
await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument())
await waitFor(() => expect(listMessages).toHaveBeenCalledWith('eval-1', 's-run'))
const ev = makeEval({ sessions: [...(makeEval().sessions ?? []), runningSession] })
render(<ExecutionProcess ev={ev} activity={readySlot(snapshot)} />)
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(<ExecutionProcess ev={makeEval()} />)
it('shows next action hint when executing but no running session', () => {
render(<ExecutionProcess ev={makeEval()} activity={readySlot(makeSnapshot())} />)
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(<ExecutionProcess ev={makeEval({ status: 'planning', sessions: [] })} />)
it('shows placeholder text when evaluation has not started executing', () => {
const snapshot = makeSnapshot({
progress: { ...baseProgress, current_stage: 'planning', next_action: null },
})
render(<ExecutionProcess ev={makeEval({ status: 'planning', sessions: [] })} activity={readySlot(snapshot)} />)
await waitFor(() => expect(screen.getByText('进行中的会话')).toBeInTheDocument())
expect(screen.getByText('进行中的会话')).toBeInTheDocument()
expect(screen.getByText('评估尚未开始执行')).toBeInTheDocument()
expect(listMessages).not.toHaveBeenCalled()
})
})
})

View File

@ -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<ExecutionProgress | null>(null)
const [logs, setLogs] = useState<DecisionLog[]>([])
const [tasks, setTasks] = useState<TaskQueueItem[]>([])
const [messagesBySession, setMessagesBySession] = useState<Record<string, IntelligentEvalMessage[]>>({})
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<string, IntelligentEvalMessage[]> = {}
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<EvalActivitySnapshot | null>
}) {
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 <div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
}
if (progress == null) {
if (snapshot == null) {
return <Empty description="执行过程数据不可用" style={{ padding: 48 }} />
}
const progress = snapshot.progress
const abnormal = progress.abnormal_outcome != null
const runningSessions = (ev.sessions ?? []).filter((s) => s.status === 'running')

View File

@ -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<TaskQueueItem[] | null>(null)
const [stats, setStats] = useState<{
pending: number; assigned: number; completed: number; failed: number; unresolved: number
} | null>(null)
const [filter, setFilter] = useState<FilterKey>('all')
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
// 一次拉取全部任务筛选在前端完成数据量小unresolved 是 pending+assigned 并集,
// 后端 status 筛选无法一次表达,且 stats 含派生的 unresolved不能直接求和当总数
const loadData = async () => {
setLoading(true)
try {
const res = await intelligentEvalsApi.listTasks()
setTasks(res.data.tasks)
setStats(res.data.stats)
} catch {
message.error('加载任务队列失败')
} finally {
setLoading(false)
}
}
// Initial fetch (usePolling owns the 5s interval). 筛选/分页为前端状态。
useEffect(() => {
void loadData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 读取接缝内置 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<FilterKey>('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() {
<div style={{ background: colors.bgContainer, borderRadius: 8, padding: 16, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} />
<Button icon={<ReloadOutlined />} onClick={() => void reload()} />
</div>
<div style={{ height: 'calc(100% - 44px)', overflowY: 'auto' }}>
<Table

View File

@ -10,16 +10,19 @@ import {
type CampaignTimelineEntry,
type Run,
} from '../api'
import {
errorMessage,
isStaleResponse,
selectedSlotCleared,
selectedSlotRequested,
slotFailed,
slotRequested,
slotSucceeded,
type ReadSlot,
type SelectedReadSlot,
} from '../read/readResource'
import { usePolling } from './usePolling'
type ReadPhase = 'idle' | 'loading' | 'refreshing' | 'ready' | 'error'
interface ReadSlot<T> {
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<CampaignListItem[]>
report: ReadSlot<CampaignReportSnapshot | null> & {
selectedId: string | null
requestId: number
}
report: SelectedReadSlot<CampaignReportSnapshot>
}
type CampaignReadAction =
@ -85,72 +85,36 @@ const initialState: CampaignReadState = {
},
}
function requestSlot<T>(slot: ReadSlot<T>, silent: boolean | undefined): ReadSlot<T> {
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])

View File

@ -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(() => {

View File

@ -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: <ExecutionProcess ev={selected} />,
children: <ExecutionProcess ev={selected} activity={activity.slot} />,
},
{
key: 'decision',
label: '决策过程',
children: (
<DecisionProcess
evalId={selected.id}
logs={logsSlot}
onReload={() => void activity.reload()}
/>
),
},
{ 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} /> }] : []),
]

View File

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

View File

@ -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<string, IntelligentEvalMessage[]>
}
export async function loadEvalActivity(evalId: string, runningSessionIds: string[]): Promise<EvalActivitySnapshot> {
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<string, IntelligentEvalMessage[]> = {}
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<EvalActivitySnapshot>
}
export const evalActivityAdapter: EvalActivityAdapter = { load: loadEvalActivity }
export function useEvalActivityRead(
evalId: string | null,
runningSessionIds: string[],
active: boolean,
adapter: EvalActivityAdapter = evalActivityAdapter,
): ReadResource<EvalActivitySnapshot> {
// 键含进行中会话集合:会话开/关时自动重取,无需手工失效。
const key = evalId == null ? null : `${evalId}|${runningSessionIds.join(',')}`
return useReadResource(
() => adapter.load(evalId as string, runningSessionIds),
{ key, pollMs: 5000, polling: active, fallbackError: '执行过程数据不可用' },
)
}

View File

@ -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<T> {
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<IntelligentEval[]> & { total: number; stats: Record<string, number> | null }
detail: ReadSlot<IntelligentEval | null> & {
selectedId: string | null
requestId: number
}
detail: SelectedReadSlot<IntelligentEval>
}
export type IntelligentEvalReadAction =
@ -56,63 +64,36 @@ export const initialIntelligentEvalReadState: IntelligentEvalReadState = {
detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 },
}
function requestPhase<S extends ReadSlot<unknown>>(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) }
}
}

View File

@ -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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<string[]>([]), 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<string[]>([]), '网络错误').phase).toBe('error')
expect(slotFailed(idleSlot<string | null>(null), '不存在').error).toBe('不存在')
})
})
describe('selected slot race guard', () => {
it('resets the value when the selection changes and drops stale responses', () => {
const first = selectedSlotRequested(selectedSlotCleared<object>(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<string>()
const second = deferred<string>()
const fetchers: Record<string, () => Promise<string>> = {
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 })
})
})

View File

@ -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<T> {
phase: ReadPhase
value: T
error: string | null
}
export function idleSlot<T>(value: T): ReadSlot<T> {
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<S extends ReadSlot<unknown>>(slot: S, silent: boolean | undefined): S {
return { ...slot, phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', error: null }
}
export function slotSucceeded<T>(value: T): ReadSlot<T> {
return { phase: 'ready', value, error: null }
}
/** 失败相位:仍有可用快照时保留数据不报错,否则落 error。 */
export function slotFailed<S extends ReadSlot<unknown>>(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<T> extends ReadSlot<T | null> {
selectedId: string | null
requestId: number
}
export function selectedSlotCleared<T>(requestId: number): SelectedReadSlot<T> {
return { phase: 'idle', value: null, error: null, selectedId: null, requestId }
}
export function selectedSlotRequested<T>(
slot: SelectedReadSlot<T>,
id: string,
requestId: number,
silent: boolean | undefined,
): SelectedReadSlot<T> {
const base = slot.selectedId === id ? slot : { ...slot, value: null, selectedId: id }
return { ...slotRequested(base, silent), selectedId: id, requestId }
}
/** 迟到响应守卫:选中已切换或序号过期时返回 truereducer 应原样返回。 */
export function isStaleResponse(slot: SelectedReadSlot<unknown>, 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<T> {
slot: ReadSlot<T | null>
reload: (silent?: boolean) => Promise<void>
}
type ResourceAction<T> =
| { type: 'requested'; silent?: boolean }
| { type: 'succeeded'; value: T }
| { type: 'failed'; error: string }
| { type: 'cleared' }
function resourceReducer<T>(slot: ReadSlot<T | null>, action: ResourceAction<T>): ReadSlot<T | null> {
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<T>(fetcher: () => Promise<T>, options: UseReadResourceOptions = {}): ReadResource<T> {
const { key = '', pollMs = 0, polling = true, fallbackError = '加载数据失败' } = options
const [slot, dispatch] = useReducer(resourceReducer<T>, 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 }
}

View File

@ -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(

View File

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