refactor(frontend): extract useResource/usePolling shared hooks
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:
sinohqb 2026-07-31 11:01:24 +08:00
parent f285738f6d
commit 050c674ee2
9 changed files with 209 additions and 192 deletions

View 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])
}

View 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 }
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space, Button, Table, Tag, Modal, Form, Select, InputNumber, Input, Space,
@ -16,7 +16,8 @@ import {
} from '../api' } from '../api'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
import { shortDateTime } from '../utils/date' 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 { useTabStore } from '../stores/tabStore'
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens' import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
@ -50,13 +51,15 @@ interface PlanFormEntry {
count?: number count?: number
} }
interface CampaignsListsData {
campaigns: CampaignListItem[]
targets: Target[]
scenarios: Scenario[]
}
export default function CampaignsPage() { export default function CampaignsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const activeKey = useTabStore((s) => s.activeKey) 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 [createOpen, setCreateOpen] = useState(false)
const [submitting, setSubmitting] = 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 targetName = (id: string) => targets.find((t) => t.id === id)?.name ?? id.slice(0, 8)
const loadData = async (silent = false) => { const { data, loading, reload } = useResource<CampaignsListsData>(
if (!silent) setLoading(true) async () => {
try { const [c, t, s] = await Promise.all([campaignsApi.list(), targetsApi.list(), scenariosApi.list()])
const [c, t, s] = await Promise.all([ return { campaigns: c.data, targets: t.data, scenarios: s.data }
campaignsApi.list(), targetsApi.list(), scenariosApi.list(), },
]) { tabPath: '/campaigns' },
setCampaigns(c.data) )
setTargets(t.data) const campaigns = data?.campaigns ?? []
setScenarios(s.data) const targets = data?.targets ?? []
} finally { const scenarios = data?.scenarios ?? []
if (!silent) setLoading(false)
}
}
useEffect(() => { loadData() }, [])
useOnTabActive('/campaigns', loadData)
// Poll the list while this tab is active and a campaign is still working — // 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. // compressed dev-line campaigns change fast. Stop once all are terminal.
const hasActiveCampaign = campaigns.some((c) => isActiveStatus(c.status)) const hasActiveCampaign = campaigns.some((c) => isActiveStatus(c.status))
useEffect(() => { usePolling(
if (activeKey !== '/campaigns' || !hasActiveCampaign) return () => void reload(true),
const id = setInterval(() => loadData(true), POLL_INTERVAL_MS) POLL_INTERVAL_MS,
return () => clearInterval(id) activeKey === '/campaigns' && hasActiveCampaign,
}, [activeKey, hasActiveCampaign]) )
const openCreate = () => { const openCreate = () => {
form.setFieldsValue({ form.setFieldsValue({
@ -120,7 +117,7 @@ export default function CampaignsPage() {
}) })
message.success('评估活动已创建并开始调度') message.success('评估活动已创建并开始调度')
setCreateOpen(false) setCreateOpen(false)
loadData() reload()
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -129,7 +126,7 @@ export default function CampaignsPage() {
const cancelCampaign = async (id: string) => { const cancelCampaign = async (id: string) => {
await campaignsApi.cancel(id) await campaignsApi.cancel(id)
message.success('活动已取消') message.success('活动已取消')
loadData() reload()
} }
const fetchReport = async (campaignId: string, silent = false) => { const fetchReport = async (campaignId: string, silent = false) => {
@ -160,11 +157,11 @@ export default function CampaignsPage() {
const reportCampaignActive = campaigns.some( const reportCampaignActive = campaigns.some(
(c) => c.id === reportId && isActiveStatus(c.status), (c) => c.id === reportId && isActiveStatus(c.status),
) )
useEffect(() => { usePolling(
if (activeKey !== '/campaigns' || !reportOpen || !reportId || !reportCampaignActive) return () => { if (reportId) void fetchReport(reportId, true) },
const id = setInterval(() => fetchReport(reportId, true), POLL_INTERVAL_MS) POLL_INTERVAL_MS,
return () => clearInterval(id) activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive,
}, [activeKey, reportOpen, reportId, reportCampaignActive]) )
const columns = [ const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' }, { title: '名称', dataIndex: 'name', key: 'name' },
@ -306,7 +303,7 @@ export default function CampaignsPage() {
fullHeight fullHeight
extra={ extra={
<Space> <Space>
<Button icon={<ReloadOutlined />} onClick={() => loadData()} /> <Button icon={<ReloadOutlined />} onClick={() => reload()} />
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space> </Space>
} }

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import { Button, Card, Col, Empty, Progress, Row, Spin, Tag, Tooltip } from 'antd' import { Button, Card, Col, Empty, Progress, Row, Spin, Tag, Tooltip } from 'antd'
import { Line } from '@ant-design/charts' import { Line } from '@ant-design/charts'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@ -17,32 +16,27 @@ import {
import { statsApi, type DashboardStats, type Run, type TrendPoint } from '../api' import { statsApi, type DashboardStats, type Run, type TrendPoint } from '../api'
import PageWrapper from '../components/PageWrapper' import PageWrapper from '../components/PageWrapper'
import StatCard from '../components/StatCard' import StatCard from '../components/StatCard'
import { useOnTabActive } from '../hooks/useOnTabActive' import { useResource } from '../hooks/useResource'
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens' import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
interface DashboardData {
stats: DashboardStats
trend: TrendPoint[]
}
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState(true) const { data, loading, reload } = useResource<DashboardData>(
const [stats, setStats] = useState<DashboardStats | null>(null) async () => {
const [trend, setTrend] = useState<TrendPoint[]>([])
const loadData = async () => {
setLoading(true)
try {
const [d, t] = await Promise.all([statsApi.dashboard(), statsApi.trend(30)]) const [d, t] = await Promise.all([statsApi.dashboard(), statsApi.trend(30)])
setStats(d.data) return { stats: d.data, trend: t.data }
setTrend(t.data) },
} catch { { tabPath: '/' },
// errors handled by interceptor )
} finally { const stats = data?.stats ?? null
setLoading(false) const trend = data?.trend ?? []
}
}
useEffect(() => { loadData() }, [])
useOnTabActive('/', loadData)
const passRate = stats?.overall_pass_rate ?? null const passRate = stats?.overall_pass_rate ?? null
@ -71,7 +65,7 @@ export default function HomePage() {
fullHeight fullHeight
extra={ extra={
<Tooltip title="刷新数据"> <Tooltip title="刷新数据">
<Button size="middle" icon={<ReloadOutlined />} onClick={loadData} /> <Button size="middle" icon={<ReloadOutlined />} onClick={() => reload()} />
</Tooltip> </Tooltip>
} }
> >

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { import {
Button, Divider, Drawer, Form, Input, InputNumber, message, Popconfirm, Select, Space, Switch, Table, Tag, Button, Divider, Drawer, Form, Input, InputNumber, message, Popconfirm, Select, Space, Switch, Table, Tag,
Tooltip, Tooltip,
@ -11,6 +11,7 @@ import {
type ModelProtocol, type ModelProtocol,
} from '../api' } from '../api'
import PageWrapper from '../components/PageWrapper' import PageWrapper from '../components/PageWrapper'
import { useResource } from '../hooks/useResource'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
import { notifyModelConfigsChanged } from '../utils/modelConfigEvents' import { notifyModelConfigsChanged } from '../utils/modelConfigEvents'
@ -82,8 +83,6 @@ const protocolOptions: Record<ModelProtocol, {
} }
export default function ModelConfigsPage() { export default function ModelConfigsPage() {
const [configs, setConfigs] = useState<ModelConfig[]>([])
const [loading, setLoading] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [editing, setEditing] = useState<ModelConfig | null>(null) const [editing, setEditing] = useState<ModelConfig | null>(null)
const [testingId, setTestingId] = useState<string | 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 selectedProtocol = Form.useWatch('provider', form) || 'openai_compatible'
const selectedProtocolMeta = protocolOptions[selectedProtocol] const selectedProtocolMeta = protocolOptions[selectedProtocol]
const load = async () => { const { data: configs, loading, reload } = useResource(
setLoading(true) () => modelConfigsApi.list({ capability, enabled }).then((r) => r.data),
try { { tabPath: '/models', deps: [capability, enabled] },
const response = await modelConfigsApi.list({ capability, enabled }) )
setConfigs(response.data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [capability, enabled])
const openCreate = () => { const openCreate = () => {
setEditing(null) setEditing(null)
@ -184,7 +176,7 @@ export default function ModelConfigsPage() {
} }
notifyModelConfigsChanged() notifyModelConfigsChanged()
setDrawerOpen(false) setDrawerOpen(false)
load() reload()
} }
const testConnection = async (config: ModelConfig) => { const testConnection = async (config: ModelConfig) => {
@ -202,7 +194,7 @@ export default function ModelConfigsPage() {
await modelConfigsApi.delete(config.id) await modelConfigsApi.delete(config.id)
message.success('模型配置已删除') message.success('模型配置已删除')
notifyModelConfigsChanged() notifyModelConfigsChanged()
load() reload()
} }
const columns = [ const columns = [
@ -356,14 +348,14 @@ export default function ModelConfigsPage() {
onChange={setEnabled} onChange={setEnabled}
options={[{ value: true, label: '启用' }, { value: false, label: '停用' }]} 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> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space> </Space>
} }
> >
<div style={{ height: '100%', minHeight: 0, overflow: 'hidden' }}> <div style={{ height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Table <Table
dataSource={configs} dataSource={configs ?? []}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}

View File

@ -12,7 +12,7 @@ import {
import { reportsApi, runsApi, type Run } from '../api' import { reportsApi, runsApi, type Run } from '../api'
import { colors, triggerColors, triggerLabels } from '../tokens' import { colors, triggerColors, triggerLabels } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
import { useOnTabActive } from '../hooks/useOnTabActive' import { useResource } from '../hooks/useResource'
interface TurnData { interface TurnData {
sent_text: string sent_text: string
@ -78,7 +78,6 @@ export default function ReportsPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const runQuery = searchParams.get('run') ?? '' const runQuery = searchParams.get('run') ?? ''
const [runs, setRuns] = useState<Run[]>([])
const [scenarioFilter, setScenarioFilter] = useState<string>('') const [scenarioFilter, setScenarioFilter] = useState<string>('')
const [selectedRunId, setSelectedRunId] = useState<string>('') const [selectedRunId, setSelectedRunId] = useState<string>('')
const [compareRunId, setCompareRunId] = useState<string>('') const [compareRunId, setCompareRunId] = useState<string>('')
@ -87,6 +86,14 @@ export default function ReportsPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('single') 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) => { const loadReport = async (runId: string) => {
setSelectedRunId(runId) setSelectedRunId(runId)
setCompareResult(null) 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(() => { useEffect(() => {
if (runQuery && runQuery !== selectedRunId) { if (runQuery && runQuery !== selectedRunId) {
loadReport(runQuery) loadReport(runQuery)
@ -274,7 +264,7 @@ export default function ReportsPage() {
<Button size="middle" icon={<ClearOutlined />} onClick={resetFilters}></Button> <Button size="middle" icon={<ClearOutlined />} onClick={resetFilters}></Button>
</Tooltip> </Tooltip>
<Tooltip title="刷新列表"> <Tooltip title="刷新列表">
<Button size="middle" icon={<ReloadOutlined />} onClick={loadRuns} /> <Button size="middle" icon={<ReloadOutlined />} onClick={() => reloadRuns()} />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>

View File

@ -13,18 +13,21 @@ import RuleOverview from '../components/RuleOverview'
import CaseDetail from '../components/CaseDetail' import CaseDetail from '../components/CaseDetail'
import { useRunSession } from '../hooks/useRunSession' import { useRunSession } from '../hooks/useRunSession'
import { useTicker } from '../hooks/useTicker' 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 { colors, statusColors, statusLabels } from '../tokens'
import { formatDateTime, elapsedStr } from '../utils/date' import { formatDateTime, elapsedStr } from '../utils/date'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
interface RunsListsData {
runs: Run[]
targets: Target[]
scenarios: Scenario[]
}
export default function RunsPage() { export default function RunsPage() {
const navigate = useNavigate() 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 [targetId, setTargetId] = useState<string>()
const [scenarioId, setScenarioId] = useState<string>() const [scenarioId, setScenarioId] = useState<string>()
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
@ -34,41 +37,27 @@ export default function RunsPage() {
const session = useRunSession() 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 // Keep-alive tabs never remount: refresh the list whenever the tab is
// re-activated so runs triggered elsewhere (e.g. AI assistant) show up. // 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(() => { useEffect(() => {
if (!session.isLive) return if (session.completed) void reload(true)
const id = window.setInterval(() => { // eslint-disable-next-line react-hooks/exhaustive-deps
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(() => {})
}
}, [session.completed]) }, [session.completed])
// 提取为共享函数handleStart 和 onRerun 共用 // 提取为共享函数handleStart 和 onRerun 共用
@ -76,7 +65,7 @@ export default function RunsPage() {
setStarting(true) setStarting(true)
try { try {
const res = await runsApi.start(tId, sId) const res = await runsApi.start(tId, sId)
await loadLists() await reload()
session.select(res.data, { live: true }) session.select(res.data, { live: true })
setTabOverrideId(null) setTabOverrideId(null)
setFocusCaseId(null) setFocusCaseId(null)

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Button, Card, Drawer, Form, Input, message, Modal, Popconfirm, Button, Card, Drawer, Form, Input, message, Modal, Popconfirm,
Select, Space, Table, Tag, Tooltip, Select, Space, Table, Tag, Tooltip,
@ -8,8 +8,9 @@ import {
ReloadOutlined, AppstoreAddOutlined, ReloadOutlined, AppstoreAddOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import Editor from '@monaco-editor/react' 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 PageWrapper from '../components/PageWrapper'
import { useResource } from '../hooks/useResource'
import { colors } from '../tokens' import { colors } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
import { MODEL_CONFIGS_CHANGED_EVENT } from '../utils/modelConfigEvents' import { MODEL_CONFIGS_CHANGED_EVENT } from '../utils/modelConfigEvents'
@ -36,17 +37,21 @@ const YAML_TEMPLATE = `# 场景用例示例JSON 格式)
]` ]`
export default function ScenariosPage() { export default function ScenariosPage() {
const [scenarios, setScenarios] = useState<Scenario[]>([]) const { data: scenarios, loading, reload } = useResource(
const [loading, setLoading] = useState(false) () => 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 [drawerOpen, setDrawerOpen] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const [previewData, setPreviewData] = useState<any>(null) const [previewData, setPreviewData] = useState<any>(null)
const [editingScenario, setEditingScenario] = useState<Scenario | null>(null) const [editingScenario, setEditingScenario] = useState<Scenario | null>(null)
const [casesJson, setCasesJson] = useState(YAML_TEMPLATE) const [casesJson, setCasesJson] = useState(YAML_TEMPLATE)
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([])
const [modelConfigsLoading, setModelConfigsLoading] = useState(false)
const [modelBindings, setModelBindings] = useState<Record<string, string>>({}) const [modelBindings, setModelBindings] = useState<Record<string, string>>({})
const modelConfigRequestId = useRef(0)
const [form] = Form.useForm() const [form] = Form.useForm()
// Template picker // Template picker
@ -54,36 +59,14 @@ export default function ScenariosPage() {
const [templates, setTemplates] = useState<any[]>([]) const [templates, setTemplates] = useState<any[]>([])
const [tplLoading, setTplLoading] = useState(false) 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(() => { useEffect(() => {
load() const onChanged = () => void reloadModelConfigs()
loadModelConfigs() window.addEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
window.addEventListener(MODEL_CONFIGS_CHANGED_EVENT, loadModelConfigs) return () => window.removeEventListener(MODEL_CONFIGS_CHANGED_EVENT, onChanged)
return () => window.removeEventListener(MODEL_CONFIGS_CHANGED_EVENT, loadModelConfigs) }, [reloadModelConfigs])
}, [])
const openCreate = () => { const openCreate = () => {
loadModelConfigs() reloadModelConfigs()
setEditingScenario(null) setEditingScenario(null)
form.resetFields() form.resetFields()
setCasesJson(YAML_TEMPLATE) setCasesJson(YAML_TEMPLATE)
@ -104,7 +87,7 @@ export default function ScenariosPage() {
} }
const applyTemplate = (tpl: any) => { const applyTemplate = (tpl: any) => {
loadModelConfigs() reloadModelConfigs()
setTplModalOpen(false) setTplModalOpen(false)
setEditingScenario(null) setEditingScenario(null)
form.resetFields() form.resetFields()
@ -115,7 +98,7 @@ export default function ScenariosPage() {
} }
const openEdit = (scenario: Scenario) => { const openEdit = (scenario: Scenario) => {
loadModelConfigs() reloadModelConfigs()
setEditingScenario(scenario) setEditingScenario(scenario)
form.setFieldsValue({ form.setFieldsValue({
name: scenario.name, name: scenario.name,
@ -162,13 +145,13 @@ export default function ScenariosPage() {
message.success('创建成功') message.success('创建成功')
} }
setDrawerOpen(false) setDrawerOpen(false)
load() reload()
} }
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
await scenariosApi.delete(id) await scenariosApi.delete(id)
message.success('已删除') message.success('已删除')
load() reload()
} }
const columns = [ const columns = [
@ -220,8 +203,8 @@ export default function ScenariosPage() {
size="middle" size="middle"
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => { onClick={() => {
load() reload()
loadModelConfigs() reloadModelConfigs()
}} }}
/> />
</Tooltip> </Tooltip>
@ -237,7 +220,7 @@ export default function ScenariosPage() {
{/* 表格区 — 撑满剩余高度 */} {/* 表格区 — 撑满剩余高度 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Table <Table
dataSource={scenarios} dataSource={scenarios ?? []}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}
@ -300,7 +283,7 @@ export default function ScenariosPage() {
else delete next[item.purpose] else delete next[item.purpose]
setModelBindings(next) setModelBindings(next)
}} }}
options={modelConfigs options={(modelConfigs ?? [])
.filter((config) => config.capability === item.capability) .filter((config) => config.capability === item.capability)
.map((config) => ({ value: config.id, label: `${config.name} · ${config.model_name || config.capability}` }))} .map((config) => ({ value: config.id, label: `${config.name} · ${config.model_name || config.capability}` }))}
/> />

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { import {
Button, Drawer, Form, Input, message, Popconfirm, Button, Drawer, Form, Input, message, Popconfirm,
Space, Table, Tag, Select, Tooltip, Space, Table, Tag, Select, Tooltip,
@ -6,28 +6,19 @@ import {
import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons' import { PlusOutlined, ApiOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
import { targetsApi, type Target } from '../api' import { targetsApi, type Target } from '../api'
import PageWrapper from '../components/PageWrapper' import PageWrapper from '../components/PageWrapper'
import { useResource } from '../hooks/useResource'
import { colors } from '../tokens' import { colors } from '../tokens'
import { formatDateTime } from '../utils/date' import { formatDateTime } from '../utils/date'
export default function TargetsPage() { export default function TargetsPage() {
const [targets, setTargets] = useState<Target[]>([]) const { data: targets, loading, reload } = useResource(
const [loading, setLoading] = useState(false) () => targetsApi.list().then((r) => r.data),
{ tabPath: '/targets' },
)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [editingTarget, setEditingTarget] = useState<Target | null>(null) const [editingTarget, setEditingTarget] = useState<Target | null>(null)
const [form] = Form.useForm() 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 = () => { const openCreate = () => {
setEditingTarget(null) setEditingTarget(null)
form.resetFields() form.resetFields()
@ -80,7 +71,7 @@ export default function TargetsPage() {
message.success('创建成功') message.success('创建成功')
} }
setDrawerOpen(false) setDrawerOpen(false)
load() reload()
} }
const handleTest = async (id: string) => { const handleTest = async (id: string) => {
@ -99,7 +90,7 @@ export default function TargetsPage() {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
await targetsApi.delete(id) await targetsApi.delete(id)
message.success('已删除') message.success('已删除')
load() reload()
} }
const statusColor: Record<string, string> = { const statusColor: Record<string, string> = {
@ -150,7 +141,7 @@ export default function TargetsPage() {
extra={ extra={
<Space> <Space>
<Tooltip title="刷新"> <Tooltip title="刷新">
<Button size="middle" icon={<ReloadOutlined />} onClick={load} /> <Button size="middle" icon={<ReloadOutlined />} onClick={() => reload()} />
</Tooltip> </Tooltip>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
@ -161,7 +152,7 @@ export default function TargetsPage() {
{/* 表格区 — 撑满剩余高度 */} {/* 表格区 — 撑满剩余高度 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<Table <Table
dataSource={targets} dataSource={targets ?? []}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={loading} loading={loading}