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
+218 -1
View File
@@ -6,11 +6,19 @@
GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标 GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标
""" """
import json
import hashlib
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import extract from sqlalchemy import extract
from sqlalchemy import distinct from sqlalchemy import distinct
from sqlmodel import Session, select from sqlmodel import Session, select
from openai import OpenAI
from app.core.config import settings
from app.core.deps import require_role from app.core.deps import require_role
from app.db.session import get_session from app.db.session import get_session
from app.models.episode import Episode from app.models.episode import Episode
@@ -19,6 +27,13 @@ from app.models.user import UserRole
router = APIRouter(prefix="/api/analytics", tags=["收视分析"]) router = APIRouter(prefix="/api/analytics", tags=["收视分析"])
# 诊断报告缓存(内存,重启清空)
_report_cache = {}
# prompt5 文件路径
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
_PROMPT5_PATH = _PROJECT_ROOT / "ai-labeling" / "prompts" / "prompt5_diagnosis_report.md"
def _require_read(): def _require_read():
"""三角色都可读""" """三角色都可读"""
@@ -111,4 +126,206 @@ def get_analytics_episodes(
"year": year, "year": year,
"yearly_target": yearly_target, "yearly_target": yearly_target,
"episodes": ep_list, "episodes": ep_list,
} }
# ── AI 诊断报告 ──
class DiagnosisRequest(BaseModel):
year: int
ep_start: int
ep_end: int
force: bool = False
def _build_user_message(episodes, base_target, stretch_target, avg_share, pass_count, max_ep, min_ep):
"""组装给 DeepSeek 的 user message,格式对齐 prompt5 的输入规范。"""
first_ep = episodes[0]
last_ep = episodes[-1]
count = len(episodes)
# 判色函数
def judge(share):
if share >= stretch_target:
return "优秀"
elif share >= base_target:
return "达标"
else:
return "待提升"
# 摸高完成率
stretch_pct = round(avg_share / stretch_target * 100, 1) if stretch_target > 0 else 0
lines = []
lines.append("请根据以下数据,撰写收视诊断分析报告。\n")
# 分析范围
lines.append("## 分析范围\n")
lines.append(
f"{first_ep.episode_number}期《{first_ep.program_name}》至 "
f"{last_ep.episode_number}期《{last_ep.program_name}》(共{count}期),"
f"{first_ep.air_date}{last_ep.air_date}播出"
)
lines.append(f"年度目标:基础目标 {base_target},摸高目标 {stretch_target}\n")
# 整体统计
lines.append("## 整体统计\n")
lines.append(f"- 平均份额:{avg_share}(摸高完成率 {stretch_pct}%")
lines.append(f"- 达标期数:{pass_count}/{count}")
lines.append(f"- 最高份额:{float(max_ep.audience_share)}(第{max_ep.episode_number}期《{max_ep.program_name}》)")
lines.append(f"- 最低份额:{float(min_ep.audience_share)}(第{min_ep.episode_number}期《{min_ep.program_name}》)\n")
# 逐期数据表格
lines.append("## 逐期数据\n")
lines.append("| 播出期号 | 节目名 | 份额 | 判定 | 题材类型 | 叙事结构 | 钩子强度 | 装备领域 |")
lines.append("|---------|-------|------|------|---------|---------|---------|---------|")
for ep in episodes:
share = float(ep.audience_share)
domain_str = "".join(ep.equipment_domain) if ep.equipment_domain else "-"
lines.append(
f"| 第{ep.episode_number}期 | {ep.program_name} | {share} | {judge(share)} "
f"| {ep.program_format or '-'} | {ep.narrative_structure or '-'} "
f"| {ep.opening_hook or '-'} | {domain_str} |"
)
lines.append("")
# 各期内容摘要卡
lines.append("## 各期内容摘要卡\n")
for ep in episodes:
share = float(ep.audience_share)
lines.append(f"### 第{ep.episode_number}期《{ep.program_name}》(份额 {share}")
digest = ep.content_digest
if digest:
lines.append(f"- 核心切口:{digest.get('核心切口', '-')}")
# 叙事亮点可能是数组
highlights = digest.get('叙事亮点', [])
if isinstance(highlights, list):
lines.append(f"- 叙事亮点:{''.join(highlights)}")
else:
lines.append(f"- 叙事亮点:{highlights}")
lines.append(f"- 观众门槛:{digest.get('观众门槛', '-')}")
# 话题性是嵌套结构
topic = digest.get('话题性', {})
if isinstance(topic, dict):
lines.append(
f"- 话题性:{topic.get('总评', '-')}"
f"大众认知度:{topic.get('大众认知度', '-')}"
f"降维切口:{topic.get('降维切口', '-')}"
f"惊奇密度:{topic.get('惊奇密度', '-')}"
)
else:
lines.append(f"- 话题性:{topic}")
# 潜在弱点可能是数组
weaknesses = digest.get('潜在弱点', [])
if isinstance(weaknesses, list):
lines.append(f"- 潜在弱点:{''.join(weaknesses)}")
else:
lines.append(f"- 潜在弱点:{weaknesses}")
lines.append(f"- 时效关联:{digest.get('时效关联', '-')}")
else:
lines.append("- (无文稿摘要)")
lines.append("")
return "\n".join(lines)
@router.post("/diagnosis-report")
def generate_diagnosis_report(
req: DiagnosisRequest,
session: Session = Depends(get_session),
current_user=Depends(_require_read()),
):
"""生成 AI 诊断报告。同一范围缓存结果,force=True 时重新生成。"""
cache_key = f"{req.year}_{req.ep_start}_{req.ep_end}"
# 检查缓存
if not req.force and cache_key in _report_cache:
return _report_cache[cache_key]
# 1. 查询所选范围的 episodes
ep_stmt = (
select(Episode)
.where(extract("year", Episode.air_date) == req.year)
.where(Episode.episode_number >= req.ep_start)
.where(Episode.episode_number <= req.ep_end)
.where(Episode.audience_share.is_not(None))
.order_by(Episode.episode_number.asc())
)
episodes = session.exec(ep_stmt).all()
if not episodes:
return {"error": "所选范围内没有收视数据"}
# 2. 查年度目标
target_stmt = select(YearlyTarget).where(YearlyTarget.year == req.year)
target = session.exec(target_stmt).first()
if not target:
return {"error": f"{req.year}年没有设置年度目标"}
base_target = float(target.base_target)
stretch_target = float(target.stretch_target)
# 3. 计算统计数据
shares = [float(ep.audience_share) for ep in episodes]
avg_share = round(sum(shares) / len(shares), 4) if shares else 0
pass_count = sum(1 for s in shares if s >= base_target)
max_ep = max(episodes, key=lambda e: float(e.audience_share))
min_ep = min(episodes, key=lambda e: float(e.audience_share))
# 三档判定
if avg_share >= stretch_target:
tier = "excellent"
elif avg_share >= base_target:
tier = "on_target"
else:
tier = "danger"
# 4. 组装 user message
user_message = _build_user_message(episodes, base_target, stretch_target, avg_share, pass_count, max_ep, min_ep)
# 5. 读 system prompt
system_prompt = _PROMPT5_PATH.read_text(encoding="utf-8")
# 6. 调 DeepSeek
if not settings.DEEPSEEK_API_KEY:
return {"error": "DEEPSEEK_API_KEY 未配置"}
client = OpenAI(
api_key=settings.DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com",
)
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
temperature=0.3,
)
report_markdown = response.choices[0].message.content
# 7. 组装返回
result = {
"tier": tier,
"avg_share": avg_share,
"episode_count": len(episodes),
"pass_count": pass_count,
"highest": {
"ep": max_ep.episode_number,
"name": max_ep.program_name,
"share": float(max_ep.audience_share),
},
"lowest": {
"ep": min_ep.episode_number,
"name": min_ep.program_name,
"share": float(min_ep.audience_share),
},
"report_markdown": report_markdown,
"generated_at": datetime.now().isoformat(),
"model": "deepseek-chat",
"disclaimer": "本报告基于已入库的收视数据、节目标签及内容摘要生成,未纳入同时段竞品、社会热点等外部因素。分析结论难免挂一漏万,仅供栏目内部讨论参考,不构成节目决策依据。",
}
# 8. 缓存
_report_cache[cache_key] = result
return result
+4
View File
@@ -20,6 +20,9 @@ _SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "") _MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "") _MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
# DeepSeek API 凭证(诊断报告用)
_DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
# 验证必需配置 # 验证必需配置
if not _DATABASE_URL: if not _DATABASE_URL:
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。") raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
@@ -31,6 +34,7 @@ class Settings:
SESSION_MAX_AGE: int = _SESSION_MAX_AGE SESSION_MAX_AGE: int = _SESSION_MAX_AGE
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
DEEPSEEK_API_KEY: str = _DEEPSEEK_API_KEY
settings = Settings() settings = Settings()
+2 -1
View File
@@ -11,4 +11,5 @@ python-dotenv==1.0.1
httpx==0.27.0 httpx==0.27.0
python-frontmatter==1.1.0 python-frontmatter==1.1.0
pandas>=2.0.0 pandas>=2.0.0
openpyxl>=3.1.0 openpyxl>=3.1.0
openai>=1.0.0,<1.55.0
+2
View File
@@ -11,6 +11,7 @@ import Doco from './pages/Doco/Doco'
import UserManage from './pages/UserManage/UserManage' import UserManage from './pages/UserManage/UserManage'
import EditorDesk from './pages/EditorDesk/EditorDesk' import EditorDesk from './pages/EditorDesk/EditorDesk'
import Analytics from './pages/Analytics/Analytics' import Analytics from './pages/Analytics/Analytics'
import DiagnosisReport from './pages/Analytics/DiagnosisReport'
import AuthGuard from './components/AuthGuard/AuthGuard' import AuthGuard from './components/AuthGuard/AuthGuard'
import RoleGuard from './components/AuthGuard/RoleGuard' import RoleGuard from './components/AuthGuard/RoleGuard'
@@ -33,6 +34,7 @@ function App() {
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="analytics" element={<Analytics />} /> <Route path="analytics" element={<Analytics />} />
<Route path="analytics/report" element={<DiagnosisReport />} />
<Route path="tps" element={<TPS />} /> <Route path="tps" element={<TPS />} />
<Route path="knowledge" element={<KnowledgeBase />} /> <Route path="knowledge" element={<KnowledgeBase />} />
<Route path="doco" element={<Doco />} /> <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; 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) { @media (max-width: 768px) {
.analytics-page { .analytics-page {
+7 -5
View File
@@ -7,6 +7,7 @@ import EditorCompare from '../../components/Analytics/EditorCompare'
import QuarterCompare from '../../components/Analytics/QuarterCompare' import QuarterCompare from '../../components/Analytics/QuarterCompare'
import TopicCompare from '../../components/Analytics/TopicCompare' import TopicCompare from '../../components/Analytics/TopicCompare'
import QuadrantChart from '../../components/Analytics/QuadrantChart' import QuadrantChart from '../../components/Analytics/QuadrantChart'
import DiagnosisSummary from '../../components/Analytics/DiagnosisSummary'
import './Analytics.css' import './Analytics.css'
/** /**
@@ -565,11 +566,12 @@ function Analytics() {
)} )}
</div> </div>
{/* ── AI 诊断报告占位 ── */} {/* ── AI 诊断报告 ── */}
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}> <DiagnosisSummary
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2> episodes={filteredEpisodes}
<p style={{ color: '#aaa', fontSize: 14 }}>即将上线 - 基于双引擎模型的智能收视诊断</p> yearlyTarget={yearlyTarget}
</div> selectedYear={selectedYear}
/>
{/* ── 双引擎象限图 ── */} {/* ── 双引擎象限图 ── */}
<div className="analytics-chart-card"> <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() { export async function getAvailableYears() {
const response = await http.get('/analytics/years') const response = await http.get('/analytics/years')
return response.data 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
} }