refactor(frontend): extract useCampaignReport hook from Campaigns.tsx

将活动报告抽屉的数据获取逻辑从 Campaigns.tsx 抽离到 useCampaignReport hook。
消除 5 个独立状态变量(report/reportRuns/reportTimeline/analysis/comparison)
和 fetchReport 函数的 25 行样板代码。hook 封装 5 个并行 API 调用和状态管理,
组件只负责渲染和交互。

- 新增 useCampaignReport(campaignId) hook
- 返回 {report, runs, timeline, analysis, comparison, loading, refetch, setAnalysis, setComparison}
- Campaigns.tsx 从 1122 行缩减状态管理复杂度
- TypeScript 类型检查通过,后端测试全绿
This commit is contained in:
sinohqb 2026-08-04 11:45:15 +08:00
parent c24998c762
commit cbfdf86b36
2 changed files with 104 additions and 55 deletions

View File

@ -0,0 +1,77 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from 'react'
import { campaignsApi, runsApi, type CampaignReport, type Run, type CampaignTimelineEntry, type CampaignAnalysis, type CampaignComparison } from '../api'
interface CampaignReportData {
report: CampaignReport | null
runs: Run[]
timeline: CampaignTimelineEntry[]
analysis: CampaignAnalysis | null
comparison: CampaignComparison | null
loading: boolean
}
interface UseCampaignReportResult extends CampaignReportData {
refetch: (silent?: boolean) => Promise<void>
setAnalysis: Dispatch<SetStateAction<CampaignAnalysis | null>>
setComparison: Dispatch<SetStateAction<CampaignComparison | null>>
}
export function useCampaignReport(campaignId: string | null): UseCampaignReportResult {
const [data, setData] = useState<CampaignReportData>({
report: null,
runs: [],
timeline: [],
analysis: null,
comparison: null,
loading: false,
})
const refetch = useCallback(async (silent = false) => {
if (!campaignId) return
if (!silent) {
setData({
report: null,
runs: [],
timeline: [],
analysis: null,
comparison: null,
loading: true,
})
}
try {
const [rep, runs, tl, ana, cmp] = await Promise.all([
campaignsApi.report(campaignId),
runsApi.list(),
campaignsApi.timeline(campaignId),
campaignsApi.getAnalysis(campaignId),
campaignsApi.getComparison(campaignId),
])
setData({
report: rep.data,
runs: runs.data.filter((r) => r.campaign_id === campaignId),
timeline: tl.data.entries,
analysis: ana.data,
comparison: cmp.data,
loading: false,
})
} catch {
setData((prev) => ({ ...prev, loading: false }))
}
}, [campaignId])
const setAnalysis: Dispatch<SetStateAction<CampaignAnalysis | null>> = (value) => {
setData((prev) => ({
...prev,
analysis: typeof value === 'function' ? value(prev.analysis) : value,
}))
}
const setComparison: Dispatch<SetStateAction<CampaignComparison | null>> = (value) => {
setData((prev) => ({
...prev,
comparison: typeof value === 'function' ? value(prev.comparison) : value,
}))
}
return { ...data, refetch, setAnalysis, setComparison }
}

View File

