feat: 添加 L4 AI 诊断报告(后端 DeepSeek 端点 + 前端摘要块与详情页)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Spin } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { generateDiagnosisReport } from '../../services/analyticsService'
|
||||
|
||||
/**
|
||||
* AI 诊断报告摘要块 — 收视分析页内嵌
|
||||
* - 根据 avgShare 判定三档 tier
|
||||
* - 调用 DeepSeek 生成报告
|
||||
* - 从 markdown 提取核心发现和行动建议
|
||||
*/
|
||||
|
||||
/** 从 markdown 中提取指定章节内容 */
|
||||
function extractSection(markdown, startMarker, endMarkers) {
|
||||
if (!markdown) return ''
|
||||
const startIdx = markdown.indexOf(startMarker)
|
||||
if (startIdx === -1) return ''
|
||||
const afterStart = startIdx + startMarker.length
|
||||
// 找最近的结束标记
|
||||
let endIdx = markdown.length
|
||||
for (const marker of endMarkers) {
|
||||
const idx = markdown.indexOf(marker, afterStart)
|
||||
if (idx !== -1 && idx < endIdx) {
|
||||
endIdx = idx
|
||||
}
|
||||
}
|
||||
return markdown.substring(afterStart, endIdx).trim()
|
||||
}
|
||||
|
||||
/** 清理 markdown 为纯文本(去掉标题标记、多余符号等) */
|
||||
function cleanMarkdown(text) {
|
||||
if (!text) return []
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^#+\s*/, '').replace(/^\*\*(.+?)\*\*/, '$1').trim())
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
const TIER_CONFIG = {
|
||||
danger: {
|
||||
leftTitle: '问题聚焦',
|
||||
rightTitle: '病因与预警',
|
||||
color: '#7aa874',
|
||||
rightBg: 'rgba(122,168,116,0.06)',
|
||||
},
|
||||
on_target: {
|
||||
leftTitle: '核心发现',
|
||||
rightTitle: '病因与提振建议',
|
||||
color: '#5b8db8',
|
||||
rightBg: 'rgba(91,141,184,0.06)',
|
||||
},
|
||||
excellent: {
|
||||
leftTitle: '高光复盘',
|
||||
rightTitle: '经验总结',
|
||||
color: '#c0584f',
|
||||
rightBg: 'rgba(192,88,79,0.06)',
|
||||
},
|
||||
}
|
||||
|
||||
function DiagnosisSummary({ episodes, yearlyTarget, selectedYear }) {
|
||||
const navigate = useNavigate()
|
||||
const [report, setReport] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// 计算平均份额和 tier
|
||||
const { avgShare, tier, epStart, epEnd } = useMemo(() => {
|
||||
if (!episodes || episodes.length === 0 || !yearlyTarget) {
|
||||
return { avgShare: 0, tier: null, epStart: 0, epEnd: 0 }
|
||||
}
|
||||
const withShare = episodes.filter((ep) => ep.audience_share != null)
|
||||
if (withShare.length === 0) {
|
||||
return { avgShare: 0, tier: null, epStart: 0, epEnd: 0 }
|
||||
}
|
||||
const sum = withShare.reduce((acc, ep) => acc + Number(ep.audience_share), 0)
|
||||
const avg = sum / withShare.length
|
||||
const base = Number(yearlyTarget.base_target)
|
||||
const stretch = Number(yearlyTarget.stretch_target)
|
||||
|
||||
let t = 'danger'
|
||||
if (avg > stretch) t = 'excellent'
|
||||
else if (avg >= base) t = 'on_target'
|
||||
|
||||
const nums = withShare.map((ep) => ep.episode_number)
|
||||
return {
|
||||
avgShare: avg,
|
||||
tier: t,
|
||||
epStart: Math.min(...nums),
|
||||
epEnd: Math.max(...nums),
|
||||
}
|
||||
}, [episodes, yearlyTarget])
|
||||
|
||||
const config = tier ? TIER_CONFIG[tier] : null
|
||||
|
||||
// 调用 API
|
||||
useEffect(() => {
|
||||
if (!tier || !episodes || episodes.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setReport(null)
|
||||
|
||||
generateDiagnosisReport({
|
||||
year: selectedYear,
|
||||
ep_start: epStart,
|
||||
ep_end: epEnd,
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
} else {
|
||||
setReport(data)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setError(err?.response?.data?.detail || '报告生成失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedYear, epStart, epEnd, tier, episodes])
|
||||
|
||||
// 空态
|
||||
if (!episodes || episodes.length === 0 || !tier) {
|
||||
return (
|
||||
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}>
|
||||
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2>
|
||||
<p style={{ color: '#aaa', fontSize: 14 }}>请先选择包含收视数据的期次范围</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 提取核心发现和行动建议
|
||||
const markdown = report?.report_markdown || ''
|
||||
// 核心发现:从 "#### 二、核心发现" 到 "#### 三、"
|
||||
let coreFindings = extractSection(markdown, '#### 二、核心发现', ['#### 三、'])
|
||||
// 行动建议:从 "#### 四、行动建议" 到末尾
|
||||
let actions = extractSection(markdown, '#### 四、行动建议', [])
|
||||
|
||||
const coreLines = cleanMarkdown(coreFindings)
|
||||
const actionLines = cleanMarkdown(actions)
|
||||
|
||||
return (
|
||||
<div className="analytics-chart-card">
|
||||
{/* 标题行 */}
|
||||
<div className="diagnosis-summary-header">
|
||||
<h2 className="analytics-chart-title" style={{ marginBottom: 0 }}>AI 诊断报告</h2>
|
||||
{report && !loading && (
|
||||
<span
|
||||
className="diagnosis-summary-link"
|
||||
onClick={() =>
|
||||
navigate(`/analytics/report?year=${selectedYear}&start=${epStart}&end=${epEnd}`)
|
||||
}
|
||||
>
|
||||
查看完整报告 →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{loading ? (
|
||||
<div className="diagnosis-loading">
|
||||
<Spin size="small" />
|
||||
<span style={{ marginLeft: 8 }}>正在生成 AI 诊断报告…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="diagnosis-loading" style={{ color: '#c0584f' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : report ? (
|
||||
<div className="diagnosis-summary-content">
|
||||
{/* 左块 */}
|
||||
<div
|
||||
className="diagnosis-summary-left"
|
||||
style={{ borderColor: config.color }}
|
||||
>
|
||||
<div className="diagnosis-block-title">{config.leftTitle}</div>
|
||||
{coreLines.length > 0 ? (
|
||||
coreLines.map((line, i) => (
|
||||
<p key={i} style={{ margin: '0 0 8px 0' }}>{line}</p>
|
||||
))
|
||||
) : (
|
||||
<p>报告已生成,请查看完整报告</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右块 */}
|
||||
<div
|
||||
className="diagnosis-summary-right"
|
||||
style={{ background: config.rightBg }}
|
||||
>
|
||||
<div className="diagnosis-block-title">{config.rightTitle}</div>
|
||||
{actionLines.length > 0 ? (
|
||||
actionLines.map((line, i) => (
|
||||
<p key={i} style={{ margin: '0 0 8px 0' }}>{line}</p>
|
||||
))
|
||||
) : (
|
||||
<p>报告已生成,请查看完整报告</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiagnosisSummary
|
||||
Reference in New Issue
Block a user