From f37679530a2b65fcc4e86212f38b82c133a5e65a Mon Sep 17 00:00:00 2001 From: simonkoson <28867558@qq.com> Date: Fri, 3 Jul 2026 17:48:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E8=A7=86=E5=88=86=E6=9E=90?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E5=89=8D=E7=AB=AF=20L1-L4=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20+=2025=E6=9C=9F=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收视分析页面完整实现:指标卡(含四档动画)、走势折线图(dataZoom滑块+确认按钮)、 季度/编导/题材对比(双列布局)、双引擎象限图(题材热度×叙事结构散点)。 导入25期真实收视数据及AI标签,修复侧边栏fixed定位和滚轮冲突。 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 23 +- backend/app/api/analytics.py | 4 + .../components/Analytics/EditorCompare.jsx | 156 +++++ .../components/Analytics/QuadrantChart.jsx | 444 +++++++++++++ .../components/Analytics/QuarterCompare.jsx | 265 ++++++++ .../src/components/Analytics/TopicCompare.jsx | 183 ++++++ frontend/src/components/Layout/AppLayout.css | 10 +- frontend/src/pages/Analytics/Analytics.css | 258 ++++++-- frontend/src/pages/Analytics/Analytics.jsx | 589 +++++++++++++----- scripts/fix_label_mapping.py | 106 ++++ scripts/import_real_episodes.py | 94 +++ 11 files changed, 1922 insertions(+), 210 deletions(-) create mode 100644 frontend/src/components/Analytics/EditorCompare.jsx create mode 100644 frontend/src/components/Analytics/QuadrantChart.jsx create mode 100644 frontend/src/components/Analytics/QuarterCompare.jsx create mode 100644 frontend/src/components/Analytics/TopicCompare.jsx create mode 100644 scripts/fix_label_mapping.py create mode 100644 scripts/import_real_episodes.py diff --git a/CLAUDE.md b/CLAUDE.md index 08df021..9029e77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,8 @@ ## 🔖 状态栏(每次结束 session 前必须更新这三行) - - **最后更新**:Claude(顾问)| 2026-07-03 - - **当前状态一句话**:远程仓库已迁至腾讯云 Gitea,双机开发就绪。Phase 3 知识库已完成。**看板升级子项目原型阶段收工**(2026-07-03),交付清单已写入寄存条,主项目可启动收视分析看板 React 前端实现。 + - **最后更新**:Claude(顾问+动手)| 2026-07-03 + - **当前状态一句话**:收视分析看板 React 前端 L1–L4 已实现并验收(指标卡+走势图+季度/编导/题材对比+双引擎象限图)。25 期真实收视+AI 标签已导入。下一步:L4 AI 诊断报告(新 session 讨论)。 - **下一个动手的人从这里开始**:见下方「⏩ 交接备注」 --- @@ -74,15 +74,16 @@ ## 3. 当前进度(动态,核心交接区 — 以最新快照为准) - - **已完成至**:Phase 3 / Task 3(知识库树形视图·按来源两层分组,路线 B),含 Task 3.1、3.2,验收通过,最终 commit `3409d48`。 - - **正在做**:无。看板升级子项目原型阶段已收工(2026-07-03),交付物就绪,可启动收视分析看板 React 前端实现。 - - **卡点/待解**:下一刀方向未定(见「待办」)——现在多了一个选项:收视分析看板前端实现(子项目已交付设计规范+原型)。 - - **Schema 状态**:知识库两表 schema 已就绪,**Phase 3 不再改表**。episodes 表已通过 003 迁移加 7 列 AI 标签字段(看板升级子项目 2026-06-26 执行)。 + - **已完成至**:收视分析看板 React 前端 L1–L4(指标卡、走势图、季度/编导/题材对比、双引擎象限图),25 期真实数据已导入。 + - **正在做**:无。下一步为 L4 AI 诊断报告,制片人将开新 session 讨论。 + - **卡点/待解**:AI 诊断报告方案待细化(双步骤生成+制片人人工验证,方案草稿见 `memory/project_ai_diagnosis_plan.md`)。 + - **Schema 状态**:知识库两表 schema 已就绪,**Phase 3 不再改表**。episodes 表已通过 003 迁移加 7 列 AI 标签字段(看板升级子项目 2026-06-26 执行),25 期数据含 AI 标签已就位。 --- ## 4. 已完成(只追加,最新在上) + - [2026-07-03] **收视分析看板 React 前端实现(L1–L4)**:① 指标卡 5 列(均值/基础达标率/摸高完成率含四档动画/期次数/年度目标);② 走势折线图(dataZoom 滑块+确认按钮范围过滤,下游模块联动);③ 双列对比区(左列季度+编导、右列题材饼图+条形图);④ 双引擎象限图(题材热度×叙事结构散点,气泡编码份额/三色/钩子强度)。附带:25 期真实收视+AI 标签数据导入、侧边栏 fixed 定位、滑块滚轮冲突修复。后端 analytics API 增返 4 个 AI 标签字段。 - [2026-07-03] 看板升级子项目原型阶段收工:L2 打标流水线就绪(GT v0.6.0 / 25 期 / 三个 Prompt 可用)+ L3 HTML 原型验证通过(双引擎象限图+5 模块+视觉定稿)+ 003 迁移已执行(episodes +7 AI 标签列)。交付清单见寄存条。 - [2026-05-27] Phase 3 Task 3/3.1/3.2:知识库树形视图(按来源大类→二级维度分组、左右布局、节点联动、展开收起;修出处筛选/宽度跳动/排版)。commit `3409d48`。 - [2026-05-27] Phase 3 Task 2:知识库管理后台最小版(上传/列表/删除)。 @@ -95,7 +96,8 @@ ## 5. 待办(下一刀候选,开局前定先做哪件) - - [ ] **下一刀四选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 导入真实收视 Excel + 清测试数据(Phase 4a 另一前置);④ **收视分析看板 React 前端实现**(看板升级子项目已交付设计规范+HTML 原型,详见 `note/寄存条看板升级已外迁.md` 交付清单)。 + - [ ] **L4 AI 诊断报告**:收视分析看板最后一个模块,双步骤生成(先 AI 出草稿→制片人审核发布),方案草稿见 `memory/project_ai_diagnosis_plan.md`,制片人将开新 session 讨论。 + - [ ] **下一刀三选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 界面像素级打磨+视觉规范统一。 - [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。 - [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。 - [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。 @@ -121,12 +123,15 @@ - 向量 1536 维、Session 认证、不用 Alembic/TS/async —— 见技术栈节,宪法锁定。 - embedding 用 **MiniMax embo-01**:请求用 `texts` 数组 + `type`(db/query),返回在 `vectors` 字段,需 API Key + GroupId;花 MiniMax 账号额度(与 Claude token 两笔账,一篇几厘钱)。 - **PDF 等大文件不入数据库**,单独文件仓库 + DB 存指针(详见待办,待立项)。 + - **收视分析看板 dataZoom 模式**:只用 slider(禁 `type:'inside'` 避免滚轮冲突),拖滑块更新 `zoomRange` 但不立即过滤,用户点「应用此范围」按钮才写入 `appliedRange` → `filteredEpisodes`;走势图始终用全量 `episodes`,其余模块(指标卡/对比/象限图)用 `filteredEpisodes`。 + - **收视分析看板页面顺序**:指标卡 → 走势图 → AI 诊断报告(占位) → 双引擎象限图 → 双列对比(左列季度+编导堆叠、右列题材跨两行)。 --- ## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上) - - **下一刀未定**,开工第一件事:与制片人确认四选一(语义搜索 / PDF 存储 / 真实收视导入 / **收视分析看板 React 前端**)。看板前端的设计规范+原型已由子项目交付(见 `note/寄存条看板升级已外迁.md` 交付清单 + `ai-labeling/CLAUDE.md` §1/§6/§7)。PDF 存储须先和 Opus 讨论方案才立项。 + - **收视分析看板前端 L1–L4 已验收**,下一步是 L4 AI 诊断报告(新 session)。页面布局顺序已定稿:指标卡 → 走势图 → AI 诊断报告(占位) → 双引擎象限图 → 双列对比(左:季度+编导, 右:题材)。 + - **看板前端新增文件**:`frontend/src/components/Analytics/`(QuarterCompare / EditorCompare / TopicCompare / QuadrantChart);`scripts/`(import_real_episodes.py / fix_label_mapping.py,一次性导入脚本,已用完)。 - **schema 红线**:Phase 3 不改知识库两表;`topics` / `topic_embeddings` 属 Phase 4a,**勿碰**;五类 `source_type` 枚举固定不增减,细分杂志/军报=改表=须 Opus 审。 - **`logs/` 是受保护资产**:Act 模式严禁 Cline 改;phase 日志由顾问协助生成。Task 3 三轮内容待并入 `phase3_log`(Task 3 段)。 - **避雷(Phase 3 实战教训)**: @@ -146,6 +151,6 @@ ## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设) - - [ ] 下一刀选谁(三选一)。 + - [ ] AI 诊断报告(L4)方案细化(新 session 讨论)。 - [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。 - [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。 diff --git a/backend/app/api/analytics.py b/backend/app/api/analytics.py index a0da0b1..015c1f9 100644 --- a/backend/app/api/analytics.py +++ b/backend/app/api/analytics.py @@ -92,6 +92,10 @@ def get_analytics_episodes( "editor_name_snapshot": ep.editor_name_snapshot, "audience_share": ep.audience_share, "audience_rating": ep.audience_rating, + "program_format": ep.program_format, + "narrative_structure": ep.narrative_structure, + "opening_hook": ep.opening_hook, + "equipment_domain": ep.equipment_domain, } for ep in episodes ] diff --git a/frontend/src/components/Analytics/EditorCompare.jsx b/frontend/src/components/Analytics/EditorCompare.jsx new file mode 100644 index 0000000..e51ac9d --- /dev/null +++ b/frontend/src/components/Analytics/EditorCompare.jsx @@ -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 = `${name}(${stat.count}期)
` + s += `平均份额:${stat.avgShare}
` + s += `最佳:${stat.bestShare}(${stat.bestTitle.substring(0, 10)})
` + 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 + } + + return ( + + ) +} + +export default EditorCompare \ No newline at end of file diff --git a/frontend/src/components/Analytics/QuadrantChart.jsx b/frontend/src/components/Analytics/QuadrantChart.jsx new file mode 100644 index 0000000..17e3860 --- /dev/null +++ b/frontend/src/components/Analytics/QuadrantChart.jsx @@ -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 ( + '第' + d._ep + '期 ' + d._title + '
' + + '编导:' + d._editor + ' | 题材:' + d._format + '
' + + '份额:' + d._share.toFixed(3) + '(' + colorLabel + ')
' + + '开篇钩子:' + (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 + } + + return ( +
+ {/* ── 图例栏 ── */} +
+ 气泡大小 = 收视份额 + + {' '} + 强钩子 + + + {' '} + 中钩子 + + + {' '} + 弱钩子 + + + {' '} + 优秀 + + + {' '} + 达标 + + + {' '} + 待提升 + +
+ + {/* ── ECharts 象限图 ── */} + + + {/* ── 读图说明 ── */} +
+ 读图说明|{instructionText} +
+
+ ) +} + +export default QuadrantChart \ No newline at end of file diff --git a/frontend/src/components/Analytics/QuarterCompare.jsx b/frontend/src/components/Analytics/QuarterCompare.jsx new file mode 100644 index 0000000..a965a96 --- /dev/null +++ b/frontend/src/components/Analytics/QuarterCompare.jsx @@ -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 `${name}
期数:${stat.count} 期
平均份额:${stat.avgShare}
基础完成率:${stat.baseCompletion}
达标率:${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 + } + + // 表格列定义 + const columns = [ + { + title: '季度', + dataIndex: 'quarter', + key: 'quarter', + render: (text) => {text}, + }, + { + title: '期数', + dataIndex: 'count', + key: 'count', + align: 'center', + }, + { + title: '平均份额', + dataIndex: 'avgShare', + key: 'avgShare', + render: (value) => { + const color = getShareColor( + value, + yearlyTarget ? [{ year: selectedYear, ...yearlyTarget }] : [], + selectedYear + ) + return ( + {value.toFixed(4)} + ) + }, + }, + { + title: '基础完成率', + dataIndex: 'baseCompletion', + key: 'baseCompletion', + align: 'center', + }, + { + title: '达标率', + dataIndex: 'passRate', + key: 'passRate', + align: 'center', + render: (value, record) => ( + + {value}({record.passCount}/{record.count}) + + ), + }, + ] + + return ( +
+ {/* 汇总表格 */} +
+ + + + {/* 柱状图 */} +
+ +
+ + ) +} + +export default QuarterCompare \ No newline at end of file diff --git a/frontend/src/components/Analytics/TopicCompare.jsx b/frontend/src/components/Analytics/TopicCompare.jsx new file mode 100644 index 0000000..4c2ce2f --- /dev/null +++ b/frontend/src/components/Analytics/TopicCompare.jsx @@ -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 + '
期数:' + 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 + '
平均份额:' + Number(params[0].value).toFixed(3) + '
期数:' + 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 + } + + return ( +
+ + +
+ ) +} + +export default TopicCompare \ No newline at end of file diff --git a/frontend/src/components/Layout/AppLayout.css b/frontend/src/components/Layout/AppLayout.css index b063b3b..562b505 100644 --- a/frontend/src/components/Layout/AppLayout.css +++ b/frontend/src/components/Layout/AppLayout.css @@ -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 { diff --git a/frontend/src/pages/Analytics/Analytics.css b/frontend/src/pages/Analytics/Analytics.css index 820b3c6..9745759 100644 --- a/frontend/src/pages/Analytics/Analytics.css +++ b/frontend/src/pages/Analytics/Analytics.css @@ -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; } -} \ No newline at end of file + + .analytics-compare-section { + grid-template-columns: 1fr; + grid-template-rows: auto; + } + + .analytics-compare-section .analytics-topic-section { + grid-column: 1; + grid-row: auto; + } +} diff --git a/frontend/src/pages/Analytics/Analytics.jsx b/frontend/src/pages/Analytics/Analytics.jsx index 4f785de..34bc213 100644 --- a/frontend/src/pages/Analytics/Analytics.jsx +++ b/frontend/src/pages/Analytics/Analytics.jsx @@ -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 `第 ${ep.episode_number} 期 · ${ep.program_name}
` + - `编导:${editor}
` + - `收视份额:${share}
` + - `收视率:${rating}` + const date = ep.air_date || '' + const label = getColorLabel(ep.audience_share, targetsForChart, selectedYear) + let s = `第${ep.episode_number}期 ${ep.program_name}
` + s += `${date} | ${editor}
` + s += `份额:${share}(${label})` + const rollingParam = params.find((p) => p.seriesName === '滚动3期均值') + if (rollingParam && rollingParam.value != null) { + s += `
滚动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 (
- {/* 页面头部 + 年份选择器 */} + {/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}
-

收视分析

+
+

+ 军事科技 {selectedYear} 年度收视分析 +

+ {hasData &&

{subtitle}

} +