2026-07-03 17:48:38 +08:00
|
|
|
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
|
|
|
|
|
import { Select, Spin, Empty } from 'antd'
|
2026-07-03 10:21:56 +08:00
|
|
|
|
import ReactECharts from 'echarts-for-react'
|
2026-07-03 17:48:38 +08:00
|
|
|
|
import { getShareColor, getColorLabel } from '../../utils/ratingColors'
|
2026-07-03 10:21:56 +08:00
|
|
|
|
import { getAnalyticsEpisodes, getAvailableYears } from '../../services/analyticsService'
|
2026-07-03 17:48:38 +08:00
|
|
|
|
import EditorCompare from '../../components/Analytics/EditorCompare'
|
|
|
|
|
|
import QuarterCompare from '../../components/Analytics/QuarterCompare'
|
|
|
|
|
|
import TopicCompare from '../../components/Analytics/TopicCompare'
|
|
|
|
|
|
import QuadrantChart from '../../components/Analytics/QuadrantChart'
|
2026-07-03 10:21:56 +08:00
|
|
|
|
import './Analytics.css'
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-03 17:48:38 +08:00
|
|
|
|
* 收视分析页面 — 对齐 l3_report.html 原型
|
|
|
|
|
|
* - 居中大标题 + 副标题
|
|
|
|
|
|
* - 三色图例(指标卡上方)
|
|
|
|
|
|
* - 5 张指标卡(平均份额 / 摸高完成率 / 最高份额 / 最低份额 / 达标期数)
|
|
|
|
|
|
* - 收视走势:柱状图 + 折线图组合(柱子三色着色,柱顶节目名,橙色趋势线)
|
|
|
|
|
|
* - 年份选择器(HTML 原型无,保留功能)
|
|
|
|
|
|
* - dataZoom 滑块保留
|
|
|
|
|
|
* - 空状态兜底保留
|
2026-07-03 10:21:56 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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)
|
2026-07-03 17:48:38 +08:00
|
|
|
|
const [loadTime, setLoadTime] = useState(null)
|
|
|
|
|
|
const [zoomRange, setZoomRange] = useState([0, 100])
|
|
|
|
|
|
const [appliedRange, setAppliedRange] = useState([0, 100])
|
|
|
|
|
|
const chartRef = useRef(null)
|
2026-07-03 10:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取可用年份列表
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
getAvailableYears()
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
setYears(data || [])
|
|
|
|
|
|
if (data && data.length > 0) {
|
2026-07-03 17:48:38 +08:00
|
|
|
|
setSelectedYear(data[0])
|
2026-07-03 10:21:56 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// 年份切换时获取数据
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedYear == null) return
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
getAnalyticsEpisodes(selectedYear)
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
setEpisodes(data.episodes || [])
|
|
|
|
|
|
setYearlyTarget(data.yearly_target)
|
2026-07-03 17:48:38 +08:00
|
|
|
|
setLoadTime(new Date())
|
2026-07-03 10:21:56 +08:00
|
|
|
|
setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
setEpisodes([])
|
|
|
|
|
|
setYearlyTarget(null)
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [selectedYear])
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// ===== 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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 10:21:56 +08:00
|
|
|
|
// ===== 指标卡计算 =====
|
|
|
|
|
|
const kpiMetrics = useMemo(() => {
|
2026-07-03 17:48:38 +08:00
|
|
|
|
const withShare = filteredEpisodes
|
2026-07-03 10:21:56 +08:00
|
|
|
|
const count = withShare.length
|
|
|
|
|
|
if (count === 0) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
count: 0,
|
|
|
|
|
|
avgShare: null,
|
|
|
|
|
|
avgShareColor: '#999',
|
|
|
|
|
|
baseRate: '—',
|
|
|
|
|
|
stretchRate: '—',
|
2026-07-03 17:48:38 +08:00
|
|
|
|
stretchRateColor: '#7aa874',
|
|
|
|
|
|
maxShare: null,
|
|
|
|
|
|
maxShareColor: '#999',
|
|
|
|
|
|
maxShareEpisode: null,
|
|
|
|
|
|
minShare: null,
|
|
|
|
|
|
minShareColor: '#999',
|
|
|
|
|
|
minShareEpisode: null,
|
|
|
|
|
|
passCount: 0,
|
|
|
|
|
|
passRate: '—',
|
|
|
|
|
|
excellentCount: 0,
|
2026-07-03 10:21:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const avgShare =
|
|
|
|
|
|
withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
const base = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
|
|
|
|
|
const stretch = yearlyTarget ? Number(yearlyTarget.stretch_target) : null
|
|
|
|
|
|
|
|
|
|
|
|
// 平均份额颜色
|
2026-07-03 10:21:56 +08:00
|
|
|
|
let avgShareColor = '#999'
|
2026-07-03 17:48:38 +08:00
|
|
|
|
if (base != null && stretch != null) {
|
|
|
|
|
|
if (avgShare > stretch) avgShareColor = '#c0584f'
|
|
|
|
|
|
else if (avgShare >= base) avgShareColor = '#5b8db8'
|
|
|
|
|
|
else avgShareColor = '#7aa874'
|
2026-07-03 10:21:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// 完成率
|
2026-07-03 10:21:56 +08:00
|
|
|
|
let baseRate = '—'
|
|
|
|
|
|
let stretchRate = '—'
|
2026-07-03 17:48:38 +08:00
|
|
|
|
let stretchRateColor = '#7aa874'
|
|
|
|
|
|
let passCount = 0
|
|
|
|
|
|
let excellentCount = 0
|
|
|
|
|
|
|
|
|
|
|
|
if (base != null) {
|
2026-07-03 10:21:56 +08:00
|
|
|
|
baseRate = ((avgShare / base) * 100).toFixed(1) + '%'
|
2026-07-03 17:48:38 +08:00
|
|
|
|
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
|
2026-07-03 10:21:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
2026-07-03 10:21:56 +08:00
|
|
|
|
return {
|
|
|
|
|
|
count,
|
2026-07-03 17:48:38 +08:00
|
|
|
|
avgShare: avgShare.toFixed(4),
|
2026-07-03 10:21:56 +08:00
|
|
|
|
avgShareColor,
|
|
|
|
|
|
baseRate,
|
|
|
|
|
|
stretchRate,
|
2026-07-03 17:48:38 +08:00
|
|
|
|
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',
|
2026-07-03 10:21:56 +08:00
|
|
|
|
}
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [filteredEpisodes, yearlyTarget])
|
2026-07-03 10:21:56 +08:00
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// ===== 副标题 =====
|
|
|
|
|
|
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 配置(柱状图 + 折线图组合) =====
|
2026-07-03 10:21:56 +08:00
|
|
|
|
const chartOption = useMemo(() => {
|
|
|
|
|
|
if (!episodes.length) return {}
|
|
|
|
|
|
|
|
|
|
|
|
const withShare = episodes.filter((ep) => ep.audience_share != null)
|
2026-07-03 17:48:38 +08:00
|
|
|
|
const xData = withShare.map((ep) => String(ep.episode_number))
|
2026-07-03 10:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
const targetsForChart = yearlyTarget
|
|
|
|
|
|
? [{ year: selectedYear, ...yearlyTarget }]
|
|
|
|
|
|
: []
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// 柱状图数据 — 每根柱子单独着色
|
|
|
|
|
|
const barData = withShare.map((ep) => ({
|
2026-07-03 10:21:56 +08:00
|
|
|
|
value: Number(ep.audience_share),
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getShareColor(ep.audience_share, targetsForChart, selectedYear),
|
|
|
|
|
|
},
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// 滚动 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
|
2026-07-03 10:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
const idx = params[0].dataIndex
|
|
|
|
|
|
const ep = withShare[idx]
|
|
|
|
|
|
if (!ep) return ''
|
2026-07-03 17:48:38 +08:00
|
|
|
|
const share = Number(ep.audience_share).toFixed(4)
|
2026-07-03 10:21:56 +08:00
|
|
|
|
const editor = ep.editor_name_snapshot || '未知'
|
2026-07-03 17:48:38 +08:00
|
|
|
|
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
|
2026-07-03 10:21:56 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: {
|
2026-07-03 17:48:38 +08:00
|
|
|
|
left: 50,
|
2026-07-03 10:21:56 +08:00
|
|
|
|
right: 30,
|
2026-07-03 17:48:38 +08:00
|
|
|
|
top: 80,
|
|
|
|
|
|
bottom: 60,
|
2026-07-03 10:21:56 +08:00
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: xData,
|
2026-07-03 17:48:38 +08:00
|
|
|
|
axisLabel: { fontSize: 11 },
|
2026-07-03 10:21:56 +08:00
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
2026-07-03 17:48:38 +08:00
|
|
|
|
min: yMin,
|
|
|
|
|
|
max: yMax,
|
|
|
|
|
|
axisLabel: { fontSize: 11 },
|
2026-07-03 10:21:56 +08:00
|
|
|
|
},
|
|
|
|
|
|
dataZoom: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'slider',
|
|
|
|
|
|
bottom: 10,
|
|
|
|
|
|
height: 20,
|
|
|
|
|
|
start: 0,
|
|
|
|
|
|
end: 100,
|
|
|
|
|
|
borderColor: '#ddd',
|
|
|
|
|
|
fillerColor: 'rgba(107,142,107,0.15)',
|
|
|
|
|
|
handleStyle: { color: '#6b8e6b' },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
series: [
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// 柱状图 — 收视份额
|
2026-07-03 10:21:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: '收视份额',
|
2026-07-03 17:48:38 +08:00
|
|
|
|
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
|
|
|
|
|
|
},
|
2026-07-03 10:21:56 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// 折线 — 滚动 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 },
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
: []),
|
2026-07-03 10:21:56 +08:00
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [episodes, yearlyTarget, selectedYear])
|
|
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
// ===== 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)
|
2026-07-03 10:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="analytics-page">
|
2026-07-03 17:48:38 +08:00
|
|
|
|
{/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
<div className="analytics-header">
|
2026-07-03 17:48:38 +08:00
|
|
|
|
<div className="analytics-header-center">
|
|
|
|
|
|
<h1 className="analytics-page-title">
|
|
|
|
|
|
军事科技 {selectedYear} 年度收视分析
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
{hasData && <p className="analytics-subtitle">{subtitle}</p>}
|
|
|
|
|
|
</div>
|
2026-07-03 10:21:56 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
{/* ── 图例栏(指标卡上方,居中) ── */}
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 指标卡 — 5 列网格 ── */}
|
|
|
|
|
|
<div className="analytics-kpi-grid">
|
|
|
|
|
|
{/* 卡 1:平均份额 */}
|
|
|
|
|
|
<div className="analytics-kpi-card">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="analytics-kpi-value"
|
|
|
|
|
|
style={{ color: kpiMetrics.avgShareColor }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{kpiMetrics.avgShare || '—'}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
</div>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
<div className="analytics-kpi-desc">平均份额</div>
|
|
|
|
|
|
<div className="analytics-kpi-sub">
|
|
|
|
|
|
基础完成率 {kpiMetrics.baseRate}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
</div>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
</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}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
</div>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
<div className="analytics-kpi-desc">摸高目标完成率</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 卡 3:最高份额 */}
|
|
|
|
|
|
<div className="analytics-kpi-card">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="analytics-kpi-value"
|
|
|
|
|
|
style={{ color: kpiMetrics.maxShareColor }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{kpiMetrics.maxShare || '—'}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
</div>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
<div className="analytics-kpi-desc">最高份额</div>
|
|
|
|
|
|
<div className="analytics-kpi-sub">
|
|
|
|
|
|
第{kpiMetrics.maxShareEpisode?.episode_number}期{' '}
|
|
|
|
|
|
{truncateName(kpiMetrics.maxShareEpisode?.program_name)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-03 10:21:56 +08:00
|
|
|
|
|
2026-07-03 17:48:38 +08:00
|
|
|
|
{/* 卡 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>
|
2026-07-03 10:21:56 +08:00
|
|
|
|
</div>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── 收视走势图(柱状图 + 折线图组合) ── */}
|
|
|
|
|
|
<div className="analytics-chart-card">
|
|
|
|
|
|
<h2 className="analytics-chart-title">收视走势</h2>
|
2026-07-03 10:21:56 +08:00
|
|
|
|
<ReactECharts
|
2026-07-03 17:48:38 +08:00
|
|
|
|
ref={chartRef}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
option={chartOption}
|
2026-07-03 17:48:38 +08:00
|
|
|
|
style={{ height: 380, width: '100%' }}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
notMerge
|
2026-07-03 17:48:38 +08:00
|
|
|
|
onEvents={chartEvents}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
/>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
{(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}
|
2026-07-03 10:21:56 +08:00
|
|
|
|
/>
|
2026-07-03 17:48:38 +08:00
|
|
|
|
</div>
|
2026-07-03 10:21:56 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Analytics
|