318 lines
9.7 KiB
React
318 lines
9.7 KiB
React
|
|
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
|