Files
tps-dashboard/frontend/src/components/Analytics/DiagnosisSummary.jsx
T
simonkoson 7a48af3e9e fix: AI诊断摘要正则提取 + 仪表盘排序/宽度修复
- DiagnosisSummary extractSection 改为正则模糊匹配,兼容 DeepSeek 不同标题格式
- 仪表盘近12期柱状图改为从左到右按播出时间升序
- 全局内容区宽度参数定位:.app-content max-width 1190px + width 100%(修复 Ant Design flex 下 margin auto shrink-to-fit)
- Dashboard.css 去除多余 max-width 限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-06 20:38:33 +08:00

242 lines
7.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, keyword, endKeywords) {
if (!markdown) return ''
// 用正则匹配:任意数量的 # 开头,后面包含关键词的行
const startRegex = new RegExp(`^#{1,6}\\s*.*${keyword}.*$`, 'm')
const match = markdown.match(startRegex)
if (!match) return ''
const afterStart = match.index + match[0].length
// 找最近的结束标记(下一个同级或更高级标题)
let endIdx = markdown.length
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
}
}
return markdown.substring(afterStart, endIdx).trim()
}
/** 将 markdown 文本按行拆分,保留 **bold** 为 <strong> */
function parseMarkdownLines(text) {
if (!text) return []
return text
.split('\n')
.map((line) => line.replace(/^#+\s*/, '').trim())
.filter((line) => line.length > 0)
}
/** 将行内 **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)',
}
const TIER_CONFIG = {
danger: {
leftTitle: '问题聚焦',
rightTitle: '病因与预警',
rightColor: '#7aa874',
rightBg: 'rgba(122,168,116,0.06)',
},
on_target: {
leftTitle: '核心发现',
rightTitle: '病因与提振建议',
rightColor: '#5b8db8',
rightBg: 'rgba(91,141,184,0.06)',
},
excellent: {
leftTitle: '高光复盘',
rightTitle: '经验总结',
rightColor: '#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 = parseMarkdownLines(coreFindings)
const actionLines = parseMarkdownLines(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: LEFT_STYLE.color, background: LEFT_STYLE.bg }}
>
<div className="diagnosis-block-title" style={{ color: LEFT_STYLE.color }}>{config.leftTitle}</div>
{coreLines.length > 0 ? (
coreLines.map((line, i) => (
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
))
) : (
<p>报告已生成请查看完整报告</p>
)}
</div>
{/* 右块(跟随 tier 变色) */}
<div
className="diagnosis-summary-right"
style={{ borderColor: config.rightColor, background: config.rightBg }}
>
<div className="diagnosis-block-title" style={{ color: config.rightColor }}>{config.rightTitle}</div>
{actionLines.length > 0 ? (
actionLines.map((line, i) => (
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
))
) : (
<p>报告已生成请查看完整报告</p>
)}
</div>
</div>
) : null}
</div>
)
}
export default DiagnosisSummary