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:
parent
15c542d92c
commit
6d9e49768c
@ -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<CampaignReport>(`/campaigns/${id}/report`),
|
||||
timeline: (id: string) =>
|
||||
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) => {
|
||||
const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(res.data as Blob)
|
||||
|
||||
@ -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<string, { label: string; color: string }> = {
|
||||
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 UNIT_OPTIONS = [
|
||||
@ -116,6 +123,8 @@ export default function CampaignsPage() {
|
||||
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 [expandedIds, setExpandedIds] = useState<string[]>([])
|
||||
const [timelines, setTimelines] = useState<Record<string, CampaignTimelineEntry[]>>({})
|
||||
@ -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) => (
|
||||
<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 (
|
||||
<PageWrapper
|
||||
title="评估活动"
|
||||
@ -761,6 +939,9 @@ export default function CampaignsPage() {
|
||||
{' · '}开始于 {shortDateTime(report.started_at)}
|
||||
</div>
|
||||
|
||||
<SectionTitle><RobotOutlined /> 智能分析</SectionTitle>
|
||||
{renderAnalysisSection()}
|
||||
|
||||
<SectionTitle>过程时间轴</SectionTitle>
|
||||
<div style={{
|
||||
border: `1px solid ${colors.border}`, borderRadius: 8,
|
||||
|
||||
@ -21,6 +21,9 @@ export const colors = {
|
||||
textSecondary: '#6b7280',
|
||||
textMuted: '#9ca3af',
|
||||
|
||||
// Feedback
|
||||
warning: '#faad14',
|
||||
|
||||
// Chat bubbles
|
||||
chatUser: '#e6f4ff',
|
||||
chatAgent: '#f0fdf4',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user