feat: 收视分析看板前端 L1-L4 实现 + 25期真实数据导入

收视分析页面完整实现:指标卡(含四档动画)、走势折线图(dataZoom滑块+确认按钮)、
季度/编导/题材对比(双列布局)、双引擎象限图(题材热度×叙事结构散点)。
导入25期真实收视数据及AI标签,修复侧边栏fixed定位和滚轮冲突。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-03 17:48:38 +08:00
parent 8d880f06cf
commit f37679530a
11 changed files with 1922 additions and 210 deletions
@@ -0,0 +1,183 @@
import { useMemo } from 'react'
import { Empty } from 'antd'
import ReactECharts from 'echarts-for-react'
import * as echarts from 'echarts'
/**
* 题材对比 — 饼图(期数占比)+ 水平柱图(按平均份额排名)
* 右列通高,对齐 l3_report.html 原型
*/
const TOPIC_COLORS = {
'装备深解': '#5b8db8',
'历史纵深': '#e8a838',
'前沿科技': '#7aa874',
'横切类比': '#9b7eb8',
'人物牵引': '#d4816b',
'事件战例': '#9b7eb8',
}
/**
* 将 hex 颜色转为 rgba 字符串
*/
function hexToRgba(hex, alpha) {
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
function TopicCompare({ episodes }) {
// 过滤有 program_format 的期次
const validEpisodes = useMemo(() => {
if (!episodes || !episodes.length) return []
return episodes.filter((ep) => ep.program_format != null && ep.program_format !== '')
}, [episodes])
// 按 program_format 分组统计
const topicStats = useMemo(() => {
if (!validEpisodes.length) return null
const groupMap = {}
validEpisodes.forEach((ep) => {
const format = ep.program_format
if (!groupMap[format]) {
groupMap[format] = []
}
groupMap[format].push(ep)
})
return Object.entries(groupMap).map(([name, eps]) => {
const count = eps.length
const withShare = eps.filter((ep) => ep.audience_share != null)
const avgShare =
withShare.length > 0
? withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / withShare.length
: 0
return {
name,
count,
avgShare: Number(avgShare.toFixed(4)),
}
})
}, [validEpisodes])
// 饼图配置
const pieOption = useMemo(() => {
if (!topicStats || !topicStats.length) return null
// 按期数排序
const sorted = [...topicStats].sort((a, b) => b.count - a.count)
const data = sorted.map((t) => ({ name: t.name, value: t.count }))
const colorArr = sorted.map((t) => TOPIC_COLORS[t.name] || '#999')
return {
tooltip: {
trigger: 'item',
formatter: (params) => {
return params.name + '<br/>期数:' + params.value + ' 期(' + params.percent + '%'
},
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '55%'],
data,
color: colorArr,
label: {
fontSize: 11,
formatter: '{b}\n{c}期 ({d}%)',
},
emphasis: {
label: { fontSize: 14, fontWeight: 'bold' },
},
},
],
}
}, [topicStats])
// 水平柱图配置
const barOption = useMemo(() => {
if (!topicStats || !topicStats.length) return null
// 按平均份额升序排列(最低在下,最高在上)
const sorted = [...topicStats].sort((a, b) => a.avgShare - b.avgShare)
const yData = sorted.map((t) => t.name)
const countMap = {}
sorted.forEach((t) => {
countMap[t.name] = t.count
})
// 每根柱子渐变填充 + 胶囊圆角,对齐原型 LinearGradient 写法
const barData = sorted.map((t) => {
const color = TOPIC_COLORS[t.name] || '#999'
return {
value: t.avgShare,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: hexToRgba(color, 0.15) },
{ offset: 1, color },
]),
borderRadius: [0, 10, 10, 0],
},
}
})
// x 轴范围
const values = sorted.map((t) => t.avgShare)
const dataMin = Math.min(...values)
const dataMax = Math.max(...values)
const xMin = Math.max(0, Math.floor((dataMin - 0.1) * 10) / 10)
const xMax = Math.ceil((dataMax + 0.1) * 10) / 10
return {
tooltip: {
trigger: 'axis',
formatter: (params) => {
const name = params[0].name
const count = countMap[name] || 0
return name + '<br/>平均份额:' + Number(params[0].value).toFixed(3) + '<br/>期数:' + count
},
},
grid: { left: 80, right: 30, top: 10, bottom: 30 },
xAxis: {
type: 'value',
min: xMin,
max: xMax,
},
yAxis: {
type: 'category',
data: yData,
axisLabel: { fontSize: 12 },
},
series: [
{
type: 'bar',
data: barData,
barWidth: '50%',
label: {
show: true,
position: 'right',
fontSize: 11,
formatter: (p) => p.value.toFixed(3),
},
},
],
}
}, [topicStats])
if (!validEpisodes.length) {
return <Empty description="暂无题材标签数据" />
}
return (
<div>
<ReactECharts option={pieOption} style={{ height: 280 }} notMerge />
<ReactECharts option={barOption} style={{ height: 280 }} notMerge />
</div>
)
}
export default TopicCompare