feat: 收视分析看板前端 L1-L4 实现 + 25期真实数据导入

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-07-03 17:48:38 +08:00
parent 8d880f06cf
commit f37679530a
11 changed files with 1922 additions and 210 deletions
+14 -9
View File
@@ -13,8 +13,8 @@
## 🔖 状态栏(每次结束 session 前必须更新这三行) ## 🔖 状态栏(每次结束 session 前必须更新这三行)
- **最后更新**Claude(顾问)| 2026-07-03 - **最后更新**Claude(顾问+动手)| 2026-07-03
- **当前状态一句话**远程仓库已迁至腾讯云 Gitea,双机开发就绪。Phase 3 知识库已完成。**看板升级子项目原型阶段收工**(2026-07-03),交付清单已写入寄存条,主项目可启动收视分析看板 React 前端实现 - **当前状态一句话**收视分析看板 React 前端 L1–L4 已实现并验收(指标卡+走势图+季度/编导/题材对比+双引擎象限图)。25 期真实收视+AI 标签已导入。下一步:L4 AI 诊断报告(新 session 讨论)
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」 - **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
--- ---
@@ -74,15 +74,16 @@
## 3. 当前进度(动态,核心交接区 — 以最新快照为准) ## 3. 当前进度(动态,核心交接区 — 以最新快照为准)
- **已完成至**Phase 3 / Task 3(知识库树形视图·按来源两层分组,路线 B),含 Task 3.1、3.2,验收通过,最终 commit `3409d48` - **已完成至**收视分析看板 React 前端 L1–L4(指标卡、走势图、季度/编导/题材对比、双引擎象限图),25 期真实数据已导入
- **正在做**:无。看板升级子项目原型阶段已收工(2026-07-03),交付物就绪,可启动收视分析看板 React 前端实现 - **正在做**:无。下一步为 L4 AI 诊断报告,制片人将开新 session 讨论
- **卡点/待解**下一刀方向未定(见「待办」)——现在多了一个选项:收视分析看板前端实现(子项目已交付设计规范+原型)。 - **卡点/待解**AI 诊断报告方案待细化(双步骤生成+制片人人工验证,方案草稿见 `memory/project_ai_diagnosis_plan.md`)。
- **Schema 状态**:知识库两表 schema 已就绪,**Phase 3 不再改表**。episodes 表已通过 003 迁移加 7 列 AI 标签字段(看板升级子项目 2026-06-26 执行)。 - **Schema 状态**:知识库两表 schema 已就绪,**Phase 3 不再改表**。episodes 表已通过 003 迁移加 7 列 AI 标签字段(看板升级子项目 2026-06-26 执行)25 期数据含 AI 标签已就位
--- ---
## 4. 已完成(只追加,最新在上) ## 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-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 3/3.1/3.2:知识库树形视图(按来源大类→二级维度分组、左右布局、节点联动、展开收起;修出处筛选/宽度跳动/排版)。commit `3409d48`
- [2026-05-27] Phase 3 Task 2:知识库管理后台最小版(上传/列表/删除)。 - [2026-05-27] Phase 3 Task 2:知识库管理后台最小版(上传/列表/删除)。
@@ -95,7 +96,8 @@
## 5. 待办(下一刀候选,开局前定先做哪件) ## 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 仅按需调阅)。地基一次定对,避免上云返工。 - [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。
- [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。 - [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。
- [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。 - [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。
@@ -121,12 +123,15 @@
- 向量 1536 维、Session 认证、不用 Alembic/TS/async —— 见技术栈节,宪法锁定。 - 向量 1536 维、Session 认证、不用 Alembic/TS/async —— 见技术栈节,宪法锁定。
- embedding 用 **MiniMax embo-01**:请求用 `texts` 数组 + `type`db/query),返回在 `vectors` 字段,需 API Key + GroupId;花 MiniMax 账号额度(与 Claude token 两笔账,一篇几厘钱)。 - embedding 用 **MiniMax embo-01**:请求用 `texts` 数组 + `type`db/query),返回在 `vectors` 字段,需 API Key + GroupId;花 MiniMax 账号额度(与 Claude token 两笔账,一篇几厘钱)。
- **PDF 等大文件不入数据库**,单独文件仓库 + DB 存指针(详见待办,待立项)。 - **PDF 等大文件不入数据库**,单独文件仓库 + DB 存指针(详见待办,待立项)。
- **收视分析看板 dataZoom 模式**:只用 slider(禁 `type:'inside'` 避免滚轮冲突),拖滑块更新 `zoomRange` 但不立即过滤,用户点「应用此范围」按钮才写入 `appliedRange``filteredEpisodes`;走势图始终用全量 `episodes`,其余模块(指标卡/对比/象限图)用 `filteredEpisodes`
- **收视分析看板页面顺序**:指标卡 → 走势图 → AI 诊断报告(占位) → 双引擎象限图 → 双列对比(左列季度+编导堆叠、右列题材跨两行)。
--- ---
## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上) ## 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 审。 - **schema 红线**Phase 3 不改知识库两表;`topics` / `topic_embeddings` 属 Phase 4a**勿碰**;五类 `source_type` 枚举固定不增减,细分杂志/军报=改表=须 Opus 审。
- **`logs/` 是受保护资产**:Act 模式严禁 Cline 改;phase 日志由顾问协助生成。Task 3 三轮内容待并入 `phase3_log`Task 3 段)。 - **`logs/` 是受保护资产**:Act 模式严禁 Cline 改;phase 日志由顾问协助生成。Task 3 三轮内容待并入 `phase3_log`Task 3 段)。
- **避雷(Phase 3 实战教训)** - **避雷(Phase 3 实战教训)**
@@ -146,6 +151,6 @@
## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设) ## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设)
- [ ] 下一刀选谁(三选一)。 - [ ] AI 诊断报告(L4)方案细化(新 session 讨论)。
- [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。 - [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。
- [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。 - [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。
+4
View File
@@ -92,6 +92,10 @@ def get_analytics_episodes(
"editor_name_snapshot": ep.editor_name_snapshot, "editor_name_snapshot": ep.editor_name_snapshot,
"audience_share": ep.audience_share, "audience_share": ep.audience_share,
"audience_rating": ep.audience_rating, "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 for ep in episodes
] ]
@@ -0,0 +1,156 @@
import { useMemo } from 'react'
import { Empty } from 'antd'
import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors'
/**
* 编导对比柱状图
* - 按 editor_name_snapshot 分组
* - 每人一根水平柱子,高度 = 该编导本年平均收视份额
* - 柱子颜色按三色判定(该编导平均份额 vs 年度目标)
* - tooltip:编导名、负责期数、平均份额、最佳期次名+份额、最差期次名+份额
* - 参考线:基础目标蓝色虚线
*/
function EditorCompare({ episodes, yearlyTarget, selectedYear }) {
const chartOption = useMemo(() => {
if (!episodes || !episodes.length) return null
// 过滤有份额的期次
const withShare = episodes.filter((ep) => ep.audience_share != null)
if (withShare.length === 0) return null
// 按编导分组
const editorMap = {}
withShare.forEach((ep) => {
const name = ep.editor_name_snapshot || '未知'
if (!editorMap[name]) {
editorMap[name] = []
}
editorMap[name].push(ep)
})
// 构造 targetsForChart
const targetsForChart = yearlyTarget
? [{ year: selectedYear, ...yearlyTarget }]
: []
// 计算每位编导的统计
const editorStats = Object.entries(editorMap).map(([name, eps]) => {
const count = eps.length
const avgShare =
eps.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
// 按份额排序找最佳/最差
const sorted = [...eps].sort(
(a, b) => Number(b.audience_share) - Number(a.audience_share)
)
const best = sorted[0]
const worst = sorted[sorted.length - 1]
return {
name,
count,
avgShare: Number(avgShare.toFixed(4)),
bestShare: Number(best.audience_share).toFixed(3),
bestTitle: best.program_name || '',
worstShare: Number(worst.audience_share).toFixed(3),
worstTitle: worst.program_name || '',
}
})
// 按平均份额升序排列(水平柱状图从下到上递增)
editorStats.sort((a, b) => a.avgShare - b.avgShare)
const yData = editorStats.map((e) => e.name)
// 柱子颜色
const barData = editorStats.map((e) => ({
value: e.avgShare,
itemStyle: {
color: getShareColor(e.avgShare, targetsForChart, selectedYear),
},
}))
// Y 轴范围
const dataMin = Math.min(...editorStats.map((e) => e.avgShare))
const dataMax = Math.max(...editorStats.map((e) => e.avgShare))
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
const allMin = baseValue != null ? Math.min(dataMin, baseValue) : dataMin
const allMax = baseValue != null ? Math.max(dataMax, baseValue) : dataMax
const xMin = Math.max(0, Math.floor((allMin - 0.1) * 10) / 10)
const xMax = Math.ceil((allMax + 0.1) * 10) / 10
return {
tooltip: {
trigger: 'axis',
formatter: (params) => {
const name = params[0].name
const stat = editorStats.find((e) => e.name === name)
if (!stat) return ''
let s = `<b>${name}</b>${stat.count}期)<br/>`
s += `平均份额:<b>${stat.avgShare}</b><br/>`
s += `最佳:${stat.bestShare}${stat.bestTitle.substring(0, 10)}<br/>`
s += `最差:${stat.worstShare}${stat.worstTitle.substring(0, 10)}`
return s
},
},
grid: {
left: 70,
right: 40,
top: 10,
bottom: 30,
},
xAxis: {
type: 'value',
min: xMin,
max: xMax,
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'category',
data: yData,
axisLabel: { fontSize: 13 },
},
series: [
{
type: 'bar',
data: barData,
barWidth: '40%',
label: {
show: true,
position: 'right',
fontSize: 12,
fontWeight: 'bold',
formatter: (p) => p.value.toFixed(4),
},
},
// 基础目标参考线
...(baseValue != null
? [
{
type: 'line',
data: yData.map(() => baseValue),
lineStyle: { color: '#5b8db8', type: 'dashed', width: 1 },
symbol: 'none',
tooltip: { show: false },
},
]
: []),
],
}
}, [episodes, yearlyTarget, selectedYear])
if (!chartOption) {
return <Empty description="暂无编导数据" />
}
return (
<ReactECharts
option={chartOption}
style={{ height: 300, width: '100%' }}
notMerge
/>
)
}
export default EditorCompare
@@ -0,0 +1,444 @@
import { useMemo } from 'react'
import { Empty } from 'antd'
import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors'
/**
* 确定性 jitter — 用 episode_number 做种子,保证每次渲染结果一致
* @param {number} seed - episode_number
* @param {number} range - 范围(正数),输出在 [-range, +range]
*/
function deterministicJitter(seed, range) {
return (Math.sin(seed * 12345.6789) * 10000 % 1) * range * 2 - range
}
/**
* 双引擎象限图 — 对齐 ai-labeling/output/l3_report.html 第 513-719 行
* Props: { episodes, yearlyTarget, selectedYear }
*/
function QuadrantChart({ episodes, yearlyTarget, selectedYear }) {
// ===== 数据准备 =====
const chartData = useMemo(() => {
if (!episodes || !episodes.length || !yearlyTarget) return null
// 过滤:program_format 和 narrative_structure 都不为 null,且有 audience_share
const valid = episodes.filter(
(ep) =>
ep.program_format != null &&
ep.program_format !== '' &&
ep.narrative_structure != null &&
ep.narrative_structure !== '' &&
ep.audience_share != null
)
if (valid.length === 0) return null
// 栏目整体均值(所有有份额期次)
const allWithShare = episodes.filter((ep) => ep.audience_share != null)
const overallMean =
allWithShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) /
allWithShare.length
// 按 program_format 分组,计算各题材均值
const groupMap = {}
valid.forEach((ep) => {
const fmt = ep.program_format
if (!groupMap[fmt]) groupMap[fmt] = []
groupMap[fmt].push(Number(ep.audience_share))
})
const formatDeviation = {}
Object.entries(groupMap).forEach(([fmt, shares]) => {
const avg = shares.reduce((s, v) => s + v, 0) / shares.length
formatDeviation[fmt] = avg - overallMean
})
// 构造 scatter 数据
const targetsArray = [{ year: selectedYear, ...yearlyTarget }]
const scatterData = valid.map((ep) => {
const share = Number(ep.audience_share)
const deviation = formatDeviation[ep.program_format] || 0
const jitterX = deterministicJitter(ep.episode_number, 0.04)
// 纵轴:主线演进 → 1,并列结构 → 0
const yBase = ep.narrative_structure === '主线演进' ? 1 : 0
const jitterY = deterministicJitter(ep.episode_number * 7, 0.15)
const x = deviation + jitterX
const y = yBase + jitterY
// 气泡大小
const symbolSize = 14 + (share - 0.3) * 35
// 颜色(三色判定,复用 ratingColors.js
const color = getShareColor(share, targetsArray, selectedYear)
// 边框(根据 opening_hook
let borderConfig = {}
if (ep.opening_hook === '强') {
borderConfig = { borderWidth: 3, borderColor: '#333', borderType: 'solid' }
} else if (ep.opening_hook === '弱') {
borderConfig = { borderWidth: 2, borderColor: '#aaa', borderType: [4, 4] }
} else {
// '中' 或默认
borderConfig = { borderWidth: 1.5, borderColor: '#999', borderType: 'solid' }
}
return {
value: [x, y],
symbolSize,
itemStyle: {
color,
opacity: 0.85,
...borderConfig,
},
_ep: ep.episode_number,
_title: ep.program_name,
_share: share,
_hook: ep.opening_hook || '中',
_format: ep.program_format,
_editor: ep.editor_name_snapshot || '',
}
})
// 动态计算 x 轴范围
const xValues = scatterData.map((d) => d.value[0])
const xDataMin = Math.min(...xValues)
const xDataMax = Math.max(...xValues)
const xPadding = 0.05
const xMin = Math.min(-0.25, Math.floor((xDataMin - xPadding) * 100) / 100)
const xMax = Math.max(0.22, Math.ceil((xDataMax + xPadding) * 100) / 100)
// 按气泡大小降序排列,大的先画、小的后画(渲染在上层)
scatterData.sort((a, b) => b.symbolSize - a.symbolSize)
return { scatterData, overallMean, formatDeviation, xMin, xMax }
}, [episodes, yearlyTarget, selectedYear])
// ===== ECharts 配置 =====
const option = useMemo(() => {
if (!chartData) return null
const { scatterData, xMin, xMax } = chartData
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
return {
tooltip: {
trigger: 'item',
formatter: function (p) {
var d = p.data
var hookMap = { '强': '强 ●●●', '中': '中 ●●○', '弱': '弱 ●○○' }
var colorLabel = d._share > stretch ? '优秀' : d._share >= base ? '达标' : '待提升'
return (
'<b>第' + d._ep + '期 ' + d._title + '</b><br/>' +
'编导:' + d._editor + ' 题材:' + d._format + '<br/>' +
'份额:<b>' + d._share.toFixed(3) + '</b>' + colorLabel + '<br/>' +
'开篇钩子:' + (hookMap[d._hook] || d._hook)
)
},
backgroundColor: 'rgba(255,255,250,0.95)',
borderColor: '#ddd',
textStyle: { color: '#333', fontSize: 13 },
},
grid: { left: 80, right: 80, top: 50, bottom: 50 },
xAxis: [
{
type: 'value',
name: '热门题材 →',
nameLocation: 'end',
nameGap: 5,
nameTextStyle: { color: '#999', fontSize: 12 },
min: xMin,
max: xMax,
splitLine: { show: false },
axisLine: { lineStyle: { color: '#999', width: 1.5 } },
axisTick: { show: true, lineStyle: { color: '#bbb' } },
axisLabel: {
formatter: function (v) {
if (v === 0) return ''
return (v > 0 ? '+' : '') + v.toFixed(2)
},
color: '#999',
fontSize: 10,
},
},
{
type: 'value',
name: '← 冷门题材',
nameLocation: 'start',
nameGap: 5,
nameTextStyle: { color: '#999', fontSize: 12 },
min: xMin,
max: xMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
type: 'value',
name: '故事化 ↑',
nameLocation: 'end',
nameGap: 12,
nameTextStyle: { color: '#999', fontSize: 12 },
min: -0.3,
max: 1.3,
interval: 1,
splitLine: { show: false },
axisLine: { lineStyle: { color: '#999', width: 1.5 } },
axisTick: { show: false },
axisLabel: {
formatter: function (v) {
if (v === 0) return '并列结构'
if (v === 1) return '主线演进'
return ''
},
color: '#4a6741',
fontSize: 13,
fontWeight: 'bold',
},
},
{
type: 'value',
name: '板块化 ↓',
nameLocation: 'start',
nameGap: 12,
nameTextStyle: { color: '#999', fontSize: 12 },
min: -0.3,
max: 1.3,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
],
series: [
// 主散点
{
type: 'scatter',
data: scatterData,
emphasis: {
itemStyle: { opacity: 1, shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)' },
scale: 1.2,
},
label: {
show: true,
position: 'right',
formatter: function (p) {
var title = p.data._title || ''
return title.length > 5 ? title.substring(0, 5) + '..' : title
},
fontSize: 9,
color: '#999',
distance: 5,
},
labelLayout: { hideOverlap: true },
},
// 零线(x=0 竖虚线)
{
type: 'line',
data: [[0, -0.3], [0, 1.3]],
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
symbol: 'none',
silent: true,
z: 0,
},
// 并列结构水平带
{
type: 'line',
data: [[xMin, 0], [xMax, 0]],
lineStyle: { color: '#e8e4d8', width: 1 },
symbol: 'none',
silent: true,
z: 0,
},
// 主线演进水平带
{
type: 'line',
data: [[xMin, 1], [xMax, 1]],
lineStyle: { color: '#e8e4d8', width: 1 },
symbol: 'none',
silent: true,
z: 0,
},
],
graphic: [
// 左上:冷题材 + 好结构
{
type: 'text',
left: 90,
top: 35,
style: {
text: '冷题材 + 好结构\n逆袭区:靠叙事拉升',
fill: '#aaa',
fontSize: 11,
lineHeight: 16,
},
},
// 右上:热题材 + 好结构
{
type: 'text',
right: 90,
top: 35,
style: {
text: '热题材 + 好结构\n双引擎全开',
fill: '#aaa',
fontSize: 11,
lineHeight: 16,
align: 'right',
},
},
// 左下:冷题材 + 弱结构
{
type: 'text',
left: 90,
bottom: 55,
style: {
text: '冷题材 + 弱结构\n⚠ 高风险区',
fill: '#c0584f',
fontSize: 11,
lineHeight: 16,
opacity: 0.6,
},
},
// 右下:热题材 + 弱结构
{
type: 'text',
right: 90,
bottom: 55,
style: {
text: '热题材 + 弱结构\n题材托底',
fill: '#aaa',
fontSize: 11,
lineHeight: 16,
align: 'right',
},
},
],
}
}, [chartData, yearlyTarget])
// ===== 读图说明文字(动态生成) =====
const instructionText = useMemo(() => {
if (!chartData) return ''
const { overallMean, formatDeviation } = chartData
const meanStr = overallMean.toFixed(4)
// 按偏离值降序列出各题材
const entries = Object.entries(formatDeviation).sort((a, b) => b[1] - a[1])
const examples = entries
.map(([fmt, dev]) => `"${fmt}" 偏离 ${dev >= 0 ? '+' : ''}${dev.toFixed(3)}${dev >= 0 ? '热门' : '偏冷'}`)
.join('')
return `横轴"题材热度"= 该题材类别所有期次的平均份额 − 栏目整体均值(${meanStr}),反映题材自带的收视基础。例如 ${examples}。同一题材的节目共享横轴位置。纵轴为叙事结构两档分类(非连续刻度):上行 = 主线演进,下行 = 并列结构,行内散布仅为避免气泡重叠。气泡越大 = 收视份额越高;颜色 = 三色判定;边框粗细 = 开篇钩子强度。`
}, [chartData])
// ===== 空状态 =====
if (!chartData) {
return <Empty description="暂无双引擎分析数据(需 AI 标签)" />
}
return (
<div>
{/* ── 图例栏 ── */}
<div
style={{
display: 'flex',
justifyContent: 'center',
flexWrap: 'wrap',
gap: 16,
marginBottom: 8,
fontSize: 11,
color: '#888',
}}
>
<span>气泡大小 = 收视份额</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
border: '3px solid #333',
}}
/>{' '}
强钩子
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
border: '1.5px solid #999',
}}
/>{' '}
中钩子
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
border: '1.5px dashed #999',
}}
/>{' '}
弱钩子
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: '#c0584f',
}}
/>{' '}
优秀
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: '#5b8db8',
}}
/>{' '}
达标
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: '#7aa874',
}}
/>{' '}
待提升
</span>
</div>
{/* ── ECharts 象限图 ── */}
<ReactECharts option={option} style={{ height: 520, width: '100%' }} notMerge />
{/* ── 读图说明 ── */}
<div style={{ fontSize: 11, color: '#aaa', lineHeight: 1.7, marginTop: 8, padding: '0 4px' }}>
<b style={{ color: '#999' }}>读图说明</b>{instructionText}
</div>
</div>
)
}
export default QuadrantChart
@@ -0,0 +1,265 @@
import { useMemo } from 'react'
import { Empty, Table } from 'antd'
import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors'
/**
* 季度对比 — 含汇总表格 + 柱状图
* 对齐 l3_report.html 原型:
* - 上方:汇总表格(季度/期数/平均份额/基础完成率/达标率)
* - 下方:垂直柱状图(柱子着色,参考线)
*/
function QuarterCompare({ episodes, yearlyTarget, selectedYear }) {
const { quarterStats, chartOption, tableData } = useMemo(() => {
if (!episodes || !episodes.length)
return { quarterStats: null, chartOption: null, tableData: [] }
const withShare = episodes.filter(
(ep) => ep.audience_share != null && ep.air_date
)
if (withShare.length === 0)
return { quarterStats: null, chartOption: null, tableData: [] }
// 按季度分组
const quarterMap = { Q1: [], Q2: [], Q3: [], Q4: [] }
withShare.forEach((ep) => {
const month = new Date(ep.air_date).getMonth() + 1
if (month <= 3) quarterMap.Q1.push(ep)
else if (month <= 6) quarterMap.Q2.push(ep)
else if (month <= 9) quarterMap.Q3.push(ep)
else quarterMap.Q4.push(ep)
})
const targetsForChart = yearlyTarget
? [{ year: selectedYear, ...yearlyTarget }]
: []
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
const stretchValue = yearlyTarget
? Number(yearlyTarget.stretch_target)
: null
// 只保留有数据的季度
const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
const quarterStats = quarterNames
.filter((q) => quarterMap[q].length > 0)
.map((q) => {
const eps = quarterMap[q]
const count = eps.length
const avgShare =
eps.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
// 计算基础完成率和达标率
let baseCompletion = '—'
let passRate = '—'
let passCount = 0
if (baseValue != null) {
baseCompletion = ((avgShare / baseValue) * 100).toFixed(1) + '%'
passCount = eps.filter(
(ep) => Number(ep.audience_share) >= baseValue
).length
passRate =
count > 0
? ((passCount / count) * 100).toFixed(1) + '%'
: '—'
}
return {
name: q,
count,
avgShare: Number(avgShare.toFixed(4)),
baseCompletion,
passRate,
passCount,
}
})
if (quarterStats.length === 0)
return { quarterStats: null, chartOption: null, tableData: [] }
// 表格数据
const tableData = quarterStats.map((q) => ({
key: q.name,
quarter: q.name,
count: q.count,
avgShare: q.avgShare,
baseCompletion: q.baseCompletion,
passRate: q.passRate,
passCount: q.passCount,
}))
// 图表配置
const xData = quarterStats.map((q) => q.name)
const barData = quarterStats.map((q) => ({
value: q.avgShare,
itemStyle: {
color: getShareColor(q.avgShare, targetsForChart, selectedYear),
},
}))
const dataMin = Math.min(...quarterStats.map((q) => q.avgShare))
const dataMax = Math.max(...quarterStats.map((q) => q.avgShare))
const allMin =
baseValue != null ? Math.min(dataMin, baseValue) : dataMin
const allMax =
stretchValue != null ? Math.max(dataMax, stretchValue) : dataMax
const yMin = Math.max(0, Math.floor((allMin - 0.1) * 10) / 10)
const yMax = Math.ceil((allMax + 0.1) * 10) / 10
const markLineData = []
if (baseValue != null) {
markLineData.push({
yAxis: baseValue,
lineStyle: { color: '#93b8d7', type: 'dashed', width: 1 },
label: {
show: true,
position: 'insideEndTop',
formatter: '基础',
color: '#93b8d7',
fontSize: 10,
},
})
}
if (stretchValue != null) {
markLineData.push({
yAxis: stretchValue,
lineStyle: { color: '#d9a09a', type: 'dashed', width: 1 },
label: {
show: true,
position: 'insideEndTop',
formatter: '摸高',
color: '#d9a09a',
fontSize: 10,
},
})
}
const chartOption = {
tooltip: {
trigger: 'axis',
formatter: (params) => {
const name = params[0].name
const stat = quarterStats.find((q) => q.name === name)
if (!stat) return ''
return `<b>${name}</b><br/>期数:${stat.count} 期<br/>平均份额:<b>${stat.avgShare}</b><br/>基础完成率:${stat.baseCompletion}<br/>达标率:${stat.passRate}`
},
},
grid: {
left: 50,
right: 30,
top: 20,
bottom: 30,
},
xAxis: {
type: 'category',
data: xData,
axisLabel: { fontSize: 12 },
},
yAxis: {
type: 'value',
min: yMin,
max: yMax,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: barData,
barWidth: '40%',
label: {
show: true,
position: 'top',
fontSize: 12,
fontWeight: 'bold',
formatter: (p) => p.value.toFixed(4),
},
...(markLineData.length > 0
? { markLine: { symbol: ['none', 'none'], data: markLineData, silent: true } }
: {}),
},
],
}
return { quarterStats, chartOption, tableData }
}, [episodes, yearlyTarget, selectedYear])
if (!chartOption || !quarterStats) {
return <Empty description="暂无季度数据" />
}
// 表格列定义
const columns = [
{
title: '季度',
dataIndex: 'quarter',
key: 'quarter',
render: (text) => <span style={{ fontWeight: 600 }}>{text}</span>,
},
{
title: '期数',
dataIndex: 'count',
key: 'count',
align: 'center',
},
{
title: '平均份额',
dataIndex: 'avgShare',
key: 'avgShare',
render: (value) => {
const color = getShareColor(
value,
yearlyTarget ? [{ year: selectedYear, ...yearlyTarget }] : [],
selectedYear
)
return (
<span style={{ color, fontWeight: 600 }}>{value.toFixed(4)}</span>
)
},
},
{
title: '基础完成率',
dataIndex: 'baseCompletion',
key: 'baseCompletion',
align: 'center',
},
{
title: '达标率',
dataIndex: 'passRate',
key: 'passRate',
align: 'center',
render: (value, record) => (
<span>
{value}{record.passCount}/{record.count}
</span>
),
},
]
return (
<div className="quarter-compare">
{/* 汇总表格 */}
<div className="quarter-table">
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
bordered
style={{ marginBottom: 16 }}
/>
</div>
{/* 柱状图 */}
<div className="quarter-chart">
<ReactECharts
option={chartOption}
style={{ height: 280, width: '100%' }}
notMerge
/>
</div>
</div>
)
}
export default QuarterCompare
@@ -0,0 +1,183 @@
import { useMemo } from 'react'
import { Empty } from 'antd'
import ReactECharts from 'echarts-for-react'
import * as echarts from 'echarts'
/**
* 题材对比 — 饼图(期数占比)+ 水平柱图(按平均份额排名)
* 右列通高,对齐 l3_report.html 原型
*/
const TOPIC_COLORS = {
'装备深解': '#5b8db8',
'历史纵深': '#e8a838',
'前沿科技': '#7aa874',
'横切类比': '#9b7eb8',
'人物牵引': '#d4816b',
'事件战例': '#9b7eb8',
}
/**
* 将 hex 颜色转为 rgba 字符串
*/
function hexToRgba(hex, alpha) {
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
function TopicCompare({ episodes }) {
// 过滤有 program_format 的期次
const validEpisodes = useMemo(() => {
if (!episodes || !episodes.length) return []
return episodes.filter((ep) => ep.program_format != null && ep.program_format !== '')
}, [episodes])
// 按 program_format 分组统计
const topicStats = useMemo(() => {
if (!validEpisodes.length) return null
const groupMap = {}
validEpisodes.forEach((ep) => {
const format = ep.program_format
if (!groupMap[format]) {
groupMap[format] = []
}
groupMap[format].push(ep)
})
return Object.entries(groupMap).map(([name, eps]) => {
const count = eps.length
const withShare = eps.filter((ep) => ep.audience_share != null)
const avgShare =
withShare.length > 0
? withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / withShare.length
: 0
return {
name,
count,
avgShare: Number(avgShare.toFixed(4)),
}
})
}, [validEpisodes])
// 饼图配置
const pieOption = useMemo(() => {
if (!topicStats || !topicStats.length) return null
// 按期数排序
const sorted = [...topicStats].sort((a, b) => b.count - a.count)
const data = sorted.map((t) => ({ name: t.name, value: t.count }))
const colorArr = sorted.map((t) => TOPIC_COLORS[t.name] || '#999')
return {
tooltip: {
trigger: 'item',
formatter: (params) => {
return params.name + '<br/>期数:' + params.value + ' 期(' + params.percent + '%'
},
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '55%'],
data,
color: colorArr,
label: {
fontSize: 11,
formatter: '{b}\n{c}期 ({d}%)',
},
emphasis: {
label: { fontSize: 14, fontWeight: 'bold' },
},
},
],
}
}, [topicStats])
// 水平柱图配置
const barOption = useMemo(() => {
if (!topicStats || !topicStats.length) return null
// 按平均份额升序排列(最低在下,最高在上)
const sorted = [...topicStats].sort((a, b) => a.avgShare - b.avgShare)
const yData = sorted.map((t) => t.name)
const countMap = {}
sorted.forEach((t) => {
countMap[t.name] = t.count
})
// 每根柱子渐变填充 + 胶囊圆角,对齐原型 LinearGradient 写法
const barData = sorted.map((t) => {
const color = TOPIC_COLORS[t.name] || '#999'
return {
value: t.avgShare,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: hexToRgba(color, 0.15) },
{ offset: 1, color },
]),
borderRadius: [0, 10, 10, 0],
},
}
})
// x 轴范围
const values = sorted.map((t) => t.avgShare)
const dataMin = Math.min(...values)
const dataMax = Math.max(...values)
const xMin = Math.max(0, Math.floor((dataMin - 0.1) * 10) / 10)
const xMax = Math.ceil((dataMax + 0.1) * 10) / 10
return {
tooltip: {
trigger: 'axis',
formatter: (params) => {
const name = params[0].name
const count = countMap[name] || 0
return name + '<br/>平均份额:' + Number(params[0].value).toFixed(3) + '<br/>期数:' + count
},
},
grid: { left: 80, right: 30, top: 10, bottom: 30 },
xAxis: {
type: 'value',
min: xMin,
max: xMax,
},
yAxis: {
type: 'category',
data: yData,
axisLabel: { fontSize: 12 },
},
series: [
{
type: 'bar',
data: barData,
barWidth: '50%',
label: {
show: true,
position: 'right',
fontSize: 11,
formatter: (p) => p.value.toFixed(3),
},
},
],
}
}, [topicStats])
if (!validEpisodes.length) {
return <Empty description="暂无题材标签数据" />
}
return (
<div>
<ReactECharts option={pieOption} style={{ height: 280 }} notMerge />
<ReactECharts option={barOption} style={{ height: 280 }} notMerge />
</div>
)
}
export default TopicCompare
+8 -2
View File
@@ -5,13 +5,19 @@
.app-sider { .app-sider {
background: #fff !important; background: #fff !important;
border-right: 1px solid #f0f0f0; border-right: 1px solid #f0f0f0;
width: 220px; width: 220px !important;
flex: 0 0 220px; flex: 0 0 220px !important;
overflow-y: auto; overflow-y: auto;
position: fixed !important;
top: 0;
left: 0;
bottom: 0;
z-index: 200;
} }
.app-main { .app-main {
min-height: 100vh; min-height: 100vh;
margin-left: 220px;
} }
.app-header { .app-header {
+201 -55
View File
@@ -1,29 +1,49 @@
/* ===== 收视分析页面 ===== */ /* ===== 收视分析页面 — 对齐 l3_report.html 原型 ===== */
.analytics-page { .analytics-page {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 32px 24px;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #fdf6ee 0%, #f0f4f8 100%); font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
} background: linear-gradient(135deg, #f5f0e8 0%, #e8e4d8 30%, #f0ece2 60%, #ebe5d5 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;
color: #333; 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 { .analytics-loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -45,72 +65,122 @@
padding: 80px 0; padding: 80px 0;
} }
/* ===== 指标卡行 ===== */ /* ── 图例栏(指标卡上方,居中) ── */
.analytics-kpi-row { .analytics-legend-bar {
margin-bottom: 24px; 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 { .analytics-kpi-card {
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 16px; border-radius: 16px;
padding: 20px 24px; padding: 20px;
margin-bottom: 16px; text-align: center;
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
.analytics-kpi-card:hover { .analytics-kpi-card:hover {
transform: translateY(-2px); 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; /* ≥95% 庆祝效果 */
margin-bottom: 8px; @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 { .analytics-kpi-value {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: #333; margin-bottom: 4px;
font-variant-numeric: tabular-nums; 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 { .analytics-chart-card {
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: 16px; border-radius: 16px;
padding: 24px; padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.5); margin-bottom: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); border: 1px solid rgba(255, 255, 255, 0.6);
} box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
.analytics-chart-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
} }
.analytics-chart-title { .analytics-chart-title {
margin: 0; margin: 0 0 14px 0;
font-size: 16px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #333; color: #4a6741;
padding-left: 8px;
} }
.analytics-chart-hint { /* ── 图例(保留在走势图内备用,但主要用 legend-bar) ── */
font-size: 12px;
color: #aaa;
}
/* 图例 */
.analytics-chart-legend { .analytics-chart-legend {
display: flex; display: flex;
gap: 20px; gap: 20px;
@@ -120,10 +190,23 @@
color: #666; 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 { .analytics-legend-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
} }
.analytics-legend-dot { .analytics-legend-dot {
@@ -133,6 +216,51 @@
border-radius: 50%; 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) { @media (max-width: 768px) {
.analytics-page { .analytics-page {
@@ -141,10 +269,18 @@
.analytics-header { .analytics-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: center;
gap: 12px; gap: 12px;
} }
.analytics-year-select {
position: static;
}
.analytics-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.analytics-kpi-card { .analytics-kpi-card {
padding: 16px; padding: 16px;
} }
@@ -156,4 +292,14 @@
.analytics-chart-card { .analytics-chart-card {
padding: 16px; padding: 16px;
} }
.analytics-compare-section {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.analytics-compare-section .analytics-topic-section {
grid-column: 1;
grid-row: auto;
}
} }
+446 -143
View File
@@ -1,16 +1,23 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { Select, Spin, Empty, Row, Col } from 'antd' import { Select, Spin, Empty } from 'antd'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors' import { getShareColor, getColorLabel } from '../../utils/ratingColors'
import { getAnalyticsEpisodes, getAvailableYears } from '../../services/analyticsService' 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' import './Analytics.css'
/** /**
* 收视分析页面 * 收视分析页面 — 对齐 l3_report.html 原型
* - 年份选择器 * - 居中大标题 + 副标题
* - 指标卡行(4 张 * - 三色图例(指标卡上方
* - 收视走势折线图(ECharts * - 5 张指标卡(平均份额 / 摸高完成率 / 最高份额 / 最低份额 / 达标期数
* - 空状态兜底 * - 收视走势:柱状图 + 折线图组合(柱子三色着色,柱顶节目名,橙色趋势线)
* - 年份选择器(HTML 原型无,保留功能)
* - dataZoom 滑块保留
* - 空状态兜底保留
*/ */
function Analytics() { function Analytics() {
const [years, setYears] = useState([]) const [years, setYears] = useState([])
@@ -18,6 +25,10 @@ function Analytics() {
const [yearlyTarget, setYearlyTarget] = useState(null) const [yearlyTarget, setYearlyTarget] = useState(null)
const [episodes, setEpisodes] = useState([]) const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(true) 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(() => { useEffect(() => {
@@ -25,7 +36,7 @@ function Analytics() {
.then((data) => { .then((data) => {
setYears(data || []) setYears(data || [])
if (data && data.length > 0) { if (data && data.length > 0) {
setSelectedYear(data[0]) // 默认选最近年份 setSelectedYear(data[0])
} else { } else {
setLoading(false) setLoading(false)
} }
@@ -43,6 +54,7 @@ function Analytics() {
.then((data) => { .then((data) => {
setEpisodes(data.episodes || []) setEpisodes(data.episodes || [])
setYearlyTarget(data.yearly_target) setYearlyTarget(data.yearly_target)
setLoadTime(new Date())
setLoading(false) setLoading(false)
}) })
.catch(() => { .catch(() => {
@@ -52,9 +64,29 @@ function Analytics() {
}) })
}, [selectedYear]) }, [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 kpiMetrics = useMemo(() => {
const withShare = episodes.filter((ep) => ep.audience_share != null) const withShare = filteredEpisodes
const count = withShare.length const count = withShare.length
if (count === 0) { if (count === 0) {
return { return {
@@ -63,84 +95,160 @@ function Analytics() {
avgShareColor: '#999', avgShareColor: '#999',
baseRate: '—', baseRate: '—',
stretchRate: '—', stretchRate: '—',
stretchRateColor: '#7aa874',
maxShare: null,
maxShareColor: '#999',
maxShareEpisode: null,
minShare: null,
minShareColor: '#999',
minShareEpisode: null,
passCount: 0,
passRate: '—',
excellentCount: 0,
} }
} }
const avgShare = const avgShare =
withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count 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' let avgShareColor = '#999'
if (yearlyTarget) { if (base != null && stretch != null) {
const base = Number(yearlyTarget.base_target) if (avgShare > stretch) avgShareColor = '#c0584f'
const stretch = Number(yearlyTarget.stretch_target) else if (avgShare >= base) avgShareColor = '#5b8db8'
if (avgShare > stretch) avgShareColor = '#c0584f' // 红=优秀 else avgShareColor = '#7aa874'
else if (avgShare >= base) avgShareColor = '#5b8db8' // 蓝=达标
else avgShareColor = '#7aa874' // 绿=待提升
} }
// 完成率
let baseRate = '—' let baseRate = '—'
let stretchRate = '—' let stretchRate = '—'
if (yearlyTarget) { let stretchRateColor = '#7aa874'
const base = Number(yearlyTarget.base_target) let passCount = 0
const stretch = Number(yearlyTarget.stretch_target) let excellentCount = 0
if (base != null) {
baseRate = ((avgShare / base) * 100).toFixed(1) + '%' 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 { return {
count, count,
avgShare: (avgShare * 100).toFixed(2) + '%', avgShare: avgShare.toFixed(4),
avgShareColor, avgShareColor,
baseRate, baseRate,
stretchRate, 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(() => { const chartOption = useMemo(() => {
if (!episodes.length) return {} if (!episodes.length) return {}
const withShare = episodes.filter((ep) => ep.audience_share != null) 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 const targetsForChart = yearlyTarget
? [{ year: selectedYear, ...yearlyTarget }] ? [{ year: selectedYear, ...yearlyTarget }]
: [] : []
const sharePoints = withShare.map((ep) => ({ // 柱状图数据 — 每根柱子单独着色
const barData = withShare.map((ep) => ({
value: Number(ep.audience_share), value: Number(ep.audience_share),
itemStyle: { itemStyle: {
color: getShareColor(ep.audience_share, targetsForChart, selectedYear), color: getShareColor(ep.audience_share, targetsForChart, selectedYear),
}, },
})) }))
const markLineData = [] // 滚动 3 期均值
if (yearlyTarget) { const rollingAvg = withShare.map((ep, i) => {
markLineData.push( if (i < 2) return null
{ const sum =
name: '基础目标', Number(withShare[i - 2].audience_share) +
yAxis: Number(yearlyTarget.base_target), Number(withShare[i - 1].audience_share) +
lineStyle: { color: '#5b8db8', type: 'dashed', width: 2 }, Number(ep.audience_share)
label: { return Number((sum / 3).toFixed(4))
formatter: '基础目标 {c}', })
position: 'insideEndTop',
color: '#5b8db8', // 目标线
}, const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
}, const stretchValue = yearlyTarget ? Number(yearlyTarget.stretch_target) : null
{ const baseLine = baseValue != null ? withShare.map(() => baseValue) : []
name: '摸高目标', const stretchLine = stretchValue != null ? withShare.map(() => stretchValue) : []
yAxis: Number(yearlyTarget.stretch_target),
lineStyle: { color: '#c0584f', type: 'dashed', width: 2 }, // Y 轴范围
label: { const shares = withShare.map((ep) => Number(ep.audience_share))
formatter: '摸高目标 {c}', const dataMin = Math.min(...shares)
position: 'insideEndTop', const dataMax = Math.max(...shares)
color: '#c0584f', 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 { return {
tooltip: { tooltip: {
@@ -149,38 +257,38 @@ function Analytics() {
const idx = params[0].dataIndex const idx = params[0].dataIndex
const ep = withShare[idx] const ep = withShare[idx]
if (!ep) return '' if (!ep) return ''
const share = ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据' const share = Number(ep.audience_share).toFixed(4)
const rating = ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'
const editor = ep.editor_name_snapshot || '未知' const editor = ep.editor_name_snapshot || '未知'
return `<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>` + const date = ep.air_date || ''
`编导:${editor}<br/>` + const label = getColorLabel(ep.audience_share, targetsForChart, selectedYear)
`收视份额:${share}<br/>` + let s = `<strong>第${ep.episode_number}${ep.program_name}</strong><br/>`
`收视率:${rating}` s += `${date} ${editor}<br/>`
s += `份额:<strong>${share}</strong>${label}`
const rollingParam = params.find((p) => p.seriesName === '滚动3期均值')
if (rollingParam && rollingParam.value != null) {
s += `<br/>滚动3期均值:${Number(rollingParam.value).toFixed(4)}`
}
return s
}, },
}, },
grid: { grid: {
left: 60, left: 50,
right: 30, right: 30,
top: 40, top: 80,
bottom: 70, bottom: 60,
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: xData, data: xData,
axisLabel: { axisLabel: { fontSize: 11 },
rotate: 30,
fontSize: 11,
},
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: '收视份额', min: yMin,
axisLabel: { max: yMax,
formatter: (val) => val.toFixed(2), axisLabel: { fontSize: 11 },
},
}, },
dataZoom: [ dataZoom: [
{ type: 'inside', start: 0, end: 100 },
{ {
type: 'slider', type: 'slider',
bottom: 10, bottom: 10,
@@ -193,30 +301,92 @@ function Analytics() {
}, },
], ],
series: [ series: [
// 柱状图 — 收视份额
{ {
name: '收视份额', name: '收视份额',
type: 'line', type: 'bar',
data: sharePoints, data: barData,
smooth: false, barWidth: '50%',
symbol: 'circle', label: {
symbolSize: 8, show: true,
lineStyle: { width: 2, color: '#6b8e6b' }, position: 'top',
markLine: { rotate: 45,
silent: true, align: 'left',
data: markLineData, 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]) }, [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 ( return (
<div className="analytics-page"> <div className="analytics-page">
{/* 页面头部 + 年份选择器 */} {/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}
<div className="analytics-header"> <div className="analytics-header">
<h2 className="analytics-title">收视分析</h2> <div className="analytics-header-center">
<h1 className="analytics-page-title">
军事科技 {selectedYear} 年度收视分析
</h1>
{hasData && <p className="analytics-subtitle">{subtitle}</p>}
</div>
<Select <Select
className="analytics-year-select" className="analytics-year-select"
value={selectedYear} value={selectedYear}
@@ -239,74 +409,207 @@ function Analytics() {
</div> </div>
) : ( ) : (
<> <>
{/* 指标卡行 */} {/* ── 图例栏(指标卡上方,居中) ── */}
<Row gutter={16} className="analytics-kpi-row"> <div className="analytics-legend-bar">
<Col xs={24} sm={12} md={6}> <span className="analytics-legend-item">
<div className="analytics-kpi-card"> <span
<div className="analytics-kpi-label">本年已播期数</div> className="analytics-legend-dot"
<div className="analytics-kpi-value">{kpiMetrics.count}</div> style={{ background: '#c0584f' }}
</div> />
</Col> 优秀{'>摸高'}
<Col xs={24} sm={12} md={6}> </span>
<div className="analytics-kpi-card"> <span className="analytics-legend-item">
<div className="analytics-kpi-label">平均收视份额</div> <span
<div className="analytics-legend-dot"
className="analytics-kpi-value" style={{ background: '#5b8db8' }}
style={{ color: kpiMetrics.avgShareColor }} />
> 达标基础~摸高
{kpiMetrics.avgShare || '—'} </span>
</div> <span className="analytics-legend-item">
</div> <span
</Col> className="analytics-legend-dot"
<Col xs={24} sm={12} md={6}> style={{ background: '#7aa874' }}
<div className="analytics-kpi-card"> />
<div className="analytics-kpi-label">基础目标完成率</div> 待提升{'<基础'}
<div className="analytics-kpi-value">{kpiMetrics.baseRate}</div> </span>
</div> </div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">摸高目标完成率</div>
<div className="analytics-kpi-value">{kpiMetrics.stretchRate}</div>
</div>
</Col>
</Row>
{/* 收视走势折线图 */} {/* ── 指标卡 — 5 列网格 ── */}
<div className="analytics-chart-card"> <div className="analytics-kpi-grid">
<div className="analytics-chart-header"> {/* 卡 1:平均份额 */}
<h3 className="analytics-chart-title">{selectedYear} 年收视走势</h3> <div className="analytics-kpi-card">
<span className="analytics-chart-hint"> <div
拖拽底部滑块缩放 · 颜色只对收视份额 className="analytics-kpi-value"
</span> style={{ color: kpiMetrics.avgShareColor }}
>
{kpiMetrics.avgShare || '—'}
</div>
<div className="analytics-kpi-desc">平均份额</div>
<div className="analytics-kpi-sub">
基础完成率 {kpiMetrics.baseRate}
</div>
</div> </div>
{/* 卡 2:摸高目标完成率(四档动态效果) */}
<div className={`analytics-kpi-card ${kpiMetrics.stretchPulseClass}`}>
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.stretchRateColor }}
>
{kpiMetrics.stretchPulseClass === 'analytics-kpi-card-excellent' && '🎉 '}
{kpiMetrics.stretchRate}
</div>
<div className="analytics-kpi-desc">摸高目标完成率</div>
</div>
{/* 卡 3:最高份额 */}
<div className="analytics-kpi-card">
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.maxShareColor }}
>
{kpiMetrics.maxShare || '—'}
</div>
<div className="analytics-kpi-desc">最高份额</div>
<div className="analytics-kpi-sub">
{kpiMetrics.maxShareEpisode?.episode_number}{' '}
{truncateName(kpiMetrics.maxShareEpisode?.program_name)}
</div>
</div>
{/* 卡 4:最低份额 */}
<div className="analytics-kpi-card">
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.minShareColor }}
>
{kpiMetrics.minShare || '—'}
</div>
<div className="analytics-kpi-desc">最低份额</div>
<div className="analytics-kpi-sub">
{kpiMetrics.minShareEpisode?.episode_number}{' '}
{truncateName(kpiMetrics.minShareEpisode?.program_name)}
</div>
</div>
{/* 卡 5:达标期数 */}
<div className="analytics-kpi-card">
<div className="analytics-kpi-value">
{kpiMetrics.passCount}
<span className="analytics-kpi-value-total">
/{kpiMetrics.count}
</span>
</div>
<div className="analytics-kpi-desc">达标期数</div>
<div className="analytics-kpi-sub">
达标率 {kpiMetrics.passRate} 优秀{' '}
{kpiMetrics.excellentCount}
</div>
</div>
</div>
{/* ── 收视走势图(柱状图 + 折线图组合) ── */}
<div className="analytics-chart-card">
<h2 className="analytics-chart-title">收视走势</h2>
<ReactECharts <ReactECharts
ref={chartRef}
option={chartOption} option={chartOption}
style={{ height: 400, width: '100%' }} style={{ height: 380, width: '100%' }}
notMerge notMerge
onEvents={chartEvents}
/> />
<div className="analytics-chart-legend"> {(zoomRange[0] !== appliedRange[0] ||
<span className="analytics-legend-item"> zoomRange[1] !== appliedRange[1]) && (
<span <div style={{ textAlign: 'center', marginTop: 8 }}>
className="analytics-legend-dot" <button
style={{ background: '#7aa874' }} onClick={() => setAppliedRange([...zoomRange])}
style={{
background: '#6b8e6b',
color: '#fff',
border: 'none',
borderRadius: 8,
padding: '6px 24px',
fontSize: 13,
cursor: 'pointer',
}}
>
应用此范围
</button>
<button
onClick={() => {
setZoomRange([0, 100])
setAppliedRange([0, 100])
const chart = chartRef.current?.getEchartsInstance()
if (chart) {
chart.dispatchAction({
type: 'dataZoom',
start: 0,
end: 100,
})
}
}}
style={{
background: 'transparent',
color: '#999',
border: '1px solid #ddd',
borderRadius: 8,
padding: '6px 16px',
fontSize: 13,
cursor: 'pointer',
marginLeft: 8,
}}
>
重置
</button>
</div>
)}
</div>
{/* ── AI 诊断报告占位 ── */}
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}>
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2>
<p style={{ color: '#aaa', fontSize: 14 }}>即将上线 - 基于双引擎模型的智能收视诊断</p>
</div>
{/* ── 双引擎象限图 ── */}
<div className="analytics-chart-card">
<h2 className="analytics-chart-title">双引擎象限分析</h2>
<QuadrantChart
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
</div>
{/* ── 左列(季度+编导) + 右列(题材 Task3) ── */}
<div className="analytics-compare-section">
<div className="analytics-chart-card analytics-stage-section">
<h2 className="analytics-chart-title">季度对比</h2>
<QuarterCompare
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
</div>
<div className="analytics-chart-card analytics-editor-section">
<h2 className="analytics-chart-title">编导对比</h2>
<EditorCompare
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/>
</div>
{/* 题材对比 — 右列通高 */}
<div className="analytics-topic-section">
<div className="analytics-chart-card">
<h2 className="analytics-chart-title">题材对比</h2>
<TopicCompare
episodes={filteredEpisodes}
yearlyTarget={yearlyTarget}
selectedYear={selectedYear}
/> />
绿=未达基础目标待提升 </div>
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#5b8db8' }}
/>
=未达摸高目标达标
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#c0584f' }}
/>
=超过摸高目标优秀
</span>
</div> </div>
</div> </div>
</> </>
+106
View File
@@ -0,0 +1,106 @@
"""
修复 AI 标签映射:ground-truth ep编号 ≠ Excel 播出期号
用标题模糊匹配重新关联正确的 AI 标签
"""
import json
import sys
import os
from difflib import SequenceMatcher
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app.db.session import get_session
from app.models.episode import Episode
from app.models.user import User
from sqlmodel import select
project_root = os.path.join(os.path.dirname(__file__), '..')
# 读 ground-truth
with open(os.path.join(project_root, 'ai-labeling', 'benchmark-set', 'ground-truth.json'), 'r', encoding='utf-8') as f:
gt_data = json.load(f)
gt_episodes = gt_data['episodes']
# 读数据库
session = next(get_session())
db_episodes = session.exec(select(Episode).order_by(Episode.episode_number)).all()
print(f"DB episodes: {len(db_episodes)}")
print(f"GT episodes: {len(gt_episodes)}")
print()
# ── 标题相似度匹配 ──
def title_similarity(a, b):
"""计算两个标题的相似度"""
# 清理标题
clean_a = a.replace('"', '').replace('"', '').replace("'", '').replace('——', '').replace('', '').replace(' ', '').replace('', '').replace('', '')
clean_b = b.replace('"', '').replace('"', '').replace("'", '').replace('——', '').replace('', '').replace(' ', '').replace('', '').replace('', '')
return SequenceMatcher(None, clean_a, clean_b).ratio()
# 对每个 DB episode,找最佳匹配的 GT episode
matched = []
used_gt = set()
for db_ep in db_episodes:
best_score = 0
best_gt = None
for i, gt_ep in enumerate(gt_episodes):
if i in used_gt:
continue
gt_title = gt_ep.get('title', '')
score = title_similarity(db_ep.program_name, gt_title)
# 也检查份额是否匹配(辅助判断)
gt_share = gt_ep.get('share')
share_match = (gt_share is not None and db_ep.audience_share is not None
and abs(float(gt_share) - float(db_ep.audience_share)) < 0.01)
# 份额匹配加分
if share_match:
score += 0.3
if score > best_score:
best_score = score
best_gt = (i, gt_ep)
if best_gt and best_score > 0.3:
used_gt.add(best_gt[0])
matched.append((db_ep, best_gt[1], best_score))
status = 'OK' if best_score > 0.5 else 'WEAK'
print(f"[{status}] DB ep{db_ep.episode_number:02d} \"{db_ep.program_name[:15]}\" -> GT ep{best_gt[1]['ep']:02d} \"{best_gt[1]['title'][:15]}\" (score={best_score:.2f})")
else:
print(f"[MISS] DB ep{db_ep.episode_number:02d} \"{db_ep.program_name[:15]}\" -> NO MATCH")
matched.append((db_ep, None, 0))
print(f"\nMatched: {sum(1 for _,gt,_ in matched if gt is not None)}/{len(db_episodes)}")
# ── 确认后更新 ──
print("\n--- Updating AI labels ---")
updated = 0
for db_ep, gt_ep, score in matched:
if gt_ep is None:
continue
db_ep.program_format = gt_ep.get('program_format')
db_ep.equipment_domain = gt_ep.get('equipment_domain')
db_ep.scene_tags = gt_ep.get('scene_tags')
db_ep.tech_tags = gt_ep.get('tech_tags')
db_ep.narrative_structure = gt_ep.get('narrative_structure')
db_ep.opening_hook = gt_ep.get('opening_hook')
db_ep.ai_label_confidence = 'reviewed'
session.add(db_ep)
updated += 1
session.commit()
print(f"Updated {updated} episodes")
# ── 验证 ──
print("\n--- Verification ---")
db_episodes = session.exec(select(Episode).order_by(Episode.episode_number)).all()
for ep in db_episodes:
print(f"ep{ep.episode_number:02d} | {ep.program_name[:18]:18s} | {ep.program_format or '-':8s} | {ep.narrative_structure or '-':6s} | {ep.opening_hook or '-'}")
+94
View File
@@ -0,0 +1,94 @@
"""
一次性脚本:清除测试数据,导入 25 期真实收视数据 + 回填 AI 标签
数据来源:
- 收视数据:ai-labeling/example/2026收视update.xlsx(已导出为 _tmp_excel.json
- AI 标签:ai-labeling/benchmark-set/ground-truth.jsonv0.6.0,制片人审定)
"""
import json
import sys
import os
from datetime import date
# 加 backend 到 path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
from app.db.session import get_session
from app.models.episode import Episode
from app.models.user import User # 注册 User 表元数据,FK 解析需要
from sqlmodel import select, delete
# ── 1. 读数据源 ──
project_root = os.path.join(os.path.dirname(__file__), '..')
with open(os.path.join(project_root, '_tmp_excel.json'), 'r', encoding='utf-8') as f:
excel_rows = json.load(f)
with open(os.path.join(project_root, 'ai-labeling', 'benchmark-set', 'ground-truth.json'), 'r', encoding='utf-8') as f:
gt_data = json.load(f)
# ground-truth 按 ep 编号索引
gt_map = {ep['ep']: ep for ep in gt_data['episodes']}
print(f"Excel: {len(excel_rows)} rows")
print(f"Ground-truth: {len(gt_map)} episodes (v{gt_data['version']})")
# ── 2. 构造 Episode 对象 ──
new_episodes = []
for row in excel_rows:
# 解析期号:"第1期" -> 1
ep_num_str = row['ep'].replace('', '').replace('', '')
ep_num = int(ep_num_str)
# 解析日期:"2026 01 06" -> date(2026, 1, 6)
parts = row['date'].split()
air = date(int(parts[0]), int(parts[1]), int(parts[2]))
# 编导名(去空格)
editor_name = row['editor'].replace(' ', '').replace(' ', '')
# 从 ground-truth 取 AI 标签
gt = gt_map.get(ep_num, {})
ep = Episode(
episode_number=ep_num,
program_name=row['title'],
air_date=air,
editor_id=None, # 软引用,暂不关联 user id
editor_name_snapshot=editor_name,
audience_share=row['share'],
audience_rating=row['rating'],
# AI 标签
program_format=gt.get('program_format'),
equipment_domain=gt.get('equipment_domain'),
scene_tags=gt.get('scene_tags'),
tech_tags=gt.get('tech_tags'),
narrative_structure=gt.get('narrative_structure'),
opening_hook=gt.get('opening_hook'),
ai_label_confidence='reviewed' if gt else None,
)
new_episodes.append(ep)
# ── 3. 执行:清旧 + 插新 ──
session = next(get_session())
# 删除所有旧数据
old_count = len(session.exec(select(Episode)).all())
session.exec(delete(Episode))
print(f"Deleted {old_count} old test episodes")
# 插入新数据
for ep in new_episodes:
session.add(ep)
session.commit()
print(f"Inserted {len(new_episodes)} real episodes")
# ── 4. 验证 ──
all_eps = session.exec(select(Episode).order_by(Episode.air_date)).all()
print(f"\nVerification: {len(all_eps)} episodes in DB")
labeled = sum(1 for e in all_eps if e.program_format is not None)
print(f"With program_format: {labeled}/{len(all_eps)}")
print()
for e in all_eps:
print(f" ep{e.episode_number:02d} | {str(e.air_date)} | {e.audience_share} | {e.editor_name_snapshot} | {e.program_format or '-'}")