refactor(frontend): extract useResource/usePolling shared hooks
Some checks failed
CI / test (push) Failing after 1m10s
Some checks failed
CI / test (push) Failing after 1m10s
Seven pages repeated the same load-on-mount + loading + try/finally +
reload-button skeleton, each re-implementing tab-active refresh, silent
polling, and (in two places) a hand-rolled requestId race guard. Extract two
composable hooks: useResource(fetcher, {tabPath, deps}) owning data/loading/
reload with a built-in race guard and auto tab-active refresh, and
usePolling(fn, ms, enabled) replacing the hand-written setInterval effects.
Migrate all seven pages onto them; Targets/Scenarios/ModelConfigs also gain a
uniform tab-active refresh they previously lacked. Verified via tsc --noEmit
and npm run build (no frontend test runner exists).
This commit is contained in:
parent
f285738f6d
commit
050c674ee2
18
frontend/web/src/hooks/usePolling.ts
Normal file
18
frontend/web/src/hooks/usePolling.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Call `fn` every `ms` milliseconds while `enabled` is true; clears the timer
|
||||
* when it flips false or the component unmounts. The latest `fn` is always
|
||||
* used without restarting the interval, so callers can pass a fresh closure
|
||||
* each render (e.g. `() => reload(true)`) without churning the timer.
|
||||
*/
|
||||
export function usePolling(fn: () => void, ms: number, enabled: boolean): void {
|
||||
const fnRef = useRef(fn)
|
||||
fnRef.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const id = window.setInterval(() => fnRef.current(), ms)
|
||||
return () => window.clearInterval(id)
|
||||
}, [enabled, ms])
|
||||
}
|
||||
63
frontend/web/src/hooks/useResource.ts
Normal file
63
frontend/web/src/hooks/useResource.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useOnTabActive } from './useOnTabActive'
|
||||
|
||||
interface UseResourceOptions {
|
||||
/** Refetch whenever this keep-alive tab path becomes active again. */
|
||||
tabPath?: string
|
||||
/** Re-run the fetcher when any of these values change (like useEffect deps). */
|
||||
deps?: readonly unknown[]
|
||||
/** Fetch immediately on mount (default true). */
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
interface UseResource<T> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
/** Refetch. Pass `true` to skip the loading flag (silent poll refresh). */
|
||||
reload: (silent?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-on-mount data with a loading flag, a manual/tab-active refetch, and a
|
||||
* request-id race guard that drops stale responses (so a slow earlier fetch
|
||||
* never overwrites a newer one). Errors propagate to the axios interceptor,
|
||||
* matching every page's existing swallow-and-toast behaviour.
|
||||
*
|
||||
* `fetcher` returns the already-unwrapped value (call `.then(r => r.data)` or
|
||||
* combine several requests with Promise.all before resolving).
|
||||
*/
|
||||
export function useResource<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
options: UseResourceOptions = {},
|
||||
): UseResource<T> {
|
||||
const { tabPath, deps = [], immediate = true } = options
|
||||
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [loading, setLoading] = useState(immediate)
|
||||
|
||||
const fetcherRef = useRef(fetcher)
|
||||
fetcherRef.current = fetcher
|
||||
const requestId = useRef(0)
|
||||
|
||||
const reload = useCallback(async (silent = false) => {
|
||||
const id = ++requestId.current
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const result = await fetcherRef.current()
|
||||
if (id === requestId.current) setData(result)
|
||||
} finally {
|
||||
if (id === requestId.current && !silent) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (immediate) void reload()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useOnTabActive(tabPath ?? '', () => {
|
||||
if (tabPath) void reload()
|
||||
})
|
||||
|
||||
return { data, loading, reload }
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
|
||||
@ -16,7 +16,8 @@ import {
|
||||
} from '../api'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
import { shortDateTime } from '../utils/date'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
|
||||
|
||||
@ -50,13 +51,15 @@ interface PlanFormEntry {
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface CampaignsListsData {
|
||||
campaigns: CampaignListItem[]
|
||||
targets: Target[]
|
||||
scenarios: Scenario[]
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const navigate = useNavigate()
|
||||
const activeKey = useTabStore((s) => s.activeKey)
|
||||
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([])
|
||||
const [targets, setTargets] = useState<Target[]>([])
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@ -69,31 +72,25 @@ export default function CampaignsPage() {
|
||||
|
||||
const targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8)
|
||||
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const [c, t, s] = await Promise.all([
|
||||
campaignsApi.list(), targetsApi.list(), scenariosApi.list(),
|
||||
])
|
||||
setCampaigns(c.data)
|
||||
setTargets(t.data)
|
||||
setScenarios(s.data)
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
useOnTabActive('/campaigns', loadData)
|
||||
const { data, loading, reload } = useResource<CampaignsListsData>(
|
||||
async () => {
|
||||
const [c, t, s] = await Promise.all([campaignsApi.list(), targetsApi.list(), scenariosApi.list()])
|
||||
return { campaigns: c.data, targets: t.data, scenarios: s.data }
|
||||
},
|
||||
{ tabPath: '/campaigns' },
|
||||
)
|
||||
const campaigns = data?.campaigns ?? []
|
||||
const targets = data?.targets ?? []
|
||||
const scenarios = data?.scenarios ?? []
|
||||
|
||||
// Poll the list while this tab is active and a campaign is still working —
|
||||
// compressed dev-line campaigns change fast. Stop once all are terminal.
|
||||
const hasActiveCampaign = campaigns.some((c) => isActiveStatus(c.status))
|
||||
useEffect(() => {
|
||||
if (activeKey !== '/campaigns' || !hasActiveCampaign) return
|
||||
const id = setInterval(() => loadData(true), POLL_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [activeKey, hasActiveCampaign])
|
||||
usePolling(
|
||||
() => void reload(true),
|
||||
POLL_INTERVAL_MS,
|
||||
activeKey === '/campaigns' && hasActiveCampaign,
|
||||
)
|
||||
|
||||
const openCreate = () => {
|
||||
form.setFieldsValue({
|
||||
@ -120,7 +117,7 @@ export default function CampaignsPage() {
|
||||
})
|
||||
message.success('评估活动已创建并开始调度')
|
||||
setCreateOpen(false)
|
||||
loadData()
|
||||
reload()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@ -129,7 +126,7 @@ export default function CampaignsPage() {
|
||||
const cancelCampaign = async (id: string) => {
|
||||
await campaignsApi.cancel(id)
|
||||
message.success('活动已取消')
|
||||
loadData()
|
||||
reload()
|
||||
}
|
||||
|
||||
const fetchReport = async (campaignId: string, silent = false) => {
|
||||
@ -160,11 +157,11 @@ export default function CampaignsPage() {
|
||||
const reportCampaignActive = campaigns.some(
|
||||
(c) => c.id === reportId && isActiveStatus(c.status),
|
||||
)
|
||||
useEffect(() => {
|
||||
if (activeKey !== '/campaigns' || !reportOpen || !reportId || !reportCampaignActive) return
|
||||
const id = setInterval(() => fetchReport(reportId, true), POLL_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [activeKey, reportOpen, reportId, reportCampaignActive])
|
||||
usePolling(
|
||||
() => { if (reportId) void fetchReport(reportId, true) },
|
||||
POLL_INTERVAL_MS,
|
||||
activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive,
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
@ -306,7 +303,7 @@ export default function CampaignsPage() {
|
||||
fullHeight
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => loadData()} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => reload()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建活动</Button>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Empty, Progress, Row, Spin, Tag, Tooltip } from 'antd'
|
||||
import { Line } from '@ant-design/charts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@ -17,32 +16,27 @@ import {
|
||||
import { statsApi, type DashboardStats, type Run, type TrendPoint } from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import StatCard from '../components/StatCard'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
|
||||
interface DashboardData {
|
||||
stats: DashboardStats
|
||||
trend: TrendPoint[]
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [trend, setTrend] = useState<TrendPoint[]>([])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data, loading, reload } = useResource<DashboardData>(
|
||||
async () => {
|
||||
const [d, t] = await Promise.all([statsApi.dashboard(), statsApi.trend(30)])
|
||||
setStats(d.data)
|
||||
setTrend(t.data)
|
||||
} catch {
|
||||
// errors handled by interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
useOnTabActive('/', loadData)
|
||||
return { stats: d.data, trend: t.data }
|
||||
},
|
||||
{ tabPath: '/' },
|
||||
)
|
||||
const stats = data?.stats ?? null
|
||||
const trend = data?.trend ?? []
|
||||
|
||||
const passRate = stats?.overall_pass_rate ?? null
|
||||
|
||||
@ -71,7 +65,7 @@ export default function HomePage() {
|
||||
fullHeight
|
||||
extra={
|
||||
<Tooltip title="刷新数据">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadData} />
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={() => reload()} />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button, Divider, Drawer, Form, Input, InputNumber, message, Popconfirm, Select, Space, Switch, Table, Tag,
|
||||
Tooltip,
|
||||
@ -11,6 +11,7 @@ import {
|
||||
type ModelProtocol,
|
||||
} from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
import { notifyModelConfigsChanged } from '../utils/modelConfigEvents'
|
||||
|
||||
@ -82,8 +83,6 @@ const protocolOptions: Record<ModelProtocol, {
|
||||
}
|
||||
|
||||
export default function ModelConfigsPage() {
|
||||
const [configs, setConfigs] = useState<ModelConfig[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ModelConfig | null>(null)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
@ -93,17 +92,10 @@ export default function ModelConfigsPage() {
|
||||
const selectedProtocol = Form.useWatch('provider', form) || 'openai_compatible'
|
||||
const selectedProtocolMeta = protocolOptions[selectedProtocol]
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await modelConfigsApi.list({ capability, enabled })
|
||||
setConfigs(response.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [capability, enabled])
|
||||
const { data: configs, loading, reload } = useResource(
|
||||
() => modelConfigsApi.list({ capability, enabled }).then((r) => r.data),
|
||||
{ tabPath: '/models', deps: [capability, enabled] },
|
||||
)
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
@ -184,7 +176,7 @@ export default function ModelConfigsPage() {
|
||||
}
|
||||
notifyModelConfigsChanged()
|
||||
setDrawerOpen(false)
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const testConnection = async (config: ModelConfig) => {
|
||||
@ -202,7 +194,7 @@ export default function ModelConfigsPage() {
|
||||
await modelConfigsApi.delete(config.id)
|
||||
message.success('模型配置已删除')
|
||||
notifyModelConfigsChanged()
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
@ -356,14 +348,14 @@ export default function ModelConfigsPage() {
|
||||
onChange={setEnabled}
|
||||
options={[{ value: true, label: '启用' }, { value: false, label: '停用' }]}
|
||||
/>
|
||||
<Tooltip title="刷新"><Button icon={<ReloadOutlined />} onClick={load} /></Tooltip>
|
||||
<Tooltip title="刷新"><Button icon={<ReloadOutlined />} onClick={() => reload()} /></Tooltip>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{ height: '100%', minHeight: 0, overflow: 'hidden' }}>
|
||||
<Table
|
||||
dataSource={configs}
|
||||
dataSource={configs ?? []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
import { reportsApi, runsApi, type Run } from '../api'
|
||||
import { colors, triggerColors, triggerLabels } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
|
||||
interface TurnData {
|
||||
sent_text: string
|
||||
@ -78,7 +78,6 @@ export default function ReportsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const runQuery = searchParams.get('run') ?? ''
|
||||
|
||||
const [runs, setRuns] = useState<Run[]>([])
|
||||
const [scenarioFilter, setScenarioFilter] = useState<string>('')
|
||||
const [selectedRunId, setSelectedRunId] = useState<string>('')
|
||||
const [compareRunId, setCompareRunId] = useState<string>('')
|
||||
@ -87,6 +86,14 @@ export default function ReportsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('single')
|
||||
|
||||
// Keep-alive tabs never remount: refresh whenever this tab is re-activated
|
||||
// so runs triggered elsewhere (e.g. AI assistant) show up.
|
||||
const { data: runsData, reload: reloadRuns } = useResource(
|
||||
() => runsApi.list().then((r) => r.data.filter((run) => run.status === 'completed')),
|
||||
{ tabPath: '/reports' },
|
||||
)
|
||||
const runs = useMemo(() => runsData ?? [], [runsData])
|
||||
|
||||
const loadReport = async (runId: string) => {
|
||||
setSelectedRunId(runId)
|
||||
setCompareResult(null)
|
||||
@ -99,23 +106,6 @@ export default function ReportsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRuns = async () => {
|
||||
const res = await runsApi.list()
|
||||
// API 已按 started_at DESC 排序,最新的在前
|
||||
setRuns(res.data.filter((r) => r.status === 'completed'))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRuns().then(() => {
|
||||
if (runQuery) loadReport(runQuery)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Keep-alive tabs never remount: refresh whenever this tab is re-activated
|
||||
// so runs triggered elsewhere (e.g. AI assistant) show up.
|
||||
useOnTabActive('/reports', loadRuns)
|
||||
|
||||
useEffect(() => {
|
||||
if (runQuery && runQuery !== selectedRunId) {
|
||||
loadReport(runQuery)
|
||||
@ -274,7 +264,7 @@ export default function ReportsPage() {
|
||||
<Button size="middle" icon={<ClearOutlined />} onClick={resetFilters}>重置</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新列表">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} />
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={() => reloadRuns()} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,18 +13,21 @@ import RuleOverview from '../components/RuleOverview'
|
||||
import CaseDetail from '../components/CaseDetail'
|
||||
import { useRunSession } from '../hooks/useRunSession'
|
||||
import { useTicker } from '../hooks/useTicker'
|
||||
import { useOnTabActive } from '../hooks/useOnTabActive'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { colors, statusColors, statusLabels } from '../tokens'
|
||||
import { formatDateTime, elapsedStr } from '../utils/date'
|
||||
import { passRateColor } from '../utils/colors'
|
||||
|
||||
interface RunsListsData {
|
||||
runs: Run[]
|
||||
targets: Target[]
|
||||
scenarios: Scenario[]
|
||||
}
|
||||
|
||||
export default function RunsPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [runs, setRuns] = useState<Run[]>([])
|
||||
const [targets, setTargets] = useState<Target[]>([])
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [targetId, setTargetId] = useState<string>()
|
||||
const [scenarioId, setScenarioId] = useState<string>()
|
||||
const [starting, setStarting] = useState(false)
|
||||
@ -34,41 +37,27 @@ export default function RunsPage() {
|
||||
|
||||
const session = useRunSession()
|
||||
|
||||
const loadLists = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [r, t, s] = await Promise.all([
|
||||
runsApi.list(),
|
||||
targetsApi.list(),
|
||||
scenariosApi.list(),
|
||||
])
|
||||
// API 已按 started_at DESC 排序,直接使用,最新的在前
|
||||
setRuns(r.data)
|
||||
setTargets(t.data)
|
||||
setScenarios(s.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadLists() }, [])
|
||||
|
||||
// Keep-alive tabs never remount: refresh the list whenever the tab is
|
||||
// re-activated so runs triggered elsewhere (e.g. AI assistant) show up.
|
||||
useOnTabActive('/runs', loadLists)
|
||||
const { data, loading, reload } = useResource<RunsListsData>(
|
||||
async () => {
|
||||
const [r, t, s] = await Promise.all([runsApi.list(), targetsApi.list(), scenariosApi.list()])
|
||||
// API 已按 started_at DESC 排序,最新的在前
|
||||
return { runs: r.data, targets: t.data, scenarios: s.data }
|
||||
},
|
||||
{ tabPath: '/runs' },
|
||||
)
|
||||
const runs = data?.runs ?? []
|
||||
const targets = data?.targets ?? []
|
||||
const scenarios = data?.scenarios ?? []
|
||||
|
||||
// While a run is live, poll the lists so status/pass-rate refresh; a silent
|
||||
// reload avoids flashing the loading spinner on each tick.
|
||||
usePolling(() => void reload(true), 3000, session.isLive)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.isLive) return
|
||||
const id = window.setInterval(() => {
|
||||
runsApi.list().then((r) => setRuns(r.data)).catch(() => {})
|
||||
}, 3000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [session.isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (session.completed) {
|
||||
runsApi.list().then((r) => setRuns(r.data)).catch(() => {})
|
||||
}
|
||||
if (session.completed) void reload(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.completed])
|
||||
|
||||
// 提取为共享函数,handleStart 和 onRerun 共用
|
||||
@ -76,7 +65,7 @@ export default function RunsPage() {
|
||||
setStarting(true)
|
||||
try {
|
||||
const res = await runsApi.start(tId, sId)
|
||||
await loadLists()
|
||||
await reload()
|
||||
session.select(res.data, { live: true })
|
||||
setTabOverrideId(null)
|
||||
setFocusCaseId(null)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Card, Drawer, Form, Input, message, Modal, Popconfirm,
|
||||
Select, Space, Table, Tag, Tooltip,
|
||||
@ -8,8 +8,9 @@ import {
|
||||
ReloadOutlined, AppstoreAddOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import { modelConfigsApi, scenariosApi, type ModelConfig, type Scenario } from '../api'
|
||||
import { modelConfigsApi, scenariosApi, type Scenario } from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { colors } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
import { MODEL_CONFIGS_CHANGED_EVENT } from '../utils/modelConfigEvents'
|
||||
@ -36,17 +37,21 @@ const YAML_TEMPLATE = `# 场景用例示例(JSON 格式)
|
||||
]`
|
||||
|
||||
export default function ScenariosPage() {
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { data: scenarios, loading, reload } = useResource(
|
||||
() => scenariosApi.list().then((r) => r.data),
|
||||
{ tabPath: '/scenarios' },
|
||||
)
|
||||
const {
|
||||
data: modelConfigs,
|
||||
loading: modelConfigsLoading,
|
||||
reload: reloadModelConfigs,
|
||||
} = useResource(() => modelConfigsApi.list({ enabled: true }).then((r) => r.data))
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewData, setPreviewData] = useState<any>(null)
|
||||
const [editingScenario, setEditingScenario] = useState<Scenario | null>(null)
|
||||
const [casesJson, setCasesJson] = useState(YAML_TEMPLATE)
|
||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([])
|
||||
const [modelConfigsLoading, setModelConfigsLoading] = useState(false)
|
||||
const [modelBindings, setModelBindings] = useState<Record<string, string>>({})
|
||||
const modelConfigRequestId = useRef(0)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// Template picker
|
||||
@ -54,36 +59,14 @@ export default function ScenariosPage() {
|
||||
const [templates, setTemplates] = useState<any[]>([])
|
||||
const [tplLoading, setTplLoading] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scenariosApi.list()
|
||||
setScenarios(res.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadModelConfigs = async () => {
|
||||
const requestId = ++modelConfigRequestId.current
|
||||
setModelConfigsLoading(true)
|
||||
try {
|
||||
const res = await modelConfigsApi.list({ enabled: true })
|
||||
if (requestId === modelConfigRequestId.current) setModelConfigs(res.data)
|
||||
} finally {
|
||||
if (requestId === modelConfigRequestId.current) setModelConfigsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
loadModelConfigs()
|
||||
window.addEventListener(MODEL_CONFIGS_CHANGED_EVENT, loadModelConfigs)
|
||||
return () => window.removeEventListener(MODEL_CONFIGS_CHANGED_EVENT, loadModelConfigs)
|
||||
}, [])
|
||||
const onChanged = () => void reloadModelConfigs()
|
||||
window.addEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
|
||||
return () => window.removeEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
|
||||
}, [reloadModelConfigs])
|
||||
|
||||
const openCreate = () => {
|
||||
loadModelConfigs()
|
||||
reloadModelConfigs()
|
||||
setEditingScenario(null)
|
||||
form.resetFields()
|
||||
setCasesJson(YAML_TEMPLATE)
|
||||
@ -104,7 +87,7 @@ export default function ScenariosPage() {
|
||||
}
|
||||
|
||||
const applyTemplate = (tpl: any) => {
|
||||
loadModelConfigs()
|
||||
reloadModelConfigs()
|
||||
setTplModalOpen(false)
|
||||
setEditingScenario(null)
|
||||
form.resetFields()
|
||||
@ -115,7 +98,7 @@ export default function ScenariosPage() {
|
||||
}
|
||||
|
||||
const openEdit = (scenario: Scenario) => {
|
||||
loadModelConfigs()
|
||||
reloadModelConfigs()
|
||||
setEditingScenario(scenario)
|
||||
form.setFieldsValue({
|
||||
name: scenario.name,
|
||||
@ -162,13 +145,13 @@ export default function ScenariosPage() {
|
||||
message.success('创建成功')
|
||||
}
|
||||
setDrawerOpen(false)
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await scenariosApi.delete(id)
|
||||
message.success('已删除')
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const columns = [
|
||||
@ -220,8 +203,8 @@ export default function ScenariosPage() {
|
||||
size="middle"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
load()
|
||||
loadModelConfigs()
|
||||
reload()
|
||||
reloadModelConfigs()
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -237,7 +220,7 @@ export default function ScenariosPage() {
|
||||
{/* 表格区 — 撑满剩余高度 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<Table
|
||||
dataSource={scenarios}
|
||||
dataSource={scenarios ?? []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
@ -300,7 +283,7 @@ export default function ScenariosPage() {
|
||||
else delete next[item.purpose]
|
||||
setModelBindings(next)
|
||||
}}
|
||||
options={modelConfigs
|
||||
options={(modelConfigs ?? [])
|
||||
.filter((config) => config.capability === item.capability)
|
||||
.map((config) => ({ value: config.id, label: `${config.name} · ${config.model_name || config.capability}` }))}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button, Drawer, Form, Input, message, Popconfirm,
|
||||
Space, Table, Tag, Select, Tooltip,
|
||||
@ -6,28 +6,19 @@ import {
|
||||
import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { targetsApi, type Target } from '../api'
|
||||
import PageWrapper from '../components/PageWrapper'
|
||||
import { useResource } from '../hooks/useResource'
|
||||
import { colors } from '../tokens'
|
||||
import { formatDateTime } from '../utils/date'
|
||||
|
||||
export default function TargetsPage() {
|
||||
const [targets, setTargets] = useState<Target[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { data: targets, loading, reload } = useResource(
|
||||
() => targetsApi.list().then((r) => r.data),
|
||||
{ tabPath: '/targets' },
|
||||
)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [editingTarget, setEditingTarget] = useState<Target | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await targetsApi.list()
|
||||
setTargets(res.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingTarget(null)
|
||||
form.resetFields()
|
||||
@ -80,7 +71,7 @@ export default function TargetsPage() {
|
||||
message.success('创建成功')
|
||||
}
|
||||
setDrawerOpen(false)
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
@ -99,7 +90,7 @@ export default function TargetsPage() {
|
||||
const handleDelete = async (id: string) => {
|
||||
await targetsApi.delete(id)
|
||||
message.success('已删除')
|
||||
load()
|
||||
reload()
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
@ -150,7 +141,7 @@ export default function TargetsPage() {
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip title="刷新">
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={load} />
|
||||
<Button size="middle" icon={<ReloadOutlined />} onClick={() => reload()} />
|
||||
</Tooltip>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新增
|
||||
@ -161,7 +152,7 @@ export default function TargetsPage() {
|
||||
{/* 表格区 — 撑满剩余高度 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<Table
|
||||
dataSource={targets}
|
||||
dataSource={targets ?? []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user