feat(campaigns): add analysis section to report drawer

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.
This commit is contained in:
sinohqb 2026-08-03 02:17:28 +08:00
parent 15c542d92c
commit 6d9e49768c
3 changed files with 216 additions and 4 deletions

View File

@ -394,6 +394,32 @@ export interface CampaignTimelineEntry {
started_at: string | null 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 { export interface CreateCampaignPayload {
name: string name: string
target_id: string target_id: string
@ -411,6 +437,8 @@ export const campaignsApi = {
report: (id: string) => api.get<CampaignReport>(`/campaigns/${id}/report`), report: (id: string) => api.get<CampaignReport>(`/campaigns/${id}/report`),
timeline: (id: string) => timeline: (id: string) =>
api.get<{ entries: CampaignTimelineEntry[] }>(`/campaigns/${id}/timeline`), api.get<{ entries: CampaignTimelineEntry[] }>(`/campaigns/${id}/timeline`),
getAnalysis: (id: string) => api.get<CampaignAnalysis>(`/campaigns/${id}/analysis`),
generateAnalysis: (id: string) => api.post<{ status: string }>(`/campaigns/${id}/analysis`),
downloadReport: async (id: string) => { downloadReport: async (id: string) => {
const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' }) const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data as Blob) const url = URL.createObjectURL(res.data as Blob)

View File

@ -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 { useNavigate } from 'react-router-dom'
import { 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, Popconfirm, Drawer, Row, Col, Progress, Empty, Spin, message, Switch,
} from 'antd' } from 'antd'
import { import {
PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined, PlusOutlined, ReloadOutlined, StopOutlined, BarChartOutlined,
FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined, FileMarkdownOutlined, MinusCircleOutlined, QuestionCircleOutlined,
RocketOutlined, CheckCircleOutlined, SafetyOutlined, ClockCircleOutlined, RocketOutlined, CheckCircleOutlined, SafetyOutlined, ClockCircleOutlined,
RobotOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { Line, Bar } from '@ant-design/charts' import { Line, Bar } from '@ant-design/charts'
import PageWrapper from '../components/PageWrapper' import PageWrapper from '../components/PageWrapper'
@ -15,7 +16,7 @@ import StatCard from '../components/StatCard'
import { import {
campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi, campaignsApi, targetsApi, scenariosApi, runsApi, modelConfigsApi,
type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run, type CampaignListItem, type CampaignReport, type Target, type Scenario, type Run,
type CampaignTimelineEntry, type ModelConfig, type CampaignTimelineEntry, type ModelConfig, type CampaignAnalysis,
} 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'
@ -35,6 +36,12 @@ const CAMPAIGN_STATUS: Record<string, { label: string; color: string }> = {
failed: { label: '失败', color: 'error' }, failed: { label: '失败', color: 'error' },
} }
const SEVERITY_META: Record<string, { label: string; color: string }> = {
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 WINDOW_OPTIONS = [6, 12, 24, 48, 72].map((h) => ({ label: `${h} 小时`, value: h * 3600 }))
const UNIT_OPTIONS = [ const UNIT_OPTIONS = [
@ -116,6 +123,8 @@ export default function CampaignsPage() {
const [report, setReport] = useState<CampaignReport | null>(null) const [report, setReport] = useState<CampaignReport | null>(null)
const [reportRuns, setReportRuns] = useState<Run[]>([]) const [reportRuns, setReportRuns] = useState<Run[]>([])
const [reportTimeline, setReportTimeline] = useState<CampaignTimelineEntry[]>([]) const [reportTimeline, setReportTimeline] = useState<CampaignTimelineEntry[]>([])
const [analysis, setAnalysis] = useState<CampaignAnalysis | null>(null)
const [analysisBusy, setAnalysisBusy] = useState(false)
const [expandedIds, setExpandedIds] = useState<string[]>([]) const [expandedIds, setExpandedIds] = useState<string[]>([])
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({}) const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
@ -223,16 +232,19 @@ export default function CampaignsPage() {
setReport(null) setReport(null)
setReportRuns([]) setReportRuns([])
setReportTimeline([]) setReportTimeline([])
setAnalysis(null)
} }
try { try {
const [rep, runs, tl] = await Promise.all([ const [rep, runs, tl, ana] = await Promise.all([
campaignsApi.report(campaignId), campaignsApi.report(campaignId),
runsApi.list(), runsApi.list(),
campaignsApi.timeline(campaignId), campaignsApi.timeline(campaignId),
campaignsApi.getAnalysis(campaignId),
]) ])
setReport(rep.data) setReport(rep.data)
setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId)) setReportRuns(runs.data.filter((r) => r.campaign_id === campaignId))
setReportTimeline(tl.data.entries) setReportTimeline(tl.data.entries)
setAnalysis(ana.data)
} finally { } finally {
if (!silent) setReportLoading(false) if (!silent) setReportLoading(false)
} }
@ -254,6 +266,28 @@ export default function CampaignsPage() {
activeKey === '/campaigns' && reportOpen && !!reportId && reportCampaignActive, 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 // Grow the expanded timeline of any still-running campaign as new child Runs
// spawn. Completed/cancelled campaigns are fetched once on expand. // spawn. Completed/cancelled campaigns are fetched once on expand.
const activeExpandedIds = expandedIds.filter( 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) => (
<Tooltip title={analysisTerminal ? undefined : '活动完成后可生成'}>
<Button
size="small" type="primary" ghost loading={analysisBusy}
disabled={!analysisTerminal} onClick={() => void generateAnalysis()}
>
{label}
</Button>
</Tooltip>
)
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 (
<div style={analysisBoxStyle}>
<Spin size="small" />
<span style={{ marginLeft: 10, color: colors.textSecondary }}>
</span>
</div>
)
}
if (status === 'failed') {
return (
<div style={{ marginBottom: 16 }}>
<Alert
type="error" showIcon message="智能分析生成失败"
description={analysis?.error || '未知错误'}
action={analysisButton('重试')}
/>
</div>
)
}
const result = status === 'completed' ? analysis?.result : null
if (!result) {
return (
<div style={{ ...analysisBoxStyle, background: colors.bgSubtle }}>
<div style={{ color: colors.textSecondary, marginBottom: 8 }}>
</div>
<Space size={12} wrap>
{analysisButton('生成分析')}
{analysisModelMissing && (
<span style={{ fontSize: 12, color: colors.warning }}>
chat
</span>
)}
</Space>
</div>
)
}
return (
<div style={analysisBoxStyle}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
fontSize: 12, color: colors.textSecondary, marginBottom: 10,
}}>
<span>
{analysisModelName ?? '未知'}
{analysis?.updated_at && <> · {shortDateTime(analysis.updated_at)}</>}
</span>
{analysisButton('重新生成')}
</div>
<div style={{
background: '#e6f4ff', border: '1px solid #91caff', borderRadius: 8,
padding: '10px 14px', marginBottom: 12,
}}>
<b></b>{result.overall}
</div>
{result.problems.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{result.problems.map((p, i) => {
const meta = SEVERITY_META[p.severity] ?? SEVERITY_META.medium
return (
<div key={i} style={{ border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 12px' }}>
<Space size={8} wrap>
<Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>
<b>{p.title}</b>
{p.scenario_ids.map((sid) => (
<Tag key={sid} style={{ margin: 0 }}>{scenarioNames[sid] ?? sid.slice(0, 8)}</Tag>
))}
</Space>
<div style={{ color: colors.textSecondary, margin: '6px 0' }}>{p.description}</div>
{p.evidence_run_ids.length > 0 && (
<Space size={4} wrap>
<span style={{ fontSize: 12, color: colors.textMuted }}></span>
{p.evidence_run_ids.map((rid) => (
<Tag
key={rid} color="blue" style={{ cursor: 'pointer', margin: 0 }}
onClick={() => navigate(`/reports?run=${rid}`)}
>
{rid.slice(0, 8)}
</Tag>
))}
</Space>
)}
</div>
)
})}
</Space>
</div>
)}
{result.scenario_narratives.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
{result.scenario_narratives.map((n) => (
<div key={n.scenario_id} style={{ marginBottom: 6 }}>
<Tag>{scenarioNames[n.scenario_id] ?? n.scenario_id.slice(0, 8)}</Tag>
<span>{n.narrative}</span>
</div>
))}
</div>
)}
{result.suggestions.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
<ol style={{ margin: 0, paddingLeft: 20 }}>
{[...result.suggestions]
.sort((a, b) => a.priority - b.priority)
.map((s, i) => <li key={i} style={{ marginBottom: 4 }}>{s.text}</li>)}
</ol>
</div>
)}
</div>
)
}
return ( return (
<PageWrapper <PageWrapper
title="评估活动" title="评估活动"
@ -761,6 +939,9 @@ export default function CampaignsPage() {
{' · '} {shortDateTime(report.started_at)} {' · '} {shortDateTime(report.started_at)}
</div> </div>
<SectionTitle><RobotOutlined /> </SectionTitle>
{renderAnalysisSection()}
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<div style={{ <div style={{
border: `1px solid ${colors.border}`, borderRadius: 8, border: `1px solid ${colors.border}`, borderRadius: 8,

View File

@ -21,6 +21,9 @@ export const colors = {
textSecondary: '#6b7280', textSecondary: '#6b7280',
textMuted: '#9ca3af', textMuted: '#9ca3af',
// Feedback
warning: '#faad14',
// Chat bubbles // Chat bubbles
chatUser: '#e6f4ff', chatUser: '#e6f4ff',
chatAgent: '#f0fdf4', chatAgent: '#f0fdf4',