Files
tps-dashboard/frontend/src/pages/Analytics/Analytics.jsx
T

623 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<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: 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 (
<div className="analytics-page">
{/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}
<div className="analytics-header">
<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}
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>
) : (
<>
{/* ── 图例栏(指标卡上方,居中) ── */}
<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 || '—'}
</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: 380, width: '100%' }}
notMerge
onEvents={chartEvents}
/>
{(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 诊断报告 ── */}
<DiagnosisSummary
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
{/* ── 双引擎象限图 ── */}
<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}
/>
</div>
</div>
</div>
</>
)}
</div>
)
}
export default Analytics