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,156 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Empty } from 'antd'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { getShareColor } from '../../utils/ratingColors'
|
||||
|
||||
/**
|
||||
* 编导对比柱状图
|
||||
* - 按 editor_name_snapshot 分组
|
||||
* - 每人一根水平柱子,高度 = 该编导本年平均收视份额
|
||||
* - 柱子颜色按三色判定(该编导平均份额 vs 年度目标)
|
||||
* - tooltip:编导名、负责期数、平均份额、最佳期次名+份额、最差期次名+份额
|
||||
* - 参考线:基础目标蓝色虚线
|
||||
*/
|
||||
function EditorCompare({ episodes, yearlyTarget, selectedYear }) {
|
||||
const chartOption = useMemo(() => {
|
||||
if (!episodes || !episodes.length) return null
|
||||
|
||||
// 过滤有份额的期次
|
||||
const withShare = episodes.filter((ep) => ep.audience_share != null)
|
||||
if (withShare.length === 0) return null
|
||||
|
||||
// 按编导分组
|
||||
const editorMap = {}
|
||||
withShare.forEach((ep) => {
|
||||
const name = ep.editor_name_snapshot || '未知'
|
||||
if (!editorMap[name]) {
|
||||
editorMap[name] = []
|
||||
}
|
||||
editorMap[name].push(ep)
|
||||
})
|
||||
|
||||
// 构造 targetsForChart
|
||||
const targetsForChart = yearlyTarget
|
||||
? [{ year: selectedYear, ...yearlyTarget }]
|
||||
: []
|
||||
|
||||
// 计算每位编导的统计
|
||||
const editorStats = Object.entries(editorMap).map(([name, eps]) => {
|
||||
const count = eps.length
|
||||
const avgShare =
|
||||
eps.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
|
||||
|
||||
// 按份额排序找最佳/最差
|
||||
const sorted = [...eps].sort(
|
||||
(a, b) => Number(b.audience_share) - Number(a.audience_share)
|
||||
)
|
||||
const best = sorted[0]
|
||||
const worst = sorted[sorted.length - 1]
|
||||
|
||||
return {
|
||||
name,
|
||||
count,
|
||||
avgShare: Number(avgShare.toFixed(4)),
|
||||
bestShare: Number(best.audience_share).toFixed(3),
|
||||
bestTitle: best.program_name || '',
|
||||
worstShare: Number(worst.audience_share).toFixed(3),
|
||||
worstTitle: worst.program_name || '',
|
||||
}
|
||||
})
|
||||
|
||||
// 按平均份额升序排列(水平柱状图从下到上递增)
|
||||
editorStats.sort((a, b) => a.avgShare - b.avgShare)
|
||||
|
||||
const yData = editorStats.map((e) => e.name)
|
||||
|
||||
// 柱子颜色
|
||||
const barData = editorStats.map((e) => ({
|
||||
value: e.avgShare,
|
||||
itemStyle: {
|
||||
color: getShareColor(e.avgShare, targetsForChart, selectedYear),
|
||||
},
|
||||
}))
|
||||
|
||||
// Y 轴范围
|
||||
const dataMin = Math.min(...editorStats.map((e) => e.avgShare))
|
||||
const dataMax = Math.max(...editorStats.map((e) => e.avgShare))
|
||||
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
||||
const allMin = baseValue != null ? Math.min(dataMin, baseValue) : dataMin
|
||||
const allMax = baseValue != null ? Math.max(dataMax, baseValue) : dataMax
|
||||
const xMin = Math.max(0, Math.floor((allMin - 0.1) * 10) / 10)
|
||||
const xMax = Math.ceil((allMax + 0.1) * 10) / 10
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const name = params[0].name
|
||||
const stat = editorStats.find((e) => e.name === name)
|
||||
if (!stat) return ''
|
||||
let s = `<b>${name}</b>(${stat.count}期)<br/>`
|
||||
s += `平均份额:<b>${stat.avgShare}</b><br/>`
|
||||
s += `最佳:${stat.bestShare}(${stat.bestTitle.substring(0, 10)})<br/>`
|
||||
s += `最差:${stat.worstShare}(${stat.worstTitle.substring(0, 10)})`
|
||||
return s
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 70,
|
||||
right: 40,
|
||||
top: 10,
|
||||
bottom: 30,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: xMin,
|
||||
max: xMax,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: yData,
|
||||
axisLabel: { fontSize: 13 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: barData,
|
||||
barWidth: '40%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
formatter: (p) => p.value.toFixed(4),
|
||||
},
|
||||
},
|
||||
// 基础目标参考线
|
||||
...(baseValue != null
|
||||
? [
|
||||
{
|
||||
type: 'line',
|
||||
data: yData.map(() => baseValue),
|
||||
lineStyle: { color: '#5b8db8', type: 'dashed', width: 1 },
|
||||
symbol: 'none',
|
||||
tooltip: { show: false },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}, [episodes, yearlyTarget, selectedYear])
|
||||
|
||||
if (!chartOption) {
|
||||
return <Empty description="暂无编导数据" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: 300, width: '100%' }}
|
||||
notMerge
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorCompare
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Empty } from 'antd'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { getShareColor } from '../../utils/ratingColors'
|
||||
|
||||
/**
|
||||
* 确定性 jitter — 用 episode_number 做种子,保证每次渲染结果一致
|
||||
* @param {number} seed - episode_number
|
||||
* @param {number} range - 范围(正数),输出在 [-range, +range]
|
||||
*/
|
||||
function deterministicJitter(seed, range) {
|
||||
return (Math.sin(seed * 12345.6789) * 10000 % 1) * range * 2 - range
|
||||
}
|
||||
|
||||
/**
|
||||
* 双引擎象限图 — 对齐 ai-labeling/output/l3_report.html 第 513-719 行
|
||||
* Props: { episodes, yearlyTarget, selectedYear }
|
||||
*/
|
||||
function QuadrantChart({ episodes, yearlyTarget, selectedYear }) {
|
||||
// ===== 数据准备 =====
|
||||
const chartData = useMemo(() => {
|
||||
if (!episodes || !episodes.length || !yearlyTarget) return null
|
||||
|
||||
// 过滤:program_format 和 narrative_structure 都不为 null,且有 audience_share
|
||||
const valid = episodes.filter(
|
||||
(ep) =>
|
||||
ep.program_format != null &&
|
||||
ep.program_format !== '' &&
|
||||
ep.narrative_structure != null &&
|
||||
ep.narrative_structure !== '' &&
|
||||
ep.audience_share != null
|
||||
)
|
||||
|
||||
if (valid.length === 0) return null
|
||||
|
||||
// 栏目整体均值(所有有份额期次)
|
||||
const allWithShare = episodes.filter((ep) => ep.audience_share != null)
|
||||
const overallMean =
|
||||
allWithShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) /
|
||||
allWithShare.length
|
||||
|
||||
// 按 program_format 分组,计算各题材均值
|
||||
const groupMap = {}
|
||||
valid.forEach((ep) => {
|
||||
const fmt = ep.program_format
|
||||
if (!groupMap[fmt]) groupMap[fmt] = []
|
||||
groupMap[fmt].push(Number(ep.audience_share))
|
||||
})
|
||||
|
||||
const formatDeviation = {}
|
||||
Object.entries(groupMap).forEach(([fmt, shares]) => {
|
||||
const avg = shares.reduce((s, v) => s + v, 0) / shares.length
|
||||
formatDeviation[fmt] = avg - overallMean
|
||||
})
|
||||
|
||||
// 构造 scatter 数据
|
||||
const targetsArray = [{ year: selectedYear, ...yearlyTarget }]
|
||||
const scatterData = valid.map((ep) => {
|
||||
const share = Number(ep.audience_share)
|
||||
const deviation = formatDeviation[ep.program_format] || 0
|
||||
const jitterX = deterministicJitter(ep.episode_number, 0.04)
|
||||
|
||||
// 纵轴:主线演进 → 1,并列结构 → 0
|
||||
const yBase = ep.narrative_structure === '主线演进' ? 1 : 0
|
||||
const jitterY = deterministicJitter(ep.episode_number * 7, 0.15)
|
||||
|
||||
const x = deviation + jitterX
|
||||
const y = yBase + jitterY
|
||||
|
||||
// 气泡大小
|
||||
const symbolSize = 14 + (share - 0.3) * 35
|
||||
|
||||
// 颜色(三色判定,复用 ratingColors.js)
|
||||
const color = getShareColor(share, targetsArray, selectedYear)
|
||||
|
||||
// 边框(根据 opening_hook)
|
||||
let borderConfig = {}
|
||||
if (ep.opening_hook === '强') {
|
||||
borderConfig = { borderWidth: 3, borderColor: '#333', borderType: 'solid' }
|
||||
} else if (ep.opening_hook === '弱') {
|
||||
borderConfig = { borderWidth: 2, borderColor: '#aaa', borderType: [4, 4] }
|
||||
} else {
|
||||
// '中' 或默认
|
||||
borderConfig = { borderWidth: 1.5, borderColor: '#999', borderType: 'solid' }
|
||||
}
|
||||
|
||||
return {
|
||||
value: [x, y],
|
||||
symbolSize,
|
||||
itemStyle: {
|
||||
color,
|
||||
opacity: 0.85,
|
||||
...borderConfig,
|
||||
},
|
||||
_ep: ep.episode_number,
|
||||
_title: ep.program_name,
|
||||
_share: share,
|
||||
_hook: ep.opening_hook || '中',
|
||||
_format: ep.program_format,
|
||||
_editor: ep.editor_name_snapshot || '',
|
||||
}
|
||||
})
|
||||
|
||||
// 动态计算 x 轴范围
|
||||
const xValues = scatterData.map((d) => d.value[0])
|
||||
const xDataMin = Math.min(...xValues)
|
||||
const xDataMax = Math.max(...xValues)
|
||||
const xPadding = 0.05
|
||||
const xMin = Math.min(-0.25, Math.floor((xDataMin - xPadding) * 100) / 100)
|
||||
const xMax = Math.max(0.22, Math.ceil((xDataMax + xPadding) * 100) / 100)
|
||||
|
||||
// 按气泡大小降序排列,大的先画、小的后画(渲染在上层)
|
||||
scatterData.sort((a, b) => b.symbolSize - a.symbolSize)
|
||||
|
||||
return { scatterData, overallMean, formatDeviation, xMin, xMax }
|
||||
}, [episodes, yearlyTarget, selectedYear])
|
||||
|
||||
// ===== ECharts 配置 =====
|
||||
const option = useMemo(() => {
|
||||
if (!chartData) return null
|
||||
|
||||
const { scatterData, xMin, xMax } = chartData
|
||||
const base = Number(yearlyTarget.base_target)
|
||||
const stretch = Number(yearlyTarget.stretch_target)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function (p) {
|
||||
var d = p.data
|
||||
var hookMap = { '强': '强 ●●●', '中': '中 ●●○', '弱': '弱 ●○○' }
|
||||
var colorLabel = d._share > stretch ? '优秀' : d._share >= base ? '达标' : '待提升'
|
||||
return (
|
||||
'<b>第' + d._ep + '期 ' + d._title + '</b><br/>' +
|
||||
'编导:' + d._editor + ' | 题材:' + d._format + '<br/>' +
|
||||
'份额:<b>' + d._share.toFixed(3) + '</b>(' + colorLabel + ')<br/>' +
|
||||
'开篇钩子:' + (hookMap[d._hook] || d._hook)
|
||||
)
|
||||
},
|
||||
backgroundColor: 'rgba(255,255,250,0.95)',
|
||||
borderColor: '#ddd',
|
||||
textStyle: { color: '#333', fontSize: 13 },
|
||||
},
|
||||
grid: { left: 80, right: 80, top: 50, bottom: 50 },
|
||||
xAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '热门题材 →',
|
||||
nameLocation: 'end',
|
||||
nameGap: 5,
|
||||
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||
min: xMin,
|
||||
max: xMax,
|
||||
splitLine: { show: false },
|
||||
axisLine: { lineStyle: { color: '#999', width: 1.5 } },
|
||||
axisTick: { show: true, lineStyle: { color: '#bbb' } },
|
||||
axisLabel: {
|
||||
formatter: function (v) {
|
||||
if (v === 0) return ''
|
||||
return (v > 0 ? '+' : '') + v.toFixed(2)
|
||||
},
|
||||
color: '#999',
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '← 冷门题材',
|
||||
nameLocation: 'start',
|
||||
nameGap: 5,
|
||||
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||
min: xMin,
|
||||
max: xMax,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '故事化 ↑',
|
||||
nameLocation: 'end',
|
||||
nameGap: 12,
|
||||
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||
min: -0.3,
|
||||
max: 1.3,
|
||||
interval: 1,
|
||||
splitLine: { show: false },
|
||||
axisLine: { lineStyle: { color: '#999', width: 1.5 } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
formatter: function (v) {
|
||||
if (v === 0) return '并列结构'
|
||||
if (v === 1) return '主线演进'
|
||||
return ''
|
||||
},
|
||||
color: '#4a6741',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '板块化 ↓',
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||
min: -0.3,
|
||||
max: 1.3,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// 主散点
|
||||
{
|
||||
type: 'scatter',
|
||||
data: scatterData,
|
||||
emphasis: {
|
||||
itemStyle: { opacity: 1, shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)' },
|
||||
scale: 1.2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: function (p) {
|
||||
var title = p.data._title || ''
|
||||
return title.length > 5 ? title.substring(0, 5) + '..' : title
|
||||
},
|
||||
fontSize: 9,
|
||||
color: '#999',
|
||||
distance: 5,
|
||||
},
|
||||
labelLayout: { hideOverlap: true },
|
||||
},
|
||||
// 零线(x=0 竖虚线)
|
||||
{
|
||||
type: 'line',
|
||||
data: [[0, -0.3], [0, 1.3]],
|
||||
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||
symbol: 'none',
|
||||
silent: true,
|
||||
z: 0,
|
||||
},
|
||||
// 并列结构水平带
|
||||
{
|
||||
type: 'line',
|
||||
data: [[xMin, 0], [xMax, 0]],
|
||||
lineStyle: { color: '#e8e4d8', width: 1 },
|
||||
symbol: 'none',
|
||||
silent: true,
|
||||
z: 0,
|
||||
},
|
||||
// 主线演进水平带
|
||||
{
|
||||
type: 'line',
|
||||
data: [[xMin, 1], [xMax, 1]],
|
||||
lineStyle: { color: '#e8e4d8', width: 1 },
|
||||
symbol: 'none',
|
||||
silent: true,
|
||||
z: 0,
|
||||
},
|
||||
],
|
||||
graphic: [
|
||||
// 左上:冷题材 + 好结构
|
||||
{
|
||||
type: 'text',
|
||||
left: 90,
|
||||
top: 35,
|
||||
style: {
|
||||
text: '冷题材 + 好结构\n逆袭区:靠叙事拉升',
|
||||
fill: '#aaa',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
},
|
||||
// 右上:热题材 + 好结构
|
||||
{
|
||||
type: 'text',
|
||||
right: 90,
|
||||
top: 35,
|
||||
style: {
|
||||
text: '热题材 + 好结构\n双引擎全开',
|
||||
fill: '#aaa',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
align: 'right',
|
||||
},
|
||||
},
|
||||
// 左下:冷题材 + 弱结构
|
||||
{
|
||||
type: 'text',
|
||||
left: 90,
|
||||
bottom: 55,
|
||||
style: {
|
||||
text: '冷题材 + 弱结构\n⚠ 高风险区',
|
||||
fill: '#c0584f',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
// 右下:热题材 + 弱结构
|
||||
{
|
||||
type: 'text',
|
||||
right: 90,
|
||||
bottom: 55,
|
||||
style: {
|
||||
text: '热题材 + 弱结构\n题材托底',
|
||||
fill: '#aaa',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
align: 'right',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [chartData, yearlyTarget])
|
||||
|
||||
// ===== 读图说明文字(动态生成) =====
|
||||
const instructionText = useMemo(() => {
|
||||
if (!chartData) return ''
|
||||
const { overallMean, formatDeviation } = chartData
|
||||
const meanStr = overallMean.toFixed(4)
|
||||
|
||||
// 按偏离值降序列出各题材
|
||||
const entries = Object.entries(formatDeviation).sort((a, b) => b[1] - a[1])
|
||||
const examples = entries
|
||||
.map(([fmt, dev]) => `"${fmt}" 偏离 ${dev >= 0 ? '+' : ''}${dev.toFixed(3)}(${dev >= 0 ? '热门' : '偏冷'})`)
|
||||
.join(';')
|
||||
|
||||
return `横轴"题材热度"= 该题材类别所有期次的平均份额 − 栏目整体均值(${meanStr}),反映题材自带的收视基础。例如 ${examples}。同一题材的节目共享横轴位置。纵轴为叙事结构两档分类(非连续刻度):上行 = 主线演进,下行 = 并列结构,行内散布仅为避免气泡重叠。气泡越大 = 收视份额越高;颜色 = 三色判定;边框粗细 = 开篇钩子强度。`
|
||||
}, [chartData])
|
||||
|
||||
// ===== 空状态 =====
|
||||
if (!chartData) {
|
||||
return <Empty description="暂无双引擎分析数据(需 AI 标签)" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── 图例栏 ── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
marginBottom: 8,
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
}}
|
||||
>
|
||||
<span>气泡大小 = 收视份额</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: '3px solid #333',
|
||||
}}
|
||||
/>{' '}
|
||||
强钩子
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid #999',
|
||||
}}
|
||||
/>{' '}
|
||||
中钩子
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px dashed #999',
|
||||
}}
|
||||
/>{' '}
|
||||
弱钩子
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: '#c0584f',
|
||||
}}
|
||||
/>{' '}
|
||||
优秀
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: '#5b8db8',
|
||||
}}
|
||||
/>{' '}
|
||||
达标
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: '#7aa874',
|
||||
}}
|
||||
/>{' '}
|
||||
待提升
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── ECharts 象限图 ── */}
|
||||
<ReactECharts option={option} style={{ height: 520, width: '100%' }} notMerge />
|
||||
|
||||
{/* ── 读图说明 ── */}
|
||||
<div style={{ fontSize: 11, color: '#aaa', lineHeight: 1.7, marginTop: 8, padding: '0 4px' }}>
|
||||
<b style={{ color: '#999' }}>读图说明</b>|{instructionText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuadrantChart
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Empty, Table } from 'antd'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { getShareColor } from '../../utils/ratingColors'
|
||||
|
||||
/**
|
||||
* 季度对比 — 含汇总表格 + 柱状图
|
||||
* 对齐 l3_report.html 原型:
|
||||
* - 上方:汇总表格(季度/期数/平均份额/基础完成率/达标率)
|
||||
* - 下方:垂直柱状图(柱子着色,参考线)
|
||||
*/
|
||||
function QuarterCompare({ episodes, yearlyTarget, selectedYear }) {
|
||||
const { quarterStats, chartOption, tableData } = useMemo(() => {
|
||||
if (!episodes || !episodes.length)
|
||||
return { quarterStats: null, chartOption: null, tableData: [] }
|
||||
|
||||
const withShare = episodes.filter(
|
||||
(ep) => ep.audience_share != null && ep.air_date
|
||||
)
|
||||
if (withShare.length === 0)
|
||||
return { quarterStats: null, chartOption: null, tableData: [] }
|
||||
|
||||
// 按季度分组
|
||||
const quarterMap = { Q1: [], Q2: [], Q3: [], Q4: [] }
|
||||
withShare.forEach((ep) => {
|
||||
const month = new Date(ep.air_date).getMonth() + 1
|
||||
if (month <= 3) quarterMap.Q1.push(ep)
|
||||
else if (month <= 6) quarterMap.Q2.push(ep)
|
||||
else if (month <= 9) quarterMap.Q3.push(ep)
|
||||
else quarterMap.Q4.push(ep)
|
||||
})
|
||||
|
||||
const targetsForChart = yearlyTarget
|
||||
? [{ year: selectedYear, ...yearlyTarget }]
|
||||
: []
|
||||
|
||||
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
||||
const stretchValue = yearlyTarget
|
||||
? Number(yearlyTarget.stretch_target)
|
||||
: null
|
||||
|
||||
// 只保留有数据的季度
|
||||
const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
|
||||
const quarterStats = quarterNames
|
||||
.filter((q) => quarterMap[q].length > 0)
|
||||
.map((q) => {
|
||||
const eps = quarterMap[q]
|
||||
const count = eps.length
|
||||
const avgShare =
|
||||
eps.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
|
||||
|
||||
// 计算基础完成率和达标率
|
||||
let baseCompletion = '—'
|
||||
let passRate = '—'
|
||||
let passCount = 0
|
||||
|
||||
if (baseValue != null) {
|
||||
baseCompletion = ((avgShare / baseValue) * 100).toFixed(1) + '%'
|
||||
passCount = eps.filter(
|
||||
(ep) => Number(ep.audience_share) >= baseValue
|
||||
).length
|
||||
passRate =
|
||||
count > 0
|
||||
? ((passCount / count) * 100).toFixed(1) + '%'
|
||||
: '—'
|
||||
}
|
||||
|
||||
return {
|
||||
name: q,
|
||||
count,
|
||||
avgShare: Number(avgShare.toFixed(4)),
|
||||
baseCompletion,
|
||||
passRate,
|
||||
passCount,
|
||||
}
|
||||
})
|
||||
|
||||
if (quarterStats.length === 0)
|
||||
return { quarterStats: null, chartOption: null, tableData: [] }
|
||||
|
||||
// 表格数据
|
||||
const tableData = quarterStats.map((q) => ({
|
||||
key: q.name,
|
||||
quarter: q.name,
|
||||
count: q.count,
|
||||
avgShare: q.avgShare,
|
||||
baseCompletion: q.baseCompletion,
|
||||
passRate: q.passRate,
|
||||
passCount: q.passCount,
|
||||
}))
|
||||
|
||||
// 图表配置
|
||||
const xData = quarterStats.map((q) => q.name)
|
||||
const barData = quarterStats.map((q) => ({
|
||||
value: q.avgShare,
|
||||
itemStyle: {
|
||||
color: getShareColor(q.avgShare, targetsForChart, selectedYear),
|
||||
},
|
||||
}))
|
||||
|
||||
const dataMin = Math.min(...quarterStats.map((q) => q.avgShare))
|
||||
const dataMax = Math.max(...quarterStats.map((q) => q.avgShare))
|
||||
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
|
||||
|
||||
const markLineData = []
|
||||
if (baseValue != null) {
|
||||
markLineData.push({
|
||||
yAxis: baseValue,
|
||||
lineStyle: { color: '#93b8d7', type: 'dashed', width: 1 },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideEndTop',
|
||||
formatter: '基础',
|
||||
color: '#93b8d7',
|
||||
fontSize: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (stretchValue != null) {
|
||||
markLineData.push({
|
||||
yAxis: stretchValue,
|
||||
lineStyle: { color: '#d9a09a', type: 'dashed', width: 1 },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideEndTop',
|
||||
formatter: '摸高',
|
||||
color: '#d9a09a',
|
||||
fontSize: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const chartOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const name = params[0].name
|
||||
const stat = quarterStats.find((q) => q.name === name)
|
||||
if (!stat) return ''
|
||||
return `<b>${name}</b><br/>期数:${stat.count} 期<br/>平均份额:<b>${stat.avgShare}</b><br/>基础完成率:${stat.baseCompletion}<br/>达标率:${stat.passRate}`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 50,
|
||||
right: 30,
|
||||
top: 20,
|
||||
bottom: 30,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLabel: { fontSize: 12 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: barData,
|
||||
barWidth: '40%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
formatter: (p) => p.value.toFixed(4),
|
||||
},
|
||||
...(markLineData.length > 0
|
||||
? { markLine: { symbol: ['none', 'none'], data: markLineData, silent: true } }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return { quarterStats, chartOption, tableData }
|
||||
}, [episodes, yearlyTarget, selectedYear])
|
||||
|
||||
if (!chartOption || !quarterStats) {
|
||||
return <Empty description="暂无季度数据" />
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '季度',
|
||||
dataIndex: 'quarter',
|
||||
key: 'quarter',
|
||||
render: (text) => <span style={{ fontWeight: 600 }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '期数',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '平均份额',
|
||||
dataIndex: 'avgShare',
|
||||
key: 'avgShare',
|
||||
render: (value) => {
|
||||
const color = getShareColor(
|
||||
value,
|
||||
yearlyTarget ? [{ year: selectedYear, ...yearlyTarget }] : [],
|
||||
selectedYear
|
||||
)
|
||||
return (
|
||||
<span style={{ color, fontWeight: 600 }}>{value.toFixed(4)}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '基础完成率',
|
||||
dataIndex: 'baseCompletion',
|
||||
key: 'baseCompletion',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '达标率',
|
||||
dataIndex: 'passRate',
|
||||
key: 'passRate',
|
||||
align: 'center',
|
||||
render: (value, record) => (
|
||||
<span>
|
||||
{value}({record.passCount}/{record.count})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="quarter-compare">
|
||||
{/* 汇总表格 */}
|
||||
<div className="quarter-table">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 柱状图 */}
|
||||
<div className="quarter-chart">
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: 280, width: '100%' }}
|
||||
notMerge
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuarterCompare
|
||||
@@ -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
|
||||
@@ -5,13 +5,19 @@
|
||||
.app-sider {
|
||||
background: #fff !important;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
width: 220px;
|
||||
flex: 0 0 220px;
|
||||
width: 220px !important;
|
||||
flex: 0 0 220px !important;
|
||||
overflow-y: auto;
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: 100vh;
|
||||
margin-left: 220px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
/* ===== 收视分析页面 ===== */
|
||||
/* ===== 收视分析页面 — 对齐 l3_report.html 原型 ===== */
|
||||
|
||||
.analytics-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
padding: 32px 24px;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #fdf6ee 0%, #f0f4f8 100%);
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.analytics-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.analytics-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: linear-gradient(135deg, #f5f0e8 0%, #e8e4d8 30%, #f0ece2 60%, #ebe5d5 100%);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 加载与空状态 */
|
||||
/* ── 页面头部 ── */
|
||||
.analytics-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.analytics-header-center {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.analytics-page-title {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #4a6741;
|
||||
}
|
||||
|
||||
.analytics-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.analytics-year-select {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* ── 加载与空状态 ── */
|
||||
.analytics-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -45,72 +65,122 @@
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
/* ===== 指标卡行 ===== */
|
||||
.analytics-kpi-row {
|
||||
margin-bottom: 24px;
|
||||
/* ── 图例栏(指标卡上方,居中) ── */
|
||||
.analytics-legend-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* ── 指标卡 — 5 列网格 ── */
|
||||
.analytics-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.analytics-kpi-card {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.analytics-kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.analytics-kpi-label {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
/* 摸高目标完成率 — 四档动态效果 */
|
||||
|
||||
/* ≥95% 庆祝效果 */
|
||||
@keyframes celebrate-glow {
|
||||
0%, 100% { box-shadow: 0 4px 16px rgba(232,168,56,0.2); }
|
||||
50% { box-shadow: 0 4px 28px rgba(232,168,56,0.45); }
|
||||
}
|
||||
.analytics-kpi-card-excellent {
|
||||
border-color: rgba(232,168,56,0.4);
|
||||
animation: celebrate-glow 2s infinite;
|
||||
}
|
||||
|
||||
/* 85-94% 蓝色(达标区间,静态,无动画) */
|
||||
.analytics-kpi-card-good {
|
||||
border-color: rgba(91,141,184,0.3);
|
||||
}
|
||||
|
||||
/* 80-84% 绿色脉冲(临界) */
|
||||
@keyframes pulse-warn {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
.analytics-kpi-card-warn {
|
||||
animation: pulse-warn 2s infinite;
|
||||
}
|
||||
|
||||
/* <80% 红色警报光晕(危险) */
|
||||
@keyframes pulse-danger {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(180,30,30,0.3); }
|
||||
50% { box-shadow: 0 0 20px rgba(180,30,30,0.6); }
|
||||
}
|
||||
.analytics-kpi-card-danger {
|
||||
border: 2px solid rgba(180,30,30,0.4);
|
||||
animation: pulse-danger 1.5s infinite;
|
||||
}
|
||||
|
||||
.analytics-kpi-value {
|
||||
font-size: 26px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* ===== 走势图卡片 ===== */
|
||||
.analytics-kpi-value-total {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.analytics-kpi-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.analytics-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── 走势图卡片 ── */
|
||||
.analytics-chart-card {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.analytics-chart-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.analytics-chart-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
margin: 0 0 14px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: #4a6741;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.analytics-chart-hint {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* 图例 */
|
||||
/* ── 图例(保留在走势图内备用,但主要用 legend-bar) ── */
|
||||
.analytics-chart-legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
@@ -120,10 +190,23 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.analytics-chart-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.analytics-chart-hint {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* ── 图例圆点 ── */
|
||||
.analytics-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analytics-legend-dot {
|
||||
@@ -133,6 +216,51 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ── 双列布局:左列(季度+编导) 右列(题材,Task 3) ── */
|
||||
.analytics-compare-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.analytics-stage-section {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.analytics-editor-section {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.analytics-compare-section .analytics-topic-section {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
/* 季度对比内部:表格 + 图表 */
|
||||
.quarter-compare {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.quarter-table .ant-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quarter-table .ant-table-thead > tr > th {
|
||||
background: #f5f3eb;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.quarter-table .ant-table-tbody > tr > td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.analytics-page {
|
||||
@@ -141,10 +269,18 @@
|
||||
|
||||
.analytics-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.analytics-year-select {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.analytics-kpi-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.analytics-kpi-card {
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -156,4 +292,14 @@
|
||||
.analytics-chart-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-compare-section {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.analytics-compare-section .analytics-topic-section {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Select, Spin, Empty, Row, Col } from 'antd'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Select, Spin, Empty } from 'antd'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { getShareColor } from '../../utils/ratingColors'
|
||||
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 './Analytics.css'
|
||||
|
||||
/**
|
||||
* 收视分析页面
|
||||
* - 年份选择器
|
||||
* - 指标卡行(4 张)
|
||||
* - 收视走势折线图(ECharts)
|
||||
* - 空状态兜底
|
||||
* 收视分析页面 — 对齐 l3_report.html 原型
|
||||
* - 居中大标题 + 副标题
|
||||
* - 三色图例(指标卡上方)
|
||||
* - 5 张指标卡(平均份额 / 摸高完成率 / 最高份额 / 最低份额 / 达标期数)
|
||||
* - 收视走势:柱状图 + 折线图组合(柱子三色着色,柱顶节目名,橙色趋势线)
|
||||
* - 年份选择器(HTML 原型无,保留功能)
|
||||
* - dataZoom 滑块保留
|
||||
* - 空状态兜底保留
|
||||
*/
|
||||
function Analytics() {
|
||||
const [years, setYears] = useState([])
|
||||
@@ -18,6 +25,10 @@ function Analytics() {
|
||||
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(() => {
|
||||
@@ -25,7 +36,7 @@ function Analytics() {
|
||||
.then((data) => {
|
||||
setYears(data || [])
|
||||
if (data && data.length > 0) {
|
||||
setSelectedYear(data[0]) // 默认选最近年份
|
||||
setSelectedYear(data[0])
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -43,6 +54,7 @@ function Analytics() {
|
||||
.then((data) => {
|
||||
setEpisodes(data.episodes || [])
|
||||
setYearlyTarget(data.yearly_target)
|
||||
setLoadTime(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -52,9 +64,29 @@ function Analytics() {
|
||||
})
|
||||
}, [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 = episodes.filter((ep) => ep.audience_share != null)
|
||||
const withShare = filteredEpisodes
|
||||
const count = withShare.length
|
||||
if (count === 0) {
|
||||
return {
|
||||
@@ -63,84 +95,160 @@ function Analytics() {
|
||||
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
|
||||
|
||||
// 平均份额的颜色判定(用选定年份的 target)
|
||||
const base = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
||||
const stretch = yearlyTarget ? Number(yearlyTarget.stretch_target) : null
|
||||
|
||||
// 平均份额颜色
|
||||
let avgShareColor = '#999'
|
||||
if (yearlyTarget) {
|
||||
const base = Number(yearlyTarget.base_target)
|
||||
const stretch = Number(yearlyTarget.stretch_target)
|
||||
if (avgShare > stretch) avgShareColor = '#c0584f' // 红=优秀
|
||||
else if (avgShare >= base) avgShareColor = '#5b8db8' // 蓝=达标
|
||||
else avgShareColor = '#7aa874' // 绿=待提升
|
||||
if (base != null && stretch != null) {
|
||||
if (avgShare > stretch) avgShareColor = '#c0584f'
|
||||
else if (avgShare >= base) avgShareColor = '#5b8db8'
|
||||
else avgShareColor = '#7aa874'
|
||||
}
|
||||
|
||||
// 完成率
|
||||
let baseRate = '—'
|
||||
let stretchRate = '—'
|
||||
if (yearlyTarget) {
|
||||
const base = Number(yearlyTarget.base_target)
|
||||
const stretch = Number(yearlyTarget.stretch_target)
|
||||
let stretchRateColor = '#7aa874'
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
|
||||
if (base != null) {
|
||||
baseRate = ((avgShare / base) * 100).toFixed(1) + '%'
|
||||
stretchRate = ((avgShare / stretch) * 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 * 100).toFixed(2) + '%',
|
||||
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',
|
||||
}
|
||||
}, [episodes, yearlyTarget])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filteredEpisodes, yearlyTarget])
|
||||
|
||||
// ===== ECharts 配置 =====
|
||||
// ===== 副标题 =====
|
||||
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) => `第${ep.episode_number}期`)
|
||||
const xData = withShare.map((ep) => String(ep.episode_number))
|
||||
|
||||
// 构建用于 getShareColor 的 targets 数组格式
|
||||
const targetsForChart = yearlyTarget
|
||||
? [{ year: selectedYear, ...yearlyTarget }]
|
||||
: []
|
||||
|
||||
const sharePoints = withShare.map((ep) => ({
|
||||
// 柱状图数据 — 每根柱子单独着色
|
||||
const barData = withShare.map((ep) => ({
|
||||
value: Number(ep.audience_share),
|
||||
itemStyle: {
|
||||
color: getShareColor(ep.audience_share, targetsForChart, selectedYear),
|
||||
},
|
||||
}))
|
||||
|
||||
const markLineData = []
|
||||
if (yearlyTarget) {
|
||||
markLineData.push(
|
||||
{
|
||||
name: '基础目标',
|
||||
yAxis: Number(yearlyTarget.base_target),
|
||||
lineStyle: { color: '#5b8db8', type: 'dashed', width: 2 },
|
||||
label: {
|
||||
formatter: '基础目标 {c}',
|
||||
position: 'insideEndTop',
|
||||
color: '#5b8db8',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '摸高目标',
|
||||
yAxis: Number(yearlyTarget.stretch_target),
|
||||
lineStyle: { color: '#c0584f', type: 'dashed', width: 2 },
|
||||
label: {
|
||||
formatter: '摸高目标 {c}',
|
||||
position: 'insideEndTop',
|
||||
color: '#c0584f',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
// 滚动 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: {
|
||||
@@ -149,38 +257,38 @@ function Analytics() {
|
||||
const idx = params[0].dataIndex
|
||||
const ep = withShare[idx]
|
||||
if (!ep) return ''
|
||||
const share = ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'
|
||||
const rating = ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'
|
||||
const share = Number(ep.audience_share).toFixed(4)
|
||||
const editor = ep.editor_name_snapshot || '未知'
|
||||
return `<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>` +
|
||||
`编导:${editor}<br/>` +
|
||||
`收视份额:${share}<br/>` +
|
||||
`收视率:${rating}`
|
||||
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: 60,
|
||||
left: 50,
|
||||
right: 30,
|
||||
top: 40,
|
||||
bottom: 70,
|
||||
top: 80,
|
||||
bottom: 60,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
fontSize: 11,
|
||||
},
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '收视份额',
|
||||
axisLabel: {
|
||||
formatter: (val) => val.toFixed(2),
|
||||
},
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', start: 0, end: 100 },
|
||||
{
|
||||
type: 'slider',
|
||||
bottom: 10,
|
||||
@@ -193,30 +301,92 @@ function Analytics() {
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// 柱状图 — 收视份额
|
||||
{
|
||||
name: '收视份额',
|
||||
type: 'line',
|
||||
data: sharePoints,
|
||||
smooth: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 2, color: '#6b8e6b' },
|
||||
markLine: {
|
||||
silent: true,
|
||||
data: markLineData,
|
||||
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])
|
||||
|
||||
const hasData = episodes.length > 0 && episodes.some((ep) => ep.audience_share != null)
|
||||
// ===== 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">
|
||||
<h2 className="analytics-title">收视分析</h2>
|
||||
<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}
|
||||
@@ -239,74 +409,207 @@ function Analytics() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 指标卡行 */}
|
||||
<Row gutter={16} className="analytics-kpi-row">
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="analytics-kpi-card">
|
||||
<div className="analytics-kpi-label">本年已播期数</div>
|
||||
<div className="analytics-kpi-value">{kpiMetrics.count}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="analytics-kpi-card">
|
||||
<div className="analytics-kpi-label">平均收视份额</div>
|
||||
<div
|
||||
className="analytics-kpi-value"
|
||||
style={{ color: kpiMetrics.avgShareColor }}
|
||||
>
|
||||
{kpiMetrics.avgShare || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="analytics-kpi-card">
|
||||
<div className="analytics-kpi-label">基础目标完成率</div>
|
||||
<div className="analytics-kpi-value">{kpiMetrics.baseRate}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div className="analytics-kpi-card">
|
||||
<div className="analytics-kpi-label">摸高目标完成率</div>
|
||||
<div className="analytics-kpi-value">{kpiMetrics.stretchRate}</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* ── 图例栏(指标卡上方,居中) ── */}
|
||||
<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>
|
||||
|
||||
{/* 收视走势折线图 */}
|
||||
<div className="analytics-chart-card">
|
||||
<div className="analytics-chart-header">
|
||||
<h3 className="analytics-chart-title">{selectedYear} 年收视走势</h3>
|
||||
<span className="analytics-chart-hint">
|
||||
拖拽底部滑块缩放 · 颜色只对收视份额
|
||||
</span>
|
||||
{/* ── 指标卡 — 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: 400, width: '100%' }}
|
||||
style={{ height: 380, width: '100%' }}
|
||||
notMerge
|
||||
onEvents={chartEvents}
|
||||
/>
|
||||
<div className="analytics-chart-legend">
|
||||
<span className="analytics-legend-item">
|
||||
<span
|
||||
className="analytics-legend-dot"
|
||||
style={{ background: '#7aa874' }}
|
||||
{(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 诊断报告占位 ── */}
|
||||
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}>
|
||||
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2>
|
||||
<p style={{ color: '#aaa', fontSize: 14 }}>即将上线 - 基于双引擎模型的智能收视诊断</p>
|
||||
</div>
|
||||
|
||||
{/* ── 双引擎象限图 ── */}
|
||||
<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}
|
||||
/>
|
||||
绿=未达基础目标(待提升)
|
||||
</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: '#c0584f' }}
|
||||
/>
|
||||
红=超过摸高目标(优秀)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user