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}
}
+