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')} + > + ← 返回收视分析 + +
+

{error}

+
+
+ ) + } + + 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) || '—'} +
+
平均份额
+
+
+
{passRate}
+
达标期数
+
+
+
+ {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} - { 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 } \ No newline at end of file