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 PageWrapper from '../components/PageWrapper'
import StatCard from '../components/StatCard' import StatCard from '../components/StatCard'
import { import {
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi, campaignsApi, targetsApi, scenariosApi, modelConfigsApi,
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run, type CampaignListItem, type Target, type Scenario, type Run,
type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis, type CampaignTimelineEntry, type ModelConfig,
type CampaignComparison,
} from '../api' } from '../api'
import { passRateColor } from '../utils/colors' import { passRateColor } from '../utils/colors'
import { shortDateTime, toDate } from '../utils/date' import { shortDateTime, toDate } from '../utils/date'
@ -28,6 +27,7 @@ import PeriodComparisonSection from '../components/PeriodComparisonSection'
import ExplorationSection from '../components/ExplorationSection' import ExplorationSection from '../components/ExplorationSection'
import { useResource } from '../hooks/useResource' import { useResource } from '../hooks/useResource'
import { usePolling } from '../hooks/usePolling' import { usePolling } from '../hooks/usePolling'
import { useCampaignReport } from '../hooks/useCampaignReport'
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'
@ -154,13 +154,8 @@ export default function CampaignsPage() {
const derivedScale = scaleFor(!!wRealtime, wWindow, wTargetValue, wTargetUnit) const derivedScale = scaleFor(!!wRealtime, wWindow, wTargetValue, wTargetUnit)
const [reportOpen, setReportOpen] = useState(false) const [reportOpen, setReportOpen] = useState(false)
const [reportLoading, setReportLoading] = useState(false) const [reportCampaignId, setReportCampaignId] = useState<string | null>(null)
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 [analysisBusy, setAnalysisBusy] = useState(false) const [analysisBusy, setAnalysisBusy] = useState(false)
const [comparison, setComparison] = useState<CampaignComparison | null>(null)
const [expandedIds, setExpandedIds] = useState<string[]>([]) const [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({}) 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 analysisDefault = chatConfigs.find((m) => m.is_analysis_default)
const scenarioNames = Object.fromEntries(scenarios.map((s) => [s.id, s.name])) 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 wPlan = (Form.useWatch('plan', form) as PlanFormEntry[] | undefined) ?? []
const planMarkers: TimelineMarker[] = wPlan const planMarkers: TimelineMarker[] = wPlan
.map((e, i) => ({ e, i })) .map((e, i) => ({ e, i }))
@ -277,55 +277,27 @@ export default function CampaignsPage() {
if (expanded) void fetchTimeline(record.id) 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) => { const openReport = (campaign: CampaignListItem) => {
setReportOpen(true) setReportOpen(true)
fetchReport(campaign.id) setReportCampaignId(campaign.id)
} }
// Keep the open report drawer live while its campaign is still running. // Keep the open report drawer live while its campaign is still running.
const reportId = report?.campaign_id ?? null
const reportCampaignActive = campaigns.some( const reportCampaignActive = campaigns.some(
(c) => c.id === reportId && isActiveStatus(c.status), (c) => c.id === reportCampaignId && isActiveStatus(c.status),
) )
usePolling( usePolling(
() => { if (reportId) void fetchReport(reportId, true) }, () => { if (reportCampaignId) void fetchReport(true) },
POLL_INTERVAL_MS, POLL_INTERVAL_MS,
activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive, activeKey === '/campaigns' && reportOpen && !!reportCampaignId && reportCampaignActive,
) )
const generateAnalysis = async () => { const generateAnalysis = async () => {
if (!reportId) return if (!reportCampaignId) return
setAnalysisBusy(true) setAnalysisBusy(true)
try { try {
await campaignsApi.generateAnalysis(reportId) await campaignsApi.generateAnalysis(reportCampaignId)
const res = await campaignsApi.getAnalysis(reportId) const res = await campaignsApi.getAnalysis(reportCampaignId)
setAnalysis(res.data) setAnalysis(res.data)
} finally { } finally {
setAnalysisBusy(false) setAnalysisBusy(false)
@ -335,28 +307,28 @@ export default function CampaignsPage() {
// 分析后台任务进行时轮询,直到进入 completed/failed 终态。 // 分析后台任务进行时轮询,直到进入 completed/failed 终态。
usePolling( usePolling(
() => { () => {
if (!reportId) return if (!reportCampaignId) return
void campaignsApi.getAnalysis(reportId).then((res) => setAnalysis(res.data)) void campaignsApi.getAnalysis(reportCampaignId).then((res) => setAnalysis(res.data))
}, },
POLL_INTERVAL_MS, POLL_INTERVAL_MS,
activeKey === '/campaigns' && reportOpen && !!reportId && analysis?.status === 'generating', activeKey === '/campaigns' && reportOpen && !!reportCampaignId && analysis?.status === 'generating',
) )
const generateComparison = async (baselineCampaignId?: string) => { const generateComparison = async (baselineCampaignId?: string) => {
if (!reportId) return if (!reportCampaignId) return
await campaignsApi.generateComparison(reportId, baselineCampaignId) await campaignsApi.generateComparison(reportCampaignId, baselineCampaignId)
const res = await campaignsApi.getComparison(reportId) const res = await campaignsApi.getComparison(reportCampaignId)
setComparison(res.data) setComparison(res.data)
} }
// 对比生成中同样轮询,直到 completed/failed。 // 对比生成中同样轮询,直到 completed/failed。
usePolling( usePolling(
() => { () => {
if (!reportId) return if (!reportCampaignId) return
void campaignsApi.getComparison(reportId).then((res) => setComparison(res.data)) void campaignsApi.getComparison(reportCampaignId).then((res) => setComparison(res.data))
}, },
POLL_INTERVAL_MS, 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 // 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 analysisTerminal = !!report && !isActiveStatus(report.status)
const analysisModelMissing = !reportCampaign?.analysis_model_config_id && !analysisDefault const analysisModelMissing = !reportCampaign?.analysis_model_config_id && !analysisDefault
const analysisModelName = analysis?.model_config_id const analysisModelName = analysis?.model_config_id