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 { 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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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}` }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user