import { useState, useEffect, useMemo, useRef } from 'react' import { Select, Spin, Empty } from 'antd' import ReactECharts from 'echarts-for-react' 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 DiagnosisSummary from '../../components/Analytics/DiagnosisSummary' import './Analytics.css' /** * 收视分析页面 — 对齐 l3_report.html 原型 * - 居中大标题 + 副标题 * - 三色图例(指标卡上方) * - 5 张指标卡(平均份额 / 摸高完成率 / 最高份额 / 最低份额 / 达标期数) * - 收视走势:柱状图 + 折线图组合(柱子三色着色,柱顶节目名,橙色趋势线) * - 年份选择器(HTML 原型无,保留功能) * - dataZoom 滑块保留 * - 空状态兜底保留 */ 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) const [loadTime, setLoadTime] = useState(null) const [zoomRange, setZoomRange] = useState([0, 100]) const [appliedRange, setAppliedRange] = useState([0, 100]) const chartRef = useRef(null) // 获取可用年份列表 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) setLoadTime(new Date()) setLoading(false) }) .catch(() => { setEpisodes([]) setYearlyTarget(null) setLoading(false) }) }, [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 = filteredEpisodes const count = withShare.length if (count === 0) { return { count: 0, avgShare: null, 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 const base = yearlyTarget ? Number(yearlyTarget.base_target) : null const stretch = yearlyTarget ? Number(yearlyTarget.stretch_target) : null // 平均份额颜色 let avgShareColor = '#999' if (base != null && stretch != null) { if (avgShare > stretch) avgShareColor = '#c0584f' else if (avgShare >= base) avgShareColor = '#5b8db8' else avgShareColor = '#7aa874' } // 完成率 let baseRate = '—' let stretchRate = '—' let stretchRateColor = '#7aa874' let passCount = 0 let excellentCount = 0 if (base != null) { baseRate = ((avgShare / base) * 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.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', } // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredEpisodes, yearlyTarget]) // ===== 副标题 ===== 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) => String(ep.episode_number)) const targetsForChart = yearlyTarget ? [{ year: selectedYear, ...yearlyTarget }] : [] // 柱状图数据 — 每根柱子单独着色 const barData = withShare.map((ep) => ({ value: Number(ep.audience_share), itemStyle: { color: getShareColor(ep.audience_share, targetsForChart, selectedYear), }, })) // 滚动 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: { trigger: 'axis', formatter: (params) => { const idx = params[0].dataIndex const ep = withShare[idx] if (!ep) return '' const share = Number(ep.audience_share).toFixed(4) const editor = ep.editor_name_snapshot || '未知' const date = ep.air_date || '' const label = getColorLabel(ep.audience_share, targetsForChart, selectedYear) let s = `第${ep.episode_number}期 ${ep.program_name}
` s += `${date} | ${editor}
` s += `份额:${share}(${label})` const rollingParam = params.find((p) => p.seriesName === '滚动3期均值') if (rollingParam && rollingParam.value != null) { s += `
滚动3期均值:${Number(rollingParam.value).toFixed(4)}` } return s }, }, grid: { left: 50, right: 30, top: 80, bottom: 60, }, xAxis: { type: 'category', data: xData, axisLabel: { fontSize: 11 }, }, yAxis: { type: 'value', min: yMin, max: yMax, axisLabel: { fontSize: 11 }, }, dataZoom: [ { 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: '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]) // ===== 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 (
{/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}

军事科技 {selectedYear} 年度收视分析

{hasData &&

{subtitle}

}