diff --git a/frontend/web/src/hooks/usePolling.ts b/frontend/web/src/hooks/usePolling.ts new file mode 100644 index 0000000..8dec79d --- /dev/null +++ b/frontend/web/src/hooks/usePolling.ts @@ -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]) +} diff --git a/frontend/web/src/hooks/useResource.ts b/frontend/web/src/hooks/useResource.ts new file mode 100644 index 0000000..e0ff33e --- /dev/null +++ b/frontend/web/src/hooks/useResource.ts @@ -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 { + data: T | null + loading: boolean + /** Refetch. Pass `true` to skip the loading flag (silent poll refresh). */ + reload: (silent?: boolean) => Promise +} + +/** + * 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( + fetcher: () => Promise, + options: UseResourceOptions = {}, +): UseResource { + const { tabPath, deps = [], immediate = true } = options + + const [data, setData] = useState(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 } +} diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index 817159b..bfd9730 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -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([]) - const [targets, setTargets] = useState([]) - const [scenarios, setScenarios] = useState([]) - 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( + 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={ - } diff --git a/frontend/web/src/pages/Home.tsx b/frontend/web/src/pages/Home.tsx index fb812f5..5f9e68d 100644 --- a/frontend/web/src/pages/Home.tsx +++ b/frontend/web/src/pages/Home.tsx @@ -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(null) - const [trend, setTrend] = useState([]) - - const loadData = async () => { - setLoading(true) - try { + const { data, loading, reload } = useResource( + 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={ - } >
([]) const [scenarioFilter, setScenarioFilter] = useState('') const [selectedRunId, setSelectedRunId] = useState('') const [compareRunId, setCompareRunId] = useState('') @@ -87,6 +86,14 @@ export default function ReportsPage() { const [loading, setLoading] = useState(false) const [viewMode, setViewMode] = useState('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() { -
config.capability === item.capability) .map((config) => ({ value: config.id, label: `${config.name} · ${config.model_name || config.capability}` }))} /> diff --git a/frontend/web/src/pages/Targets.tsx b/frontend/web/src/pages/Targets.tsx index 26c747a..2c88290 100644 --- a/frontend/web/src/pages/Targets.tsx +++ b/frontend/web/src/pages/Targets.tsx @@ -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([]) - 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(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 = { @@ -150,7 +141,7 @@ export default function TargetsPage() { extra={ -