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:
parent
7eae6de52d
commit
f458897ab5
@ -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}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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} /> }] : []),
|
||||
]
|
||||
|
||||
52
frontend/web/src/read/evalActivity.test.ts
Normal file
52
frontend/web/src/read/evalActivity.test.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
57
frontend/web/src/read/evalActivity.ts
Normal file
57
frontend/web/src/read/evalActivity.ts
Normal 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: '执行过程数据不可用' },
|
||||
)
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
118
frontend/web/src/read/readResource.test.tsx
Normal file
118
frontend/web/src/read/readResource.test.tsx
Normal 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 })
|
||||
})
|
||||
})
|
||||
144
frontend/web/src/read/readResource.ts
Normal file
144
frontend/web/src/read/readResource.ts
Normal 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 }
|
||||
}
|
||||
|
||||
/** 迟到响应守卫:选中已切换或序号过期时返回 true,reducer 应原样返回。 */
|
||||
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 }
|
||||
}
|
||||
@ -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(
|
||||
|
||||
17
frontend/web/src/utils/download.ts
Normal file
17
frontend/web/src/utils/download.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user