feat: 添加 L4 AI 诊断报告(后端 DeepSeek 端点 + 前端摘要块与详情页)

This commit is contained in:
simonkoson
2026-07-03 21:04:08 +08:00
parent f37679530a
commit 3dd718f53d
10 changed files with 917 additions and 7 deletions
+2
View File
@@ -11,6 +11,7 @@ import Doco from './pages/Doco/Doco'
import UserManage from './pages/UserManage/UserManage'
import EditorDesk from './pages/EditorDesk/EditorDesk'
import Analytics from './pages/Analytics/Analytics'
import DiagnosisReport from './pages/Analytics/DiagnosisReport'
import AuthGuard from './components/AuthGuard/AuthGuard'
import RoleGuard from './components/AuthGuard/RoleGuard'
@@ -33,6 +34,7 @@ function App() {
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="analytics" element={<Analytics />} />
<Route path="analytics/report" element={<DiagnosisReport />} />
<Route path="tps" element={<TPS />} />
<Route path="knowledge" element={<KnowledgeBase />} />
<Route path="doco" element={<Doco />} />
@@ -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
@@ -261,6 +261,63 @@
padding: 8px 12px;
}
/* ── AI 诊断报告摘要块 ── */
.diagnosis-summary-content {
display: flex;
gap: 20px;
margin-top: 16px;
}
.diagnosis-summary-left,
.diagnosis-summary-right {
flex: 1;
padding: 16px 20px;
border-radius: 12px;
font-size: 13px;
line-height: 1.8;
color: #444;
}
.diagnosis-summary-left {
border-left: 4px solid currentColor;
background: rgba(255, 255, 255, 0.3);
}
.diagnosis-summary-right {
background: rgba(255, 255, 255, 0.3);
}
.diagnosis-block-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: #333;
}
.diagnosis-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.diagnosis-summary-link {
font-size: 13px;
color: #5b8db8;
text-decoration: none;
cursor: pointer;
}
.diagnosis-summary-link:hover {
text-decoration: underline;
}
.diagnosis-loading {
text-align: center;
padding: 32px 0;
color: #999;
font-size: 13px;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.analytics-page {
+7 -5
View File
@@ -7,6 +7,7 @@ import EditorCompare from '../../components/Analytics/EditorCompare'
import QuarterCompare from '../../components/Analytics/QuarterCompare'
import TopicCompare from '../../components/Analytics/TopicCompare'
import QuadrantChart from '../../components/Analytics/QuadrantChart'
import DiagnosisSummary from '../../components/Analytics/DiagnosisSummary'
import './Analytics.css'
/**
@@ -565,11 +566,12 @@ function Analytics() {
)}
</div>
{/* ── AI 诊断报告占位 ── */}
<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>
{/* ── AI 诊断报告 ── */}
<DiagnosisSummary
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
{/* ── 双引擎象限图 ── */}
<div className="analytics-chart-card">
@@ -0,0 +1,217 @@
/* ── AI 诊断报告详情页 ── */
.diagnosis-report-page {
max-width: 900px;
margin: 0 auto;
padding: 32px 24px;
min-height: 100vh;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: linear-gradient(135deg, #f5f0e8 0%, #e8e4d8 30%, #f0ece2 60%, #ebe5d5 100%);
color: #333;
}
/* ── 返回链接 ── */
.diagnosis-report-back {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #5b8db8;
text-decoration: none;
cursor: pointer;
margin-bottom: 24px;
}
.diagnosis-report-back:hover {
text-decoration: underline;
}
/* ── 头部信息 ── */
.diagnosis-report-header {
margin-bottom: 24px;
}
.diagnosis-report-header h1 {
margin: 0 0 8px 0;
font-size: 22px;
font-weight: 700;
color: #4a6741;
}
.diagnosis-report-header .diagnosis-report-range {
font-size: 14px;
color: #666;
margin: 0 0 4px 0;
}
.diagnosis-report-header .diagnosis-report-date {
font-size: 13px;
color: #999;
margin: 0;
}
/* ── 指标条 ── */
.diagnosis-report-kpi-bar {
display: flex;
gap: 16px;
margin-bottom: 24px;
border-radius: 12px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.6);
}
.diagnosis-report-kpi-item {
flex: 1;
text-align: center;
}
.diagnosis-report-kpi-item .kpi-value {
font-size: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.diagnosis-report-kpi-item .kpi-label {
font-size: 12px;
color: #888;
margin-top: 2px;
}
/* ── 报告正文卡片 ── */
.diagnosis-report-body {
background: #fff;
border-radius: 16px;
padding: 36px 40px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
line-height: 1.9;
font-size: 15px;
color: #333;
}
.diagnosis-report-body h4 {
color: #4a6741;
font-size: 17px;
font-weight: 600;
margin: 28px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px solid #e8e4d8;
}
.diagnosis-report-body h4:first-child {
margin-top: 0;
}
.diagnosis-report-body p {
margin: 0 0 12px 0;
}
.diagnosis-report-body strong {
color: #333;
}
.diagnosis-report-body ul,
.diagnosis-report-body ol {
margin: 0 0 12px 0;
padding-left: 24px;
}
.diagnosis-report-body li {
margin-bottom: 6px;
}
.diagnosis-report-body table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.diagnosis-report-body th,
.diagnosis-report-body td {
border: 1px solid #e0ddd4;
padding: 8px 12px;
text-align: left;
}
.diagnosis-report-body th {
background: #f5f3eb;
font-weight: 600;
color: #555;
}
/* ── 重新生成按钮 ── */
.diagnosis-report-actions {
text-align: center;
margin-bottom: 24px;
}
.diagnosis-report-actions button {
background: #6b8e6b;
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 28px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.diagnosis-report-actions button:hover {
background: #5a7d5a;
}
.diagnosis-report-actions button:disabled {
background: #ccc;
cursor: not-allowed;
}
/* ── 免责声明 ── */
.diagnosis-report-disclaimer {
font-size: 12px;
color: #aaa;
text-align: center;
padding: 0 24px;
line-height: 1.6;
}
/* ── Loading ── */
.diagnosis-report-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: #999;
}
.diagnosis-report-loading p {
margin-top: 12px;
font-size: 14px;
}
/* ── 错误 ── */
.diagnosis-report-error {
text-align: center;
padding: 60px 0;
color: #c0584f;
font-size: 14px;
}
/* ── 响应式 ── */
@media (max-width: 768px) {
.diagnosis-report-page {
padding: 16px;
}
.diagnosis-report-body {
padding: 24px 20px;
}
.diagnosis-report-kpi-bar {
flex-direction: column;
gap: 12px;
}
}
@@ -0,0 +1,184 @@
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { Spin } from 'antd'
import Markdown from 'react-markdown'
import { generateDiagnosisReport } from '../../services/analyticsService'
import './DiagnosisReport.css'
/**
* AI 诊断报告详情页
* - 从 URL query 读取 year、start、end
* - 调用 generateDiagnosisReport(命中缓存则秒出)
* - 用 react-markdown 渲染完整报告
*/
const TIER_COLOR = {
danger: '#7aa874',
on_target: '#5b8db8',
excellent: '#c0584f',
}
function DiagnosisReport() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const year = Number(searchParams.get('year'))
const start = Number(searchParams.get('start'))
const end = Number(searchParams.get('end'))
const [report, setReport] = useState(null)
const [loading, setLoading] = useState(true)
const [regenerating, setRegenerating] = useState(false)
const [error, setError] = useState(null)
const fetchReport = (force = false) => {
if (!year || !start || !end) {
setError('缺少必要参数(year / start / end')
setLoading(false)
return
}
if (force) {
setRegenerating(true)
} else {
setLoading(true)
}
setError(null)
generateDiagnosisReport({ year, ep_start: start, ep_end: end, force })
.then((data) => {
if (data.error) {
setError(data.error)
} else {
setReport(data)
}
})
.catch((err) => {
setError(err?.response?.data?.detail || '报告生成失败')
})
.finally(() => {
setLoading(false)
setRegenerating(false)
})
}
useEffect(() => {
fetchReport(false)
}, [year, start, end])
// 摸高完成率
const stretchPct =
report?.avg_share && report?.highest
? null // 没有 stretch_target 直接信息,从 tier 推断
: null
// 从 report 中计算摸高完成率(如果有 avg_share 和 episode_count
// 实际上后端没返回 stretch_target,所以这里用 pass_count / episode_count 做达标率展示
const passRate =
report?.episode_count > 0
? `${report.pass_count}/${report.episode_count}`
: '—'
const tierColor = report?.tier ? TIER_COLOR[report.tier] : '#999'
// 从 report_markdown 中提取第一句作为范围描述的补充
const startDate = report?.report_markdown
? '' // 已经在 header 中展示了期号范围
: ''
if (loading) {
return (
<div className="diagnosis-report-page">
<div className="diagnosis-report-loading">
<Spin size="large" />
<p>正在生成诊断报告预计 15-30 </p>
</div>
</div>
)
}
if (error) {
return (
<div className="diagnosis-report-page">
<span
className="diagnosis-report-back"
onClick={() => navigate('/analytics')}
>
返回收视分析
</span>
<div className="diagnosis-report-error">
<p>{error}</p>
</div>
</div>
)
}
return (
<div className="diagnosis-report-page">
{/* 返回链接 */}
<span
className="diagnosis-report-back"
onClick={() => navigate('/analytics')}
>
返回收视分析
</span>
{/* 头部 */}
<div className="diagnosis-report-header">
<h1>收视诊断报告</h1>
<p className="diagnosis-report-range">
{start} {end} {year} {report?.episode_count}
</p>
{report?.generated_at && (
<p className="diagnosis-report-date">
生成时间{new Date(report.generated_at).toLocaleString('zh-CN')}
{report?.model && ` 模型:${report.model}`}
</p>
)}
</div>
{/* 指标条 */}
<div className="diagnosis-report-kpi-bar">
<div className="diagnosis-report-kpi-item">
<div className="kpi-value" style={{ color: tierColor }}>
{report?.avg_share?.toFixed(4) || '—'}
</div>
<div className="kpi-label">平均份额</div>
</div>
<div className="diagnosis-report-kpi-item">
<div className="kpi-value">{passRate}</div>
<div className="kpi-label">达标期数</div>
</div>
<div className="diagnosis-report-kpi-item">
<div className="kpi-value" style={{ color: '#c0584f' }}>
{report?.highest
? `${report.highest.share.toFixed(3)}(第${report.highest.ep}期)`
: '—'}
</div>
<div className="kpi-label">最高份额</div>
</div>
</div>
{/* 报告正文 */}
<div className="diagnosis-report-body">
<Markdown>{report?.report_markdown || ''}</Markdown>
</div>
{/* 重新生成 */}
<div className="diagnosis-report-actions">
<button onClick={() => fetchReport(true)} disabled={regenerating}>
{regenerating ? '正在重新生成…' : '重新生成'}
</button>
</div>
{/* 免责声明 */}
{report?.disclaimer && (
<p className="diagnosis-report-disclaimer">
{report.disclaimer}
</p>
)}
</div>
)
}
export default DiagnosisReport
+12
View File
@@ -19,4 +19,16 @@ export async function getAnalyticsEpisodes(year) {
export async function getAvailableYears() {
const response = await http.get('/analytics/years')
return response.data
}
/**
* 生成 AI 诊断报告
* @param {Object} params - { year, ep_start, ep_end, force? }
* @returns {Promise<Object>} - { tier, avg_share, episode_count, pass_count, highest, lowest, report_markdown, generated_at, model, disclaimer }
*/
export async function generateDiagnosisReport(params) {
const response = await http.post('/analytics/diagnosis-report', params, {
timeout: 120000, // DeepSeek 可能需要 30-60 秒
})
return response.data
}