From 6d9e49768c4b3f9238497be4f17a7104ab40095e Mon Sep 17 00:00:00 2001 From: sinohqb Date: Mon, 3 Aug 2026 02:17:28 +0800 Subject: [PATCH] feat(campaigns): add analysis section to report drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the campaign analysis in the report drawer: status row for generating (5s polling), failed (error + retry) and empty states, then the structured result — overall callout, problem cards with severity tags and evidence chips linking to run reports, per-scenario narratives and priority-sorted suggestions. Generate buttons are terminal-only with guidance when no analysis model is configured. --- frontend/web/src/api.ts | 28 ++++ frontend/web/src/pages/Campaigns.tsx | 189 ++++++++++++++++++++++++++- frontend/web/src/tokens.ts | 3 + 3 files changed, 216 insertions(+), 4 deletions(-) diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 50b9ca3..bdc75a0 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -394,6 +394,32 @@ export interface CampaignTimelineEntry { started_at: string | null } +export type CampaignAnalysisStatus = 'none' | 'generating' | 'completed' | 'failed' + +export interface CampaignAnalysisProblem { + severity: string + title: string + description: string + scenario_ids: string[] + evidence_run_ids: string[] +} + +export interface CampaignAnalysisResult { + overall: string + problems: CampaignAnalysisProblem[] + scenario_narratives: { scenario_id: string; narrative: string }[] + suggestions: { priority: number; text: string }[] +} + +export interface CampaignAnalysis { + status: CampaignAnalysisStatus + result?: CampaignAnalysisResult | null + error?: string | null + model_config_id?: string | null + triggered_by?: string + updated_at?: string | null +} + export interface CreateCampaignPayload { name: string target_id: string @@ -411,6 +437,8 @@ export const campaignsApi = { report: (id: string) => api.get(`/campaigns/${id}/report`), timeline: (id: string) => api.get<{ entries: CampaignTimelineEntry[] }>(`/campaigns/${id}/timeline`), + getAnalysis: (id: string) => api.get(`/campaigns/${id}/analysis`), + generateAnalysis: (id: string) => api.post<{ status: string }>(`/campaigns/${id}/analysis`), downloadReport: async (id: string) => { const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' }) const url = URL.createObjectURL(res.data as Blob) diff --git a/frontend/web/src/pages/Campaigns.tsx b/frontend/web/src/pages/Campaigns.tsx index cad716d..c16874f 100644 --- a/frontend/web/src/pages/Campaigns.tsx +++ b/frontend/web/src/pages/Campaigns.tsx @@ -1,13 +1,14 @@ -import { useState, type ReactNode } from 'react' +import { useState, type CSSProperties, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { - Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip, + Alert, Button, Table, Tag, Form, Select, InputNumber, Input, Space, Tooltip, Popconfirm, Drawer, Row, Col, Progress, Empty, Spin, message, Switch, } from 'antd' import { PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined, RocketOutlined, CheckCircleOutlined, SafetyOutlined, ClockCircleOutlined, + RobotOutlined, } from '@ant-design/icons' import { Line, Bar } from '@ant-design/charts' import PageWrapper from '../components/PageWrapper' @@ -15,7 +16,7 @@ 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 CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis, } from '../api' import { passRateColor } from '../utils/colors' import { shortDateTime, toDate } from '../utils/date' @@ -35,6 +36,12 @@ const CAMPAIGN_STATUS: Record = { failed: { label: '失败', color: 'error' }, } +const SEVERITY_META: Record = { + high: { label: '高', color: 'red' }, + medium: { label: '中', color: 'orange' }, + low: { label: '低', color: 'blue' }, +} + const WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 })) const UNIT_OPTIONS = [ @@ -116,6 +123,8 @@ export default function CampaignsPage() { const [report, setReport] = useState(null) const [reportRuns, setReportRuns] = useState([]) const [reportTimeline, setReportTimeline] = useState([]) + const [analysis, setAnalysis] = useState(null) + const [analysisBusy, setAnalysisBusy] = useState(false) const [expandedIds, setExpandedIds] = useState([]) const [timelines, setTimelines] = useState>({}) @@ -223,16 +232,19 @@ export default function CampaignsPage() { setReport(null) setReportRuns([]) setReportTimeline([]) + setAnalysis(null) } try { - const [rep, runs, tl] = await Promise.all([ + const [rep, runs, tl, ana] = await Promise.all([ campaignsApi.report(campaignId), runsApi.list(), campaignsApi.timeline(campaignId), + campaignsApi.getAnalysis(campaignId), ]) setReport(rep.data) setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId)) setReportTimeline(tl.data.entries) + setAnalysis(ana.data) } finally { if (!silent) setReportLoading(false) } @@ -254,6 +266,28 @@ export default function CampaignsPage() { activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive, ) + const generateAnalysis = async () => { + if (!reportId) return + setAnalysisBusy(true) + try { + await campaignsApi.generateAnalysis(reportId) + const res = await campaignsApi.getAnalysis(reportId) + setAnalysis(res.data) + } finally { + setAnalysisBusy(false) + } + } + + // 分析后台任务进行时轮询,直到进入 completed/failed 终态。 + usePolling( + () => { + if (!reportId) return + void campaignsApi.getAnalysis(reportId).then((res) => setAnalysis(res.data)) + }, + POLL_INTERVAL_MS, + activeKey === '/campaigns' && reportOpen && !!reportId && analysis?.status === 'generating', + ) + // Grow the expanded timeline of any still-running campaign as new child Runs // spawn. Completed/cancelled campaigns are fetched once on expand. const activeExpandedIds = expandedIds.filter( @@ -458,6 +492,150 @@ export default function CampaignsPage() { ) } + // ── 智能分析区块 ───────────────────────────────────────────────────── + const reportCampaign = campaigns.find((c) => c.id === reportId) + const analysisTerminal = !!report && !isActiveStatus(report.status) + const analysisModelMissing = !reportCampaign?.analysis_model_config_id && !analysisDefault + const analysisModelName = analysis?.model_config_id + ? (data?.modelConfigs ?? []).find((m) => m.id === analysis.model_config_id)?.name + ?? analysis.model_config_id.slice(0, 8) + : null + + const analysisButton = (label: string) => ( + + + + ) + + const analysisBoxStyle: CSSProperties = { + border: `1px solid ${colors.border}`, borderRadius: 8, + padding: '12px 14px', marginBottom: 16, + } + + const renderAnalysisSection = () => { + const status = analysis?.status ?? 'none' + if (status === 'generating') { + return ( +
+ + + 正在生成智能分析报告,通常需要几十秒… + +
+ ) + } + if (status === 'failed') { + return ( +
+ +
+ ) + } + const result = status === 'completed' ? analysis?.result : null + if (!result) { + return ( +
+
+ 尚未生成智能分析报告。生成后将在此展示总体结论、问题诊断、分场景叙述与改善建议。 +
+ + {analysisButton('生成分析')} + {analysisModelMissing && ( + + 未配置分析模型:请先在模型配置中心将某个 chat 配置设为「分析默认」,或重新创建活动时指定 + + )} + +
+ ) + } + return ( +
+
+ + 分析模型 {analysisModelName ?? '未知'} + {analysis?.updated_at && <> · 生成于 {shortDateTime(analysis.updated_at)}} + + {analysisButton('重新生成')} +
+
+ 总体结论:{result.overall} +
+ {result.problems.length > 0 && ( +
+
问题诊断
+ + {result.problems.map((p, i) => { + const meta = SEVERITY_META[p.severity] ?? SEVERITY_META.medium + return ( +
+ + {meta.label} + {p.title} + {p.scenario_ids.map((sid) => ( + {scenarioNames[sid] ?? sid.slice(0, 8)} + ))} + +
{p.description}
+ {p.evidence_run_ids.length > 0 && ( + + 证据: + {p.evidence_run_ids.map((rid) => ( + navigate(`/reports?run=${rid}`)} + > + {rid.slice(0, 8)} + + ))} + + )} +
+ ) + })} +
+
+ )} + {result.scenario_narratives.length > 0 && ( +
+
分场景叙述
+ {result.scenario_narratives.map((n) => ( +
+ {scenarioNames[n.scenario_id] ?? n.scenario_id.slice(0, 8)} + {n.narrative} +
+ ))} +
+ )} + {result.suggestions.length > 0 && ( +
+
改善建议
+
    + {[...result.suggestions] + .sort((a, b) => a.priority - b.priority) + .map((s, i) =>
  1. {s.text}
  2. )} +
+
+ )} +
+ ) + } + return ( + 智能分析 + {renderAnalysisSection()} + 过程时间轴