2026-07-03 21:04:08 +08:00
|
|
|
|
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 提取核心发现和行动建议
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-07-06 20:38:33 +08:00
|
|
|
|
/** 从 markdown 中提取指定章节内容(正则模糊匹配) */
|
|
|
|
|
|
function extractSection(markdown, keyword, endKeywords) {
|
2026-07-03 21:04:08 +08:00
|
|
|
|
if (!markdown) return ''
|
2026-07-06 20:38:33 +08:00
|
|
|
|
// 用正则匹配:任意数量的 # 开头,后面包含关键词的行
|
|
|
|
|
|
const startRegex = new RegExp(`^#{1,6}\\s*.*${keyword}.*$`, 'm')
|
|
|
|
|
|
const match = markdown.match(startRegex)
|
|
|
|
|
|
if (!match) return ''
|
|
|
|
|
|
const afterStart = match.index + match[0].length
|
|
|
|
|
|
// 找最近的结束标记(下一个同级或更高级标题)
|
2026-07-03 21:04:08 +08:00
|
|
|
|
let endIdx = markdown.length
|
2026-07-06 20:38:33 +08:00
|
|
|
|
if (endKeywords && endKeywords.length > 0) {
|
|
|
|
|
|
for (const kw of endKeywords) {
|
|
|
|
|
|
const endRegex = new RegExp(`^#{1,6}\\s*.*${kw}.*$`, 'm')
|
|
|
|
|
|
const endMatch = markdown.substring(afterStart).match(endRegex)
|
|
|
|
|
|
if (endMatch && (afterStart + endMatch.index) < endIdx) {
|
|
|
|
|
|
endIdx = afterStart + endMatch.index
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 没有 endKeywords 时,找下一个同级标题作为结束
|
|
|
|
|
|
const nextHeading = markdown.substring(afterStart).match(/^#{1,6}\s+/m)
|
|
|
|
|
|
if (nextHeading) {
|
|
|
|
|
|
endIdx = afterStart + nextHeading.index
|
2026-07-03 21:04:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return markdown.substring(afterStart, endIdx).trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 21:25:38 +08:00
|
|
|
|
/** 将 markdown 文本按行拆分,保留 **bold** 为 <strong> */
|
|
|
|
|
|
function parseMarkdownLines(text) {
|
2026-07-03 21:04:08 +08:00
|
|
|
|
if (!text) return []
|
|
|
|
|
|
return text
|
|
|
|
|
|
.split('\n')
|
2026-07-03 21:25:38 +08:00
|
|
|
|
.map((line) => line.replace(/^#+\s*/, '').trim())
|
2026-07-03 21:04:08 +08:00
|
|
|
|
.filter((line) => line.length > 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 21:25:38 +08:00
|
|
|
|
/** 将行内 **text** 转为 React 元素 */
|
|
|
|
|
|
function renderInlineMarkdown(text) {
|
|
|
|
|
|
const parts = text.split(/(\*\*[^*]+\*\*)/)
|
|
|
|
|
|
return parts.map((part, i) => {
|
|
|
|
|
|
const boldMatch = part.match(/^\*\*(.+)\*\*$/)
|
|
|
|
|
|
if (boldMatch) {
|
|
|
|
|
|
return <strong key={i}>{boldMatch[1]}</strong>
|
|
|
|
|
|
}
|
|
|
|
|
|
return part
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const LEFT_STYLE = {
|
|
|
|
|
|
color: '#c0584f',
|
|
|
|
|
|
bg: 'rgba(192,88,79,0.06)',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 21:04:08 +08:00
|
|
|
|
const TIER_CONFIG = {
|
|
|
|
|
|
danger: {
|
|
|
|
|
|
leftTitle: '问题聚焦',
|
|
|
|
|
|
rightTitle: '病因与预警',
|
2026-07-03 21:25:38 +08:00
|
|
|
|
rightColor: '#7aa874',
|
2026-07-03 21:04:08 +08:00
|
|
|
|
rightBg: 'rgba(122,168,116,0.06)',
|
|
|
|
|
|
},
|
|
|
|
|
|
on_target: {
|
|
|
|
|
|
leftTitle: '核心发现',
|
|
|
|
|
|
rightTitle: '病因与提振建议',
|
2026-07-03 21:25:38 +08:00
|
|
|
|
rightColor: '#5b8db8',
|
2026-07-03 21:04:08 +08:00
|
|
|
|
rightBg: 'rgba(91,141,184,0.06)',
|
|
|
|
|
|
},
|
|
|
|
|
|
excellent: {
|
|
|
|
|
|
leftTitle: '高光复盘',
|
|
|
|
|
|
rightTitle: '经验总结',
|
2026-07-03 21:25:38 +08:00
|
|
|
|
rightColor: '#c0584f',
|
2026-07-03 21:04:08 +08:00
|
|
|
|
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 || ''
|
2026-07-06 20:38:33 +08:00
|
|
|
|
// 核心发现:关键词匹配"核心发现",到"深度分析"或"三、"
|
|
|
|
|
|
let coreFindings = extractSection(markdown, '核心发现', ['深度分析', '三、'])
|
|
|
|
|
|
// 行动建议:关键词匹配"行动建议",到末尾
|
|
|
|
|
|
let actions = extractSection(markdown, '行动建议', [])
|
2026-07-03 21:04:08 +08:00
|
|
|
|
|
2026-07-03 21:25:38 +08:00
|
|
|
|
const coreLines = parseMarkdownLines(coreFindings)
|
|
|
|
|
|
const actionLines = parseMarkdownLines(actions)
|
2026-07-03 21:04:08 +08:00
|
|
|
|
|
|
|
|
|
|
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">
|
2026-07-03 21:25:38 +08:00
|
|
|
|
{/* 左块(固定粉红色系) */}
|
2026-07-03 21:04:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className="diagnosis-summary-left"
|
2026-07-03 21:25:38 +08:00
|
|
|
|
style={{ borderColor: LEFT_STYLE.color, background: LEFT_STYLE.bg }}
|
2026-07-03 21:04:08 +08:00
|
|
|
|
>
|
2026-07-03 21:25:38 +08:00
|
|
|
|
<div className="diagnosis-block-title" style={{ color: LEFT_STYLE.color }}>{config.leftTitle}</div>
|
2026-07-03 21:04:08 +08:00
|
|
|
|
{coreLines.length > 0 ? (
|
|
|
|
|
|
coreLines.map((line, i) => (
|
2026-07-03 21:25:38 +08:00
|
|
|
|
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
|
2026-07-03 21:04:08 +08:00
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p>报告已生成,请查看完整报告</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-03 21:25:38 +08:00
|
|
|
|
{/* 右块(跟随 tier 变色) */}
|
2026-07-03 21:04:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className="diagnosis-summary-right"
|
2026-07-03 21:25:38 +08:00
|
|
|
|
style={{ borderColor: config.rightColor, background: config.rightBg }}
|
2026-07-03 21:04:08 +08:00
|
|
|
|
>
|
2026-07-03 21:25:38 +08:00
|
|
|
|
<div className="diagnosis-block-title" style={{ color: config.rightColor }}>{config.rightTitle}</div>
|
2026-07-03 21:04:08 +08:00
|
|
|
|
{actionLines.length > 0 ? (
|
|
|
|
|
|
actionLines.map((line, i) => (
|
2026-07-03 21:25:38 +08:00
|
|
|
|
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
|
2026-07-03 21:04:08 +08:00
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p>报告已生成,请查看完整报告</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default DiagnosisSummary
|