feat: 收视分析看板前端 L1-L4 实现 + 25期真实数据导入

收视分析页面完整实现:指标卡(含四档动画)、走势折线图(dataZoom滑块+确认按钮)、
季度/编导/题材对比(双列布局)、双引擎象限图(题材热度×叙事结构散点)。
导入25期真实收视数据及AI标签,修复侧边栏fixed定位和滚轮冲突。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-03 17:48:38 +08:00
parent 8d880f06cf
commit f37679530a
11 changed files with 1922 additions and 210 deletions
+446 -143
View File
@@ -1,16 +1,23 @@
import { useState, useEffect, useMemo } from 'react'
import { Select, Spin, Empty, Row, Col } from 'antd'
import { useState, useEffect, useMemo, useRef } from 'react'
import { Select, Spin, Empty } from 'antd'
import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors'
import { getShareColor, getColorLabel } from '../../utils/ratingColors'
import { getAnalyticsEpisodes, getAvailableYears } from '../../services/analyticsService'
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 './Analytics.css'
/**
* 收视分析页面
* - 年份选择器
* - 指标卡行(4 张
* - 收视走势折线图(ECharts
* - 空状态兜底
* 收视分析页面 — 对齐 l3_report.html 原型
* - 居中大标题 + 副标题
* - 三色图例(指标卡上方
* - 5 张指标卡(平均份额 / 摸高完成率 / 最高份额 / 最低份额 / 达标期数
* - 收视走势:柱状图 + 折线图组合(柱子三色着色,柱顶节目名,橙色趋势线)
* - 年份选择器(HTML 原型无,保留功能)
* - dataZoom 滑块保留
* - 空状态兜底保留
*/
function Analytics() {
const [years, setYears] = useState([])
@@ -18,6 +25,10 @@ function Analytics() {
const [yearlyTarget, setYearlyTarget] = useState(null)
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(true)
const [loadTime, setLoadTime] = useState(null)
const [zoomRange, setZoomRange] = useState([0, 100])
const [appliedRange, setAppliedRange] = useState([0, 100])
const chartRef = useRef(null)
// 获取可用年份列表
useEffect(() => {
@@ -25,7 +36,7 @@ function Analytics() {
.then((data) => {
setYears(data || [])
if (data && data.length > 0) {
setSelectedYear(data[0]) // 默认选最近年份
setSelectedYear(data[0])
} else {
setLoading(false)
}
@@ -43,6 +54,7 @@ function Analytics() {
.then((data) => {
setEpisodes(data.episodes || [])
setYearlyTarget(data.yearly_target)
setLoadTime(new Date())
setLoading(false)
})
.catch(() => {
@@ -52,9 +64,29 @@ function Analytics() {
})
}, [selectedYear])
// ===== filteredEpisodes:根据滑块范围切片子集 =====
const filteredEpisodes = useMemo(() => {
const withShare = episodes.filter((ep) => ep.audience_share != null)
if (withShare.length === 0) return []
const startIdx = Math.floor((appliedRange[0] / 100) * withShare.length)
const endIdx = Math.ceil((appliedRange[1] / 100) * withShare.length)
return withShare.slice(startIdx, endIdx)
}, [episodes, appliedRange])
// ===== 辅助:根据份额值返回颜色 =====
const _getShareColor = (share) => {
if (!yearlyTarget) return '#999'
const n = Number(share)
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
if (n > stretch) return '#c0584f'
if (n >= base) return '#5b8db8'
return '#7aa874'
}
// ===== 指标卡计算 =====
const kpiMetrics = useMemo(() => {
const withShare = episodes.filter((ep) => ep.audience_share != null)
const withShare = filteredEpisodes
const count = withShare.length
if (count === 0) {
return {
@@ -63,84 +95,160 @@ function Analytics() {
avgShareColor: '#999',
baseRate: '—',
stretchRate: '—',
stretchRateColor: '#7aa874',
maxShare: null,
maxShareColor: '#999',
maxShareEpisode: null,
minShare: null,
minShareColor: '#999',
minShareEpisode: null,
passCount: 0,
passRate: '—',
excellentCount: 0,
}
}
const avgShare =
withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
// 平均份额的颜色判定(用选定年份的 target)
const base = yearlyTarget ? Number(yearlyTarget.base_target) : null
const stretch = yearlyTarget ? Number(yearlyTarget.stretch_target) : null
// 平均份额颜色
let avgShareColor = '#999'
if (yearlyTarget) {
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
if (avgShare > stretch) avgShareColor = '#c0584f' // 红=优秀
else if (avgShare >= base) avgShareColor = '#5b8db8' // 蓝=达标
else avgShareColor = '#7aa874' // 绿=待提升
if (base != null && stretch != null) {
if (avgShare > stretch) avgShareColor = '#c0584f'
else if (avgShare >= base) avgShareColor = '#5b8db8'
else avgShareColor = '#7aa874'
}
// 完成率
let baseRate = '—'
let stretchRate = '—'
if (yearlyTarget) {
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
let stretchRateColor = '#7aa874'
let passCount = 0
let excellentCount = 0
if (base != null) {
baseRate = ((avgShare / base) * 100).toFixed(1) + '%'
stretchRate = ((avgShare / stretch) * 100).toFixed(1) + '%'
passCount = withShare.filter((ep) => Number(ep.audience_share) >= base).length
}
let stretchRatePercent = 0
if (stretch != null) {
const rate = avgShare / stretch
stretchRatePercent = rate * 100
stretchRate = stretchRatePercent.toFixed(1) + '%'
if (rate > 1) stretchRateColor = '#c0584f'
else if (rate >= 1) stretchRateColor = '#5b8db8'
else stretchRateColor = '#7aa874'
excellentCount = withShare.filter((ep) => Number(ep.audience_share) > stretch).length
}
const passRate = count > 0 ? ((passCount / count) * 100).toFixed(1) + '%' : '—'
// 最高 / 最低份额
const sorted = [...withShare].sort(
(a, b) => Number(b.audience_share) - Number(a.audience_share)
)
const maxEp = sorted[0]
const minEp = sorted[sorted.length - 1]
return {
count,
avgShare: (avgShare * 100).toFixed(2) + '%',
avgShare: avgShare.toFixed(4),
avgShareColor,
baseRate,
stretchRate,
stretchRateColor,
maxShare: Number(maxEp.audience_share).toFixed(3),
maxShareColor: _getShareColor(maxEp.audience_share),
maxShareEpisode: maxEp,
minShare: Number(minEp.audience_share).toFixed(3),
minShareColor: _getShareColor(minEp.audience_share),
minShareEpisode: minEp,
passCount,
passRate,
excellentCount,
// 摸高完成率四档动画 class
stretchPulseClass:
stretchRatePercent >= 95
? 'analytics-kpi-card-excellent'
: stretchRatePercent >= 85
? 'analytics-kpi-card-good'
: stretchRatePercent >= 80
? 'analytics-kpi-card-warn'
: 'analytics-kpi-card-danger',
}
}, [episodes, yearlyTarget])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredEpisodes, yearlyTarget])
// ===== ECharts 配置 =====
// ===== 副标题 =====
const subtitle = useMemo(() => {
const withShare = filteredEpisodes
if (withShare.length === 0) return ''
const first = withShare[0]
const last = withShare[withShare.length - 1]
const range = `${first.episode_number}${last.episode_number}`
const base = yearlyTarget ? Number(yearlyTarget.base_target) : '—'
const stretch = yearlyTarget ? Number(yearlyTarget.stretch_target) : '—'
let time = ''
if (loadTime) {
const pad = (n) => String(n).padStart(2, '0')
time = `${loadTime.getFullYear()}-${pad(loadTime.getMonth() + 1)}-${pad(loadTime.getDate())} ${pad(loadTime.getHours())}:${pad(loadTime.getMinutes())}`
}
return `数据范围:${range} 基础目标 ${base} 摸高目标 ${stretch} 生成时间 ${time}`
}, [filteredEpisodes, yearlyTarget, loadTime])
// ===== 节目名截断 =====
const truncateName = (name, maxLen = 8) => {
if (!name) return ''
const cleaned = name.replace(/^第\d+期\s*/, '')
return cleaned.length > maxLen ? cleaned.substring(0, maxLen) + '…' : cleaned
}
// ===== ECharts 配置(柱状图 + 折线图组合) =====
const chartOption = useMemo(() => {
if (!episodes.length) return {}
const withShare = episodes.filter((ep) => ep.audience_share != null)
const xData = withShare.map((ep) => `${ep.episode_number}`)
const xData = withShare.map((ep) => String(ep.episode_number))
// 构建用于 getShareColor 的 targets 数组格式
const targetsForChart = yearlyTarget
? [{ year: selectedYear, ...yearlyTarget }]
: []
const sharePoints = withShare.map((ep) => ({
// 柱状图数据 — 每根柱子单独着色
const barData = withShare.map((ep) => ({
value: Number(ep.audience_share),
itemStyle: {
color: getShareColor(ep.audience_share, targetsForChart, selectedYear),
},
}))
const markLineData = []
if (yearlyTarget) {
markLineData.push(
{
name: '基础目标',
yAxis: Number(yearlyTarget.base_target),
lineStyle: { color: '#5b8db8', type: 'dashed', width: 2 },
label: {
formatter: '基础目标 {c}',
position: 'insideEndTop',
color: '#5b8db8',
},
},
{
name: '摸高目标',
yAxis: Number(yearlyTarget.stretch_target),
lineStyle: { color: '#c0584f', type: 'dashed', width: 2 },
label: {
formatter: '摸高目标 {c}',
position: 'insideEndTop',
color: '#c0584f',
},
}
)
}
// 滚动 3 期均值
const rollingAvg = withShare.map((ep, i) => {
if (i < 2) return null
const sum =
Number(withShare[i - 2].audience_share) +
Number(withShare[i - 1].audience_share) +
Number(ep.audience_share)
return Number((sum / 3).toFixed(4))
})
// 目标线
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
const stretchValue = yearlyTarget ? Number(yearlyTarget.stretch_target) : null
const baseLine = baseValue != null ? withShare.map(() => baseValue) : []
const stretchLine = stretchValue != null ? withShare.map(() => stretchValue) : []
// Y 轴范围
const shares = withShare.map((ep) => Number(ep.audience_share))
const dataMin = Math.min(...shares)
const dataMax = Math.max(...shares)
const allMin = baseValue != null ? Math.min(dataMin, baseValue) : dataMin
const allMax = stretchValue != null ? Math.max(dataMax, stretchValue) : dataMax
const yMin = Math.max(0, Math.floor((allMin - 0.1) * 10) / 10)
const yMax = Math.ceil((allMax + 0.1) * 10) / 10
return {
tooltip: {
@@ -149,38 +257,38 @@ function Analytics() {
const idx = params[0].dataIndex
const ep = withShare[idx]
if (!ep) return ''
const share = ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'
const rating = ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'
const share = Number(ep.audience_share).toFixed(4)
const editor = ep.editor_name_snapshot || '未知'
return `<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>` +
`编导:${editor}<br/>` +
`收视份额:${share}<br/>` +
`收视率:${rating}`
const date = ep.air_date || ''
const label = getColorLabel(ep.audience_share, targetsForChart, selectedYear)
let s = `<strong>第${ep.episode_number}${ep.program_name}</strong><br/>`
s += `${date} ${editor}<br/>`
s += `份额:<strong>${share}</strong>${label}`
const rollingParam = params.find((p) => p.seriesName === '滚动3期均值')
if (rollingParam && rollingParam.value != null) {
s += `<br/>滚动3期均值:${Number(rollingParam.value).toFixed(4)}`
}
return s
},
},
grid: {
left: 60,
left: 50,
right: 30,
top: 40,
bottom: 70,
top: 80,
bottom: 60,
},
xAxis: {
type: 'category',
data: xData,
axisLabel: {
rotate: 30,
fontSize: 11,
},
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'value',
name: '收视份额',
axisLabel: {
formatter: (val) => val.toFixed(2),
},
min: yMin,
max: yMax,
axisLabel: { fontSize: 11 },
},
dataZoom: [
{ type: 'inside', start: 0, end: 100 },
{
type: 'slider',
bottom: 10,
@@ -193,30 +301,92 @@ function Analytics() {
},
],
series: [
// 柱状图 — 收视份额
{
name: '收视份额',
type: 'line',
data: sharePoints,
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#6b8e6b' },
markLine: {
silent: true,
data: markLineData,
type: 'bar',
data: barData,
barWidth: '50%',
label: {
show: true,
position: 'top',
rotate: 45,
align: 'left',
fontSize: 10,
color: 'inherit',
opacity: 0.7,
formatter: (params) => {
const ep = withShare[params.dataIndex]
if (!ep) return ''
const name = (ep.program_name || '').replace(/^第\d+期\s*/, '')
return name.length > 4 ? name.substring(0, 4) + '..' : name
},
},
},
// 折线 — 滚动 3 期均值(橙色趋势线)
{
name: '滚动3期均值',
type: 'line',
data: rollingAvg,
smooth: true,
lineStyle: { color: '#e8a838', width: 2 },
itemStyle: { color: '#e8a838' },
symbolSize: 4,
},
// 基础目标虚线
...(baseLine.length > 0
? [
{
name: '基础目标',
type: 'line',
data: baseLine,
lineStyle: { color: '#5b8db8', type: 'dashed', width: 1.5 },
symbol: 'none',
tooltip: { show: false },
},
]
: []),
// 摸高目标虚线
...(stretchLine.length > 0
? [
{
name: '摸高目标',
type: 'line',
data: stretchLine,
lineStyle: { color: '#c0584f', type: 'dashed', width: 1.5 },
symbol: 'none',
tooltip: { show: false },
},
]
: []),
],
}
}, [episodes, yearlyTarget, selectedYear])
const hasData = episodes.length > 0 && episodes.some((ep) => ep.audience_share != null)
// ===== dataZoom 事件:只更新 zoomRange,不立即刷新统计 =====
const chartEvents = {
datazoom: (params) => {
if (params.batch) {
setZoomRange([params.batch[0].start, params.batch[0].end])
} else {
setZoomRange([params.start, params.end])
}
},
}
const hasData =
episodes.length > 0 && episodes.some((ep) => ep.audience_share != null)
return (
<div className="analytics-page">
{/* 页面头部 + 年份选择器 */}
{/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}
<div className="analytics-header">
<h2 className="analytics-title">收视分析</h2>
<div className="analytics-header-center">
<h1 className="analytics-page-title">
军事科技 {selectedYear} 年度收视分析
</h1>
{hasData && <p className="analytics-subtitle">{subtitle}</p>}
</div>
<Select
className="analytics-year-select"
value={selectedYear}
@@ -239,74 +409,207 @@ function Analytics() {
</div>
) : (
<>
{/* 指标卡行 */}
<Row gutter={16} className="analytics-kpi-row">
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">本年已播期数</div>
<div className="analytics-kpi-value">{kpiMetrics.count}</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">平均收视份额</div>
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.avgShareColor }}
>
{kpiMetrics.avgShare || '—'}
</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">基础目标完成率</div>
<div className="analytics-kpi-value">{kpiMetrics.baseRate}</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">摸高目标完成率</div>
<div className="analytics-kpi-value">{kpiMetrics.stretchRate}</div>
</div>
</Col>
</Row>
{/* ── 图例栏(指标卡上方,居中) ── */}
<div className="analytics-legend-bar">
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#c0584f' }}
/>
优秀{'>摸高'}
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#5b8db8' }}
/>
达标基础~摸高
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#7aa874' }}
/>
待提升{'<基础'}
</span>
</div>
{/* 收视走势折线图 */}
<div className="analytics-chart-card">
<div className="analytics-chart-header">
<h3 className="analytics-chart-title">{selectedYear} 年收视走势</h3>
<span className="analytics-chart-hint">
拖拽底部滑块缩放 · 颜色只对收视份额
</span>
{/* ── 指标卡 — 5 列网格 ── */}
<div className="analytics-kpi-grid">
{/* 卡 1:平均份额 */}
<div className="analytics-kpi-card">
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.avgShareColor }}
>
{kpiMetrics.avgShare || '—'}
</div>
<div className="analytics-kpi-desc">平均份额</div>
<div className="analytics-kpi-sub">
基础完成率 {kpiMetrics.baseRate}
</div>
</div>
{/* 卡 2:摸高目标完成率(四档动态效果) */}
<div className={`analytics-kpi-card ${kpiMetrics.stretchPulseClass}`}>
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.stretchRateColor }}
>
{kpiMetrics.stretchPulseClass === 'analytics-kpi-card-excellent' && '🎉 '}
{kpiMetrics.stretchRate}
</div>
<div className="analytics-kpi-desc">摸高目标完成率</div>
</div>
{/* 卡 3:最高份额 */}
<div className="analytics-kpi-card">
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.maxShareColor }}
>
{kpiMetrics.maxShare || '—'}
</div>
<div className="analytics-kpi-desc">最高份额</div>
<div className="analytics-kpi-sub">
{kpiMetrics.maxShareEpisode?.episode_number}{' '}
{truncateName(kpiMetrics.maxShareEpisode?.program_name)}
</div>
</div>
{/* 卡 4:最低份额 */}
<div className="analytics-kpi-card">
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.minShareColor }}
>
{kpiMetrics.minShare || '—'}
</div>
<div className="analytics-kpi-desc">最低份额</div>
<div className="analytics-kpi-sub">
{kpiMetrics.minShareEpisode?.episode_number}{' '}
{truncateName(kpiMetrics.minShareEpisode?.program_name)}
</div>
</div>
{/* 卡 5:达标期数 */}
<div className="analytics-kpi-card">
<div className="analytics-kpi-value">
{kpiMetrics.passCount}
<span className="analytics-kpi-value-total">
/{kpiMetrics.count}
</span>
</div>
<div className="analytics-kpi-desc">达标期数</div>
<div className="analytics-kpi-sub">
达标率 {kpiMetrics.passRate} 优秀{' '}
{kpiMetrics.excellentCount}
</div>
</div>
</div>
{/* ── 收视走势图(柱状图 + 折线图组合) ── */}
<div className="analytics-chart-card">
<h2 className="analytics-chart-title">收视走势</h2>
<ReactECharts
ref={chartRef}
option={chartOption}
style={{ height: 400, width: '100%' }}
style={{ height: 380, width: '100%' }}
notMerge
onEvents={chartEvents}
/>
<div className="analytics-chart-legend">
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#7aa874' }}
{(zoomRange[0] !== appliedRange[0] ||
zoomRange[1] !== appliedRange[1]) && (
<div style={{ textAlign: 'center', marginTop: 8 }}>
<button
onClick={() => setAppliedRange([...zoomRange])}
style={{
background: '#6b8e6b',
color: '#fff',
border: 'none',
borderRadius: 8,
padding: '6px 24px',
fontSize: 13,
cursor: 'pointer',
}}
>
应用此范围
</button>
<button
onClick={() => {
setZoomRange([0, 100])
setAppliedRange([0, 100])
const chart = chartRef.current?.getEchartsInstance()
if (chart) {
chart.dispatchAction({
type: 'dataZoom',
start: 0,
end: 100,
})
}
}}
style={{
background: 'transparent',
color: '#999',
border: '1px solid #ddd',
borderRadius: 8,
padding: '6px 16px',
fontSize: 13,
cursor: 'pointer',
marginLeft: 8,
}}
>
重置
</button>
</div>
)}
</div>
{/* ── AI 诊断报告占位 ── */}
<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>
{/* ── 双引擎象限图 ── */}
<div className="analytics-chart-card">
<h2 className="analytics-chart-title">双引擎象限分析</h2>
<QuadrantChart
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
</div>
{/* ── 左列(季度+编导) + 右列(题材 Task3) ── */}
<div className="analytics-compare-section">
<div className="analytics-chart-card analytics-stage-section">
<h2 className="analytics-chart-title">季度对比</h2>
<QuarterCompare
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
</div>
<div className="analytics-chart-card analytics-editor-section">
<h2 className="analytics-chart-title">编导对比</h2>
<EditorCompare
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
</div>
{/* 题材对比 — 右列通高 */}
<div className="analytics-topic-section">
<div className="analytics-chart-card">
<h2 className="analytics-chart-title">题材对比</h2>
<TopicCompare
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
绿=未达基础目标待提升
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#5b8db8' }}
/>
=未达摸高目标达标
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#c0584f' }}
/>
=超过摸高目标优秀
</span>
</div>
</div>
</div>
</>