From 3dd718f53d27685b0b98ce5f2b861d51a7313f07 Mon Sep 17 00:00:00 2001
From: simonkoson <28867558@qq.com>
Date: Fri, 3 Jul 2026 21:04:08 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20L4=20AI=20?=
=?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=8A=A5=E5=91=8A=EF=BC=88=E5=90=8E=E7=AB=AF?=
=?UTF-8?q?=20DeepSeek=20=E7=AB=AF=E7=82=B9=20+=20=E5=89=8D=E7=AB=AF?=
=?UTF-8?q?=E6=91=98=E8=A6=81=E5=9D=97=E4=B8=8E=E8=AF=A6=E6=83=85=E9=A1=B5?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/analytics.py | 219 +++++++++++++++++-
backend/app/core/config.py | 4 +
backend/requirements.txt | 3 +-
frontend/src/App.jsx | 2 +
.../components/Analytics/DiagnosisSummary.jsx | 214 +++++++++++++++++
frontend/src/pages/Analytics/Analytics.css | 57 +++++
frontend/src/pages/Analytics/Analytics.jsx | 12 +-
.../src/pages/Analytics/DiagnosisReport.css | 217 +++++++++++++++++
.../src/pages/Analytics/DiagnosisReport.jsx | 184 +++++++++++++++
frontend/src/services/analyticsService.js | 12 +
10 files changed, 917 insertions(+), 7 deletions(-)
create mode 100644 frontend/src/components/Analytics/DiagnosisSummary.jsx
create mode 100644 frontend/src/pages/Analytics/DiagnosisReport.css
create mode 100644 frontend/src/pages/Analytics/DiagnosisReport.jsx
diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py
index 015c1f9..3a91a20 100644
--- a/backend/app/api/analytics.py
+++ b/backend/app/api/analytics.py
@@ -6,11 +6,19 @@
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 pydantic import BaseModel
from sqlalchemy import extract
from sqlalchemy import distinct
from sqlmodel import Session, select
+from openai import OpenAI
+from app.core.config import settings
from app.core.deps import require_role
from app.db.session import get_session
from app.models.episode import Episode
@@ -19,6 +27,13 @@ from app.models.user import UserRole
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():
"""三角色都可读"""
@@ -111,4 +126,206 @@ def get_analytics_episodes(
"year": year,
"yearly_target": yearly_target,
"episodes": ep_list,
- }
\ No newline at end of file
+ }
+
+
+# ── 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
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 5c48b13..aa24e20 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -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_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
+# DeepSeek API 凭证(诊断报告用)
+_DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
+
# 验证必需配置
if not _DATABASE_URL:
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
@@ -31,6 +34,7 @@ class Settings:
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
+ DEEPSEEK_API_KEY: str = _DEEPSEEK_API_KEY
settings = Settings()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index e13751c..40dbc3d 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -11,4 +11,5 @@ python-dotenv==1.0.1
httpx==0.27.0
python-frontmatter==1.1.0
pandas>=2.0.0
-openpyxl>=3.1.0
\ No newline at end of file
+openpyxl>=3.1.0
+openai>=1.0.0,<1.55.0
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b5c6ef7..6ff39cc 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/Analytics/DiagnosisSummary.jsx b/frontend/src/components/Analytics/DiagnosisSummary.jsx
new file mode 100644
index 0000000..ed341ab
--- /dev/null
+++ b/frontend/src/components/Analytics/DiagnosisSummary.jsx
@@ -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 (
+
+
AI 诊断报告
+
请先选择包含收视数据的期次范围
+
+ )
+ }
+
+ // 提取核心发现和行动建议
+ const markdown = report?.report_markdown || ''
+ // 核心发现:从 "#### 二、核心发现" 到 "#### 三、"
+ let coreFindings = extractSection(markdown, '#### 二、核心发现', ['#### 三、'])
+ // 行动建议:从 "#### 四、行动建议" 到末尾
+ let actions = extractSection(markdown, '#### 四、行动建议', [])
+
+ const coreLines = cleanMarkdown(coreFindings)
+ const actionLines = cleanMarkdown(actions)
+
+ return (
+
+ {/* 标题行 */}
+
+
AI 诊断报告
+ {report && !loading && (
+
+ navigate(`/analytics/report?year=${selectedYear}&start=${epStart}&end=${epEnd}`)
+ }
+ >
+ 查看完整报告 →
+
+ )}
+
+
+ {/* 内容 */}
+ {loading ? (
+
+
+ 正在生成 AI 诊断报告…
+
+ ) : error ? (
+
+ {error}
+
+ ) : report ? (
+
+ {/* 左块 */}
+
+
{config.leftTitle}
+ {coreLines.length > 0 ? (
+ coreLines.map((line, i) => (
+
{line}
+ ))
+ ) : (
+
报告已生成,请查看完整报告
+ )}
+
+
+ {/* 右块 */}
+
+
{config.rightTitle}
+ {actionLines.length > 0 ? (
+ actionLines.map((line, i) => (
+
{line}
+ ))
+ ) : (
+
报告已生成,请查看完整报告
+ )}
+
+
+ ) : null}
+
+ )
+}
+
+export default DiagnosisSummary
\ No newline at end of file
diff --git a/frontend/src/pages/Analytics/Analytics.css b/frontend/src/pages/Analytics/Analytics.css
index 9745759..871631e 100644
--- a/frontend/src/pages/Analytics/Analytics.css
+++ b/frontend/src/pages/Analytics/Analytics.css
@@ -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 {
diff --git a/frontend/src/pages/Analytics/Analytics.jsx b/frontend/src/pages/Analytics/Analytics.jsx
index 34bc213..19bdb0f 100644
--- a/frontend/src/pages/Analytics/Analytics.jsx
+++ b/frontend/src/pages/Analytics/Analytics.jsx
@@ -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() {
)}
- {/* ── AI 诊断报告占位 ── */}
-
-
AI 诊断报告
-
即将上线 - 基于双引擎模型的智能收视诊断
-
+ {/* ── AI 诊断报告 ── */}
+
{/* ── 双引擎象限图 ── */}
diff --git a/frontend/src/pages/Analytics/DiagnosisReport.css b/frontend/src/pages/Analytics/DiagnosisReport.css
new file mode 100644
index 0000000..3038a10
--- /dev/null
+++ b/frontend/src/pages/Analytics/DiagnosisReport.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Analytics/DiagnosisReport.jsx b/frontend/src/pages/Analytics/DiagnosisReport.jsx
new file mode 100644
index 0000000..57c6d00
--- /dev/null
+++ b/frontend/src/pages/Analytics/DiagnosisReport.jsx
@@ -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 (
+
+
+
+
正在生成诊断报告,预计 15-30 秒…
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
navigate('/analytics')}
+ >
+ ← 返回收视分析
+
+
+
+ )
+ }
+
+ return (
+
+ {/* 返回链接 */}
+
navigate('/analytics')}
+ >
+ ← 返回收视分析
+
+
+ {/* 头部 */}
+
+
收视诊断报告
+
+ 第{start}期 — 第{end}期 | {year}年 | 共{report?.episode_count}期
+
+ {report?.generated_at && (
+
+ 生成时间:{new Date(report.generated_at).toLocaleString('zh-CN')}
+ {report?.model && ` | 模型:${report.model}`}
+
+ )}
+
+
+ {/* 指标条 */}
+
+
+
+ {report?.avg_share?.toFixed(4) || '—'}
+
+
平均份额
+
+
+
+
+ {report?.highest
+ ? `${report.highest.share.toFixed(3)}(第${report.highest.ep}期)`
+ : '—'}
+
+
最高份额
+
+
+
+ {/* 报告正文 */}
+
+ {report?.report_markdown || ''}
+
+
+ {/* 重新生成 */}
+
+
+
+
+ {/* 免责声明 */}
+ {report?.disclaimer && (
+
+ ⚠️ {report.disclaimer}
+
+ )}
+
+ )
+}
+
+export default DiagnosisReport
\ No newline at end of file
diff --git a/frontend/src/services/analyticsService.js b/frontend/src/services/analyticsService.js
index ab82316..7cc5359 100644
--- a/frontend/src/services/analyticsService.js
+++ b/frontend/src/services/analyticsService.js
@@ -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