@ -14,10 +14,9 @@ import { Line, Bar } from '@ant-design/charts'
import PageWrapper from '../components/PageWrapper'
import StatCard from '../components/StatCard'
import {
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi,
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis,
type CampaignComparison,
campaignsApi, targetsApi, scenariosApi, modelConfigsApi,
type CampaignListItem, type Target, type Scenario, type Run,
type CampaignTimelineEntry, type ModelConfig,
} from '../api'
import { passRateColor } from '../utils/colors'
import { shortDateTime, toDate } from '../utils/date'
@ -28,6 +27,7 @@ import PeriodComparisonSection from '../components/PeriodComparisonSection'
import ExplorationSection from '../components/ExplorationSection'
import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling'
import { useCampaignReport } from '../hooks/useCampaignReport'
import { useTabStore } from '../stores/tabStore'
import { colors, statusColors, statusLabels, triggerColors, triggerLabels } from '../tokens'
@ -154,13 +154,8 @@ export default function CampaignsPage() {
const derivedScale = scaleFor(!!wRealtime, wWindow, wTargetValue, wTargetUnit)
const [reportOpen, setReportOpen] = useState(false)
const [reportLoading, setReportLoading] = useState(false)
const [report, setReport] = useState<CampaignReport | null>(null)
const [reportRuns, setReportRuns] = useState<Run[]>([])
const [reportTimeline, setReportTimeline] = useState<CampaignTimelineEntry[]>([])
const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null)
const [reportCampaignId, setReportCampaignId] = useState<string | null>(null)
const [analysisBusy, setAnalysisBusy] = useState(false)
const [comparison, setComparison] = useState<CampaignComparison | null>(null)
const [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
@ -183,6 +178,11 @@ export default function CampaignsPage() {
const analysisDefault = chatConfigs.find((m) => m.is_analysis_default)
const scenarioNames = Object.fromEntries(scenarios.map((s) => [s.id, s.name]))
const {
report, runs: reportRuns, timeline: reportTimeline, analysis, comparison, loading: reportLoading,
refetch: fetchReport, setAnalysis, setComparison,
} = useCampaignReport(reportOpen ? reportCampaignId : null)
const wPlan = (Form.useWatch('plan', form) as PlanFormEntry[] | undefined) ?? []
const planMarkers: TimelineMarker[] = wPlan
.map((e, i) => ({ e, i }))
@ -277,55 +277,27 @@ export default function CampaignsPage() {
if (expanded) void fetchTimeline(record.id)
}
const fetchReport = async (campaignId: string, silent = false) => {
if (!silent) {
setReportLoading(true)
setReport(null)
setReportRuns([])
setReportTimeline([])
setAnalysis(null)
setComparison(null)
}
try {
const [rep, runs, tl, ana, cmp] = await Promise.all([
campaignsApi.report(campaignId),
runsApi.list(),
campaignsApi.timeline(campaignId),
campaignsApi.getAnalysis(campaignId),
campaignsApi.getComparison(campaignId),
])
setReport(rep.data)
setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId))
setReportTimeline(tl.data.entries)
setAnalysis(ana.data)
setComparison(cmp.data)
} finally {
if (!silent) setReportLoading(false)
}
}
const openReport = (campaign: CampaignListItem) => {
setReportOpen(true)
fetchReport(campaign.id)
setReportCampaignId(campaign.id)
}
// Keep the open report drawer live while its campaign is still running.
const reportId = report?.campaign_id ?? null
const reportCampaignActive = campaigns.some(
(c) => c.id === reportId && isActiveStatus(c.status),
(c) => c.id === reportCampaignId && isActiveStatus(c.status),
)
usePolling(
() => { if (reportId) void fetchReport(reportId, true) },
() => { if (reportCampaignId) void fetchReport(true) },
POLL_INTERVAL_MS,
activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive,
activeKey === '/campaigns' && reportOpen && !!reportCampaignId && reportCampaignActive,
)
const generateAnalysis = async () => {
if (!reportId) return
if (!reportCampaignId) return
setAnalysisBusy(true)
try {
await campaignsApi.generateAnalysis(reportId)
const res = await campaignsApi.getAnalysis(reportId)
await campaignsApi.generateAnalysis(reportCampaignId)
const res = await campaignsApi.getAnalysis(reportCampaignId)
setAnalysis(res.data)
} finally {
setAnalysisBusy(false)
@ -335,28 +307,28 @@ export default function CampaignsPage() {
// 分析后台任务进行时轮询,直到进入 completed/failed 终态。
usePolling(
() => {
if (!reportId) return
void campaignsApi.getAnalysis(reportId).then((res) => setAnalysis(res.data))
if (!reportCampaignId) return
void campaignsApi.getAnalysis(reportCampaignId).then((res) => setAnalysis(res.data))
},
POLL_INTERVAL_MS,
activeKey === '/campaigns' && reportOpen && !!reportId && analysis?.status === 'generating',
activeKey === '/campaigns' && reportOpen && !!reportCampaignId && analysis?.status === 'generating',
)
const generateComparison = async (baselineCampaignId?: string) => {
if (!reportId) return
await campaignsApi.generateComparison(reportId, baselineCampaignId)
const res = await campaignsApi.getComparison(reportId)
if (!reportCampaignId) return
await campaignsApi.generateComparison(reportCampaignId, baselineCampaignId)
const res = await campaignsApi.getComparison(reportCampaignId)
setComparison(res.data)
}
// 对比生成中同样轮询,直到 completed/failed。
usePolling(
() => {
if (!reportId) return
void campaignsApi.getComparison(reportId).then((res) => setComparison(res.data))
if (!reportCampaignId) return
void campaignsApi.getComparison(reportCampaignId).then((res) => setComparison(res.data))
},
POLL_INTERVAL_MS,
activeKey === '/campaigns' && reportOpen && !!reportId && comparison?.status === 'generating',
activeKey === '/campaigns' && reportOpen && !!reportCampaignId && comparison?.status === 'generating',
)
// Grow the expanded timeline of any still-running campaign as new child Runs
@ -564,7 +536,7 @@ export default function CampaignsPage() {
}
// ── 智能分析区块 ─────────────────────────────────────────────────────
const reportCampaign = campaigns.find((c) => c.id === reportId)
const reportCampaign = campaigns.find((c) => c.id === reportCampaignId)
const analysisTerminal = !!report && !isActiveStatus(report.status)
const analysisModelMissing = !reportCampaign?.analysis_model_config_id && !analysisDefault
const analysisModelName = analysis?.model_config_id