feat: 收视分析页面(指标卡 + 走势图 + 路由导航)

This commit is contained in:
simonkoson
2026-07-03 10:21:56 +08:00
parent d537bee9a7
commit 8d880f06cf
4 changed files with 483 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
import { useState, useEffect, useMemo } from 'react'
import { Select, Spin, Empty, Row, Col } from 'antd'
import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors'
import { getAnalyticsEpisodes, getAvailableYears } from '../../services/analyticsService'
import './Analytics.css'
/**
* 收视分析页面
* - 年份选择器
* - 指标卡行(4 张)
* - 收视走势折线图(ECharts
* - 空状态兜底
*/
function Analytics() {
const [years, setYears] = useState([])
const [selectedYear, setSelectedYear] = useState(null)
const [yearlyTarget, setYearlyTarget] = useState(null)
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(true)
// 获取可用年份列表
useEffect(() => {
getAvailableYears()
.then((data) => {
setYears(data || [])
if (data && data.length > 0) {
setSelectedYear(data[0]) // 默认选最近年份
} else {
setLoading(false)
}
})
.catch(() => {
setLoading(false)
})
}, [])
// 年份切换时获取数据
useEffect(() => {
if (selectedYear == null) return
setLoading(true)
getAnalyticsEpisodes(selectedYear)
.then((data) => {
setEpisodes(data.episodes || [])
setYearlyTarget(data.yearly_target)
setLoading(false)
})
.catch(() => {
setEpisodes([])
setYearlyTarget(null)
setLoading(false)
})
}, [selectedYear])
// ===== 指标卡计算 =====
const kpiMetrics = useMemo(() => {
const withShare = episodes.filter((ep) => ep.audience_share != null)
const count = withShare.length
if (count === 0) {
return {
count: 0,
avgShare: null,
avgShareColor: '#999',
baseRate: '—',
stretchRate: '—',
}
}
const avgShare =
withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
// 平均份额的颜色判定(用选定年份的 target)
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' // 绿=待提升
}
let baseRate = '—'
let stretchRate = '—'
if (yearlyTarget) {
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
baseRate = ((avgShare / base) * 100).toFixed(1) + '%'
stretchRate = ((avgShare / stretch) * 100).toFixed(1) + '%'
}
return {
count,
avgShare: (avgShare * 100).toFixed(2) + '%',
avgShareColor,
baseRate,
stretchRate,
}
}, [episodes, yearlyTarget])
// ===== 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}`)
// 构建用于 getShareColor 的 targets 数组格式
const targetsForChart = yearlyTarget
? [{ year: selectedYear, ...yearlyTarget }]
: []
const sharePoints = 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',
},
}
)
}
return {
tooltip: {
trigger: 'axis',
formatter: (params) => {
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 editor = ep.editor_name_snapshot || '未知'
return `<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>` +
`编导:${editor}<br/>` +
`收视份额:${share}<br/>` +
`收视率:${rating}`
},
},
grid: {
left: 60,
right: 30,
top: 40,
bottom: 70,
},
xAxis: {
type: 'category',
data: xData,
axisLabel: {
rotate: 30,
fontSize: 11,
},
},
yAxis: {
type: 'value',
name: '收视份额',
axisLabel: {
formatter: (val) => val.toFixed(2),
},
},
dataZoom: [
{ type: 'inside', start: 0, end: 100 },
{
type: 'slider',
bottom: 10,
height: 20,
start: 0,
end: 100,
borderColor: '#ddd',
fillerColor: 'rgba(107,142,107,0.15)',
handleStyle: { color: '#6b8e6b' },
},
],
series: [
{
name: '收视份额',
type: 'line',
data: sharePoints,
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#6b8e6b' },
markLine: {
silent: true,
data: markLineData,
},
},
],
}
}, [episodes, yearlyTarget, selectedYear])
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>
<Select
className="analytics-year-select"
value={selectedYear}
onChange={setSelectedYear}
placeholder="选择年份"
loading={years.length === 0}
options={years.map((y) => ({ label: `${y}`, value: y }))}
style={{ width: 120 }}
/>
</div>
{loading ? (
<div className="analytics-loading">
<Spin size="large" />
<p>加载中...</p>
</div>
) : !hasData ? (
<div className="analytics-empty">
<Empty description="暂无收视数据" />
</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-chart-card">
<div className="analytics-chart-header">
<h3 className="analytics-chart-title">{selectedYear} 年收视走势</h3>
<span className="analytics-chart-hint">
拖拽底部滑块缩放 · 颜色只对收视份额
</span>
</div>
<ReactECharts
option={chartOption}
style={{ height: 400, width: '100%' }}
notMerge
/>
<div className="analytics-chart-legend">
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#7aa874' }}
/>
绿=未达基础目标待提升
</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>
)
}
export default Analytics