Compare commits
4 Commits
886ddd8e88
...
f37679530a
| Author | SHA1 | Date | |
|---|---|---|---|
| f37679530a | |||
| 8d880f06cf | |||
| d537bee9a7 | |||
| 1c4cbd84e5 |
@@ -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 需要。
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
收视分析 API — 提供收视走势和指标卡数据
|
||||||
|
|
||||||
|
端点:
|
||||||
|
GET /api/analytics/years → 有收视数据的年份列表(去重降序)
|
||||||
|
GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import extract
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.deps import require_role
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.episode import Episode
|
||||||
|
from app.models.yearly_target import YearlyTarget
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/analytics", tags=["收视分析"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_read():
|
||||||
|
"""三角色都可读"""
|
||||||
|
return require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/years")
|
||||||
|
def get_available_years(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user=Depends(_require_read()),
|
||||||
|
):
|
||||||
|
"""返回有收视数据的年份列表(去重,降序)。"""
|
||||||
|
statement = (
|
||||||
|
select(distinct(extract("year", Episode.air_date).label("year")))
|
||||||
|
.where(Episode.audience_share.is_not(None))
|
||||||
|
.order_by(extract("year", Episode.air_date).desc())
|
||||||
|
)
|
||||||
|
result = session.exec(statement).all()
|
||||||
|
# extract 返回 float,转 int
|
||||||
|
return [int(y) for y in result]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/episodes")
|
||||||
|
def get_analytics_episodes(
|
||||||
|
year: int | None = Query(None, description="年份,不传则取最近有数据的年份"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user=Depends(_require_read()),
|
||||||
|
):
|
||||||
|
"""返回指定年份所有期次的收视数据(按 air_date 升序),附带该年年度目标。
|
||||||
|
|
||||||
|
返回格式:
|
||||||
|
{
|
||||||
|
"year": 2026,
|
||||||
|
"yearly_target": { "base_target": 0.6448, "stretch_target": 0.8989 } | null,
|
||||||
|
"episodes": [ { id, episode_number, program_name, air_date, editor_name_snapshot,
|
||||||
|
audience_share, audience_rating }, ... ]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 如果没传 year,找最近有收视数据的年份
|
||||||
|
if year is None:
|
||||||
|
year_stmt = (
|
||||||
|
select(extract("year", Episode.air_date).label("y"))
|
||||||
|
.where(Episode.audience_share.is_not(None))
|
||||||
|
.order_by(extract("year", Episode.air_date).desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
latest_year = session.exec(year_stmt).first()
|
||||||
|
if latest_year is None:
|
||||||
|
return {"year": None, "yearly_target": None, "episodes": []}
|
||||||
|
year = int(latest_year)
|
||||||
|
|
||||||
|
# 查询该年份的期次(按 air_date 升序)
|
||||||
|
ep_stmt = (
|
||||||
|
select(Episode)
|
||||||
|
.where(extract("year", Episode.air_date) == year)
|
||||||
|
.order_by(Episode.air_date.asc())
|
||||||
|
)
|
||||||
|
episodes = session.exec(ep_stmt).all()
|
||||||
|
|
||||||
|
# 查询该年份的年度目标
|
||||||
|
target_stmt = select(YearlyTarget).where(YearlyTarget.year == year)
|
||||||
|
target = session.exec(target_stmt).first()
|
||||||
|
|
||||||
|
# 组装返回
|
||||||
|
ep_list = [
|
||||||
|
{
|
||||||
|
"id": ep.id,
|
||||||
|
"episode_number": ep.episode_number,
|
||||||
|
"program_name": ep.program_name,
|
||||||
|
"air_date": ep.air_date.isoformat() if ep.air_date else None,
|
||||||
|
"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
|
||||||
|
]
|
||||||
|
|
||||||
|
yearly_target = None
|
||||||
|
if target:
|
||||||
|
yearly_target = {
|
||||||
|
"base_target": target.base_target,
|
||||||
|
"stretch_target": target.stretch_target,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"yearly_target": yearly_target,
|
||||||
|
"episodes": ep_list,
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ from app.api.yearly_targets import router as yearly_targets_router
|
|||||||
from app.api.dashboard import router as dashboard_router
|
from app.api.dashboard import router as dashboard_router
|
||||||
from app.api.schedules import router as schedules_router
|
from app.api.schedules import router as schedules_router
|
||||||
from app.api.knowledge import router as knowledge_router
|
from app.api.knowledge import router as knowledge_router
|
||||||
|
from app.api.analytics import router as analytics_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
||||||
@@ -51,6 +52,7 @@ app.include_router(users_router)
|
|||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(schedules_router)
|
app.include_router(schedules_router)
|
||||||
app.include_router(knowledge_router)
|
app.include_router(knowledge_router)
|
||||||
|
app.include_router(analytics_router)
|
||||||
|
|
||||||
# 挂载静态文件目录(题图海报)
|
# 挂载静态文件目录(题图海报)
|
||||||
_static_dir = Path(__file__).parent.parent / "static"
|
_static_dir = Path(__file__).parent.parent / "static"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
|
|||||||
import Doco from './pages/Doco/Doco'
|
import Doco from './pages/Doco/Doco'
|
||||||
import UserManage from './pages/UserManage/UserManage'
|
import UserManage from './pages/UserManage/UserManage'
|
||||||
import EditorDesk from './pages/EditorDesk/EditorDesk'
|
import EditorDesk from './pages/EditorDesk/EditorDesk'
|
||||||
|
import Analytics from './pages/Analytics/Analytics'
|
||||||
import AuthGuard from './components/AuthGuard/AuthGuard'
|
import AuthGuard from './components/AuthGuard/AuthGuard'
|
||||||
import RoleGuard from './components/AuthGuard/RoleGuard'
|
import RoleGuard from './components/AuthGuard/RoleGuard'
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function App() {
|
|||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="analytics" element={<Analytics />} />
|
||||||
<Route path="tps" element={<TPS />} />
|
<Route path="tps" element={<TPS />} />
|
||||||
<Route path="knowledge" element={<KnowledgeBase />} />
|
<Route path="knowledge" element={<KnowledgeBase />} />
|
||||||
<Route path="doco" element={<Doco />} />
|
<Route path="doco" element={<Doco />} />
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Empty } from 'antd'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
import { getShareColor } from '../../utils/ratingColors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编导对比柱状图
|
||||||
|
* - 按 editor_name_snapshot 分组
|
||||||
|
* - 每人一根水平柱子,高度 = 该编导本年平均收视份额
|
||||||
|
* - 柱子颜色按三色判定(该编导平均份额 vs 年度目标)
|
||||||
|
* - tooltip:编导名、负责期数、平均份额、最佳期次名+份额、最差期次名+份额
|
||||||
|
* - 参考线:基础目标蓝色虚线
|
||||||
|
*/
|
||||||
|
function EditorCompare({ episodes, yearlyTarget, selectedYear }) {
|
||||||
|
const chartOption = useMemo(() => {
|
||||||
|
if (!episodes || !episodes.length) return null
|
||||||
|
|
||||||
|
// 过滤有份额的期次
|
||||||
|
const withShare = episodes.filter((ep) => ep.audience_share != null)
|
||||||
|
if (withShare.length === 0) return null
|
||||||
|
|
||||||
|
// 按编导分组
|
||||||
|
const editorMap = {}
|
||||||
|
withShare.forEach((ep) => {
|
||||||
|
const name = ep.editor_name_snapshot || '未知'
|
||||||
|
if (!editorMap[name]) {
|
||||||
|
editorMap[name] = []
|
||||||
|
}
|
||||||
|
editorMap[name].push(ep)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构造 targetsForChart
|
||||||
|
const targetsForChart = yearlyTarget
|
||||||
|
? [{ year: selectedYear, ...yearlyTarget }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
// 计算每位编导的统计
|
||||||
|
const editorStats = Object.entries(editorMap).map(([name, eps]) => {
|
||||||
|
const count = eps.length
|
||||||
|
const avgShare =
|
||||||
|
eps.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
|
||||||
|
|
||||||
|
// 按份额排序找最佳/最差
|
||||||
|
const sorted = [...eps].sort(
|
||||||
|
(a, b) => Number(b.audience_share) - Number(a.audience_share)
|
||||||
|
)
|
||||||
|
const best = sorted[0]
|
||||||
|
const worst = sorted[sorted.length - 1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
avgShare: Number(avgShare.toFixed(4)),
|
||||||
|
bestShare: Number(best.audience_share).toFixed(3),
|
||||||
|
bestTitle: best.program_name || '',
|
||||||
|
worstShare: Number(worst.audience_share).toFixed(3),
|
||||||
|
worstTitle: worst.program_name || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按平均份额升序排列(水平柱状图从下到上递增)
|
||||||
|
editorStats.sort((a, b) => a.avgShare - b.avgShare)
|
||||||
|
|
||||||
|
const yData = editorStats.map((e) => e.name)
|
||||||
|
|
||||||
|
// 柱子颜色
|
||||||
|
const barData = editorStats.map((e) => ({
|
||||||
|
value: e.avgShare,
|
||||||
|
itemStyle: {
|
||||||
|
color: getShareColor(e.avgShare, targetsForChart, selectedYear),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Y 轴范围
|
||||||
|
const dataMin = Math.min(...editorStats.map((e) => e.avgShare))
|
||||||
|
const dataMax = Math.max(...editorStats.map((e) => e.avgShare))
|
||||||
|
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
||||||
|
const allMin = baseValue != null ? Math.min(dataMin, baseValue) : dataMin
|
||||||
|
const allMax = baseValue != null ? Math.max(dataMax, baseValue) : dataMax
|
||||||
|
const xMin = Math.max(0, Math.floor((allMin - 0.1) * 10) / 10)
|
||||||
|
const xMax = Math.ceil((allMax + 0.1) * 10) / 10
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params) => {
|
||||||
|
const name = params[0].name
|
||||||
|
const stat = editorStats.find((e) => e.name === name)
|
||||||
|
if (!stat) return ''
|
||||||
|
let s = `<b>${name}</b>(${stat.count}期)<br/>`
|
||||||
|
s += `平均份额:<b>${stat.avgShare}</b><br/>`
|
||||||
|
s += `最佳:${stat.bestShare}(${stat.bestTitle.substring(0, 10)})<br/>`
|
||||||
|
s += `最差:${stat.worstShare}(${stat.worstTitle.substring(0, 10)})`
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 70,
|
||||||
|
right: 40,
|
||||||
|
top: 10,
|
||||||
|
bottom: 30,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: xMin,
|
||||||
|
max: xMax,
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: yData,
|
||||||
|
axisLabel: { fontSize: 13 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: barData,
|
||||||
|
barWidth: '40%',
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
formatter: (p) => p.value.toFixed(4),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 基础目标参考线
|
||||||
|
...(baseValue != null
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
data: yData.map(() => baseValue),
|
||||||
|
lineStyle: { color: '#5b8db8', type: 'dashed', width: 1 },
|
||||||
|
symbol: 'none',
|
||||||
|
tooltip: { show: false },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [episodes, yearlyTarget, selectedYear])
|
||||||
|
|
||||||
|
if (!chartOption) {
|
||||||
|
return <Empty description="暂无编导数据" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactECharts
|
||||||
|
option={chartOption}
|
||||||
|
style={{ height: 300, width: '100%' }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditorCompare
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Empty } from 'antd'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
import { getShareColor } from '../../utils/ratingColors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确定性 jitter — 用 episode_number 做种子,保证每次渲染结果一致
|
||||||
|
* @param {number} seed - episode_number
|
||||||
|
* @param {number} range - 范围(正数),输出在 [-range, +range]
|
||||||
|
*/
|
||||||
|
function deterministicJitter(seed, range) {
|
||||||
|
return (Math.sin(seed * 12345.6789) * 10000 % 1) * range * 2 - range
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双引擎象限图 — 对齐 ai-labeling/output/l3_report.html 第 513-719 行
|
||||||
|
* Props: { episodes, yearlyTarget, selectedYear }
|
||||||
|
*/
|
||||||
|
function QuadrantChart({ episodes, yearlyTarget, selectedYear }) {
|
||||||
|
// ===== 数据准备 =====
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!episodes || !episodes.length || !yearlyTarget) return null
|
||||||
|
|
||||||
|
// 过滤:program_format 和 narrative_structure 都不为 null,且有 audience_share
|
||||||
|
const valid = episodes.filter(
|
||||||
|
(ep) =>
|
||||||
|
ep.program_format != null &&
|
||||||
|
ep.program_format !== '' &&
|
||||||
|
ep.narrative_structure != null &&
|
||||||
|
ep.narrative_structure !== '' &&
|
||||||
|
ep.audience_share != null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (valid.length === 0) return null
|
||||||
|
|
||||||
|
// 栏目整体均值(所有有份额期次)
|
||||||
|
const allWithShare = episodes.filter((ep) => ep.audience_share != null)
|
||||||
|
const overallMean =
|
||||||
|
allWithShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) /
|
||||||
|
allWithShare.length
|
||||||
|
|
||||||
|
// 按 program_format 分组,计算各题材均值
|
||||||
|
const groupMap = {}
|
||||||
|
valid.forEach((ep) => {
|
||||||
|
const fmt = ep.program_format
|
||||||
|
if (!groupMap[fmt]) groupMap[fmt] = []
|
||||||
|
groupMap[fmt].push(Number(ep.audience_share))
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDeviation = {}
|
||||||
|
Object.entries(groupMap).forEach(([fmt, shares]) => {
|
||||||
|
const avg = shares.reduce((s, v) => s + v, 0) / shares.length
|
||||||
|
formatDeviation[fmt] = avg - overallMean
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构造 scatter 数据
|
||||||
|
const targetsArray = [{ year: selectedYear, ...yearlyTarget }]
|
||||||
|
const scatterData = valid.map((ep) => {
|
||||||
|
const share = Number(ep.audience_share)
|
||||||
|
const deviation = formatDeviation[ep.program_format] || 0
|
||||||
|
const jitterX = deterministicJitter(ep.episode_number, 0.04)
|
||||||
|
|
||||||
|
// 纵轴:主线演进 → 1,并列结构 → 0
|
||||||
|
const yBase = ep.narrative_structure === '主线演进' ? 1 : 0
|
||||||
|
const jitterY = deterministicJitter(ep.episode_number * 7, 0.15)
|
||||||
|
|
||||||
|
const x = deviation + jitterX
|
||||||
|
const y = yBase + jitterY
|
||||||
|
|
||||||
|
// 气泡大小
|
||||||
|
const symbolSize = 14 + (share - 0.3) * 35
|
||||||
|
|
||||||
|
// 颜色(三色判定,复用 ratingColors.js)
|
||||||
|
const color = getShareColor(share, targetsArray, selectedYear)
|
||||||
|
|
||||||
|
// 边框(根据 opening_hook)
|
||||||
|
let borderConfig = {}
|
||||||
|
if (ep.opening_hook === '强') {
|
||||||
|
borderConfig = { borderWidth: 3, borderColor: '#333', borderType: 'solid' }
|
||||||
|
} else if (ep.opening_hook === '弱') {
|
||||||
|
borderConfig = { borderWidth: 2, borderColor: '#aaa', borderType: [4, 4] }
|
||||||
|
} else {
|
||||||
|
// '中' 或默认
|
||||||
|
borderConfig = { borderWidth: 1.5, borderColor: '#999', borderType: 'solid' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: [x, y],
|
||||||
|
symbolSize,
|
||||||
|
itemStyle: {
|
||||||
|
color,
|
||||||
|
opacity: 0.85,
|
||||||
|
...borderConfig,
|
||||||
|
},
|
||||||
|
_ep: ep.episode_number,
|
||||||
|
_title: ep.program_name,
|
||||||
|
_share: share,
|
||||||
|
_hook: ep.opening_hook || '中',
|
||||||
|
_format: ep.program_format,
|
||||||
|
_editor: ep.editor_name_snapshot || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 动态计算 x 轴范围
|
||||||
|
const xValues = scatterData.map((d) => d.value[0])
|
||||||
|
const xDataMin = Math.min(...xValues)
|
||||||
|
const xDataMax = Math.max(...xValues)
|
||||||
|
const xPadding = 0.05
|
||||||
|
const xMin = Math.min(-0.25, Math.floor((xDataMin - xPadding) * 100) / 100)
|
||||||
|
const xMax = Math.max(0.22, Math.ceil((xDataMax + xPadding) * 100) / 100)
|
||||||
|
|
||||||
|
// 按气泡大小降序排列,大的先画、小的后画(渲染在上层)
|
||||||
|
scatterData.sort((a, b) => b.symbolSize - a.symbolSize)
|
||||||
|
|
||||||
|
return { scatterData, overallMean, formatDeviation, xMin, xMax }
|
||||||
|
}, [episodes, yearlyTarget, selectedYear])
|
||||||
|
|
||||||
|
// ===== ECharts 配置 =====
|
||||||
|
const option = useMemo(() => {
|
||||||
|
if (!chartData) return null
|
||||||
|
|
||||||
|
const { scatterData, xMin, xMax } = chartData
|
||||||
|
const base = Number(yearlyTarget.base_target)
|
||||||
|
const stretch = Number(yearlyTarget.stretch_target)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: function (p) {
|
||||||
|
var d = p.data
|
||||||
|
var hookMap = { '强': '强 ●●●', '中': '中 ●●○', '弱': '弱 ●○○' }
|
||||||
|
var colorLabel = d._share > stretch ? '优秀' : d._share >= base ? '达标' : '待提升'
|
||||||
|
return (
|
||||||
|
'<b>第' + d._ep + '期 ' + d._title + '</b><br/>' +
|
||||||
|
'编导:' + d._editor + ' | 题材:' + d._format + '<br/>' +
|
||||||
|
'份额:<b>' + d._share.toFixed(3) + '</b>(' + colorLabel + ')<br/>' +
|
||||||
|
'开篇钩子:' + (hookMap[d._hook] || d._hook)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
backgroundColor: 'rgba(255,255,250,0.95)',
|
||||||
|
borderColor: '#ddd',
|
||||||
|
textStyle: { color: '#333', fontSize: 13 },
|
||||||
|
},
|
||||||
|
grid: { left: 80, right: 80, top: 50, bottom: 50 },
|
||||||
|
xAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '热门题材 →',
|
||||||
|
nameLocation: 'end',
|
||||||
|
nameGap: 5,
|
||||||
|
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||||
|
min: xMin,
|
||||||
|
max: xMax,
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLine: { lineStyle: { color: '#999', width: 1.5 } },
|
||||||
|
axisTick: { show: true, lineStyle: { color: '#bbb' } },
|
||||||
|
axisLabel: {
|
||||||
|
formatter: function (v) {
|
||||||
|
if (v === 0) return ''
|
||||||
|
return (v > 0 ? '+' : '') + v.toFixed(2)
|
||||||
|
},
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '← 冷门题材',
|
||||||
|
nameLocation: 'start',
|
||||||
|
nameGap: 5,
|
||||||
|
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||||
|
min: xMin,
|
||||||
|
max: xMax,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '故事化 ↑',
|
||||||
|
nameLocation: 'end',
|
||||||
|
nameGap: 12,
|
||||||
|
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||||
|
min: -0.3,
|
||||||
|
max: 1.3,
|
||||||
|
interval: 1,
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLine: { lineStyle: { color: '#999', width: 1.5 } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
formatter: function (v) {
|
||||||
|
if (v === 0) return '并列结构'
|
||||||
|
if (v === 1) return '主线演进'
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
color: '#4a6741',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '板块化 ↓',
|
||||||
|
nameLocation: 'start',
|
||||||
|
nameGap: 12,
|
||||||
|
nameTextStyle: { color: '#999', fontSize: 12 },
|
||||||
|
min: -0.3,
|
||||||
|
max: 1.3,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
// 主散点
|
||||||
|
{
|
||||||
|
type: 'scatter',
|
||||||
|
data: scatterData,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: { opacity: 1, shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)' },
|
||||||
|
scale: 1.2,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
formatter: function (p) {
|
||||||
|
var title = p.data._title || ''
|
||||||
|
return title.length > 5 ? title.substring(0, 5) + '..' : title
|
||||||
|
},
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#999',
|
||||||
|
distance: 5,
|
||||||
|
},
|
||||||
|
labelLayout: { hideOverlap: true },
|
||||||
|
},
|
||||||
|
// 零线(x=0 竖虚线)
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
data: [[0, -0.3], [0, 1.3]],
|
||||||
|
lineStyle: { color: '#ccc', type: 'dashed', width: 1 },
|
||||||
|
symbol: 'none',
|
||||||
|
silent: true,
|
||||||
|
z: 0,
|
||||||
|
},
|
||||||
|
// 并列结构水平带
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
data: [[xMin, 0], [xMax, 0]],
|
||||||
|
lineStyle: { color: '#e8e4d8', width: 1 },
|
||||||
|
symbol: 'none',
|
||||||
|
silent: true,
|
||||||
|
z: 0,
|
||||||
|
},
|
||||||
|
// 主线演进水平带
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
data: [[xMin, 1], [xMax, 1]],
|
||||||
|
lineStyle: { color: '#e8e4d8', width: 1 },
|
||||||
|
symbol: 'none',
|
||||||
|
silent: true,
|
||||||
|
z: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
graphic: [
|
||||||
|
// 左上:冷题材 + 好结构
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
left: 90,
|
||||||
|
top: 35,
|
||||||
|
style: {
|
||||||
|
text: '冷题材 + 好结构\n逆袭区:靠叙事拉升',
|
||||||
|
fill: '#aaa',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 右上:热题材 + 好结构
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
right: 90,
|
||||||
|
top: 35,
|
||||||
|
style: {
|
||||||
|
text: '热题材 + 好结构\n双引擎全开',
|
||||||
|
fill: '#aaa',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 左下:冷题材 + 弱结构
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
left: 90,
|
||||||
|
bottom: 55,
|
||||||
|
style: {
|
||||||
|
text: '冷题材 + 弱结构\n⚠ 高风险区',
|
||||||
|
fill: '#c0584f',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 右下:热题材 + 弱结构
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
right: 90,
|
||||||
|
bottom: 55,
|
||||||
|
style: {
|
||||||
|
text: '热题材 + 弱结构\n题材托底',
|
||||||
|
fill: '#aaa',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [chartData, yearlyTarget])
|
||||||
|
|
||||||
|
// ===== 读图说明文字(动态生成) =====
|
||||||
|
const instructionText = useMemo(() => {
|
||||||
|
if (!chartData) return ''
|
||||||
|
const { overallMean, formatDeviation } = chartData
|
||||||
|
const meanStr = overallMean.toFixed(4)
|
||||||
|
|
||||||
|
// 按偏离值降序列出各题材
|
||||||
|
const entries = Object.entries(formatDeviation).sort((a, b) => b[1] - a[1])
|
||||||
|
const examples = entries
|
||||||
|
.map(([fmt, dev]) => `"${fmt}" 偏离 ${dev >= 0 ? '+' : ''}${dev.toFixed(3)}(${dev >= 0 ? '热门' : '偏冷'})`)
|
||||||
|
.join(';')
|
||||||
|
|
||||||
|
return `横轴"题材热度"= 该题材类别所有期次的平均份额 − 栏目整体均值(${meanStr}),反映题材自带的收视基础。例如 ${examples}。同一题材的节目共享横轴位置。纵轴为叙事结构两档分类(非连续刻度):上行 = 主线演进,下行 = 并列结构,行内散布仅为避免气泡重叠。气泡越大 = 收视份额越高;颜色 = 三色判定;边框粗细 = 开篇钩子强度。`
|
||||||
|
}, [chartData])
|
||||||
|
|
||||||
|
// ===== 空状态 =====
|
||||||
|
if (!chartData) {
|
||||||
|
return <Empty description="暂无双引擎分析数据(需 AI 标签)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ── 图例栏 ── */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#888',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>气泡大小 = 收视份额</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '3px solid #333',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
强钩子
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '1.5px solid #999',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
中钩子
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '1.5px dashed #999',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
弱钩子
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#c0584f',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
优秀
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#5b8db8',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
达标
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#7aa874',
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
待提升
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── ECharts 象限图 ── */}
|
||||||
|
<ReactECharts option={option} style={{ height: 520, width: '100%' }} notMerge />
|
||||||
|
|
||||||
|
{/* ── 读图说明 ── */}
|
||||||
|
<div style={{ fontSize: 11, color: '#aaa', lineHeight: 1.7, marginTop: 8, padding: '0 4px' }}>
|
||||||
|
<b style={{ color: '#999' }}>读图说明</b>|{instructionText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuadrantChart
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Empty, Table } from 'antd'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
import { getShareColor } from '../../utils/ratingColors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 季度对比 — 含汇总表格 + 柱状图
|
||||||
|
* 对齐 l3_report.html 原型:
|
||||||
|
* - 上方:汇总表格(季度/期数/平均份额/基础完成率/达标率)
|
||||||
|
* - 下方:垂直柱状图(柱子着色,参考线)
|
||||||
|
*/
|
||||||
|
function QuarterCompare({ episodes, yearlyTarget, selectedYear }) {
|
||||||
|
const { quarterStats, chartOption, tableData } = useMemo(() => {
|
||||||
|
if (!episodes || !episodes.length)
|
||||||
|
return { quarterStats: null, chartOption: null, tableData: [] }
|
||||||
|
|
||||||
|
const withShare = episodes.filter(
|
||||||
|
(ep) => ep.audience_share != null && ep.air_date
|
||||||
|
)
|
||||||
|
if (withShare.length === 0)
|
||||||
|
return { quarterStats: null, chartOption: null, tableData: [] }
|
||||||
|
|
||||||
|
// 按季度分组
|
||||||
|
const quarterMap = { Q1: [], Q2: [], Q3: [], Q4: [] }
|
||||||
|
withShare.forEach((ep) => {
|
||||||
|
const month = new Date(ep.air_date).getMonth() + 1
|
||||||
|
if (month <= 3) quarterMap.Q1.push(ep)
|
||||||
|
else if (month <= 6) quarterMap.Q2.push(ep)
|
||||||
|
else if (month <= 9) quarterMap.Q3.push(ep)
|
||||||
|
else quarterMap.Q4.push(ep)
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetsForChart = yearlyTarget
|
||||||
|
? [{ year: selectedYear, ...yearlyTarget }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const baseValue = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
||||||
|
const stretchValue = yearlyTarget
|
||||||
|
? Number(yearlyTarget.stretch_target)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 只保留有数据的季度
|
||||||
|
const quarterNames = ['Q1', 'Q2', 'Q3', 'Q4']
|
||||||
|
const quarterStats = quarterNames
|
||||||
|
.filter((q) => quarterMap[q].length > 0)
|
||||||
|
.map((q) => {
|
||||||
|
const eps = quarterMap[q]
|
||||||
|
const count = eps.length
|
||||||
|
const avgShare =
|
||||||
|
eps.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
|
||||||
|
|
||||||
|
// 计算基础完成率和达标率
|
||||||
|
let baseCompletion = '—'
|
||||||
|
let passRate = '—'
|
||||||
|
let passCount = 0
|
||||||
|
|
||||||
|
if (baseValue != null) {
|
||||||
|
baseCompletion = ((avgShare / baseValue) * 100).toFixed(1) + '%'
|
||||||
|
passCount = eps.filter(
|
||||||
|
(ep) => Number(ep.audience_share) >= baseValue
|
||||||
|
).length
|
||||||
|
passRate =
|
||||||
|
count > 0
|
||||||
|
? ((passCount / count) * 100).toFixed(1) + '%'
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: q,
|
||||||
|
count,
|
||||||
|
avgShare: Number(avgShare.toFixed(4)),
|
||||||
|
baseCompletion,
|
||||||
|
passRate,
|
||||||
|
passCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (quarterStats.length === 0)
|
||||||
|
return { quarterStats: null, chartOption: null, tableData: [] }
|
||||||
|
|
||||||
|
// 表格数据
|
||||||
|
const tableData = quarterStats.map((q) => ({
|
||||||
|
key: q.name,
|
||||||
|
quarter: q.name,
|
||||||
|
count: q.count,
|
||||||
|
avgShare: q.avgShare,
|
||||||
|
baseCompletion: q.baseCompletion,
|
||||||
|
passRate: q.passRate,
|
||||||
|
passCount: q.passCount,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 图表配置
|
||||||
|
const xData = quarterStats.map((q) => q.name)
|
||||||
|
const barData = quarterStats.map((q) => ({
|
||||||
|
value: q.avgShare,
|
||||||
|
itemStyle: {
|
||||||
|
color: getShareColor(q.avgShare, targetsForChart, selectedYear),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dataMin = Math.min(...quarterStats.map((q) => q.avgShare))
|
||||||
|
const dataMax = Math.max(...quarterStats.map((q) => q.avgShare))
|
||||||
|
const allMin =
|
||||||
|
baseValue != null ? Math.min(dataMin, baseValue) : dataMin
|
||||||
|
const allMax =
|
||||||
|
stretchValue != null ? Math.max(dataMax, stretchValue) : dataMax
|
||||||
|
const yMin = Math.max(0, Math.floor((allMin - 0.1) * 10) / 10)
|
||||||
|
const yMax = Math.ceil((allMax + 0.1) * 10) / 10
|
||||||
|
|
||||||
|
const markLineData = []
|
||||||
|
if (baseValue != null) {
|
||||||
|
markLineData.push({
|
||||||
|
yAxis: baseValue,
|
||||||
|
lineStyle: { color: '#93b8d7', type: 'dashed', width: 1 },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'insideEndTop',
|
||||||
|
formatter: '基础',
|
||||||
|
color: '#93b8d7',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (stretchValue != null) {
|
||||||
|
markLineData.push({
|
||||||
|
yAxis: stretchValue,
|
||||||
|
lineStyle: { color: '#d9a09a', type: 'dashed', width: 1 },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'insideEndTop',
|
||||||
|
formatter: '摸高',
|
||||||
|
color: '#d9a09a',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params) => {
|
||||||
|
const name = params[0].name
|
||||||
|
const stat = quarterStats.find((q) => q.name === name)
|
||||||
|
if (!stat) return ''
|
||||||
|
return `<b>${name}</b><br/>期数:${stat.count} 期<br/>平均份额:<b>${stat.avgShare}</b><br/>基础完成率:${stat.baseCompletion}<br/>达标率:${stat.passRate}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 50,
|
||||||
|
right: 30,
|
||||||
|
top: 20,
|
||||||
|
bottom: 30,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: xData,
|
||||||
|
axisLabel: { fontSize: 12 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: yMin,
|
||||||
|
max: yMax,
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: barData,
|
||||||
|
barWidth: '40%',
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
formatter: (p) => p.value.toFixed(4),
|
||||||
|
},
|
||||||
|
...(markLineData.length > 0
|
||||||
|
? { markLine: { symbol: ['none', 'none'], data: markLineData, silent: true } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return { quarterStats, chartOption, tableData }
|
||||||
|
}, [episodes, yearlyTarget, selectedYear])
|
||||||
|
|
||||||
|
if (!chartOption || !quarterStats) {
|
||||||
|
return <Empty description="暂无季度数据" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '季度',
|
||||||
|
dataIndex: 'quarter',
|
||||||
|
key: 'quarter',
|
||||||
|
render: (text) => <span style={{ fontWeight: 600 }}>{text}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '期数',
|
||||||
|
dataIndex: 'count',
|
||||||
|
key: 'count',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平均份额',
|
||||||
|
dataIndex: 'avgShare',
|
||||||
|
key: 'avgShare',
|
||||||
|
render: (value) => {
|
||||||
|
const color = getShareColor(
|
||||||
|
value,
|
||||||
|
yearlyTarget ? [{ year: selectedYear, ...yearlyTarget }] : [],
|
||||||
|
selectedYear
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span style={{ color, fontWeight: 600 }}>{value.toFixed(4)}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '基础完成率',
|
||||||
|
dataIndex: 'baseCompletion',
|
||||||
|
key: 'baseCompletion',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '达标率',
|
||||||
|
dataIndex: 'passRate',
|
||||||
|
key: 'passRate',
|
||||||
|
align: 'center',
|
||||||
|
render: (value, record) => (
|
||||||
|
<span>
|
||||||
|
{value}({record.passCount}/{record.count})
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quarter-compare">
|
||||||
|
{/* 汇总表格 */}
|
||||||
|
<div className="quarter-table">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={tableData}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 柱状图 */}
|
||||||
|
<div className="quarter-chart">
|
||||||
|
<ReactECharts
|
||||||
|
option={chartOption}
|
||||||
|
style={{ height: 280, width: '100%' }}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuarterCompare
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Empty } from 'antd'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题材对比 — 饼图(期数占比)+ 水平柱图(按平均份额排名)
|
||||||
|
* 右列通高,对齐 l3_report.html 原型
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOPIC_COLORS = {
|
||||||
|
'装备深解': '#5b8db8',
|
||||||
|
'历史纵深': '#e8a838',
|
||||||
|
'前沿科技': '#7aa874',
|
||||||
|
'横切类比': '#9b7eb8',
|
||||||
|
'人物牵引': '#d4816b',
|
||||||
|
'事件战例': '#9b7eb8',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 hex 颜色转为 rgba 字符串
|
||||||
|
*/
|
||||||
|
function hexToRgba(hex, alpha) {
|
||||||
|
const h = hex.replace('#', '')
|
||||||
|
const r = parseInt(h.substring(0, 2), 16)
|
||||||
|
const g = parseInt(h.substring(2, 4), 16)
|
||||||
|
const b = parseInt(h.substring(4, 6), 16)
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopicCompare({ episodes }) {
|
||||||
|
// 过滤有 program_format 的期次
|
||||||
|
const validEpisodes = useMemo(() => {
|
||||||
|
if (!episodes || !episodes.length) return []
|
||||||
|
return episodes.filter((ep) => ep.program_format != null && ep.program_format !== '')
|
||||||
|
}, [episodes])
|
||||||
|
|
||||||
|
// 按 program_format 分组统计
|
||||||
|
const topicStats = useMemo(() => {
|
||||||
|
if (!validEpisodes.length) return null
|
||||||
|
|
||||||
|
const groupMap = {}
|
||||||
|
validEpisodes.forEach((ep) => {
|
||||||
|
const format = ep.program_format
|
||||||
|
if (!groupMap[format]) {
|
||||||
|
groupMap[format] = []
|
||||||
|
}
|
||||||
|
groupMap[format].push(ep)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.entries(groupMap).map(([name, eps]) => {
|
||||||
|
const count = eps.length
|
||||||
|
const withShare = eps.filter((ep) => ep.audience_share != null)
|
||||||
|
const avgShare =
|
||||||
|
withShare.length > 0
|
||||||
|
? withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / withShare.length
|
||||||
|
: 0
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
avgShare: Number(avgShare.toFixed(4)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [validEpisodes])
|
||||||
|
|
||||||
|
// 饼图配置
|
||||||
|
const pieOption = useMemo(() => {
|
||||||
|
if (!topicStats || !topicStats.length) return null
|
||||||
|
|
||||||
|
// 按期数排序
|
||||||
|
const sorted = [...topicStats].sort((a, b) => b.count - a.count)
|
||||||
|
const data = sorted.map((t) => ({ name: t.name, value: t.count }))
|
||||||
|
const colorArr = sorted.map((t) => TOPIC_COLORS[t.name] || '#999')
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (params) => {
|
||||||
|
return params.name + '<br/>期数:' + params.value + ' 期(' + params.percent + '%)'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
center: ['50%', '55%'],
|
||||||
|
data,
|
||||||
|
color: colorArr,
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: '{b}\n{c}期 ({d}%)',
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: { fontSize: 14, fontWeight: 'bold' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [topicStats])
|
||||||
|
|
||||||
|
// 水平柱图配置
|
||||||
|
const barOption = useMemo(() => {
|
||||||
|
if (!topicStats || !topicStats.length) return null
|
||||||
|
|
||||||
|
// 按平均份额升序排列(最低在下,最高在上)
|
||||||
|
const sorted = [...topicStats].sort((a, b) => a.avgShare - b.avgShare)
|
||||||
|
const yData = sorted.map((t) => t.name)
|
||||||
|
const countMap = {}
|
||||||
|
sorted.forEach((t) => {
|
||||||
|
countMap[t.name] = t.count
|
||||||
|
})
|
||||||
|
|
||||||
|
// 每根柱子渐变填充 + 胶囊圆角,对齐原型 LinearGradient 写法
|
||||||
|
const barData = sorted.map((t) => {
|
||||||
|
const color = TOPIC_COLORS[t.name] || '#999'
|
||||||
|
return {
|
||||||
|
value: t.avgShare,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
|
{ offset: 0, color: hexToRgba(color, 0.15) },
|
||||||
|
{ offset: 1, color },
|
||||||
|
]),
|
||||||
|
borderRadius: [0, 10, 10, 0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// x 轴范围
|
||||||
|
const values = sorted.map((t) => t.avgShare)
|
||||||
|
const dataMin = Math.min(...values)
|
||||||
|
const dataMax = Math.max(...values)
|
||||||
|
const xMin = Math.max(0, Math.floor((dataMin - 0.1) * 10) / 10)
|
||||||
|
const xMax = Math.ceil((dataMax + 0.1) * 10) / 10
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params) => {
|
||||||
|
const name = params[0].name
|
||||||
|
const count = countMap[name] || 0
|
||||||
|
return name + '<br/>平均份额:' + Number(params[0].value).toFixed(3) + '<br/>期数:' + count
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { left: 80, right: 30, top: 10, bottom: 30 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: xMin,
|
||||||
|
max: xMax,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: yData,
|
||||||
|
axisLabel: { fontSize: 12 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: barData,
|
||||||
|
barWidth: '50%',
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
fontSize: 11,
|
||||||
|
formatter: (p) => p.value.toFixed(3),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [topicStats])
|
||||||
|
|
||||||
|
if (!validEpisodes.length) {
|
||||||
|
return <Empty description="暂无题材标签数据" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ReactECharts option={pieOption} style={{ height: 280 }} notMerge />
|
||||||
|
<ReactECharts option={barOption} style={{ height: 280 }} notMerge />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicCompare
|
||||||
@@ -5,13 +5,19 @@
|
|||||||
.app-sider {
|
.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 {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
SoundOutlined,
|
SoundOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import useAuthStore from '../../stores/authStore'
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ function SideNav() {
|
|||||||
|
|
||||||
const allMenuItems = [
|
const allMenuItems = [
|
||||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
|
{ key: '/analytics', icon: <LineChartOutlined />, label: '收视分析' },
|
||||||
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
|
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
|
||||||
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
|
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
|
||||||
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
|
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
|
||||||
@@ -53,6 +55,8 @@ function SideNav() {
|
|||||||
const visibleItems = allMenuItems.filter(item => {
|
const visibleItems = allMenuItems.filter(item => {
|
||||||
// /dashboard 三角色都可见
|
// /dashboard 三角色都可见
|
||||||
if (item.key === '/dashboard') return true
|
if (item.key === '/dashboard') return true
|
||||||
|
// /analytics 三角色都可见
|
||||||
|
if (item.key === '/analytics') return true
|
||||||
// /users 仅 zhipianren
|
// /users 仅 zhipianren
|
||||||
if (item.key === '/users') return String(user?.role) === 'zhipianren'
|
if (item.key === '/users') return String(user?.role) === 'zhipianren'
|
||||||
// /editor-desk 仅 zhipianren + zebian
|
// /editor-desk 仅 zhipianren + zebian
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/* ===== 收视分析页面 — 对齐 l3_report.html 原型 ===== */
|
||||||
|
|
||||||
|
.analytics-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-loading p {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 图例栏(指标卡上方,居中) ── */
|
||||||
|
.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.55);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-radius: 16px;
|
||||||
|
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 6px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 摸高目标完成率 — 四档动态效果 */
|
||||||
|
|
||||||
|
/* ≥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: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
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.55);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
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 0 14px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a6741;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 图例(保留在走势图内备用,但主要用 legend-bar) ── */
|
||||||
|
.analytics-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
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: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-legend-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
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 {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-year-select {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-kpi-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-kpi-value {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-chart-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-compare-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-compare-section .analytics-topic-section {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { Select, Spin, Empty } from 'antd'
|
||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收视分析页面 — 对齐 l3_report.html 原型
|
||||||
|
* - 居中大标题 + 副标题
|
||||||
|
* - 三色图例(指标卡上方)
|
||||||
|
* - 5 张指标卡(平均份额 / 摸高完成率 / 最高份额 / 最低份额 / 达标期数)
|
||||||
|
* - 收视走势:柱状图 + 折线图组合(柱子三色着色,柱顶节目名,橙色趋势线)
|
||||||
|
* - 年份选择器(HTML 原型无,保留功能)
|
||||||
|
* - dataZoom 滑块保留
|
||||||
|
* - 空状态兜底保留
|
||||||
|
*/
|
||||||
|
function Analytics() {
|
||||||
|
const [years, setYears] = useState([])
|
||||||
|
const [selectedYear, setSelectedYear] = useState(null)
|
||||||
|
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(() => {
|
||||||
|
getAvailableYears()
|
||||||
|
.then((data) => {
|
||||||
|
setYears(data || [])
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setSelectedYear(data[0])
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 年份切换时获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedYear == null) return
|
||||||
|
setLoading(true)
|
||||||
|
getAnalyticsEpisodes(selectedYear)
|
||||||
|
.then((data) => {
|
||||||
|
setEpisodes(data.episodes || [])
|
||||||
|
setYearlyTarget(data.yearly_target)
|
||||||
|
setLoadTime(new Date())
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setEpisodes([])
|
||||||
|
setYearlyTarget(null)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [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 = filteredEpisodes
|
||||||
|
const count = withShare.length
|
||||||
|
if (count === 0) {
|
||||||
|
return {
|
||||||
|
count: 0,
|
||||||
|
avgShare: null,
|
||||||
|
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
|
||||||
|
|
||||||
|
const base = yearlyTarget ? Number(yearlyTarget.base_target) : null
|
||||||
|
const stretch = yearlyTarget ? Number(yearlyTarget.stretch_target) : null
|
||||||
|
|
||||||
|
// 平均份额颜色
|
||||||
|
let avgShareColor = '#999'
|
||||||
|
if (base != null && stretch != null) {
|
||||||
|
if (avgShare > stretch) avgShareColor = '#c0584f'
|
||||||
|
else if (avgShare >= base) avgShareColor = '#5b8db8'
|
||||||
|
else avgShareColor = '#7aa874'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成率
|
||||||
|
let baseRate = '—'
|
||||||
|
let stretchRate = '—'
|
||||||
|
let stretchRateColor = '#7aa874'
|
||||||
|
let passCount = 0
|
||||||
|
let excellentCount = 0
|
||||||
|
|
||||||
|
if (base != null) {
|
||||||
|
baseRate = ((avgShare / base) * 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.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',
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filteredEpisodes, yearlyTarget])
|
||||||
|
|
||||||
|
// ===== 副标题 =====
|
||||||
|
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) => String(ep.episode_number))
|
||||||
|
|
||||||
|
const targetsForChart = yearlyTarget
|
||||||
|
? [{ year: selectedYear, ...yearlyTarget }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
// 柱状图数据 — 每根柱子单独着色
|
||||||
|
const barData = withShare.map((ep) => ({
|
||||||
|
value: Number(ep.audience_share),
|
||||||
|
itemStyle: {
|
||||||
|
color: getShareColor(ep.audience_share, targetsForChart, selectedYear),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 滚动 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: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params) => {
|
||||||
|
const idx = params[0].dataIndex
|
||||||
|
const ep = withShare[idx]
|
||||||
|
if (!ep) return ''
|
||||||
|
const share = Number(ep.audience_share).toFixed(4)
|
||||||
|
const editor = ep.editor_name_snapshot || '未知'
|
||||||
|
const date = ep.air_date || ''
|
||||||
|
const label = getColorLabel(ep.audience_share, targetsForChart, selectedYear)
|
||||||
|
let s = `<strong>第${ep.episode_number}期 ${ep.program_name}</strong><br/>`
|
||||||
|
s += `${date} | ${editor}<br/>`
|
||||||
|
s += `份额:<strong>${share}</strong>(${label})`
|
||||||
|
const rollingParam = params.find((p) => p.seriesName === '滚动3期均值')
|
||||||
|
if (rollingParam && rollingParam.value != null) {
|
||||||
|
s += `<br/>滚动3期均值:${Number(rollingParam.value).toFixed(4)}`
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 50,
|
||||||
|
right: 30,
|
||||||
|
top: 80,
|
||||||
|
bottom: 60,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: xData,
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: yMin,
|
||||||
|
max: yMax,
|
||||||
|
axisLabel: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
bottom: 10,
|
||||||
|
height: 20,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
fillerColor: 'rgba(107,142,107,0.15)',
|
||||||
|
handleStyle: { color: '#6b8e6b' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
// 柱状图 — 收视份额
|
||||||
|
{
|
||||||
|
name: '收视份额',
|
||||||
|
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])
|
||||||
|
|
||||||
|
// ===== dataZoom 事件:只更新 zoomRange,不立即刷新统计 =====
|
||||||
|
const chartEvents = {
|
||||||
|
datazoom: (params) => {
|
||||||
|
if (params.batch) {
|
||||||
|
setZoomRange([params.batch[0].start, params.batch[0].end])
|
||||||
|
} else {
|
||||||
|
setZoomRange([params.start, params.end])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData =
|
||||||
|
episodes.length > 0 && episodes.some((ep) => ep.audience_share != null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="analytics-page">
|
||||||
|
{/* ── 页面头部:居中标题 + 副标题 + 年份选择器 ── */}
|
||||||
|
<div className="analytics-header">
|
||||||
|
<div className="analytics-header-center">
|
||||||
|
<h1 className="analytics-page-title">
|
||||||
|
军事科技 {selectedYear} 年度收视分析
|
||||||
|
</h1>
|
||||||
|
{hasData && <p className="analytics-subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
className="analytics-year-select"
|
||||||
|
value={selectedYear}
|
||||||
|
onChange={setSelectedYear}
|
||||||
|
placeholder="选择年份"
|
||||||
|
loading={years.length === 0}
|
||||||
|
options={years.map((y) => ({ label: `${y} 年`, value: y }))}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="analytics-loading">
|
||||||
|
<Spin size="large" />
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
) : !hasData ? (
|
||||||
|
<div className="analytics-empty">
|
||||||
|
<Empty description="暂无收视数据" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── 图例栏(指标卡上方,居中) ── */}
|
||||||
|
<div className="analytics-legend-bar">
|
||||||
|
<span className="analytics-legend-item">
|
||||||
|
<span
|
||||||
|
className="analytics-legend-dot"
|
||||||
|
style={{ background: '#c0584f' }}
|
||||||
|
/>
|
||||||
|
优秀({'>摸高'})
|
||||||
|
</span>
|
||||||
|
<span className="analytics-legend-item">
|
||||||
|
<span
|
||||||
|
className="analytics-legend-dot"
|
||||||
|
style={{ background: '#5b8db8' }}
|
||||||
|
/>
|
||||||
|
达标(基础~摸高)
|
||||||
|
</span>
|
||||||
|
<span className="analytics-legend-item">
|
||||||
|
<span
|
||||||
|
className="analytics-legend-dot"
|
||||||
|
style={{ background: '#7aa874' }}
|
||||||
|
/>
|
||||||
|
待提升({'<基础'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 指标卡 — 5 列网格 ── */}
|
||||||
|
<div className="analytics-kpi-grid">
|
||||||
|
{/* 卡 1:平均份额 */}
|
||||||
|
<div className="analytics-kpi-card">
|
||||||
|
<div
|
||||||
|
className="analytics-kpi-value"
|
||||||
|
style={{ color: kpiMetrics.avgShareColor }}
|
||||||
|
>
|
||||||
|
{kpiMetrics.avgShare || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="analytics-kpi-desc">平均份额</div>
|
||||||
|
<div className="analytics-kpi-sub">
|
||||||
|
基础完成率 {kpiMetrics.baseRate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡 2:摸高目标完成率(四档动态效果) */}
|
||||||
|
<div className={`analytics-kpi-card ${kpiMetrics.stretchPulseClass}`}>
|
||||||
|
<div
|
||||||
|
className="analytics-kpi-value"
|
||||||
|
style={{ color: kpiMetrics.stretchRateColor }}
|
||||||
|
>
|
||||||
|
{kpiMetrics.stretchPulseClass === 'analytics-kpi-card-excellent' && '🎉 '}
|
||||||
|
{kpiMetrics.stretchRate}
|
||||||
|
</div>
|
||||||
|
<div className="analytics-kpi-desc">摸高目标完成率</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡 3:最高份额 */}
|
||||||
|
<div className="analytics-kpi-card">
|
||||||
|
<div
|
||||||
|
className="analytics-kpi-value"
|
||||||
|
style={{ color: kpiMetrics.maxShareColor }}
|
||||||
|
>
|
||||||
|
{kpiMetrics.maxShare || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="analytics-kpi-desc">最高份额</div>
|
||||||
|
<div className="analytics-kpi-sub">
|
||||||
|
第{kpiMetrics.maxShareEpisode?.episode_number}期{' '}
|
||||||
|
{truncateName(kpiMetrics.maxShareEpisode?.program_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡 4:最低份额 */}
|
||||||
|
<div className="analytics-kpi-card">
|
||||||
|
<div
|
||||||
|
className="analytics-kpi-value"
|
||||||
|
style={{ color: kpiMetrics.minShareColor }}
|
||||||
|
>
|
||||||
|
{kpiMetrics.minShare || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="analytics-kpi-desc">最低份额</div>
|
||||||
|
<div className="analytics-kpi-sub">
|
||||||
|
第{kpiMetrics.minShareEpisode?.episode_number}期{' '}
|
||||||
|
{truncateName(kpiMetrics.minShareEpisode?.program_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡 5:达标期数 */}
|
||||||
|
<div className="analytics-kpi-card">
|
||||||
|
<div className="analytics-kpi-value">
|
||||||
|
{kpiMetrics.passCount}
|
||||||
|
<span className="analytics-kpi-value-total">
|
||||||
|
/{kpiMetrics.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="analytics-kpi-desc">达标期数</div>
|
||||||
|
<div className="analytics-kpi-sub">
|
||||||
|
达标率 {kpiMetrics.passRate} | 优秀{' '}
|
||||||
|
{kpiMetrics.excellentCount} 期
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 收视走势图(柱状图 + 折线图组合) ── */}
|
||||||
|
<div className="analytics-chart-card">
|
||||||
|
<h2 className="analytics-chart-title">收视走势</h2>
|
||||||
|
<ReactECharts
|
||||||
|
ref={chartRef}
|
||||||
|
option={chartOption}
|
||||||
|
style={{ height: 380, width: '100%' }}
|
||||||
|
notMerge
|
||||||
|
onEvents={chartEvents}
|
||||||
|
/>
|
||||||
|
{(zoomRange[0] !== appliedRange[0] ||
|
||||||
|
zoomRange[1] !== appliedRange[1]) && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setAppliedRange([...zoomRange])}
|
||||||
|
style={{
|
||||||
|
background: '#6b8e6b',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '6px 24px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
应用此范围
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setZoomRange([0, 100])
|
||||||
|
setAppliedRange([0, 100])
|
||||||
|
const chart = chartRef.current?.getEchartsInstance()
|
||||||
|
if (chart) {
|
||||||
|
chart.dispatchAction({
|
||||||
|
type: 'dataZoom',
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#999',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '6px 16px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── AI 诊断报告占位 ── */}
|
||||||
|
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}>
|
||||||
|
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2>
|
||||||
|
<p style={{ color: '#aaa', fontSize: 14 }}>即将上线 - 基于双引擎模型的智能收视诊断</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 双引擎象限图 ── */}
|
||||||
|
<div className="analytics-chart-card">
|
||||||
|
<h2 className="analytics-chart-title">双引擎象限分析</h2>
|
||||||
|
<QuadrantChart
|
||||||
|
episodes={filteredEpisodes}
|
||||||
|
yearlyTarget={yearlyTarget}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 左列(季度+编导) + 右列(题材 Task3) ── */}
|
||||||
|
<div className="analytics-compare-section">
|
||||||
|
<div className="analytics-chart-card analytics-stage-section">
|
||||||
|
<h2 className="analytics-chart-title">季度对比</h2>
|
||||||
|
<QuarterCompare
|
||||||
|
episodes={filteredEpisodes}
|
||||||
|
yearlyTarget={yearlyTarget}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="analytics-chart-card analytics-editor-section">
|
||||||
|
<h2 className="analytics-chart-title">编导对比</h2>
|
||||||
|
<EditorCompare
|
||||||
|
episodes={filteredEpisodes}
|
||||||
|
yearlyTarget={yearlyTarget}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 题材对比 — 右列通高 */}
|
||||||
|
<div className="analytics-topic-section">
|
||||||
|
<div className="analytics-chart-card">
|
||||||
|
<h2 className="analytics-chart-title">题材对比</h2>
|
||||||
|
<TopicCompare
|
||||||
|
episodes={filteredEpisodes}
|
||||||
|
yearlyTarget={yearlyTarget}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Analytics
|
||||||
@@ -14,39 +14,10 @@ import useAuthStore from '../../stores/authStore'
|
|||||||
import { listEpisodes } from '../../services/episodeService'
|
import { listEpisodes } from '../../services/episodeService'
|
||||||
import { listTargets } from '../../services/yearlyTargetService'
|
import { listTargets } from '../../services/yearlyTargetService'
|
||||||
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
|
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
|
||||||
|
import { getShareColor, getColorLabel } from '../../utils/ratingColors'
|
||||||
import ChangeCoverModal from './ChangeCoverModal'
|
import ChangeCoverModal from './ChangeCoverModal'
|
||||||
import './Dashboard.css'
|
import './Dashboard.css'
|
||||||
|
|
||||||
/**
|
|
||||||
* 收视份额颜色判定(极易写反,禁止凭直觉):
|
|
||||||
* audience_share > stretch_target → 🔴 红(优秀)
|
|
||||||
* base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
|
|
||||||
* audience_share < base_target → 🟢 绿(待提升)
|
|
||||||
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
|
||||||
*/
|
|
||||||
function getShareColor(share, targets, airYear) {
|
|
||||||
const n = Number(share)
|
|
||||||
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
|
||||||
if (!target) return '#999'
|
|
||||||
if (isNaN(n)) return '#999'
|
|
||||||
const stretch = Number(target.stretch_target)
|
|
||||||
const base = Number(target.base_target)
|
|
||||||
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
|
|
||||||
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
|
|
||||||
return '#7aa874' // 绿=未达基础目标(待提升)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getColorLabel(share, targets, airYear) {
|
|
||||||
const n = Number(share)
|
|
||||||
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
|
||||||
if (!target) return '未知'
|
|
||||||
const stretch = Number(target.stretch_target)
|
|
||||||
const base = Number(target.base_target)
|
|
||||||
if (n > stretch) return '优秀'
|
|
||||||
if (n >= base) return '达标'
|
|
||||||
return '待提升'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBarHeight(share) {
|
function getBarHeight(share) {
|
||||||
return Math.round((Number(share) / 1.0) * 160)
|
return Math.round((Number(share) / 1.0) * 160)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取收视分析期次数据(含年度目标)
|
||||||
|
* @param {number|null} year - 年份,不传则取最近有数据的年份
|
||||||
|
* @returns {Promise<{year: number, yearly_target: {base_target: number, stretch_target: number}|null, episodes: Array}>}
|
||||||
|
*/
|
||||||
|
export async function getAnalyticsEpisodes(year) {
|
||||||
|
const params = {}
|
||||||
|
if (year) params.year = year
|
||||||
|
const response = await http.get('/analytics/episodes', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取有收视数据的年份列表(去重降序)
|
||||||
|
* @returns {Promise<number[]>}
|
||||||
|
*/
|
||||||
|
export async function getAvailableYears() {
|
||||||
|
const response = await http.get('/analytics/years')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 收视份额颜色判定(极易写反,禁止凭直觉):
|
||||||
|
* audience_share > stretch_target → 🔴 红(优秀)
|
||||||
|
* base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
|
||||||
|
* audience_share < base_target → 🟢 绿(待提升)
|
||||||
|
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据收视份额和年度目标返回对应的颜色代码
|
||||||
|
* @param {number|null} share - 收视份额
|
||||||
|
* @param {Array} targets - 年度目标数组
|
||||||
|
* @param {number} airYear - 播出年份
|
||||||
|
* @returns {string} 颜色代码
|
||||||
|
*/
|
||||||
|
export function getShareColor(share, targets, airYear) {
|
||||||
|
const n = Number(share)
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||||||
|
if (!target) return '#999'
|
||||||
|
if (isNaN(n)) return '#999'
|
||||||
|
const stretch = Number(target.stretch_target)
|
||||||
|
const base = Number(target.base_target)
|
||||||
|
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
|
||||||
|
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
|
||||||
|
return '#7aa874' // 绿=未达基础目标(待提升)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据收视份额和年度目标返回颜色标签
|
||||||
|
* @param {number|null} share - 收视份额
|
||||||
|
* @param {Array} targets - 年度目标数组
|
||||||
|
* @param {number} airYear - 播出年份
|
||||||
|
* @returns {string} 颜色标签
|
||||||
|
*/
|
||||||
|
export function getColorLabel(share, targets, airYear) {
|
||||||
|
const n = Number(share)
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||||||
|
if (!target) return '未知'
|
||||||
|
const stretch = Number(target.stretch_target)
|
||||||
|
const base = Number(target.base_target)
|
||||||
|
if (n > stretch) return '优秀'
|
||||||
|
if (n >= base) return '达标'
|
||||||
|
return '待提升'
|
||||||
|
}
|
||||||
@@ -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 '-'}")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
一次性脚本:清除测试数据,导入 25 期真实收视数据 + 回填 AI 标签
|
||||||
|
数据来源:
|
||||||
|
- 收视数据:ai-labeling/example/2026收视update.xlsx(已导出为 _tmp_excel.json)
|
||||||
|
- AI 标签:ai-labeling/benchmark-set/ground-truth.json(v0.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 '-'}")
|
||||||
Reference in New Issue
Block a user