feat: 添加 L4 AI 诊断报告(后端 DeepSeek 端点 + 前端摘要块与详情页)
This commit is contained in:
@@ -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():
|
||||||
"""三角色都可读"""
|
"""三角色都可读"""
|
||||||
@@ -112,3 +127,205 @@ def get_analytics_episodes(
|
|||||||
"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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ 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
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -20,3 +20,15 @@ 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user