feat: 收视分析看板前端 L1-L4 实现 + 25期真实数据导入
收视分析页面完整实现:指标卡(含四档动画)、走势折线图(dataZoom滑块+确认按钮)、 季度/编导/题材对比(双列布局)、双引擎象限图(题材热度×叙事结构散点)。 导入25期真实收视数据及AI标签,修复侧边栏fixed定位和滚轮冲突。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user