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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-03 17:48:38 +08:00

183 lines
5.0 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 { 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