Compare commits
14 Commits
886ddd8e88
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db9d10a795 | |||
| c21bc7d093 | |||
| ce84616b78 | |||
| 1578163602 | |||
| 7a48af3e9e | |||
| 7eb6511169 | |||
| ede30d3043 | |||
| 1ccb37f0c7 | |||
| 9df4868191 | |||
| 3dd718f53d | |||
| f37679530a | |||
| 8d880f06cf | |||
| d537bee9a7 | |||
| 1c4cbd84e5 |
+5
-1
@@ -102,9 +102,13 @@ doco/_*.py
|
|||||||
doco/verify_out.txt
|
doco/verify_out.txt
|
||||||
doco/programs/*/*_批改*.docx
|
doco/programs/*/*_批改*.docx
|
||||||
|
|
||||||
# AI-labeling: Excel 锁文件 & 参考资料
|
# AI-labeling: Excel 锁文件 & 参考资料 & 实验中间产物
|
||||||
ai-labeling/example/~$*
|
ai-labeling/example/~$*
|
||||||
ai-labeling/example/program_docs/
|
ai-labeling/example/program_docs/
|
||||||
|
ai-labeling/experiments/
|
||||||
|
|
||||||
|
# 后端封面图缓存(运行时生成)
|
||||||
|
backend/static/covers/
|
||||||
|
|
||||||
# 临时提示词文件
|
# 临时提示词文件
|
||||||
CLAUDE生成prompt.md
|
CLAUDE生成prompt.md
|
||||||
|
|||||||
@@ -13,15 +13,15 @@
|
|||||||
|
|
||||||
## 🔖 状态栏(每次结束 session 前必须更新这三行)
|
## 🔖 状态栏(每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
- **最后更新**:Claude(顾问)| 2026-07-03
|
- **最后更新**:Claude Opus(顾问)| 2026-07-08
|
||||||
- **当前状态一句话**:远程仓库已迁至腾讯云 Gitea,双机开发就绪。Phase 3 知识库已完成。**看板升级子项目原型阶段收工**(2026-07-03),交付清单已写入寄存条,主项目可启动收视分析看板 React 前端实现。
|
- **当前状态一句话**:**知识库语义搜索基本可用**——Obsidian 知识库 164 篇批量导入完成(总计 186 条含 doco 22 篇),搜索体验三项改进已落地(智能摘要/相关度阈值/展开全文),中文分词 snippet bug 已修。下一步:搜索体验微调 + 进入 Phase 4a 对话式 TPS。
|
||||||
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
|
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤖 给 AI 的工作约定(READ FIRST)
|
## 🤖 给 AI 的工作约定(READ FIRST)
|
||||||
|
|
||||||
- 开工前先读完:本文件「状态栏 / 交接备注 / 关键决策」三节 + 四份宪法 + 两份外迁寄存条(`寄存条_看板升级已外迁.md`、`寄存条_doco子项目已外迁.md`)。
|
- 开工前先读完:本文件「状态栏 / 交接备注 / 关键决策」三节 + 四份宪法 + `note/` 下全部外迁寄存条(看板升级 / doco / CCA / 期次一条龙录入,共四份)。
|
||||||
- **角色定位**:Claude Code 默认是**顾问**——出方案、审 Plan、卡壳兜底、写交接文档。**写代码(Plan + Act)交给 Cline,不要架空 Cline**。顾问位是为**省 token**:日常 CRUD、常规实现不该 Claude Code 亲自写。**仅两种情况下场动手**:① Cline 连续几次改都过不去的坎;② 制片人明确点名让 Claude Code 操刀。
|
- **角色定位**:Claude Code 默认是**顾问**——出方案、审 Plan、卡壳兜底、写交接文档。**写代码(Plan + Act)交给 Cline,不要架空 Cline**。顾问位是为**省 token**:日常 CRUD、常规实现不该 Claude Code 亲自写。**仅两种情况下场动手**:① Cline 连续几次改都过不去的坎;② 制片人明确点名让 Claude Code 操刀。
|
||||||
- 给 Cline 的指令一律用**代码块**封装,方便复制;不要让制片人手动粘贴整份文件内容。
|
- 给 Cline 的指令一律用**代码块**封装,方便复制;不要让制片人手动粘贴整份文件内容。
|
||||||
- 完成一段工作 → **增量更新**:在「已完成」追加一条、「待办」勾掉/新增、必要时补「关键决策」,最后改「状态栏」三行。只追加/勾选,不重写、不删历史。
|
- 完成一段工作 → **增量更新**:在「已完成」追加一条、「待办」勾掉/新增、必要时补「关键决策」,最后改「状态栏」三行。只追加/勾选,不重写、不删历史。
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
|
|
||||||
- **看板分析升级**(`寄存条_看板升级已外迁.md`):关键词——看板升级 / 双引擎象限图 / 题材热度 / AI 打标 / Prompt 1·2·3 / ground-truth / episodes 加字段 / 23 期回填 / 能力地图 / 置信度三档 / 本期诊断小结。
|
- **看板分析升级**(`寄存条_看板升级已外迁.md`):关键词——看板升级 / 双引擎象限图 / 题材热度 / AI 打标 / Prompt 1·2·3 / ground-truth / episodes 加字段 / 23 期回填 / 能力地图 / 置信度三档 / 本期诊断小结。
|
||||||
- **Doco 文稿整理**(`寄存条_doco子项目已外迁.md`):关键词——doco / 文稿整理 / 三方融合 / 视频双路拆分 / A·B 稿 / ASR / 讯飞 / DeepSeek Vision / 终版文稿 / 差异报告 / 段落对齐。**⚡ 2026-06-26 已交付:22 期融合A稿成品在 `doco/deliverables/`(按播出顺序命名),缺第01期(无A稿)。下一步:批量导入知识库(走 Phase 3 上传/embedding 链路)。**
|
- **Doco 文稿整理**(`寄存条_doco子项目已外迁.md`):关键词——doco / 文稿整理 / 三方融合 / 视频双路拆分 / A·B 稿 / ASR / 讯飞 / DeepSeek Vision / 终版文稿 / 差异报告 / 段落对齐。**⚡ 2026-06-26 已交付:22 期融合A稿成品在 `doco/deliverables/`(按播出顺序命名),缺第01期(无A稿)。下一步:批量导入知识库(走 Phase 3 上传/embedding 链路)。**
|
||||||
|
- **CCA 唱词助手**(`寄存条CCA唱词助手子项目已外迁.md`):关键词——cca / 唱词助手 / changci / SRT 字幕 / 拍词规则 / 编导审稿台 / 唱词校对 / ASR 转字幕 / 大洋字幕格式 / 折行规则。**⚡ 2026-07-04 立项:编导 A 稿+人声音频 → ASR+AI 校对+编导审稿 → 大洋格式 SRT。先部署 lanhao 配音 2.0 测试,成熟后并入 TPS。**
|
||||||
|
- **期次一条龙录入**(`寄存条期次一条龙录入子项目已外迁.md`,目录 `episode-intake/`):关键词——一条龙 / 期次录入流水线 / 流水线状态点 / 文稿回联期次 / transcript_item_id / 005 迁移 / AI 处理按钮 / 标签审核台 / draft-reviewed 流转 / 责编录入抽屉。**⚡ 2026-07-07 立项:责编录入页升级为每期任务清单(收视→文稿入库→AI打标+摘要卡→制片人审核→进看板),让收视分析持续生长。PRD v1.0 就绪,暂不开发;实施时落主干、走主干纪律。已拍板:文稿口径=doco 融合A稿(doco 转常态运行)、22 期导入时回联期次、责编可触发 AI 处理。**
|
||||||
|
|
||||||
**主干仍管**:Phase 0–3 主干代码、全部 backend schema/API/迁移、全部前端 React 实施、主干 bug/性能/新需求、Cline 的全部 Plan+Act。
|
**主干仍管**:Phase 0–3 主干代码、全部 backend schema/API/迁移、全部前端 React 实施、主干 bug/性能/新需求、Cline 的全部 Plan+Act。
|
||||||
|
|
||||||
@@ -74,15 +76,21 @@
|
|||||||
|
|
||||||
## 3. 当前进度(动态,核心交接区 — 以最新快照为准)
|
## 3. 当前进度(动态,核心交接区 — 以最新快照为准)
|
||||||
|
|
||||||
- **已完成至**:Phase 3 / Task 3(知识库树形视图·按来源两层分组,路线 B),含 Task 3.1、3.2,验收通过,最终 commit `3409d48`。
|
- **已完成至**:收视分析看板 L1-L4 + 知识库语义搜索(Obsidian 164 篇 + doco 22 篇批量导入,搜索体验三项改进)。
|
||||||
- **正在做**:无。看板升级子项目原型阶段已收工(2026-07-03),交付物就绪,可启动收视分析看板 React 前端实现。
|
- **正在做**:语义搜索体验微调(中文 snippet 已修,待制片人复测),准备进入 Phase 4a 对话式 TPS。
|
||||||
- **卡点/待解**:下一刀方向未定(见「待办」)——现在多了一个选项:收视分析看板前端实现(子项目已交付设计规范+原型)。
|
- **卡点/待解**:无硬卡点。
|
||||||
- **Schema 状态**:知识库两表 schema 已就绪,**Phase 3 不再改表**。episodes 表已通过 003 迁移加 7 列 AI 标签字段(看板升级子项目 2026-06-26 执行)。
|
- **Schema 状态**:episodes 表已通过 003(+7 AI 标签列)和 004(+content_digest JSONB)迁移。知识库两表不再改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 已完成(只追加,最新在上)
|
## 4. 已完成(只追加,最新在上)
|
||||||
|
|
||||||
|
- [2026-07-08] **Obsidian 知识库批量导入 + 语义搜索体验升级**:① `parse_md_file` 补强 6 项("来源"→source_detail fallback、"相关装备/应用领域"实体提取、Obsidian 双链 `[[]]` 去括号、原始类别存 metadata、源文件路径预埋、SOURCE_TYPE_MAP 扩充含"军报文章/装备/技术/动态/术语/厂商/索引");② 脚本 `backend/scripts/import_obsidian_kb.py`(递归遍历、跳过 .obsidian+原始素材+空文件+无 frontmatter、按相对路径查重、支持 --limit/--dry-run/--dir),成功导入 164 篇(7+157),知识库从 22 → 186 条;③ 搜索体验三项改进:`search_similar` 返回完整 content_md + 智能摘要 `_extract_smart_snippet`(中文 2-gram 拆词+加权段落匹配+关键词加粗)+ min_similarity=0.3 过滤 + top_k 5→10;④ 前端搜索卡片升级(展开全文 Set 状态管理、`renderSnippet` 加粗渲染、相关度分档样式:≥70%蓝左边框/<50%半透明灰底)。
|
||||||
|
- [2026-07-07] **知识库冷启动:doco 22 期融合A稿批量导入**:脚本 `backend/scripts/import_doco_transcripts.py`,docx → 提取纯文本 → 包 YAML frontmatter → 调 `store_md_file()` 入库(自动算 MiniMax embo-01 embedding)。22 篇全部成功(第02期~第23期,缺第01期无A稿),source_type=manuscript,知识库从 0 → 22 条节目文稿。
|
||||||
|
- [2026-07-07] **期次一条龙录入子项目立项(只立项不开发)**:① PRD v1.0 写入 `episode-intake/PRD_期次一条龙录入_v1.md`(每期任务清单模式、4 状态点、抽屉四区块、6 个新 API、005 迁移只加 transcript_item_id 一列、看板只用 reviewed 期次、四刀分期+七条验收);② 制片人三项拍板:文稿口径=doco 融合A稿(CCA 是播出前工具不算终稿,doco 转常态运行)、22 期批量导入时回联期次、责编可触发 AI 处理;③ 子项目 CLAUDE.md + 寄存条建立,CCA 寄存条清单表同步更新。
|
||||||
|
- [2026-07-06] **三处 Bug 修复 + 全局宽度调优**:① AI 诊断摘要块 `extractSection` 从硬编码 `indexOf` 改为正则模糊匹配,解决 DeepSeek 返回标题格式不一致导致干条不显示的问题;② 仪表盘近 12 期柱状图加 `.reverse()` 改为从左到右按播出时间升序;③ 全局内容区宽度:定位到 `.app-content`(`AppLayout.css`)的 `max-width` 是唯一有效参数(必须搭配 `width: 100%`,否则 Ant Design flex 布局下 `margin: 0 auto` 会触发 shrink-to-fit),最终定为 **1190px**。
|
||||||
|
- [2026-07-03] **L4 AI 诊断报告**:① Prompt 4(内容摘要卡,MiMo 生成)+ Prompt 5(诊断报告,DeepSeek V4 Pro)撰写完成;② 22 期文稿摘要卡批量生成并导入 episodes.content_digest(004 迁移);③ 后端 POST /api/analytics/diagnosis-report 端点(组装数据+调 DeepSeek+内存缓存);④ 前端摘要块 DiagnosisSummary(左右两块、三档自适应色调、可滚动、粗体渲染)+ 详情页 DiagnosisReport(react-markdown 渲染+重新生成+免责声明);⑤ 话题性(社交货币)三维框架设计(大众认知度/降维切口/惊奇密度)。模型选型:DeepSeek V4 Pro 胜出(vs Qwen-Max 对比测试)。
|
||||||
|
- [2026-07-03] **收视分析看板 React 前端实现(L1–L3)**:① 指标卡 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,9 +103,12 @@
|
|||||||
|
|
||||||
## 5. 待办(下一刀候选,开局前定先做哪件)
|
## 5. 待办(下一刀候选,开局前定先做哪件)
|
||||||
|
|
||||||
- [ ] **下一刀四选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 导入真实收视 Excel + 清测试数据(Phase 4a 另一前置);④ **收视分析看板 React 前端实现**(看板升级子项目已交付设计规范+HTML 原型,详见 `note/寄存条看板升级已外迁.md` 交付清单)。
|
- [x] ~~L4 AI 诊断报告~~ → 已完成(2026-07-03)
|
||||||
- [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。
|
- [x] ~~语义搜索界面~~ → 已完成(2026-07-08),Phase 4a 硬门槛已过
|
||||||
- [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。
|
- [x] ~~200+ Obsidian md 批量录入~~ → 已完成(2026-07-08),164 篇导入成功,知识库 186 条
|
||||||
|
- [ ] **下一刀**:Phase 4a 对话式 TPS 选题策划助手(左对话右报告、脚注式引用、知识库检索底座已就绪)。
|
||||||
|
- [ ] **期次一条龙录入开发**(子项目已立项 2026-07-07,PRD 就绪在 `episode-intake/`,等制片人排期;四刀分期,第二刀含 doco 22 期导入回联)。
|
||||||
|
- [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。Obsidian 导入时已把源文件路径存入 `metadata["source_files"]` 预埋。
|
||||||
- [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。
|
- [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。
|
||||||
- [ ] 操作留痕 schema(episodes 等加 created_by/updated_by/updated_at;涉 schema 须 Opus 审)。
|
- [ ] 操作留痕 schema(episodes 等加 created_by/updated_by/updated_at;涉 schema 须 Opus 审)。
|
||||||
- [ ] 界面像素级打磨(连同全栏目视觉规范统一弄,参考「色调字体倒角」图)。
|
- [ ] 界面像素级打磨(连同全栏目视觉规范统一弄,参考「色调字体倒角」图)。
|
||||||
@@ -121,12 +132,24 @@
|
|||||||
- 向量 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 诊断报告(摘要块) → 双引擎象限图 → 双列对比(左列季度+编导堆叠、右列题材跨两行)。
|
||||||
|
- **L4 AI 诊断报告模型选型**:DeepSeek V4 Pro(胜出,分析深度+论证结构优于 Qwen-Max)。不搞多模型共识——诊断报告是完整分析文章,逻辑连贯性比多家一致更重要。
|
||||||
|
- **L4 摘要块视觉**:左块固定粉红色系(`#c0584f`),右块跟 tier 三档变色(绿/蓝/红)。两块均可滚动查看完整内容。
|
||||||
|
- **L4 话题性框架**:社交货币理论三维(大众认知度/降维切口/惊奇密度),写入摘要卡不作为独立 AI 标签字段。
|
||||||
|
- **L4 prompt5 约束**:① 提及期次必须用「第X期《节目名》」格式;② 话题性必须独立分析段;③ 军事装备分类须准确(枪械≠冷兵器)。
|
||||||
|
- **全局页面内容区宽度**:唯一有效参数是 `frontend/src/components/Layout/AppLayout.css` 里 `.app-content` 的 `max-width`(当前 **1190px**)。**必须保留 `width: 100%` + `box-sizing: border-box`**,不能单独用 `margin: 0 auto`(Ant Design flex 布局下会触发 shrink-to-fit,宽度锁死无法调节)。制片人直接说"调这个参数"即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上)
|
## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上)
|
||||||
|
|
||||||
- **下一刀未定**,开工第一件事:与制片人确认四选一(语义搜索 / PDF 存储 / 真实收视导入 / **收视分析看板 React 前端**)。看板前端的设计规范+原型已由子项目交付(见 `note/寄存条看板升级已外迁.md` 交付清单 + `ai-labeling/CLAUDE.md` §1/§6/§7)。PDF 存储须先和 Opus 讨论方案才立项。
|
- **知识库语义搜索已可用**(2026-07-08):186 条知识库条目(doco 22 篇 + Obsidian 164 篇),搜索链路完整(MiniMax embo-01 query embedding → pgvector 余弦检索 → 智能摘要 + 关键词加粗 + 展开全文)。新增文件:`backend/scripts/import_obsidian_kb.py`(批量导入脚本)。`knowledge_service.py` 的 `parse_md_file` 已补强支持全部 Obsidian frontmatter 字段。搜索结果返回 `content_md` 完整正文供展开查看。Obsidian 源文件路径已存入 `metadata["source_files"]` 预埋 PDF 关联。
|
||||||
|
- **收视分析看板 L1-L4 全部完成**。页面布局:指标卡 → 走势图 → AI 诊断报告(摘要块) → 双引擎象限图 → 双列对比(左:季度+编导, 右:题材)。
|
||||||
|
- **L4 AI 诊断报告新增文件**:后端 `backend/app/api/analytics.py`(POST 端点);前端 `DiagnosisSummary.jsx`(摘要块)+ `DiagnosisReport.jsx/.css`(详情页);Prompt 文件 `ai-labeling/prompts/prompt4_content_digest.md` + `prompt5_diagnosis_report.md`;批量脚本 `ai-labeling/scripts/gen_content_digest.py` + `scripts/import_content_digests.py`。
|
||||||
|
- **L4 依赖**:`backend/.env` 需配 `DEEPSEEK_API_KEY`;前端需 `react-markdown` 包(已装);后端需 `openai` 包(已装)。
|
||||||
|
- **004 迁移已执行**:episodes 表 +content_digest JSONB 列,22 期摘要卡已导入。
|
||||||
|
- **看板前端其他文件**:`frontend/src/components/Analytics/`(QuarterCompare / EditorCompare / TopicCompare / QuadrantChart);`scripts/`(import_real_episodes.py / fix_label_mapping.py / import_content_digests.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 实战教训)**:
|
||||||
@@ -135,6 +158,7 @@
|
|||||||
3. **已验证好用的布局别为对齐整体重构**(flex 改 Grid 连坏两轮);样式微调优先。
|
3. **已验证好用的布局别为对齐整体重构**(flex 改 Grid 连坏两轮);样式微调优先。
|
||||||
4. Cline 报告自相矛盾时,**把源码发给 Opus 一眼定位**胜过让 Cline 反复试错。
|
4. Cline 报告自相矛盾时,**把源码发给 Opus 一眼定位**胜过让 Cline 反复试错。
|
||||||
5. 像素级打磨边际收益递减,**见好就收**,记 backlog 改天连视觉规范统一弄。
|
5. 像素级打磨边际收益递减,**见好就收**,记 backlog 改天连视觉规范统一弄。
|
||||||
|
6. **页面宽度问题**:Ant Design `Layout > Content` 是 flex 子元素,`margin: 0 auto` 不搭配显式 `width` 会导致元素缩到内容宽度(shrink-to-fit),改 `max-width` 数值完全无效。必须同时写 `width: 100%`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -146,6 +170,6 @@
|
|||||||
|
|
||||||
## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设)
|
## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设)
|
||||||
|
|
||||||
- [ ] 下一刀选谁(三选一)。
|
- [x] ~~AI 诊断报告(L4)方案细化~~ → 已完成(2026-07-03)
|
||||||
- [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。
|
- [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。
|
||||||
- [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。
|
- [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Prompt 4:节目内容摘要卡生成
|
||||||
|
|
||||||
|
## 角色
|
||||||
|
|
||||||
|
你是一位资深电视节目分析师,擅长从军事科技类节目文稿中提炼核心信息。你的任务是将一篇 5000-7000 字的节目文稿压缩为一份 150-200 字的结构化摘要卡,供后续的收视诊断分析系统使用。
|
||||||
|
|
||||||
|
## 背景知识
|
||||||
|
|
||||||
|
你分析的是央视《军事科技》栏目的节目文稿。该栏目每周一期,每期约 27 分钟,面向对军事装备和国防科技感兴趣的大众观众(非专业军事人员)。
|
||||||
|
|
||||||
|
文稿由以下段落类型构成:
|
||||||
|
- 【导视】:片头预告,通常包含本期核心悬念或看点
|
||||||
|
- 【主持人N】:演播室主持人串场,负责起承转合
|
||||||
|
- 【解说N】:画外音解说,承载主要信息量
|
||||||
|
- 【三维动画解说N】:配合三维动画的技术原理讲解
|
||||||
|
- 【专家N】:专家访谈,提供专业解读
|
||||||
|
- 【街采】:街头采访普通观众(部分期次有)
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
请严格按以下 JSON 格式输出,不要输出任何其他内容:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"核心切口": "一句话(20-40字),概括本期节目用什么角度切入军事科技话题",
|
||||||
|
"叙事亮点": [
|
||||||
|
"第一个亮点(30-50字):本期最出彩的段落、转折、对比或论证设计",
|
||||||
|
"第二个亮点(30-50字,可选):如有第二个突出的叙事设计"
|
||||||
|
],
|
||||||
|
"观众门槛": "低/中/高 — 一句话说明(20-30字)",
|
||||||
|
"话题性": {
|
||||||
|
"总评": "强/中/弱",
|
||||||
|
"大众认知度": "高/中/低 — 一句话理由(15-25字)",
|
||||||
|
"降维切口": "强/中/弱 — 一句话理由(15-25字)",
|
||||||
|
"惊奇密度": "高/中/低 — 一句话理由(15-25字)"
|
||||||
|
},
|
||||||
|
"潜在弱点": [
|
||||||
|
"第一个弱点(30-50字):节奏、深度、趣味性、信息密度等方面的不足"
|
||||||
|
],
|
||||||
|
"时效关联": "有/无 — 如有则说明借势了什么热点/节日/新装备亮相(20-40字)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各字段评估标准
|
||||||
|
|
||||||
|
### 核心切口
|
||||||
|
提炼本期节目的"一句话卖点"——制片人用什么角度把军事科技话题包装成观众愿意点开的内容。重点是"角度"而非"题材"。
|
||||||
|
|
||||||
|
### 叙事亮点
|
||||||
|
关注文稿中最能抓住观众的叙事设计,例如:
|
||||||
|
- 出人意料的对比(如丑装备 vs 美装备、竞标失败者 vs 成功者)
|
||||||
|
- 引人入胜的历史故事或真实事件
|
||||||
|
- 巧妙的类比或降维解释
|
||||||
|
- 层层递进的悬念设置
|
||||||
|
- 街头采访带来的反差或趣味
|
||||||
|
|
||||||
|
### 观众门槛
|
||||||
|
评估一个对军事完全不了解的普通观众,能否顺畅理解本期内容:
|
||||||
|
- **低**:日常概念切入(如颜值、仿生、玩具),无需军事知识储备
|
||||||
|
- **中**:需要一定常识(如知道航母是什么、战斗机分代),但节目有充分铺垫
|
||||||
|
- **高**:涉及专业概念(如气动布局、相控阵雷达原理),且节目未充分降维
|
||||||
|
|
||||||
|
### 话题性(社交货币评估)
|
||||||
|
话题性的本质是:观众看完这期节目后,在多大程度上愿意主动跟别人聊起它——在微信群、饭桌上、茶歇时说"你看了昨天那期军事科技吗"。
|
||||||
|
|
||||||
|
三个判据:
|
||||||
|
|
||||||
|
**大众认知度**:节目涉及的核心装备或概念,普通人听没听说过?
|
||||||
|
- 高:航母、坦克、AK47、隐身战机、核潜艇——街上随便拉个人都知道
|
||||||
|
- 中:预警机、驱逐舰、无人机——看过新闻的人大概知道
|
||||||
|
- 低:鸭翼气动布局、舰载相控阵雷达、弹道导弹防御层级——需要军事爱好者才懂
|
||||||
|
|
||||||
|
**降维切口**:节目的切入角度,不懂军事的人能不能秒懂、想聊?
|
||||||
|
- 强:用日常概念(颜值、仿生、玩具、进化论)包装军事话题,任何人都能参与讨论
|
||||||
|
- 中:切口有一定趣味(竞标失败者的命运、谁更强),但仍需基本军事兴趣
|
||||||
|
- 弱:直接讲技术原理或装备参数,非爱好者难以产生讨论欲
|
||||||
|
|
||||||
|
**惊奇密度**:全片中有几个让观众"哇!"的瞬间?
|
||||||
|
- 高:3个以上反直觉事实、震撼对比、意外反转(如"丑的反而打赢了""这个国家竟然拦截过黑鸟")
|
||||||
|
- 中:1-2个有趣的知识点或对比,但整体平稳
|
||||||
|
- 低:全片信息性为主,缺少让人惊讶或想转述的瞬间
|
||||||
|
|
||||||
|
三项中占 2 项以上为"强",占 1 项为"中",0 项为"弱"。
|
||||||
|
|
||||||
|
### 潜在弱点
|
||||||
|
从"观众会不会中途换台"的角度审视,常见弱点包括:
|
||||||
|
- 信息密度过高,观众消化不了
|
||||||
|
- 中段节奏拖沓,缺乏新的刺激点
|
||||||
|
- 技术讲解过于抽象,缺少具象化呈现
|
||||||
|
- 案例之间缺乏递进关系,像在念清单
|
||||||
|
- 结论过于笼统,缺少有记忆点的金句
|
||||||
|
- 同类题材短期内重复出现(如连续多期讲枪械)
|
||||||
|
|
||||||
|
### 时效关联
|
||||||
|
判断本期播出时是否借势了外部事件:
|
||||||
|
- 新装备亮相/首飞/下水/交付
|
||||||
|
- 军事冲突或国际安全事件
|
||||||
|
- 国庆/建军节等军事相关节日
|
||||||
|
- 航展/军事展会
|
||||||
|
- 如文稿中未提及任何时事背景,标"无"即可
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 摘要卡的读者不是观众,而是后续的 AI 诊断系统——用分析性语言,不要用宣传性语言。
|
||||||
|
2. 每个字段的评估必须基于文稿内容本身,不要凭题目猜测。
|
||||||
|
3. 话题性评估关注的是"传播潜力"而非"内容质量"——一期制作精良但话题封闭的节目可能话题性弱,一期制作普通但切口有趣的节目可能话题性强。
|
||||||
|
4. 潜在弱点要具体到本期内容,不要给泛泛的评价。
|
||||||
|
5. 总字数控制在 150-200 字(JSON 内纯文本,不含 key 和格式符号)。
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Prompt 5:收视诊断报告生成
|
||||||
|
|
||||||
|
## 角色
|
||||||
|
|
||||||
|
你是央视《军事科技》栏目的资深收视分析顾问。你的任务是基于一组节目的收视数据、AI 标签和内容摘要,撰写一份有深度、有细节的收视诊断分析报告。
|
||||||
|
|
||||||
|
你的分析对象是"节目",不是"人"。报告中不对任何编导进行评价、排名或点名。
|
||||||
|
|
||||||
|
## 背景知识
|
||||||
|
|
||||||
|
### 栏目基本情况
|
||||||
|
《军事科技》是央视国防军事频道的周播节目,每期约 27 分钟,面向对军事装备和国防科技感兴趣的大众观众。栏目 8 人团队(制片人、责编、6 名编导)。
|
||||||
|
|
||||||
|
### 收视颜色判定(与通常直觉相反)
|
||||||
|
- 🔴 红色 = 优秀:收视份额 **高于** 摸高目标
|
||||||
|
- 🔵 蓝色 = 达标:收视份额介于基础目标与摸高目标之间
|
||||||
|
- 🟢 绿色 = 待提升:收视份额 **低于** 基础目标
|
||||||
|
|
||||||
|
### 核心分析框架:双引擎模型
|
||||||
|
收视表现由两个"引擎"驱动,加上一个独立维度:
|
||||||
|
|
||||||
|
**引擎 1:题材热度(地基)**
|
||||||
|
热门装备(航母、隐身战机)、热点事件自带观众基础。即使叙事一般,热门题材也能撑住收视。
|
||||||
|
|
||||||
|
**引擎 2:叙事结构(放大器)**
|
||||||
|
有贯穿全片的主线悬念(主线演进)能放大收视,板块式并列结构则依赖每个板块自身吸引力。
|
||||||
|
|
||||||
|
**独立维度:开篇钩子**
|
||||||
|
前 1-2 分钟决定观众是否换台。强钩子能挽留犹豫的观众,弱钩子让潜在观众流失。
|
||||||
|
|
||||||
|
**核心规律:两条腿至少占一条。** 冷门题材 + 并列结构 = 高风险组合。
|
||||||
|
|
||||||
|
### 话题性(社交货币)
|
||||||
|
话题性 = 观众看完后愿不愿意主动跟别人聊这期节目。由三个因素决定:
|
||||||
|
- 大众认知度:核心装备/概念普通人知不知道
|
||||||
|
- 降维切口:节目角度非军迷能不能秒懂、想聊
|
||||||
|
- 惊奇密度:全片有多少个让人"哇!"的瞬间
|
||||||
|
|
||||||
|
## 输入数据格式
|
||||||
|
|
||||||
|
你会收到以下结构化数据:
|
||||||
|
|
||||||
|
```
|
||||||
|
分析范围:第X期 至 第Y期(共N期)
|
||||||
|
年度目标:基础目标 0.XXXX,摸高目标 0.XXXX
|
||||||
|
整体统计:平均份额、达标率、摸高完成率等
|
||||||
|
|
||||||
|
逐期数据:
|
||||||
|
| 期号 | 节目名 | 份额 | 判定 | 题材类型 | 叙事结构 | 钩子强度 | 话题性 |
|
||||||
|
| ... |
|
||||||
|
|
||||||
|
各期内容摘要卡:
|
||||||
|
第X期《节目名》:
|
||||||
|
核心切口:...
|
||||||
|
叙事亮点:...
|
||||||
|
观众门槛:...
|
||||||
|
话题性:总评/大众认知度/降维切口/惊奇密度
|
||||||
|
潜在弱点:...
|
||||||
|
时效关联:...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
### 报告色调(三档自适应)
|
||||||
|
|
||||||
|
根据所选范围的平均收视份额,自动切换分析语气:
|
||||||
|
|
||||||
|
**当平均份额 < 基础目标时(危险区):**
|
||||||
|
- 语气严厉、直接,不回避问题
|
||||||
|
- 用词示例:"必须正视""亮红灯""持续恶化""如不及时调整"
|
||||||
|
- 重点放在问题诊断和止损建议
|
||||||
|
|
||||||
|
**当平均份额介于基础目标与摸高目标之间时(达标区):**
|
||||||
|
- 语气客观、偏鼓励,帮助团队找准发力方向
|
||||||
|
- 用词示例:"整体达标但仍有提升空间""值得关注的是""建议进一步"
|
||||||
|
- 重点放在原因分析和提振方向
|
||||||
|
|
||||||
|
**当平均份额 > 摸高目标时(优秀区):**
|
||||||
|
- 语气肯定、激励,提炼可复用的成功经验
|
||||||
|
- 用词示例:"表现亮眼""成功经验值得推广""再接再厉"
|
||||||
|
- 重点放在经验总结和保持势头
|
||||||
|
|
||||||
|
### 报告结构
|
||||||
|
|
||||||
|
请按以下结构输出(用 Markdown 格式):
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 一、总体判断(2-3句话)
|
||||||
|
|
||||||
|
用一句话定调整体表现,再用 1-2 句话点出最突出的特征或趋势。要求:精准、有冲击力、能让读者立刻把握全局。
|
||||||
|
|
||||||
|
#### 二、核心发现(3-5条)
|
||||||
|
|
||||||
|
每条发现必须:
|
||||||
|
- 以加粗的结论性判断开头
|
||||||
|
- 紧跟具体数据佐证(期号、份额、百分比)
|
||||||
|
- 点到具体节目名称(**必须用"第X期《节目名》"格式,禁止只写期号不带节目名**——读者记不住期号对应哪期节目)
|
||||||
|
- 50-80 字/条
|
||||||
|
|
||||||
|
核心发现应覆盖以下维度(不必全覆盖,选最有价值的):
|
||||||
|
- 收视走势的关键转折点或趋势
|
||||||
|
- 题材类型与收视的关联规律
|
||||||
|
- 叙事结构(主线演进 vs 并列结构)的效果差异
|
||||||
|
- 钩子强度与观众留存的关系
|
||||||
|
- 话题性与收视的对应关系
|
||||||
|
- 异常值(特别高或特别低)的原因
|
||||||
|
|
||||||
|
#### 三、深度分析(3-4段)
|
||||||
|
|
||||||
|
这是报告的核心价值区。每段 80-120 字,要求:
|
||||||
|
- 不是简单地重复数据,而是**解释为什么**
|
||||||
|
- 结合内容摘要卡中的具体信息(节目的切口、叙事亮点、观众门槛、潜在弱点)
|
||||||
|
- 运用双引擎模型解释收视高低的因果关系
|
||||||
|
- 指出**可迁移的规律**,而不是就事论事
|
||||||
|
- **必须进行跨期归纳**:这份报告分析的是一段时间内的多期节目,你要找出这些节目的共性规律。如果整体表现好,要总结"这批节目做对了什么、共同特征是什么";如果整体表现差,要总结"这批节目共同踩了什么坑";如果好坏参半,要对比分析"好的那几期和差的那几期,差异到底在哪"。逐期点评是数据罗列,跨期归纳才是分析价值。
|
||||||
|
|
||||||
|
分析角度示例:
|
||||||
|
- "第X期《节目名》虽然是冷门题材(XX),但凭借'XX'的降维切口和强钩子逆袭到XX份额,证明切口设计能弥补题材劣势"
|
||||||
|
- "第X期《节目名》至第Y期《节目名》连续N期低于基础线,共同特征是XX类题材+并列结构+XX门槛,说明……"
|
||||||
|
- "对比同为XX题材的第X期《节目名》(份额XX)和第Y期《节目名》(份额XX),差异主要在于……"
|
||||||
|
|
||||||
|
**话题性/社交货币维度必须独立分析:** 深度分析中必须有一段专门分析话题性(社交货币)维度。不要笼统地说"话题性偏弱",而要拆到大众认知度、降维切口、惊奇密度三个子维度,指出这批节目在哪个子维度上集体失分、哪个子维度是个别节目的亮点。话题性分析的价值在于:告诉制片人"观众为什么看完不想跟人聊",而非重复收视数字。
|
||||||
|
|
||||||
|
#### 四、行动建议(2-3条)
|
||||||
|
|
||||||
|
每条建议必须:
|
||||||
|
- 具体可执行,不说正确的废话
|
||||||
|
- 基于前面的分析逻辑推导出来
|
||||||
|
- 面向选题策划和节目制作环节
|
||||||
|
- 40-60 字/条
|
||||||
|
|
||||||
|
好的建议示例:
|
||||||
|
- "控制XX类题材的连续出现频次,建议间隔至少N期,中间插入XX类题材缓冲"
|
||||||
|
- "XX类节目建议优先采用主线演进结构,第X期已验证该组合的收视拉动效果"
|
||||||
|
|
||||||
|
差的建议示例(避免):
|
||||||
|
- "提高节目质量"(太空)
|
||||||
|
- "加强选题策划"(无具体方向)
|
||||||
|
- "某编导应该……"(不评价人)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **只分析节目,不评价编导。** 报告中不出现任何编导姓名,不对编导进行排名、评价或建议。
|
||||||
|
1.5. **全文使用"第X期《节目名》"格式。** 每次提及具体期次都必须同时带期号和节目名,不允许只写"第X期"而不带节目名。读者不会记住期号与节目的对应关系。
|
||||||
|
1.6. **军事装备分类须准确。** 枪械(机枪、步枪、冲锋枪等)属于轻武器/热兵器,不是冷兵器;舰炮属于海军武器系统,不是"冷兵器"。请使用准确的军事分类术语。
|
||||||
|
2. **数据驱动,不凭感觉。** 每个判断都要有数据或具体案例支撑。
|
||||||
|
3. **写给制片人看。** 语言简洁有力,不堆术语,不写学术论文。制片人熟悉每一期节目,不需要你复述剧情,只需要你指出规律和原因。
|
||||||
|
4. **实事求是。** 数据好就说好,数据差就说差,不要为了平衡而硬找优点或硬找缺点。
|
||||||
|
5. **报告总字数 600-900 字。** 这是一份内部工作文档,不是公开发表的研究报告。
|
||||||
|
6. **外部因素诚实标注。** 如果某期收视异常可能受外部因素影响(如同时段重大事件、特殊节假日),但你无法确认,可以标注"可能受外部因素影响,建议结合当期播出环境分析",不要编造外部因素。
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
gen_content_digest.py - 批量生成 22 期节目内容摘要卡
|
||||||
|
用法:
|
||||||
|
cd E:\tps-dashboard\ai-labeling
|
||||||
|
python scripts/gen_content_digest.py
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 读取 prompt4_content_digest.md 作为 system prompt
|
||||||
|
- 遍历 doco/deliverables/ 下所有融合A稿 .docx 文件
|
||||||
|
- 用 python-docx 提取文稿文本,调用 MiMo API 生成结构化摘要卡
|
||||||
|
- 支持断点续跑(已存在的 digest 文件自动跳过)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from openai import OpenAI
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
# 加载 .env(优先加载 ai-labeling 目录下的 .env)
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
BASE_DIR = SCRIPT_DIR.parent # ai-labeling/
|
||||||
|
load_dotenv(BASE_DIR / ".env")
|
||||||
|
load_dotenv() # 也尝试加载项目根目录的 .env
|
||||||
|
|
||||||
|
# 目录配置
|
||||||
|
DELIVERABLES_DIR = BASE_DIR.parent / "doco" / "deliverables"
|
||||||
|
PROMPTS_DIR = BASE_DIR / "prompts"
|
||||||
|
EXPERIMENTS_DIR = BASE_DIR / "experiments" / "content_digests"
|
||||||
|
|
||||||
|
# MiMo API 配置(与 run_labeling.py 一致)
|
||||||
|
MIMO_CONFIG = {
|
||||||
|
"base_url": "https://api.xiaomimimo.com/v1",
|
||||||
|
"model_name": "mimo-v2.5-pro",
|
||||||
|
"api_key_env": "MIMO_API_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_system_prompt() -> str:
|
||||||
|
"""加载 prompt4_content_digest.md 作为 system prompt。"""
|
||||||
|
prompt_file = PROMPTS_DIR / "prompt4_content_digest.md"
|
||||||
|
if not prompt_file.exists():
|
||||||
|
raise FileNotFoundError(f"找不到 prompt 文件: {prompt_file}")
|
||||||
|
return prompt_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filename(filename: str) -> dict:
|
||||||
|
"""
|
||||||
|
从文件名解析元信息。
|
||||||
|
文件名格式: 第02期_20260113_武器进化论:海战颠覆者_付天雨_融合A稿.docx
|
||||||
|
返回: {"ep": 2, "date": "2026-01-13", "title": "...", "editor": "..."}
|
||||||
|
"""
|
||||||
|
name = filename.replace(".docx", "")
|
||||||
|
parts = name.split("_")
|
||||||
|
if len(parts) < 4:
|
||||||
|
return None
|
||||||
|
# 解析期号
|
||||||
|
ep_match = re.search(r'第(\d+)期', parts[0])
|
||||||
|
if not ep_match:
|
||||||
|
return None
|
||||||
|
ep = int(ep_match.group(1))
|
||||||
|
# 解析日期(YYYYMMDD -> YYYY-MM-DD)
|
||||||
|
raw_date = parts[1]
|
||||||
|
if len(raw_date) == 8 and raw_date.isdigit():
|
||||||
|
date = f"{raw_date[:4]}-{raw_date[4:6]}-{raw_date[6:8]}"
|
||||||
|
else:
|
||||||
|
date = raw_date
|
||||||
|
title = parts[2]
|
||||||
|
editor = parts[3]
|
||||||
|
return {"ep": ep, "date": date, "title": title, "editor": editor}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_docx_text(filepath: Path) -> str:
|
||||||
|
"""用 python-docx 提取 .docx 文件的全部文本,逐段拼接。"""
|
||||||
|
doc = Document(str(filepath))
|
||||||
|
paragraphs = []
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
text = para.text.strip()
|
||||||
|
if text:
|
||||||
|
paragraphs.append(text)
|
||||||
|
return "\n".join(paragraphs)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(meta: dict, body: str) -> str:
|
||||||
|
"""构造 user message:元信息 + 文稿全文。"""
|
||||||
|
return (
|
||||||
|
f"期号:第{meta['ep']:02d}期\n"
|
||||||
|
f"播出日期:{meta['date']}\n"
|
||||||
|
f"节目名:{meta['title']}\n"
|
||||||
|
f"编导:{meta['editor']}\n"
|
||||||
|
f"\n以下是节目文稿全文:\n\n{body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_from_response(raw: str) -> dict:
|
||||||
|
"""从模型响应中提取 JSON,兼容推理模型的<think>...输出。"""
|
||||||
|
# 先去掉<think>...标签及其内容
|
||||||
|
text = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL)
|
||||||
|
text = text.strip()
|
||||||
|
# 去掉 markdown 代码块
|
||||||
|
text = re.sub(r'^```(?:json)?\s*', '', text)
|
||||||
|
text = re.sub(r'\s*```$', '', text)
|
||||||
|
text = text.strip()
|
||||||
|
# 从第一个 { 开始,到最后一个 } 结束
|
||||||
|
first_brace = text.find('{')
|
||||||
|
last_brace = text.rfind('}')
|
||||||
|
if first_brace != -1 and last_brace != -1 and last_brace >= first_brace:
|
||||||
|
json_str = text[first_brace:last_brace + 1]
|
||||||
|
return json.loads(json_str)
|
||||||
|
# 兜底:直接尝试解析
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def call_mimo(system_prompt: str, user_prompt: str) -> dict:
|
||||||
|
"""调用 MiMo API 生成摘要卡,返回解析后的 JSON dict。"""
|
||||||
|
api_key = os.environ.get(MIMO_CONFIG["api_key_env"])
|
||||||
|
if not api_key:
|
||||||
|
raise EnvironmentError(
|
||||||
|
f"环境变量 {MIMO_CONFIG['api_key_env']} 未设置,请检查 ai-labeling/.env 或根目录 .env 文件"
|
||||||
|
)
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=MIMO_CONFIG["base_url"],
|
||||||
|
)
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=MIMO_CONFIG["model_name"],
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
temperature=0.0,
|
||||||
|
# 关闭 thinking(与 run_labeling.py 一致)
|
||||||
|
extra_body={"thinking": {"type": "disabled"}},
|
||||||
|
)
|
||||||
|
raw = response.choices[0].message.content
|
||||||
|
return extract_json_from_response(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_docx_files() -> list[dict]:
|
||||||
|
"""收集所有 .docx 文件并按期号排序。"""
|
||||||
|
files = []
|
||||||
|
for f in sorted(DELIVERABLES_DIR.iterdir()):
|
||||||
|
if not f.name.endswith(".docx"):
|
||||||
|
continue
|
||||||
|
meta = parse_filename(f.name)
|
||||||
|
if meta is None:
|
||||||
|
print(f"⚠️ 跳过无法解析的文件: {f.name}")
|
||||||
|
continue
|
||||||
|
meta["filepath"] = f
|
||||||
|
files.append(meta)
|
||||||
|
# 按期号排序
|
||||||
|
files.sort(key=lambda x: x["ep"])
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 确保输出目录存在
|
||||||
|
EXPERIMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 加载 system prompt
|
||||||
|
system_prompt = load_system_prompt()
|
||||||
|
print(f"✅ 已加载 system prompt: prompt4_content_digest.md")
|
||||||
|
|
||||||
|
# 收集 docx 文件
|
||||||
|
docx_files = collect_docx_files()
|
||||||
|
print(f"✅ 找到 {len(docx_files)} 个融合A稿 docx 文件\n")
|
||||||
|
|
||||||
|
all_digests = []
|
||||||
|
skipped = 0
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, meta in enumerate(docx_files):
|
||||||
|
ep = meta["ep"]
|
||||||
|
out_file = EXPERIMENTS_DIR / f"ep{ep:02d}_digest.json"
|
||||||
|
|
||||||
|
# 断点续跳:已存在则跳过
|
||||||
|
if out_file.exists():
|
||||||
|
print(f"[{i+1}/{len(docx_files)}] ep{ep:02d} 已存在,跳过")
|
||||||
|
try:
|
||||||
|
existing = json.loads(out_file.read_text(encoding="utf-8"))
|
||||||
|
all_digests.append(existing)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{i+1}/{len(docx_files)}] 正在处理 ep{ep:02d} - {meta['title']} ...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 提取 docx 文本
|
||||||
|
body = extract_docx_text(meta["filepath"])
|
||||||
|
if not body.strip():
|
||||||
|
print(f" ⚠️ ep{ep:02d} 文稿内容为空,跳过")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 构造 user message
|
||||||
|
user_msg = build_user_message(meta, body)
|
||||||
|
|
||||||
|
# 调用 MiMo
|
||||||
|
result = call_mimo(system_prompt, user_msg)
|
||||||
|
|
||||||
|
# 构造输出
|
||||||
|
output = {
|
||||||
|
"ep": ep,
|
||||||
|
"date": meta["date"],
|
||||||
|
"title": meta["title"],
|
||||||
|
"editor": meta["editor"],
|
||||||
|
"filename": meta["filepath"].name,
|
||||||
|
"digest": result,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 写入单期文件
|
||||||
|
out_file.write_text(
|
||||||
|
json.dumps(output, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
all_digests.append(output)
|
||||||
|
print(f" ✅ ep{ep:02d} 摘要卡已生成 -> {out_file.name}")
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" ⚠️ ep{ep:02d} LLM 返回的不是合法 JSON: {e}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ ep{ep:02d} 处理失败: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# 限流保护:每期之间 sleep 1 秒
|
||||||
|
if i < len(docx_files) - 1:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 写入汇总文件
|
||||||
|
summary_file = EXPERIMENTS_DIR / "_all_digests.json"
|
||||||
|
summary_file.write_text(
|
||||||
|
json.dumps(all_digests, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"📊 处理完成:")
|
||||||
|
print(f" 成功: {success}")
|
||||||
|
print(f" 跳过(已存在): {skipped}")
|
||||||
|
print(f" 失败: {failed}")
|
||||||
|
print(f" 汇总文件: {summary_file}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
收视分析 API — 提供收视走势和指标卡数据
|
||||||
|
|
||||||
|
端点:
|
||||||
|
GET /api/analytics/years → 有收视数据的年份列表(去重降序)
|
||||||
|
GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import extract
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
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=["收视分析"])
|
||||||
|
|
||||||
|
# 诊断报告缓存(内存,重启清空)
|
||||||
|
_report_cache = {}
|
||||||
|
|
||||||
|
# prompt5 文件路径
|
||||||
|
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
||||||
|
_PROMPT5_PATH = _PROJECT_ROOT / "ai-labeling" / "prompts" / "prompt5_diagnosis_report.md"
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI 诊断报告 ──
|
||||||
|
|
||||||
|
class DiagnosisRequest(BaseModel):
|
||||||
|
year: int
|
||||||
|
ep_start: int
|
||||||
|
ep_end: int
|
||||||
|
force: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_message(episodes, base_target, stretch_target, avg_share, pass_count, max_ep, min_ep):
|
||||||
|
"""组装给 DeepSeek 的 user message,格式对齐 prompt5 的输入规范。"""
|
||||||
|
first_ep = episodes[0]
|
||||||
|
last_ep = episodes[-1]
|
||||||
|
count = len(episodes)
|
||||||
|
|
||||||
|
# 判色函数
|
||||||
|
def judge(share):
|
||||||
|
if share >= stretch_target:
|
||||||
|
return "优秀"
|
||||||
|
elif share >= base_target:
|
||||||
|
return "达标"
|
||||||
|
else:
|
||||||
|
return "待提升"
|
||||||
|
|
||||||
|
# 摸高完成率
|
||||||
|
stretch_pct = round(avg_share / stretch_target * 100, 1) if stretch_target > 0 else 0
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("请根据以下数据,撰写收视诊断分析报告。\n")
|
||||||
|
|
||||||
|
# 分析范围
|
||||||
|
lines.append("## 分析范围\n")
|
||||||
|
lines.append(
|
||||||
|
f"第{first_ep.episode_number}期《{first_ep.program_name}》至 "
|
||||||
|
f"第{last_ep.episode_number}期《{last_ep.program_name}》(共{count}期),"
|
||||||
|
f"{first_ep.air_date}至{last_ep.air_date}播出"
|
||||||
|
)
|
||||||
|
lines.append(f"年度目标:基础目标 {base_target},摸高目标 {stretch_target}\n")
|
||||||
|
|
||||||
|
# 整体统计
|
||||||
|
lines.append("## 整体统计\n")
|
||||||
|
lines.append(f"- 平均份额:{avg_share}(摸高完成率 {stretch_pct}%)")
|
||||||
|
lines.append(f"- 达标期数:{pass_count}/{count}")
|
||||||
|
lines.append(f"- 最高份额:{float(max_ep.audience_share)}(第{max_ep.episode_number}期《{max_ep.program_name}》)")
|
||||||
|
lines.append(f"- 最低份额:{float(min_ep.audience_share)}(第{min_ep.episode_number}期《{min_ep.program_name}》)\n")
|
||||||
|
|
||||||
|
# 逐期数据表格
|
||||||
|
lines.append("## 逐期数据\n")
|
||||||
|
lines.append("| 播出期号 | 节目名 | 份额 | 判定 | 题材类型 | 叙事结构 | 钩子强度 | 装备领域 |")
|
||||||
|
lines.append("|---------|-------|------|------|---------|---------|---------|---------|")
|
||||||
|
for ep in episodes:
|
||||||
|
share = float(ep.audience_share)
|
||||||
|
domain_str = "、".join(ep.equipment_domain) if ep.equipment_domain else "-"
|
||||||
|
lines.append(
|
||||||
|
f"| 第{ep.episode_number}期 | {ep.program_name} | {share} | {judge(share)} "
|
||||||
|
f"| {ep.program_format or '-'} | {ep.narrative_structure or '-'} "
|
||||||
|
f"| {ep.opening_hook or '-'} | {domain_str} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 各期内容摘要卡
|
||||||
|
lines.append("## 各期内容摘要卡\n")
|
||||||
|
for ep in episodes:
|
||||||
|
share = float(ep.audience_share)
|
||||||
|
lines.append(f"### 第{ep.episode_number}期《{ep.program_name}》(份额 {share})")
|
||||||
|
digest = ep.content_digest
|
||||||
|
if digest:
|
||||||
|
lines.append(f"- 核心切口:{digest.get('核心切口', '-')}")
|
||||||
|
# 叙事亮点可能是数组
|
||||||
|
highlights = digest.get('叙事亮点', [])
|
||||||
|
if isinstance(highlights, list):
|
||||||
|
lines.append(f"- 叙事亮点:{';'.join(highlights)}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- 叙事亮点:{highlights}")
|
||||||
|
lines.append(f"- 观众门槛:{digest.get('观众门槛', '-')}")
|
||||||
|
# 话题性是嵌套结构
|
||||||
|
topic = digest.get('话题性', {})
|
||||||
|
if isinstance(topic, dict):
|
||||||
|
lines.append(
|
||||||
|
f"- 话题性:{topic.get('总评', '-')} — "
|
||||||
|
f"大众认知度:{topic.get('大众认知度', '-')};"
|
||||||
|
f"降维切口:{topic.get('降维切口', '-')};"
|
||||||
|
f"惊奇密度:{topic.get('惊奇密度', '-')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"- 话题性:{topic}")
|
||||||
|
# 潜在弱点可能是数组
|
||||||
|
weaknesses = digest.get('潜在弱点', [])
|
||||||
|
if isinstance(weaknesses, list):
|
||||||
|
lines.append(f"- 潜在弱点:{';'.join(weaknesses)}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- 潜在弱点:{weaknesses}")
|
||||||
|
lines.append(f"- 时效关联:{digest.get('时效关联', '-')}")
|
||||||
|
else:
|
||||||
|
lines.append("- (无文稿摘要)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/diagnosis-report")
|
||||||
|
def generate_diagnosis_report(
|
||||||
|
req: DiagnosisRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user=Depends(_require_read()),
|
||||||
|
):
|
||||||
|
"""生成 AI 诊断报告。同一范围缓存结果,force=True 时重新生成。"""
|
||||||
|
cache_key = f"{req.year}_{req.ep_start}_{req.ep_end}"
|
||||||
|
|
||||||
|
# 检查缓存
|
||||||
|
if not req.force and cache_key in _report_cache:
|
||||||
|
return _report_cache[cache_key]
|
||||||
|
|
||||||
|
# 1. 查询所选范围的 episodes
|
||||||
|
ep_stmt = (
|
||||||
|
select(Episode)
|
||||||
|
.where(extract("year", Episode.air_date) == req.year)
|
||||||
|
.where(Episode.episode_number >= req.ep_start)
|
||||||
|
.where(Episode.episode_number <= req.ep_end)
|
||||||
|
.where(Episode.audience_share.is_not(None))
|
||||||
|
.order_by(Episode.episode_number.asc())
|
||||||
|
)
|
||||||
|
episodes = session.exec(ep_stmt).all()
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
return {"error": "所选范围内没有收视数据"}
|
||||||
|
|
||||||
|
# 2. 查年度目标
|
||||||
|
target_stmt = select(YearlyTarget).where(YearlyTarget.year == req.year)
|
||||||
|
target = session.exec(target_stmt).first()
|
||||||
|
if not target:
|
||||||
|
return {"error": f"{req.year}年没有设置年度目标"}
|
||||||
|
|
||||||
|
base_target = float(target.base_target)
|
||||||
|
stretch_target = float(target.stretch_target)
|
||||||
|
|
||||||
|
# 3. 计算统计数据
|
||||||
|
shares = [float(ep.audience_share) for ep in episodes]
|
||||||
|
avg_share = round(sum(shares) / len(shares), 4) if shares else 0
|
||||||
|
pass_count = sum(1 for s in shares if s >= base_target)
|
||||||
|
max_ep = max(episodes, key=lambda e: float(e.audience_share))
|
||||||
|
min_ep = min(episodes, key=lambda e: float(e.audience_share))
|
||||||
|
|
||||||
|
# 三档判定
|
||||||
|
if avg_share >= stretch_target:
|
||||||
|
tier = "excellent"
|
||||||
|
elif avg_share >= base_target:
|
||||||
|
tier = "on_target"
|
||||||
|
else:
|
||||||
|
tier = "danger"
|
||||||
|
|
||||||
|
# 4. 组装 user message
|
||||||
|
user_message = _build_user_message(episodes, base_target, stretch_target, avg_share, pass_count, max_ep, min_ep)
|
||||||
|
|
||||||
|
# 5. 读 system prompt
|
||||||
|
system_prompt = _PROMPT5_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 6. 调 DeepSeek
|
||||||
|
if not settings.DEEPSEEK_API_KEY:
|
||||||
|
return {"error": "DEEPSEEK_API_KEY 未配置"}
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=settings.DEEPSEEK_API_KEY,
|
||||||
|
base_url="https://api.deepseek.com",
|
||||||
|
)
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="deepseek-v4-pro",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message},
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
report_markdown = response.choices[0].message.content
|
||||||
|
|
||||||
|
# 7. 组装返回
|
||||||
|
result = {
|
||||||
|
"tier": tier,
|
||||||
|
"avg_share": avg_share,
|
||||||
|
"episode_count": len(episodes),
|
||||||
|
"pass_count": pass_count,
|
||||||
|
"highest": {
|
||||||
|
"ep": max_ep.episode_number,
|
||||||
|
"name": max_ep.program_name,
|
||||||
|
"share": float(max_ep.audience_share),
|
||||||
|
},
|
||||||
|
"lowest": {
|
||||||
|
"ep": min_ep.episode_number,
|
||||||
|
"name": min_ep.program_name,
|
||||||
|
"share": float(min_ep.audience_share),
|
||||||
|
},
|
||||||
|
"report_markdown": report_markdown,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"disclaimer": "本报告基于已入库的收视数据、节目标签及内容摘要生成,未纳入同时段竞品、社会热点等外部因素。分析结论难免挂一漏万,仅供栏目内部讨论参考,不构成节目决策依据。",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 8. 缓存
|
||||||
|
_report_cache[cache_key] = result
|
||||||
|
return result
|
||||||
@@ -109,7 +109,8 @@ def get_grouped_knowledge_items(
|
|||||||
|
|
||||||
class SearchRequest(BaseModel):
|
class SearchRequest(BaseModel):
|
||||||
query: str
|
query: str
|
||||||
top_k: int = 5
|
top_k: int = 10
|
||||||
|
min_similarity: float = 0.3
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search")
|
@router.post("/search")
|
||||||
@@ -121,10 +122,15 @@ def search_knowledge(
|
|||||||
"""
|
"""
|
||||||
语义检索:输入一段文字,返回最相关的知识库条目及相似度。
|
语义检索:输入一段文字,返回最相关的知识库条目及相似度。
|
||||||
查询向量用 type="query"(区分于存入时的 type="db")。
|
查询向量用 type="query"(区分于存入时的 type="db")。
|
||||||
|
top_k 默认 10,min_similarity 默认 0.3 过滤低相关条目。
|
||||||
三角色均可读。
|
三角色均可读。
|
||||||
"""
|
"""
|
||||||
svc = KnowledgeService()
|
svc = KnowledgeService()
|
||||||
results = svc.search_similar(query_text=body.query, top_k=body.top_k)
|
results = svc.search_similar(
|
||||||
|
query_text=body.query,
|
||||||
|
top_k=body.top_k,
|
||||||
|
min_similarity=body.min_similarity,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"results": results,
|
"results": results,
|
||||||
"query": body.query,
|
"query": body.query,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ _SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
|
|||||||
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
||||||
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
|
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
|
||||||
|
|
||||||
|
# DeepSeek API 凭证(诊断报告用)
|
||||||
|
_DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||||
|
|
||||||
# 验证必需配置
|
# 验证必需配置
|
||||||
if not _DATABASE_URL:
|
if not _DATABASE_URL:
|
||||||
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
||||||
@@ -31,6 +34,7 @@ class Settings:
|
|||||||
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
||||||
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
|
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
|
||||||
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
|
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
|
||||||
|
DEEPSEEK_API_KEY: str = _DEEPSEEK_API_KEY
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class Episode(SQLModel, table=True):
|
|||||||
opening_hook: str | None = Field(default=None, max_length=10) # 开篇钩子(单选)
|
opening_hook: str | None = Field(default=None, max_length=10) # 开篇钩子(单选)
|
||||||
ai_label_confidence: str | None = Field(default=None, max_length=10) # draft/reviewed/inferred
|
ai_label_confidence: str | None = Field(default=None, max_length=10) # draft/reviewed/inferred
|
||||||
|
|
||||||
|
# ── 内容摘要卡(AI 生成,供诊断报告输入)──
|
||||||
|
content_digest: Any | None = Field(default=None, sa_column=Column(JSONB))
|
||||||
|
|
||||||
created_at: datetime | None = Field(
|
created_at: datetime | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ class KnowledgeService:
|
|||||||
SOURCE_TYPE_MAP = {
|
SOURCE_TYPE_MAP = {
|
||||||
"杂志文章": "military_report",
|
"杂志文章": "military_report",
|
||||||
"军报": "military_report",
|
"军报": "military_report",
|
||||||
|
"军报文章": "military_report",
|
||||||
"节目文稿": "manuscript",
|
"节目文稿": "manuscript",
|
||||||
"报题单": "baoti",
|
"报题单": "baoti",
|
||||||
|
"装备": "manual",
|
||||||
|
"技术": "manual",
|
||||||
|
"动态": "manual",
|
||||||
|
"术语": "manual",
|
||||||
|
"厂商": "manual",
|
||||||
|
"索引": "manual",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 来源大类固定显示顺序(制片人 Obsidian 习惯)
|
# 来源大类固定显示顺序(制片人 Obsidian 习惯)
|
||||||
@@ -89,6 +96,12 @@ class KnowledgeService:
|
|||||||
else:
|
else:
|
||||||
source_detail = None
|
source_detail = None
|
||||||
|
|
||||||
|
# fallback:如果 source_detail 仍为空,取"来源"字段
|
||||||
|
if not source_detail:
|
||||||
|
raw_source = str(fm.get("来源", "") or "").strip()
|
||||||
|
if raw_source:
|
||||||
|
source_detail = raw_source
|
||||||
|
|
||||||
# —— 播出日期:容错 "待补充" 等非日期文本——
|
# —— 播出日期:容错 "待补充" 等非日期文本——
|
||||||
raw_date = str(fm.get("播出日期", "") or "").strip()
|
raw_date = str(fm.get("播出日期", "") or "").strip()
|
||||||
publish_date = None
|
publish_date = None
|
||||||
@@ -107,17 +120,22 @@ class KnowledgeService:
|
|||||||
# —— 权重(不展示,存 JSONB 备 Phase 4)——
|
# —— 权重(不展示,存 JSONB 备 Phase 4)——
|
||||||
weight = str(fm.get("权重", "") or "").strip() or None
|
weight = str(fm.get("权重", "") or "").strip() or None
|
||||||
|
|
||||||
# —— 相关实体(涉及装备/涉及技术/涉及厂商/主题)——
|
# —— 相关实体(涉及装备/涉及技术/涉及厂商/主题/相关装备/应用领域)——
|
||||||
|
import re as _re
|
||||||
related_entities = []
|
related_entities = []
|
||||||
for key in ("涉及装备", "涉及技术", "涉及厂商", "主题"):
|
for key in ("涉及装备", "涉及技术", "涉及厂商", "主题", "相关装备", "应用领域"):
|
||||||
val = fm.get(key)
|
val = fm.get(key)
|
||||||
if val:
|
if val:
|
||||||
if isinstance(val, list):
|
if isinstance(val, list):
|
||||||
related_entities.extend(val)
|
for item in val:
|
||||||
|
# 去掉 Obsidian 双链格式 [[xxx]]
|
||||||
|
cleaned = _re.sub(r"\[\[|\]\]", "", str(item)).strip()
|
||||||
|
if cleaned:
|
||||||
|
related_entities.append(cleaned)
|
||||||
elif isinstance(val, str):
|
elif isinstance(val, str):
|
||||||
# 可能是 "山东舰, 福建舰" 这样的逗号分隔字符串
|
# 可能是 "山东舰, 福建舰" 这样的逗号分隔字符串
|
||||||
for item in val.replace(",", ",").split(","):
|
for item in val.replace(",", ",").split(","):
|
||||||
item = item.strip()
|
item = _re.sub(r"\[\[|\]\]", "", item).strip()
|
||||||
if item:
|
if item:
|
||||||
related_entities.append(item)
|
related_entities.append(item)
|
||||||
|
|
||||||
@@ -130,6 +148,26 @@ class KnowledgeService:
|
|||||||
# related_concepts 字段预留给双链解析(Phase 4),本 Task 原样存入
|
# related_concepts 字段预留给双链解析(Phase 4),本 Task 原样存入
|
||||||
metadata["double_bracket_links"] = self._extract_double_brackets(parsed.content)
|
metadata["double_bracket_links"] = self._extract_double_brackets(parsed.content)
|
||||||
|
|
||||||
|
# 保留原始 Obsidian 类别(映射到 manual 的类型,如装备/技术/动态/术语/厂商等)
|
||||||
|
if raw_type and source_type == "manual":
|
||||||
|
metadata["obsidian_category"] = raw_type
|
||||||
|
|
||||||
|
# 源文件路径保留(预埋 PDF 原文链接)
|
||||||
|
raw_source_files = fm.get("源文件")
|
||||||
|
if raw_source_files:
|
||||||
|
cleaned_files = []
|
||||||
|
if isinstance(raw_source_files, list):
|
||||||
|
for sf in raw_source_files:
|
||||||
|
cleaned = _re.sub(r'[\[\]"]', "", str(sf)).strip()
|
||||||
|
if cleaned:
|
||||||
|
cleaned_files.append(cleaned)
|
||||||
|
elif isinstance(raw_source_files, str):
|
||||||
|
cleaned = _re.sub(r'[\[\]"]', "", raw_source_files).strip()
|
||||||
|
if cleaned:
|
||||||
|
cleaned_files.append(cleaned)
|
||||||
|
if cleaned_files:
|
||||||
|
metadata["source_files"] = cleaned_files
|
||||||
|
|
||||||
# —— 正文(去掉 frontmatter 的纯内容)——
|
# —— 正文(去掉 frontmatter 的纯内容)——
|
||||||
content_md = parsed.content
|
content_md = parsed.content
|
||||||
|
|
||||||
@@ -229,13 +267,11 @@ class KnowledgeService:
|
|||||||
sources.add(tags["source_detail"])
|
sources.add(tags["source_detail"])
|
||||||
return sorted(list(sources))
|
return sorted(list(sources))
|
||||||
|
|
||||||
def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]:
|
def search_similar(self, query_text: str, top_k: int = 10, min_similarity: float = 0.3) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
|
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
|
||||||
返回 top_k 条相似笔记,含相似度分数 + 原文片段(SQL 端截断前 200 字)。
|
返回 top_k 条相似笔记,含相似度分数 + 智能摘要片段。
|
||||||
|
过滤掉 similarity < min_similarity 的条目。
|
||||||
注意:当前取前 200 字是已知妥协(整篇向量检索无法定位中段命中点),
|
|
||||||
Phase 4a 做切块检索(chunk)时可优化为取最相关片段。
|
|
||||||
"""
|
"""
|
||||||
query_vector = self.embedder.embed_single(query_text, embed_type="query")
|
query_vector = self.embedder.embed_single(query_text, embed_type="query")
|
||||||
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
|
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
|
||||||
@@ -248,7 +284,7 @@ class KnowledgeService:
|
|||||||
ki.source_type,
|
ki.source_type,
|
||||||
ki.author,
|
ki.author,
|
||||||
ki.tags,
|
ki.tags,
|
||||||
SUBSTRING(ki.content_md, 1, 200) AS snippet,
|
ki.content_md,
|
||||||
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
|
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
|
||||||
FROM knowledge_embeddings ke
|
FROM knowledge_embeddings ke
|
||||||
JOIN knowledge_items ki ON ke.knowledge_id = ki.id
|
JOIN knowledge_items ki ON ke.knowledge_id = ki.id
|
||||||
@@ -260,19 +296,88 @@ class KnowledgeService:
|
|||||||
rows = session.execute(stmt).all()
|
rows = session.execute(stmt).all()
|
||||||
results = []
|
results = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
similarity = round(r.similarity, 4)
|
||||||
|
# 过滤低于阈值的条目
|
||||||
|
if similarity < min_similarity:
|
||||||
|
continue
|
||||||
tags = r.tags or {}
|
tags = r.tags or {}
|
||||||
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
|
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
|
||||||
|
snippet = self._extract_smart_snippet(r.content_md, query_text)
|
||||||
results.append({
|
results.append({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"title": r.title,
|
"title": r.title,
|
||||||
"source_type": r.source_type,
|
"source_type": r.source_type,
|
||||||
"author": r.author,
|
"author": r.author,
|
||||||
"source_detail": source_detail,
|
"source_detail": source_detail,
|
||||||
"snippet": r.snippet,
|
"snippet": snippet,
|
||||||
"similarity": round(r.similarity, 4),
|
"content_md": r.content_md,
|
||||||
|
"similarity": similarity,
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def _extract_smart_snippet(self, content_md: str, query_text: str, max_len: int = 300) -> str:
|
||||||
|
"""
|
||||||
|
智能摘要提取:根据搜索词定位最相关段落,截取摘要并加粗关键词。
|
||||||
|
中文连续字符串会被拆成 2-gram 子串以提高段落匹配命中率。
|
||||||
|
"""
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
if not content_md:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 1. 切分关键词:先按空格/标点拆,再把长中文词拆成 2-gram
|
||||||
|
raw_parts = _re.split(r'[\s,。!?、;:""''()\(\)\[\]\-]+', query_text)
|
||||||
|
raw_parts = [p.strip() for p in raw_parts if len(p.strip()) > 1]
|
||||||
|
|
||||||
|
keywords = []
|
||||||
|
for part in raw_parts:
|
||||||
|
keywords.append(part)
|
||||||
|
if len(part) > 2 and _re.fullmatch(r'[一-鿿]+', part):
|
||||||
|
for i in range(len(part) - 1):
|
||||||
|
bigram = part[i:i+2]
|
||||||
|
if bigram not in keywords:
|
||||||
|
keywords.append(bigram)
|
||||||
|
|
||||||
|
if not keywords:
|
||||||
|
return content_md[:max_len]
|
||||||
|
|
||||||
|
# 2. 按段落分割(跳过纯 markdown 标记行如 # ## --- 等)
|
||||||
|
paragraphs = content_md.split("\n\n")
|
||||||
|
if len(paragraphs) < 3:
|
||||||
|
paragraphs = content_md.split("\n")
|
||||||
|
paragraphs = [p.strip() for p in paragraphs
|
||||||
|
if p.strip() and not _re.fullmatch(r'[#\-=\s>]+', p.strip())]
|
||||||
|
|
||||||
|
# 3. 计算每段关键词命中数(完整词权重 3,bigram 权重 1)
|
||||||
|
best_para = ""
|
||||||
|
best_score = 0
|
||||||
|
for para in paragraphs:
|
||||||
|
score = 0
|
||||||
|
for kw in keywords:
|
||||||
|
if kw in para:
|
||||||
|
score += 3 if kw in raw_parts else 1
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_para = para
|
||||||
|
|
||||||
|
# 4. fallback:无关键词命中时用正文前 max_len 字
|
||||||
|
if best_score == 0:
|
||||||
|
snippet = content_md[:max_len]
|
||||||
|
else:
|
||||||
|
snippet = best_para[:max_len]
|
||||||
|
|
||||||
|
# 5. 加粗命中关键词(优先标记完整词,避免 bigram 重复标记)
|
||||||
|
marked = set()
|
||||||
|
for kw in sorted(keywords, key=len, reverse=True):
|
||||||
|
if kw in snippet and kw not in marked:
|
||||||
|
snippet = snippet.replace(kw, f"**{kw}**", 1)
|
||||||
|
marked.add(kw)
|
||||||
|
for sub_kw in keywords:
|
||||||
|
if sub_kw != kw and sub_kw in kw:
|
||||||
|
marked.add(sub_kw)
|
||||||
|
|
||||||
|
return snippet
|
||||||
|
|
||||||
def get_item_count(self) -> int:
|
def get_item_count(self) -> int:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
return len(session.exec(select(KnowledgeItem)).all())
|
return len(session.exec(select(KnowledgeItem)).all())
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ python-dotenv==1.0.1
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
python-frontmatter==1.1.0
|
python-frontmatter==1.1.0
|
||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
openpyxl>=3.1.0
|
openpyxl>=3.1.0
|
||||||
|
openai>=1.0.0,<1.55.0
|
||||||
|
python-docx>=1.1.0
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
一次性批量导入脚本:将 doco/deliverables/ 下的 22 期融合A稿 docx 文件
|
||||||
|
导入知识库(knowledge_items + knowledge_embeddings)。
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
cd backend && python -m scripts.import_doco_transcripts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.db.session import engine
|
||||||
|
from app.models.knowledge import KnowledgeItem
|
||||||
|
from app.services.knowledge_service import KnowledgeService
|
||||||
|
|
||||||
|
# ── 路径 ──────────────────────────────────────────────────────
|
||||||
|
# backend/scripts/ → backend/ → 项目根目录
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
DOCO_DIR = PROJECT_ROOT / "doco" / "deliverables"
|
||||||
|
|
||||||
|
# ── 文件名正则 ────────────────────────────────────────────────
|
||||||
|
# 第{NN}期_{YYYYMMDD}_{节目名}_{编导名}_融合A稿.docx
|
||||||
|
FILENAME_RE = re.compile(
|
||||||
|
r"^第(\d{2,3})期_(\d{8})_(.+?)_(.+?)_融合A稿\.docx$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filename(filename: str) -> dict | None:
|
||||||
|
"""从文件名解析期次号、播出日期、节目名、编导名。不匹配返回 None。"""
|
||||||
|
m = FILENAME_RE.match(filename)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
issue_num = int(m.group(1))
|
||||||
|
date_raw = m.group(2) # YYYYMMDD
|
||||||
|
title = m.group(3)
|
||||||
|
author = m.group(4)
|
||||||
|
broadcast_date = f"{date_raw[:4]}-{date_raw[4:6]}-{date_raw[6:8]}"
|
||||||
|
return {
|
||||||
|
"issue_num": issue_num,
|
||||||
|
"broadcast_date": broadcast_date,
|
||||||
|
"title": title,
|
||||||
|
"author": author,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_docx_text(file_path: Path) -> str:
|
||||||
|
"""用 python-docx 提取 docx 正文纯文本,段落间用 \\n\\n 分隔。"""
|
||||||
|
doc = Document(str(file_path))
|
||||||
|
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
return "\n\n".join(paragraphs)
|
||||||
|
|
||||||
|
|
||||||
|
def build_md_bytes(title: str, author: str, broadcast_date: str, body: str) -> bytes:
|
||||||
|
"""组装带 YAML frontmatter 的 md 格式 bytes,供 store_md_file() 使用。"""
|
||||||
|
md_content = (
|
||||||
|
f"---\n"
|
||||||
|
f"类型: 节目文稿\n"
|
||||||
|
f"名称: {title}\n"
|
||||||
|
f"编导: {author}\n"
|
||||||
|
f"播出日期: {broadcast_date}\n"
|
||||||
|
f"---\n"
|
||||||
|
f"{body}"
|
||||||
|
)
|
||||||
|
return md_content.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def check_exists(source_file_name: str) -> bool:
|
||||||
|
"""查 knowledge_items 表,判断 source_file_name 是否已存在。"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
stmt = select(KnowledgeItem).where(
|
||||||
|
KnowledgeItem.source_file_name == source_file_name
|
||||||
|
)
|
||||||
|
existing = session.exec(stmt).first()
|
||||||
|
return existing is not None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 确认 docx 目录存在
|
||||||
|
if not DOCO_DIR.is_dir():
|
||||||
|
print(f"✗ 目录不存在: {DOCO_DIR}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 收集并排序 docx 文件
|
||||||
|
docx_files = sorted(DOCO_DIR.glob("*.docx"), key=lambda p: p.name)
|
||||||
|
if not docx_files:
|
||||||
|
print("✗ 未找到任何 .docx 文件")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"共发现 {len(docx_files)} 个 docx 文件,开始导入...\n")
|
||||||
|
|
||||||
|
service = KnowledgeService()
|
||||||
|
success_count = 0
|
||||||
|
skip_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
total = len(docx_files)
|
||||||
|
|
||||||
|
for idx, file_path in enumerate(docx_files, 1):
|
||||||
|
filename = file_path.name
|
||||||
|
|
||||||
|
# 解析文件名
|
||||||
|
meta = parse_filename(filename)
|
||||||
|
if meta is None:
|
||||||
|
print(f"[{idx}/{total}] ✗ {filename} — 文件名格式不匹配,跳过")
|
||||||
|
fail_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_label = f"第{meta['issue_num']:02d}期 {meta['title']}"
|
||||||
|
|
||||||
|
# 防重复:用去掉 .docx 后缀加 .md 的文件名
|
||||||
|
source_file_name = filename.replace(".docx", ".md")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if check_exists(source_file_name):
|
||||||
|
print(f"[{idx}/{total}] ⊘ {display_label} — 已存在,跳过")
|
||||||
|
skip_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提取正文
|
||||||
|
body_text = extract_docx_text(file_path)
|
||||||
|
char_count = len(body_text)
|
||||||
|
|
||||||
|
# 组装 md bytes
|
||||||
|
md_bytes = build_md_bytes(
|
||||||
|
title=meta["title"],
|
||||||
|
author=meta["author"],
|
||||||
|
broadcast_date=meta["broadcast_date"],
|
||||||
|
body=body_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用入库链路(含 embedding API 调用)
|
||||||
|
service.store_md_file(file_content=md_bytes, file_name=source_file_name)
|
||||||
|
|
||||||
|
print(f"[{idx}/{total}] ✓ {display_label} — 入库成功({char_count}字)")
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{idx}/{total}] ✗ {display_label} — 失败:{e}")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"导入完成:成功 {success_count} 篇 / 跳过 {skip_count} 篇 / 失败 {fail_count} 篇")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
批量导入脚本:将 Obsidian 知识库目录下的 .md 文件
|
||||||
|
导入 TPS 知识库(knowledge_items + knowledge_embeddings)。
|
||||||
|
|
||||||
|
运行方式:
|
||||||
|
cd backend && python -m scripts.import_obsidian_kb --limit 10
|
||||||
|
cd backend && python -m scripts.import_obsidian_kb # 全量
|
||||||
|
cd backend && python -m scripts.import_obsidian_kb --dry-run # 只预览不写库
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.db.session import engine
|
||||||
|
from app.models.knowledge import KnowledgeItem
|
||||||
|
from app.services.knowledge_service import KnowledgeService
|
||||||
|
|
||||||
|
# ── 默认 Obsidian 知识库根目录 ────────────────────────────────
|
||||||
|
DEFAULT_OBSIDIAN_DIR = r"E:\AIworks\obsidian\MSTknowledge_base\知识库"
|
||||||
|
|
||||||
|
# ── 跳过的目录名 ────────────────────────────────────────────────
|
||||||
|
SKIP_DIRS = {".obsidian", "99-原始素材"}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_md_files(root: Path) -> list[Path]:
|
||||||
|
"""递归收集 root 下所有 .md 文件,跳过 SKIP_DIRS 中的目录。"""
|
||||||
|
md_files = []
|
||||||
|
for p in root.rglob("*.md"):
|
||||||
|
# 检查路径中是否包含跳过目录
|
||||||
|
parts = p.relative_to(root).parts
|
||||||
|
if any(skip in parts for skip in SKIP_DIRS):
|
||||||
|
continue
|
||||||
|
md_files.append(p)
|
||||||
|
return sorted(md_files, key=lambda p: str(p.relative_to(root)))
|
||||||
|
|
||||||
|
|
||||||
|
def check_exists(source_file_name: str) -> bool:
|
||||||
|
"""查 knowledge_items 表,判断 source_file_name 是否已存在。"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
stmt = select(KnowledgeItem).where(
|
||||||
|
KnowledgeItem.source_file_name == source_file_name
|
||||||
|
)
|
||||||
|
existing = session.exec(stmt).first()
|
||||||
|
return existing is not None
|
||||||
|
|
||||||
|
|
||||||
|
def has_frontmatter(content: str) -> bool:
|
||||||
|
"""检查文件内容是否包含 YAML frontmatter(--- 包裹)。"""
|
||||||
|
stripped = content.strip()
|
||||||
|
if not stripped.startswith("---"):
|
||||||
|
return False
|
||||||
|
# 找到第二个 ---
|
||||||
|
second = stripped.find("---", 3)
|
||||||
|
return second > 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="批量导入 Obsidian 知识库 .md 文件到 TPS 知识库")
|
||||||
|
parser.add_argument("--dir", type=str, default=DEFAULT_OBSIDIAN_DIR,
|
||||||
|
help="Obsidian 知识库根目录")
|
||||||
|
parser.add_argument("--limit", type=int, default=0,
|
||||||
|
help="只处理前 N 个文件(测试用,0=全量)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="只打印解析结果,不真正调 API 写库")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.dir)
|
||||||
|
if not root.is_dir():
|
||||||
|
print(f"[ERROR] 目录不存在: {root}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 收集文件
|
||||||
|
md_files = collect_md_files(root)
|
||||||
|
if not md_files:
|
||||||
|
print("[ERROR] 未找到任何 .md 文件")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
total = len(md_files)
|
||||||
|
if args.limit > 0:
|
||||||
|
md_files = md_files[:args.limit]
|
||||||
|
print(f"共发现 {total} 个 .md 文件,本次处理前 {len(md_files)} 个\n")
|
||||||
|
else:
|
||||||
|
print(f"共发现 {total} 个 .md 文件,开始全量导入...\n")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("【DRY-RUN 模式】只预览解析结果,不写入数据库\n")
|
||||||
|
|
||||||
|
service = KnowledgeService()
|
||||||
|
success_count = 0
|
||||||
|
skip_exists_count = 0
|
||||||
|
skip_empty_count = 0
|
||||||
|
skip_no_fm_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
processed = len(md_files)
|
||||||
|
|
||||||
|
for idx, file_path in enumerate(md_files, 1):
|
||||||
|
rel_path = str(file_path.relative_to(root)).replace("\\", "/")
|
||||||
|
|
||||||
|
# 跳过空文件
|
||||||
|
if file_path.stat().st_size == 0:
|
||||||
|
print(f"[{idx}/{processed}] [SKIP] {rel_path} -- 空文件,跳过")
|
||||||
|
skip_empty_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 读取内容
|
||||||
|
try:
|
||||||
|
content_bytes = file_path.read_bytes()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{idx}/{processed}] [FAIL] {rel_path} -- 读取失败:{e}")
|
||||||
|
fail_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查 frontmatter
|
||||||
|
content_str = content_bytes.decode("utf-8", errors="replace")
|
||||||
|
if not has_frontmatter(content_str):
|
||||||
|
print(f"[{idx}/{processed}] [SKIP] {rel_path} -- 无 frontmatter,跳过")
|
||||||
|
skip_no_fm_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 查重(用 Obsidian 相对路径作为 source_file_name)
|
||||||
|
source_file_name = rel_path
|
||||||
|
if check_exists(source_file_name):
|
||||||
|
print(f"[{idx}/{processed}] [SKIP] {rel_path} -- 已存在,跳过")
|
||||||
|
skip_exists_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# dry-run 模式:只解析不入库
|
||||||
|
if args.dry_run:
|
||||||
|
try:
|
||||||
|
parsed = service.parse_md_file(content_bytes, source_file_name)
|
||||||
|
char_count = len(parsed["content_md"])
|
||||||
|
print(f"[{idx}/{processed}] [PREVIEW] {rel_path}")
|
||||||
|
print(f" title={parsed['title']}")
|
||||||
|
print(f" source_type={parsed['source_type']}, author={parsed['author']}")
|
||||||
|
print(f" metadata={parsed['metadata']}")
|
||||||
|
print(f" entities={len(parsed['related_entities'] or [])}条, 正文{char_count}字")
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{idx}/{processed}] [FAIL] {rel_path} -- 解析失败:{e}")
|
||||||
|
fail_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 正式入库
|
||||||
|
try:
|
||||||
|
parsed = service.parse_md_file(content_bytes, source_file_name)
|
||||||
|
char_count = len(parsed["content_md"])
|
||||||
|
service.store_md_file(file_content=content_bytes, file_name=source_file_name)
|
||||||
|
print(f"[{idx}/{processed}] [OK] {rel_path} -- 入库成功({char_count}字,类型={parsed['source_type']})")
|
||||||
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{idx}/{processed}] [FAIL] {rel_path} -- 入库失败:{e}")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
mode_label = "预览" if args.dry_run else "导入"
|
||||||
|
print(f"{mode_label}完成:成功 {success_count} 篇"
|
||||||
|
f" / 跳过(已存在) {skip_exists_count} 篇"
|
||||||
|
f" / 跳过(空文件) {skip_empty_count} 篇"
|
||||||
|
f" / 跳过(无frontmatter) {skip_no_fm_count} 篇"
|
||||||
|
f" / 失败 {fail_count} 篇")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 004_add_content_digest.sql
|
||||||
|
-- 给 episodes 表添加内容摘要卡字段(AI 生成的节目内容结构化摘要,供诊断报告使用)
|
||||||
|
-- 前置:执行前先 pg_dump 备份
|
||||||
|
|
||||||
|
ALTER TABLE episodes
|
||||||
|
ADD COLUMN IF NOT EXISTS content_digest JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN episodes.content_digest
|
||||||
|
IS '节目内容摘要卡(AI生成JSONB:核心切口/叙事亮点/观众门槛/话题性/潜在弱点/时效关联)';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 讯飞录音文件转写标准版(不是大模型版)
|
||||||
|
# 控制台: https://console.xfyun.cn/
|
||||||
|
XFYUN_APP_ID=your_app_id
|
||||||
|
XFYUN_SECRET_KEY=your_secret_key
|
||||||
|
|
||||||
|
# DeepSeek V4 Pro(用于专有名词提取 + 校对)
|
||||||
|
DEEPSEEK_API_KEY=your_deepseek_api_key
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
output*/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
pr/
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
# 项目协作主控文件 (cca/CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
|
- **最后更新**:Claude Code(动手开发)| 2026-07-05
|
||||||
|
- **当前状态一句话**:CCA 唱词助手已部署至腾讯云(http://101.42.29.217/cca.html)。完整流程可用:上传音频+A稿 → ASR+AI校对 → 审稿台(含查找替换)→ 生成SRT下载。WAV自动转MP3。进入内测阶段。
|
||||||
|
- **下一个动手的人从这里开始**:读完本文件。线上地址 http://101.42.29.217/cca.html(从配音首页"唱词助手"按钮进入)。服务器凭证见 `/workspace/military_tech_voice/backend/.env`。本地调试:`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v6`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 给 AI 的工作约定 (READ FIRST)
|
||||||
|
|
||||||
|
- 开始任何工作前,先读完本文件,特别是「状态栏」「交接备注」「关键决策」三节。
|
||||||
|
- 完成一段工作后**增量更新**:「已完成」追加一条、「待办」勾掉或新增、必要时更新「关键决策」,最后改「状态栏」三行。不要重写整份文件,不要删历史,只追加/勾选。
|
||||||
|
- 涉及发送、发布、删除、改权限、付款等不可逆操作前,先在对话里跟制片人确认。
|
||||||
|
- **本项目着急用**——优先出可用版本,不追求完美架构。先跑通再优化。
|
||||||
|
- **沟通**:全程简体中文;制片人不懂代码,解释说人话;给明确推荐不反复抛选项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概览
|
||||||
|
|
||||||
|
- **项目名**:CCA 唱词助手(ChangCi Assist)— 《军事科技》栏目编导/责编用的唱词字幕生成工具
|
||||||
|
- **目标**:把编导 A 稿 + 粗编人声音频 → 经过 ASR + AI 校对 + 编导审稿 → 最终输出符合大洋系统的 SRT 字幕文件
|
||||||
|
- **使用角色**:编导(提供 A 稿+音频、审稿确认)、责编(拿 SRT 拉到大洋上线)
|
||||||
|
- **部署计划**:先独立部署在 lanhao 配音 2.0 网站测试,成熟后吸收进 TPS 主项目作为子功能
|
||||||
|
|
||||||
|
### 核心工作流
|
||||||
|
|
||||||
|
```
|
||||||
|
编导A稿 ──→ AI提取专有名词词典
|
||||||
|
↓
|
||||||
|
粗编人声音频 + 词典 ──→ 讯飞ASR(录音文件转写标准版) ──→ 带时间戳ASR稿
|
||||||
|
↓
|
||||||
|
ASR稿 ⊕ A稿 比对
|
||||||
|
↓
|
||||||
|
┌─ AI自动修正(明显错别字)
|
||||||
|
└─ 编导审稿台(拿不准的差异)
|
||||||
|
↓
|
||||||
|
B稿(定稿)
|
||||||
|
↓
|
||||||
|
按拍词规则折行 + 时间戳
|
||||||
|
↓
|
||||||
|
SRT文件(大洋格式)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈与运行方式
|
||||||
|
|
||||||
|
- **语言**:Python(与主项目 backend 对齐)
|
||||||
|
- **前端**:纯 HTML/CSS/JS 单页(cca.html),暗色主题匹配 lanhao 配音系统,无框架依赖
|
||||||
|
- **ASR**:讯飞开放平台 录音文件转写标准版(已有 API Key,与 doco 共用同一套讯飞凭证)
|
||||||
|
- **AI**:LLM 用于两处——① 从 A 稿提取专有名词词典;② ASR 稿校对(的地得、引号、错别字)
|
||||||
|
- **输出格式**:SRT(大洋系统兼容格式,有样本参照)
|
||||||
|
- **美术风格**:参考 `ai-labeling/example/`(功能区划.jpg、幅面参考.jpg、页面风格.webp),与 TPS 主项目 UI 风格对齐
|
||||||
|
- **凭证**:子项目自己 `.env`,不动主项目 `backend/.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 当前进度
|
||||||
|
|
||||||
|
- **已完成至**:腾讯云部署完成 + 审稿台(含查找替换)上线。流水线 v6 + Web 审稿台 + WAV 自动转码。进入内测。
|
||||||
|
- **正在做**:无(等待内测反馈)。
|
||||||
|
- **卡点/待解**:无硬卡点。已知残留:ASR 切句边界跨越固定搭配(如"第二次/世界大战")暂无法修复——需要跨句拆词检测(可做但需更大短语词典)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 已完成(只追加,最新在上)
|
||||||
|
|
||||||
|
- [2026-07-05] **腾讯云部署 + 审稿台上线**:① deploy/cca_route.py(Flask 蓝图,6 个 API 端点:upload/status/review/save/generate/download);② deploy/cca.html(4 步单页流程:上传→处理→审稿→下载,暗色主题匹配配音系统);③ 审稿台功能:左栏 ASR 原文 vs 右栏 AI 校对稿对比、逐句编辑确认、仅看修改过滤、全部确认、**查找替换**(Ctrl+H,支持逐个/全部替换+高亮定位);④ WAV 大文件自动 ffmpeg 转 MP3(解决讯飞上传超时);⑤ 服务器架构:Nginx→静态 HTML + Flask:5000,CCA 源码在 `/workspace/military_tech_voice/backend/cca_src/`,蓝图注册在 `app/routes/cca.py`。首页已加"唱词助手"入口按钮。
|
||||||
|
- [2026-07-05] **脚本版流水线 v6(14项审片修复+专家段识别)**:① term_normalizer 新增:波浪号→"到"、顿号→空格、小数点保留(0.9马赫)、同音字映射表(建制→舰只/沉默→沉没/继承→击沉)、引号上下文感知(日向号加引号但日向级不加)、书名号补全(《军事科技》);② ai_proofreader 新增:speaker 角色自动识别(解说/主持/专家)、专家采访增强 Prompt(严格删除嗯/呃/啊/这个/那么/就是说等口头语)、的地得规则大幅加强(+大量示例)、数字照抄 ASR 规则;③ ai_line_breaker 新增:引号不跨屏后处理(≤6字引号内容不拆两行)、极短行合并(≤3字+时长<1秒→并入相邻行)、极短句合并间隔放宽(≤4字句间隔阈值1200ms);④ line_breaker 修改:clean_punctuation 保留小数点、顿号→空格;⑤ pipeline 新增 Step 2.5 校对后二次正则修复(兜住 AI 校对引入的新问题)。
|
||||||
|
- [2026-07-04] **脚本版流水线 v5(四层纠错体系+折行优化)**:基于制片人逐帧审片反馈,解决 10+ 问题。新增四层纠错:① term_normalizer.py 正则层(型号短横线 F-15J/武器昵称引号"鱼鹰"/中文数字修复,零token);② 校对 Prompt 升级(+代词他→它、+的地得纠错、+A稿权重规则);③ 折行 Prompt 升级(+禁忌字规则"的了着过"不开头、+主谓宾拆分规则、+不可拆词示例);④ 折行后处理三层(超长切分→禁忌字修复→拆词检测 _fix_split_words)。新增短句合并预处理(解决专家气口碎片句问题)。
|
||||||
|
- [2026-07-04] **脚本版流水线 v3(绝对时间戳+严格校对)**:① 恢复绝对时间戳(方便在大洋时间线对位);② 重写校对 Prompt——铁律:只改错别字/同音字、术语格式、口语填充词,绝不润色/调序/替换实词;③ 校对效果:60 处修正(vs v2 的 100 处,去掉了过度修改)。输出目录 `output_v3/`。
|
||||||
|
- [2026-07-04] **脚本版流水线 v2 真实测试通过**:① 热词提取(规则+AI,127个术语)→ ② 讯飞ASR(94秒完成25MB音频,357句)→ ③ AI校对(DeepSeek,修正同音字/术语格式/口语填充,"建制"→"舰只"等)→ ④ AI折行(语义断句,98%行≤14字)→ ⑤ 5段SRT输出(段内相对时间戳,从00:00:00开始)。新增:hotword_extractor.py(热词提取)、ai_proofreader.py(AI校对)、ai_line_breaker.py(AI折行)。
|
||||||
|
- [2026-07-04] 脚本版流水线骨架完成:① asr_client.py(讯飞ASR适配,从doco复用);② line_breaker.py(折行引擎,≤14字/语义断句/空白行检测);③ srt_writer.py(大洋格式SRT输出);④ segment_splitter.py(节目结构切分:导视/正片×3/预告);⑤ cca_pipeline.py(主入口串联全流程)。本地测试全部通过。
|
||||||
|
- [2026-07-04] 子项目立项:目录结构、CLAUDE.md、Brief、主项目寄存条建立。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 待办(按优先级)
|
||||||
|
|
||||||
|
- [x] ~~PRD / 业务规则确认~~ → 已在对话中完成(2026-07-04)
|
||||||
|
- [x] ~~脚本版流水线~~ → v5 完成(四层纠错+折行优化+短句合并)
|
||||||
|
- [x] ~~AI 校对层~~ → 已实现(四层防线:热词→正则→AI校对→折行后处理)
|
||||||
|
- [x] ~~制片人审片第一轮~~ → 10+ 问题全部解决
|
||||||
|
- [x] ~~编导审稿台~~ → 已完成(查找替换+逐句对比+编辑确认,2026-07-05)
|
||||||
|
- [x] ~~部署至腾讯云~~ → 已完成(http://101.42.29.217/cca.html,2026-07-05)
|
||||||
|
- [ ] **内测反馈收集**:同事试用中,等待反馈
|
||||||
|
- [ ] **大洋系统验证**:导入 SRT 测试兼容性
|
||||||
|
- [ ] **热词注入真实 ASR 测试**:用 `--audio` 跑完整流水线(非缓存),验证热词在转写层的效果
|
||||||
|
- [ ] **首页入口按钮可能被遮挡**:index.html 已添加代码但可能需要样式调整(Ctrl+F5 刷新后可见)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键决策(跨 session 最易丢)
|
||||||
|
|
||||||
|
- [2026-07-04] **ASR 稿是权威底稿**:最终 SRT 必须和音频对应,ASR 稿(时间戳+内容)是基准。A 稿只提供专有名词写法和上下文参考,不能用 A 稿覆盖 ASR 的内容结构。
|
||||||
|
- [2026-07-04] **讯飞用录音文件转写标准版**(与 doco 一样,不要大模型版)。热词偏置注入专有名词词典。
|
||||||
|
- [2026-07-04] **编导审稿台是必要环节**:AI 拿不准的差异必须过编导确认,不能全自动。尊重编导创作主权(继承主项目设计哲学)。
|
||||||
|
- [2026-07-04] **先独立部署再吸收**:先在 lanhao 配音 2.0 上跑通测试,成熟后并入 TPS 主项目。不等主项目进度。
|
||||||
|
- [2026-07-04] **SRT 格式有样本**:data/ 下 3 个 SRT 是人工拍词导出的真实样本,作为格式参照。
|
||||||
|
- [2026-07-07] **大洋"单下单上"时间戳规则**:每条字幕的结束时间 = 下一条开始时间 - 40ms(如第一条结束 08,840、第二条开始 08,880)。不论有无空白行,所有相邻字幕条目都遵守此规则。实现在 `srt_writer.py` 的 `_apply_dayang_timing()` 后处理函数。
|
||||||
|
- [2026-07-04] **折行(拍词)规则**:
|
||||||
|
- A. 每行 ≤ 14 字
|
||||||
|
- B. ASR 中 >2 秒空白 → 插入空白行(屏幕清字幕)
|
||||||
|
- C. 按语义断句(不机械凑满 14 字),符合阅读习惯
|
||||||
|
- D. 去掉逗号/句号/感叹号等标点,只保留引号
|
||||||
|
- [2026-07-04] **输出结构(5 个 SRT)**:
|
||||||
|
- 导视 1 个 + 正片 3 个 + 下期预告 1 个
|
||||||
|
- 正片 3 个拆分依据:按时长大致均分,优先在角色转换处(解说词→专家采访)切分
|
||||||
|
- 5 个 SRT 时间戳连续拼接 = 音频总长
|
||||||
|
- [2026-07-04] **节目结构标志词**:
|
||||||
|
- 导视结束标志:听到"本期《军事科技》……"
|
||||||
|
- 正片开始标志:听到"各位观众你们好,我是主持人蓝皓"
|
||||||
|
- 正片结束标志:听到"好了观众朋友们,感谢您关注国防军事频道军事科技……"
|
||||||
|
- 正片之后是下期预告(无固定话术)
|
||||||
|
- [2026-07-04] **正片拆 3 个 SRT 的原因**:大洋字幕系统不稳定容易出错,责编提的需求。
|
||||||
|
- [2026-07-04] **音频是纯人声**:已分离好的干净音频(无 BGM/音效),无需人声分离。
|
||||||
|
- [2026-07-04] **先出脚本版**:命令行跑通流水线,审稿台第二步做。
|
||||||
|
- [2026-07-04] **讯飞代码从 doco 复用**:`doco/src/doco/asr_adapter.py` 有完整签名/上传/轮询/解析逻辑,直接适配。
|
||||||
|
- [2026-07-04] **SRT 用绝对时间戳**:每段 SRT 的时间戳是音频中的真实位置(不从 00:00:00 开始),方便在大洋时间线直接对位。制片人实测确认比相对时间戳好用。
|
||||||
|
- [2026-07-04] **AI 校对严格纪律**:只允许改三类——① 错别字/同音字 ② 术语格式(F-15J)③ 口语填充词删除。绝不润色、绝不调序、绝不替换实词。ASR 是已录音频的转写,改不了内容。
|
||||||
|
- [2026-07-04] **两层 ASR 纠错防线**:第一层=热词注入(预防,让讯飞在转写时就认对专有名词);第二层=AI 校对(修正,用 A 稿上下文判断同音字)。两层互补。
|
||||||
|
- [2026-07-04] **LLM 选型已定**:校对+折行+热词提取统一用 DeepSeek(deepseek-chat),性价比最优。
|
||||||
|
- [2026-07-04] **四层纠错体系**(v5 确立):① 热词注入(讯飞ASR层,预防中文同音字)→ ② term_normalizer 正则后处理(型号短横线/引号/中文数字,零token确定性替换)→ ③ AI 校对(DeepSeek,同音字/代词/的地得/术语/填充词)→ ④ 折行后处理(超长切分+禁忌字修复+拆词检测)。
|
||||||
|
- [2026-07-04] **折行三条铁律**:① 词语不可拆分到两屏 ② "的了着过地得和与及或"不能作为新行第一个字 ③ 主谓宾优先折为"主语(折行)谓语+宾语"。
|
||||||
|
- [2026-07-04] **短句合并策略**:ASR 按音频静音切句,专家气口会产生碎片短句(2-5字)。折行前先合并:≤8字+间隔<800ms→合并为一个语义单元再送AI折行。>2s静音仍插空白行。
|
||||||
|
- [2026-07-04] **A稿与ASR权重规则**:内容冲突时ASR优先(配音员可能改过措辞),但专有名词格式/写法按A稿(如F-35A、"鱼鹰"引号)。
|
||||||
|
- [2026-07-04] **国家代词不改**:指代国家时口语用"他"是可接受的,不纠正;只纠正指代武器/舰艇/飞机/导弹时的"他→它"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ⏩ 交接备注
|
||||||
|
|
||||||
|
- **立项背景**:编导剪完节目后需要给责编一份 SRT 唱词字幕文件。传统流程是责编拿折行稿到大洋系统里 1:1 实时"拍词"(播放视频、听到对应句子拍空格记录时间戳),效率极低。CCA 用 ASR 自动化这个过程。
|
||||||
|
- **"拍词"术语解释**:折行稿(去标点、按规则断行的文稿)+ 时间戳对位 = 拍词。传统靠人工实时听拍,CCA 用 ASR 时间戳代替。
|
||||||
|
- **与 doco 的区别**:doco 是"播出后"整理终版文稿(三方融合);CCA 是"剪辑后、播出前"生成唱词字幕(ASR→校对→SRT)。两者共用讯飞 ASR 能力,但流程目的完全不同。
|
||||||
|
- **数据样本**:`data/` 下有 A 稿 docx + mp3 音频 + 3 个人工拍词 SRT(对应正片三段)。
|
||||||
|
- **代码文件**:`src/` 下是核心流水线代码(asr_client / line_breaker / ai_line_breaker / ai_proofreader / srt_writer / segment_splitter / hotword_extractor / term_normalizer),入口 `cca_pipeline.py`。`deploy/` 下是部署文件(cca_route.py Flask 蓝图 + cca.html 前端页面 + deploy_to_server.py 部署脚本)。
|
||||||
|
- **凭证**:本地需在 `cca/.env` 中填写;服务器凭证在 `/workspace/military_tech_voice/backend/.env`(讯飞大号 + DeepSeek)。
|
||||||
|
- **服务器架构**:腾讯云 101.42.29.217,Nginx:80 → 静态文件(/var/www/voice/) + Flask:5000 代理(/api/)。CCA 源码部署在 `/workspace/military_tech_voice/backend/cca_src/`,任务数据在 `cca_data/`。Flask 无 systemd 服务,重启方式:`fuser -k 5000/tcp && cd backend && source venv/bin/activate && nohup python3 -m app.main > /tmp/flask_cca.log 2>&1 &`。
|
||||||
|
- **输出目录**:`output/`(ASR 缓存 + v1 输出)、`output_v2/`~`output_v6/`(各版本输出)。
|
||||||
|
- **运行命令示例**:
|
||||||
|
- 从缓存跑(调试校对/折行):`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v6`
|
||||||
|
- 完整流水线(含真实 ASR):`python -X utf8 cca_pipeline.py --audio "data/重走战争老路的日本军备A0.mp3" --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v7`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 待确认 / 开放问题
|
||||||
|
|
||||||
|
- [x] ~~拍词规则~~ → 已确认(见关键决策)
|
||||||
|
- [x] ~~大洋 SRT 样本文件~~ → data/ 下已有 3 个真实样本
|
||||||
|
- [x] ~~音频格式~~ → 纯人声 MP3,无需预处理
|
||||||
|
- [x] ~~LLM 选型~~ → DeepSeek(deepseek-chat),已验证效果好、价格低
|
||||||
|
- [x] ~~的地得纠错~~ → 已加入校对 Prompt(v5)
|
||||||
|
- [x] ~~前端审稿台技术选型~~ → 纯 HTML/JS 单页,无框架(2026-07-05)
|
||||||
|
- [ ] 大洋系统 SRT 导入兼容性(待验证)
|
||||||
|
- [ ] 跨句固定搭配拆词("第二次/世界大战"类问题,需大短语词典,优先级低)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CCA 唱词助手 — 脚本版流水线入口
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 完整流程: A稿热词 + ASR + AI校对 + AI折行 → 5个SRT
|
||||||
|
python cca_pipeline.py --audio data/xxx.mp3 --script data/xxx.docx
|
||||||
|
|
||||||
|
# 手动指定热词
|
||||||
|
python cca_pipeline.py --audio data/xxx.mp3 --hotwords "热词1|热词2"
|
||||||
|
|
||||||
|
# 跳过ASR,从缓存处理(调试折行/校对用)
|
||||||
|
python cca_pipeline.py --asr-cache output/asr_raw.json --script data/xxx.docx
|
||||||
|
|
||||||
|
# 省token模式(不用AI折行和校对)
|
||||||
|
python cca_pipeline.py --asr-cache output/asr_raw.json --no-ai
|
||||||
|
|
||||||
|
流水线:
|
||||||
|
A稿 → 热词提取 → 音频+热词 → 讯飞ASR → AI校对 → 节目结构切分 → AI折行 → 5个SRT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from asr_client import transcribe, parse_result
|
||||||
|
from line_breaker import process_sentences
|
||||||
|
from ai_line_breaker import process_sentences_with_ai
|
||||||
|
from srt_writer import write_srt, ms_to_srt_time
|
||||||
|
from segment_splitter import split_into_segments
|
||||||
|
from hotword_extractor import extract_hotwords
|
||||||
|
from ai_proofreader import proofread_batch
|
||||||
|
from term_normalizer import normalize_terms
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="CCA 唱词助手 - 自动生成拍词 SRT")
|
||||||
|
parser.add_argument("--audio", type=str, help="音频文件路径 (mp3/wav)")
|
||||||
|
parser.add_argument("--script", type=str, help="A稿路径 (.docx/.txt),用于热词提取和AI校对")
|
||||||
|
parser.add_argument("--hotwords", type=str, default="", help="手动指定热词,用|分隔(与--script可叠加)")
|
||||||
|
parser.add_argument("--asr-cache", type=str, help="ASR 缓存 JSON 路径(跳过ASR调用)")
|
||||||
|
parser.add_argument("--output-dir", type=str, default="output", help="输出目录 (默认: output/)")
|
||||||
|
parser.add_argument("--no-ai", action="store_true", help="不使用AI折行和校对(省token)")
|
||||||
|
parser.add_argument("--no-proofread", action="store_true", help="跳过AI校对(只省校对的token)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.audio and not args.asr_cache:
|
||||||
|
parser.error("必须提供 --audio 或 --asr-cache")
|
||||||
|
|
||||||
|
output_dir = Path(args.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
use_ai = not args.no_ai
|
||||||
|
|
||||||
|
# ====== Step 0: 热词提取(从A稿)======
|
||||||
|
hot_words = []
|
||||||
|
script_text = ""
|
||||||
|
|
||||||
|
if args.hotwords:
|
||||||
|
hot_words = [w.strip() for w in args.hotwords.split("|") if w.strip()]
|
||||||
|
|
||||||
|
if args.script:
|
||||||
|
print(f"[流水线] 从A稿提取热词: {args.script}")
|
||||||
|
script_hot = extract_hotwords(args.script, use_ai=use_ai)
|
||||||
|
# 合并手动热词和A稿热词
|
||||||
|
seen = set(hot_words)
|
||||||
|
for w in script_hot:
|
||||||
|
if w not in seen:
|
||||||
|
hot_words.append(w)
|
||||||
|
seen.add(w)
|
||||||
|
|
||||||
|
# 读取A稿全文(校对用)
|
||||||
|
ext = os.path.splitext(args.script)[1].lower()
|
||||||
|
if ext == ".docx":
|
||||||
|
from hotword_extractor import read_docx_text
|
||||||
|
script_text = read_docx_text(args.script)
|
||||||
|
else:
|
||||||
|
from hotword_extractor import read_text_file
|
||||||
|
script_text = read_text_file(args.script)
|
||||||
|
|
||||||
|
if hot_words:
|
||||||
|
print(f"[流水线] 热词共 {len(hot_words)} 个: {', '.join(hot_words[:10])}...")
|
||||||
|
# 保存热词列表
|
||||||
|
hotwords_path = output_dir / "hotwords.txt"
|
||||||
|
with open(hotwords_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("|".join(hot_words))
|
||||||
|
|
||||||
|
# ====== Step 1: ASR 转写 ======
|
||||||
|
if args.asr_cache:
|
||||||
|
print(f"[流水线] 从缓存加载 ASR 结果: {args.asr_cache}")
|
||||||
|
with open(args.asr_cache, "r", encoding="utf-8") as f:
|
||||||
|
raw_json = f.read()
|
||||||
|
sentences = parse_result(raw_json)
|
||||||
|
else:
|
||||||
|
print(f"[流水线] 开始 ASR 转写: {args.audio}")
|
||||||
|
sentences, raw_json = transcribe(args.audio, hot_words=hot_words if hot_words else None)
|
||||||
|
|
||||||
|
cache_path = output_dir / "asr_raw.json"
|
||||||
|
with open(cache_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(raw_json)
|
||||||
|
print(f"[流水线] ASR 原始结果已缓存: {cache_path}")
|
||||||
|
|
||||||
|
print(f"[流水线] ASR 共 {len(sentences)} 句")
|
||||||
|
|
||||||
|
# ====== Step 1.5: 术语格式化(正则后处理,不耗 token)======
|
||||||
|
if script_text:
|
||||||
|
print("[流水线] 术语格式化(型号短横线/武器昵称引号/中文数字)...")
|
||||||
|
sentences = normalize_terms(sentences, script_text)
|
||||||
|
|
||||||
|
# ====== Step 2: AI 校对 ======
|
||||||
|
if use_ai and not args.no_proofread and script_text:
|
||||||
|
print("[流水线] AI 校对中 (DeepSeek)...")
|
||||||
|
sentences = proofread_batch(sentences, script_text)
|
||||||
|
elif not script_text and not args.no_proofread and use_ai:
|
||||||
|
print("[流水线] 未提供A稿(--script),跳过AI校对")
|
||||||
|
|
||||||
|
# ====== Step 2.5: 校对后二次正则修复(兜住AI校对引入的新问题)======
|
||||||
|
if script_text:
|
||||||
|
from term_normalizer import normalize_terms as post_normalize
|
||||||
|
print("[流水线] 校对后二次正则修复...")
|
||||||
|
sentences = post_normalize(sentences, script_text)
|
||||||
|
|
||||||
|
# ====== Step 3: 节目结构切分 ======
|
||||||
|
print("[流水线] 切分节目结构...")
|
||||||
|
segments = split_into_segments(sentences)
|
||||||
|
print(f"[流水线] 切分结果: {[name for name, _ in segments]}")
|
||||||
|
|
||||||
|
# ====== Step 4: 折行 + 生成 SRT ======
|
||||||
|
if use_ai:
|
||||||
|
print("[流水线] 使用 AI 折行 (DeepSeek)...")
|
||||||
|
else:
|
||||||
|
print("[流水线] 使用机械折行规则...")
|
||||||
|
|
||||||
|
for seg_name, seg_sentences in segments:
|
||||||
|
if not seg_sentences:
|
||||||
|
print(f" [{seg_name}] 空段,跳过")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if use_ai:
|
||||||
|
subtitle_lines = process_sentences_with_ai(seg_sentences)
|
||||||
|
else:
|
||||||
|
subtitle_lines = process_sentences(seg_sentences)
|
||||||
|
|
||||||
|
seg_offset = subtitle_lines[0][0] if subtitle_lines else 0
|
||||||
|
seg_end = subtitle_lines[-1][1] if subtitle_lines else 0
|
||||||
|
seg_duration = seg_end - seg_offset
|
||||||
|
|
||||||
|
srt_filename = f"{seg_name}.srt"
|
||||||
|
srt_path = output_dir / srt_filename
|
||||||
|
write_srt(subtitle_lines, str(srt_path)) # 绝对时间戳,方便在时间线上对位
|
||||||
|
print(f" [{seg_name}] 时长 {ms_to_srt_time(seg_duration)}, 在音频中的位置: {ms_to_srt_time(seg_offset)} ~ {ms_to_srt_time(seg_end)}")
|
||||||
|
|
||||||
|
# ====== 完成 ======
|
||||||
|
print(f"\n[流水线] 完成! 输出目录: {output_dir}/")
|
||||||
|
print("[流水线] 生成的文件:")
|
||||||
|
for f in sorted(output_dir.glob("*.srt")):
|
||||||
|
print(f" {f.name}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1292
File diff suppressed because it is too large
Load Diff
+1252
File diff suppressed because it is too large
Load Diff
+1112
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,910 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CCA 唱词助手 - 军事科技</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<style>
|
||||||
|
/* CCA 专用样式 */
|
||||||
|
.cca-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.step-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-color);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.step-dot.active { background: var(--accent-primary); transform: scale(1.3); }
|
||||||
|
.step-dot.done { background: var(--success); }
|
||||||
|
|
||||||
|
.step-panel { display: none; flex: 1; flex-direction: column; }
|
||||||
|
.step-panel.active { display: flex; }
|
||||||
|
|
||||||
|
/* 上传区 */
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
background: rgba(99,102,241,0.05);
|
||||||
|
}
|
||||||
|
.upload-zone.has-file {
|
||||||
|
border-color: var(--success);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
||||||
|
.upload-label { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 4px; }
|
||||||
|
.upload-hint { color: var(--text-muted); font-size: 0.8rem; }
|
||||||
|
.upload-filename {
|
||||||
|
color: var(--success); font-weight: 600;
|
||||||
|
font-size: 1rem; margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
||||||
|
|
||||||
|
/* 进度 */
|
||||||
|
.progress-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
.progress-spinner {
|
||||||
|
width: 60px; height: 60px;
|
||||||
|
border: 4px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.progress-detail { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* 审稿台 */
|
||||||
|
.review-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.review-stats { display: flex; gap: 16px; font-size: 0.85rem; }
|
||||||
|
.review-stats span { color: var(--text-muted); }
|
||||||
|
.review-stats .count { color: var(--accent-secondary); font-weight: 600; }
|
||||||
|
.review-stats .changed { color: var(--warning); }
|
||||||
|
|
||||||
|
.review-controls {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 320px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 1fr 80px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.review-item:hover { background: var(--bg-tertiary); }
|
||||||
|
.review-item.changed { background: rgba(245,158,11,0.05); }
|
||||||
|
.review-item.confirmed { opacity: 0.7; }
|
||||||
|
|
||||||
|
.review-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.review-original {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.review-edited {
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.review-edited:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.review-item.changed .review-original {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
.review-item.changed .review-edited {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-confirm-btn {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.review-confirm-btn.checked {
|
||||||
|
background: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.review-confirm-btn:hover { border-color: var(--success); }
|
||||||
|
|
||||||
|
.review-legend {
|
||||||
|
display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.legend-dot {
|
||||||
|
display: inline-block; width: 8px; height: 8px;
|
||||||
|
border-radius: 50%; margin-right: 4px; vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 查找替换面板 */
|
||||||
|
.replace-panel {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--accent-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
animation: fadeIn 0.2s;
|
||||||
|
}
|
||||||
|
.replace-panel.active { display: block; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.replace-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.replace-row:last-child { margin-bottom: 0; }
|
||||||
|
.replace-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.replace-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.replace-input:focus { border-color: var(--accent-primary); }
|
||||||
|
.replace-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.replace-btn {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.replace-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
|
||||||
|
.replace-btn.primary { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
|
||||||
|
.replace-btn.primary:hover { opacity: 0.85; }
|
||||||
|
.replace-btn.danger { background: var(--warning); color: white; border-color: var(--warning); }
|
||||||
|
.replace-btn.danger:hover { opacity: 0.85; }
|
||||||
|
.replace-info {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.replace-highlight {
|
||||||
|
background: rgba(245,158,11,0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
.replace-current {
|
||||||
|
background: rgba(99,102,241,0.4);
|
||||||
|
outline: 2px solid var(--accent-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过滤按钮 */
|
||||||
|
.filter-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.filter-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
|
||||||
|
.filter-btn.active { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
|
||||||
|
|
||||||
|
/* 完成页 */
|
||||||
|
.done-container { text-align: center; padding: 60px 20px; }
|
||||||
|
.done-icon { font-size: 64px; margin-bottom: 16px; }
|
||||||
|
.done-title { font-size: 1.3rem; margin-bottom: 8px; }
|
||||||
|
.done-detail { color: var(--text-muted); margin-bottom: 24px; }
|
||||||
|
|
||||||
|
/* 错误 */
|
||||||
|
.error-box {
|
||||||
|
background: rgba(239,68,68,0.1);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--error);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.back-link:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.cca-badge {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upload-grid { grid-template-columns: 1fr; }
|
||||||
|
.review-item { grid-template-columns: 50px 1fr 40px; }
|
||||||
|
.review-original { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="cca-container">
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<a href="index.html" class="back-link" style="margin-right:12px">← 返回配音</a>
|
||||||
|
<span class="logo-text">CCA <span class="logo-sub">唱词助手</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="header-info">
|
||||||
|
<span class="cca-badge">唱词字幕</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 步骤指示器 -->
|
||||||
|
<div class="step-indicator">
|
||||||
|
<div class="step-dot active" id="dot-0"></div>
|
||||||
|
<div class="step-dot" id="dot-1"></div>
|
||||||
|
<div class="step-dot" id="dot-2"></div>
|
||||||
|
<div class="step-dot" id="dot-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 0: 上传 -->
|
||||||
|
<div class="step-panel active" id="step-upload">
|
||||||
|
<div class="panel" style="flex:1; padding:24px;">
|
||||||
|
<h2 style="margin-bottom:8px;">上传素材</h2>
|
||||||
|
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:24px;">
|
||||||
|
上传编导 A 稿和粗编人声音频,AI 将自动完成 ASR 转写、术语校对、的地得纠错和口头语清除
|
||||||
|
</p>
|
||||||
|
<div class="upload-grid">
|
||||||
|
<div class="upload-zone" id="zone-audio" onclick="document.getElementById('input-audio').click()">
|
||||||
|
<div class="upload-icon">🎤</div>
|
||||||
|
<div class="upload-label">人声音频</div>
|
||||||
|
<div class="upload-hint">MP3 / WAV,纯人声(必传)</div>
|
||||||
|
<div class="upload-filename" id="name-audio"></div>
|
||||||
|
<input type="file" id="input-audio" accept=".mp3,.wav,.m4a" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="upload-zone" id="zone-script" onclick="document.getElementById('input-script').click()">
|
||||||
|
<div class="upload-icon">📄</div>
|
||||||
|
<div class="upload-label">A 稿文件</div>
|
||||||
|
<div class="upload-hint">DOCX / TXT(强烈建议上传,用于术语校对)</div>
|
||||||
|
<div class="upload-filename" id="name-script"></div>
|
||||||
|
<input type="file" id="input-script" accept=".docx,.doc,.txt" style="display:none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons" style="justify-content:center;">
|
||||||
|
<button class="btn btn-primary" id="btn-start" disabled style="max-width:300px;">
|
||||||
|
开始处理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: 处理中 -->
|
||||||
|
<div class="step-panel" id="step-processing">
|
||||||
|
<div class="panel" style="flex:1;">
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-spinner"></div>
|
||||||
|
<div class="progress-text" id="progress-text">准备中...</div>
|
||||||
|
<div class="progress-detail" id="progress-detail">请勿关闭页面,ASR 转写通常需要 1-2 分钟</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: 审稿台 -->
|
||||||
|
<div class="step-panel" id="step-review">
|
||||||
|
<div class="review-header">
|
||||||
|
<div>
|
||||||
|
<span style="font-weight:600;">审稿台</span>
|
||||||
|
<span class="review-stats" style="margin-left:16px;">
|
||||||
|
总计 <span class="count" id="stat-total">0</span> 句 |
|
||||||
|
AI 修改 <span class="changed" id="stat-changed">0</span> 处 |
|
||||||
|
已确认 <span class="count" id="stat-confirmed">0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-controls">
|
||||||
|
<button class="filter-btn active" id="filter-all" onclick="filterItems('all')">全部</button>
|
||||||
|
<button class="filter-btn" id="filter-changed" onclick="filterItems('changed')">仅看修改</button>
|
||||||
|
<button class="replace-btn" onclick="toggleReplace()" id="btn-toggle-replace" title="Ctrl+H">查找替换</button>
|
||||||
|
<button class="btn btn-secondary" style="padding:6px 12px; font-size:0.8rem;" onclick="confirmAll()">全部确认</button>
|
||||||
|
<button class="btn btn-primary" id="btn-generate" style="padding:6px 16px; font-size:0.85rem;">
|
||||||
|
生成 SRT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 查找替换面板 -->
|
||||||
|
<div class="replace-panel" id="replace-panel">
|
||||||
|
<div class="replace-row">
|
||||||
|
<span class="replace-label">查找</span>
|
||||||
|
<input class="replace-input" id="replace-find" placeholder="输入要查找的文字..." oninput="onFindInput()">
|
||||||
|
<div class="replace-actions">
|
||||||
|
<span class="replace-info" id="replace-match-info"></span>
|
||||||
|
<button class="replace-btn" onclick="findPrev()" title="上一个">▲</button>
|
||||||
|
<button class="replace-btn" onclick="findNext()" title="下一个">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="replace-row">
|
||||||
|
<span class="replace-label">替换</span>
|
||||||
|
<input class="replace-input" id="replace-to" placeholder="替换为...">
|
||||||
|
<div class="replace-actions">
|
||||||
|
<button class="replace-btn primary" onclick="replaceCurrent()">替换当前</button>
|
||||||
|
<button class="replace-btn danger" onclick="replaceAll()">全部替换</button>
|
||||||
|
<button class="replace-btn" onclick="toggleReplace()">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-legend">
|
||||||
|
<span><span class="legend-dot" style="background:var(--warning);"></span>AI 已修改</span>
|
||||||
|
<span><span class="legend-dot" style="background:var(--success);"></span>已确认</span>
|
||||||
|
<span>点击文字可直接编辑,右侧勾选确认</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-list" id="review-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: 完成 -->
|
||||||
|
<div class="step-panel" id="step-done">
|
||||||
|
<div class="panel" style="flex:1;">
|
||||||
|
<div class="done-container">
|
||||||
|
<div class="done-icon">✅</div>
|
||||||
|
<div class="done-title">SRT 字幕文件生成完成</div>
|
||||||
|
<div class="done-detail" id="done-detail"></div>
|
||||||
|
<div class="action-buttons" style="justify-content:center; gap:12px;">
|
||||||
|
<button class="btn btn-primary" id="btn-download" style="max-width:200px;">
|
||||||
|
下载字幕包
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="location.reload()" style="max-width:200px;">
|
||||||
|
处理下一期
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误展示 -->
|
||||||
|
<div class="error-box" id="error-box" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api/cca';
|
||||||
|
let currentTaskId = null;
|
||||||
|
let reviewData = [];
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
// === 文件上传 ===
|
||||||
|
const audioInput = document.getElementById('input-audio');
|
||||||
|
const scriptInput = document.getElementById('input-script');
|
||||||
|
const btnStart = document.getElementById('btn-start');
|
||||||
|
|
||||||
|
audioInput.addEventListener('change', () => {
|
||||||
|
const f = audioInput.files[0];
|
||||||
|
if (f) {
|
||||||
|
document.getElementById('name-audio').textContent = f.name;
|
||||||
|
document.getElementById('zone-audio').classList.add('has-file');
|
||||||
|
}
|
||||||
|
checkReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
scriptInput.addEventListener('change', () => {
|
||||||
|
const f = scriptInput.files[0];
|
||||||
|
if (f) {
|
||||||
|
document.getElementById('name-script').textContent = f.name;
|
||||||
|
document.getElementById('zone-script').classList.add('has-file');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拖拽
|
||||||
|
['zone-audio', 'zone-script'].forEach(id => {
|
||||||
|
const zone = document.getElementById(id);
|
||||||
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||||
|
zone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove('dragover');
|
||||||
|
const inputId = id === 'zone-audio' ? 'input-audio' : 'input-script';
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
input.files = e.dataTransfer.files;
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkReady() {
|
||||||
|
btnStart.disabled = !audioInput.files[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
btnStart.addEventListener('click', async () => {
|
||||||
|
if (!audioInput.files[0]) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('audio', audioInput.files[0]);
|
||||||
|
if (scriptInput.files[0]) formData.append('script', scriptInput.files[0]);
|
||||||
|
|
||||||
|
btnStart.disabled = true;
|
||||||
|
btnStart.textContent = '上传中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) { showError(data.error); return; }
|
||||||
|
currentTaskId = data.task_id;
|
||||||
|
goToStep(1);
|
||||||
|
startPolling();
|
||||||
|
} catch (e) {
|
||||||
|
showError('上传失败: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 轮询状态 ===
|
||||||
|
function startPolling() {
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/status/${currentTaskId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('progress-text').textContent = data.progress || '处理中...';
|
||||||
|
|
||||||
|
if (data.status === 'review') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
await loadReview();
|
||||||
|
goToStep(2);
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
showError(data.error || '处理出错');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('poll error', e);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 审稿台 ===
|
||||||
|
async function loadReview() {
|
||||||
|
const res = await fetch(`${API}/review/${currentTaskId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
reviewData = data.items;
|
||||||
|
renderReview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function msToTime(ms) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const ss = String(s % 60).padStart(2, '0');
|
||||||
|
const mm = String(m % 60).padStart(2, '0');
|
||||||
|
const hh = String(h).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReview() {
|
||||||
|
const list = document.getElementById('review-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
let totalChanged = 0;
|
||||||
|
let totalConfirmed = 0;
|
||||||
|
|
||||||
|
reviewData.forEach((item, i) => {
|
||||||
|
if (item.has_change) totalChanged++;
|
||||||
|
if (item.confirmed) totalConfirmed++;
|
||||||
|
|
||||||
|
if (currentFilter === 'changed' && !item.has_change) return;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
|
||||||
|
row.dataset.index = i;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="review-time">${msToTime(item.start_ms)}</div>
|
||||||
|
<div class="review-original">${escHtml(item.original)}</div>
|
||||||
|
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}"
|
||||||
|
onfocus="this.parentElement.classList.remove('confirmed')"
|
||||||
|
onblur="onEditBlur(this)">
|
||||||
|
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
|
||||||
|
onclick="toggleConfirm(${i}, this)" title="确认">
|
||||||
|
${item.confirmed ? '✓' : ''}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('stat-total').textContent = reviewData.length;
|
||||||
|
document.getElementById('stat-changed').textContent = totalChanged;
|
||||||
|
document.getElementById('stat-confirmed').textContent = totalConfirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
function escAttr(s) { return s.replace(/"/g, '"').replace(/</g, '<'); }
|
||||||
|
|
||||||
|
function onEditBlur(input) {
|
||||||
|
const idx = parseInt(input.dataset.idx);
|
||||||
|
reviewData[idx].edited = input.value;
|
||||||
|
reviewData[idx].confirmed = true;
|
||||||
|
updateStats();
|
||||||
|
autoSave();
|
||||||
|
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
|
||||||
|
if (keyword) {
|
||||||
|
onFindInput();
|
||||||
|
} else {
|
||||||
|
renderReview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleConfirm(idx, btn) {
|
||||||
|
reviewData[idx].confirmed = !reviewData[idx].confirmed;
|
||||||
|
btn.classList.toggle('checked');
|
||||||
|
btn.innerHTML = reviewData[idx].confirmed ? '✓' : '';
|
||||||
|
btn.parentElement.classList.toggle('confirmed', reviewData[idx].confirmed);
|
||||||
|
updateStats();
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAll() {
|
||||||
|
reviewData.forEach(item => item.confirmed = true);
|
||||||
|
renderReview();
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterItems(filter) {
|
||||||
|
currentFilter = filter;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.getElementById('filter-' + filter).classList.add('active');
|
||||||
|
renderReview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
let confirmed = reviewData.filter(i => i.confirmed).length;
|
||||||
|
document.getElementById('stat-confirmed').textContent = confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveTimer = null;
|
||||||
|
function autoSave() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
fetch(`${API}/save/${currentTaskId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items: reviewData }),
|
||||||
|
}).catch(console.error);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 生成 SRT ===
|
||||||
|
document.getElementById('btn-generate').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('btn-generate');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '生成中...';
|
||||||
|
|
||||||
|
// 先保存最新编辑
|
||||||
|
await fetch(`${API}/save/${currentTaskId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items: reviewData }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/generate/${currentTaskId}`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) { showError(data.error); btn.disabled = false; btn.textContent = '生成 SRT'; return; }
|
||||||
|
document.getElementById('done-detail').textContent = data.message;
|
||||||
|
goToStep(3);
|
||||||
|
} catch (e) {
|
||||||
|
showError('生成失败: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '生成 SRT';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 下载 ===
|
||||||
|
document.getElementById('btn-download').addEventListener('click', () => {
|
||||||
|
window.location.href = `${API}/download/${currentTaskId}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 步骤控制 ===
|
||||||
|
function goToStep(n) {
|
||||||
|
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
const panels = ['step-upload', 'step-processing', 'step-review', 'step-done'];
|
||||||
|
document.getElementById(panels[n]).classList.add('active');
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const dot = document.getElementById('dot-' + i);
|
||||||
|
dot.className = 'step-dot' + (i === n ? ' active' : (i < n ? ' done' : ''));
|
||||||
|
}
|
||||||
|
document.getElementById('error-box').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const box = document.getElementById('error-box');
|
||||||
|
box.textContent = msg;
|
||||||
|
box.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 查找替换 ===
|
||||||
|
let findMatches = []; // [{idx, pos}] — idx=reviewData index, pos=match position in text
|
||||||
|
let findCursor = -1;
|
||||||
|
|
||||||
|
function toggleReplace() {
|
||||||
|
const panel = document.getElementById('replace-panel');
|
||||||
|
const btn = document.getElementById('btn-toggle-replace');
|
||||||
|
panel.classList.toggle('active');
|
||||||
|
btn.classList.toggle('active');
|
||||||
|
if (panel.classList.contains('active')) {
|
||||||
|
document.getElementById('replace-find').focus();
|
||||||
|
} else {
|
||||||
|
clearFindHighlights();
|
||||||
|
findMatches = [];
|
||||||
|
findCursor = -1;
|
||||||
|
document.getElementById('replace-match-info').textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
|
||||||
|
e.preventDefault();
|
||||||
|
const panel = document.getElementById('replace-panel');
|
||||||
|
if (!panel.classList.contains('active') && document.getElementById('step-review').classList.contains('active')) {
|
||||||
|
toggleReplace();
|
||||||
|
} else if (panel.classList.contains('active')) {
|
||||||
|
document.getElementById('replace-find').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onFindInput() {
|
||||||
|
const keyword = document.getElementById('replace-find').value;
|
||||||
|
if (!keyword) {
|
||||||
|
findMatches = [];
|
||||||
|
findCursor = -1;
|
||||||
|
document.getElementById('replace-match-info').textContent = '';
|
||||||
|
renderReview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
findMatches = [];
|
||||||
|
reviewData.forEach((item, idx) => {
|
||||||
|
let pos = 0;
|
||||||
|
const text = item.edited;
|
||||||
|
while (true) {
|
||||||
|
const found = text.indexOf(keyword, pos);
|
||||||
|
if (found === -1) break;
|
||||||
|
findMatches.push({ idx, pos: found });
|
||||||
|
pos = found + keyword.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
findCursor = findMatches.length > 0 ? 0 : -1;
|
||||||
|
updateFindInfo();
|
||||||
|
renderReview();
|
||||||
|
scrollToCurrentMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNext() {
|
||||||
|
if (findMatches.length === 0) return;
|
||||||
|
findCursor = (findCursor + 1) % findMatches.length;
|
||||||
|
updateFindInfo();
|
||||||
|
renderReview();
|
||||||
|
scrollToCurrentMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPrev() {
|
||||||
|
if (findMatches.length === 0) return;
|
||||||
|
findCursor = (findCursor - 1 + findMatches.length) % findMatches.length;
|
||||||
|
updateFindInfo();
|
||||||
|
renderReview();
|
||||||
|
scrollToCurrentMatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFindInfo() {
|
||||||
|
const info = document.getElementById('replace-match-info');
|
||||||
|
if (findMatches.length === 0) {
|
||||||
|
info.textContent = '无匹配';
|
||||||
|
info.style.color = 'var(--error)';
|
||||||
|
} else {
|
||||||
|
info.textContent = `${findCursor + 1} / ${findMatches.length}`;
|
||||||
|
info.style.color = 'var(--text-muted)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCurrent() {
|
||||||
|
if (findCursor < 0 || findCursor >= findMatches.length) return;
|
||||||
|
const keyword = document.getElementById('replace-find').value;
|
||||||
|
const replacement = document.getElementById('replace-to').value;
|
||||||
|
if (!keyword) return;
|
||||||
|
|
||||||
|
const match = findMatches[findCursor];
|
||||||
|
const item = reviewData[match.idx];
|
||||||
|
item.edited = item.edited.substring(0, match.pos) + replacement + item.edited.substring(match.pos + keyword.length);
|
||||||
|
item.confirmed = true;
|
||||||
|
|
||||||
|
onFindInput();
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAll() {
|
||||||
|
const keyword = document.getElementById('replace-find').value;
|
||||||
|
const replacement = document.getElementById('replace-to').value;
|
||||||
|
if (!keyword) return;
|
||||||
|
if (findMatches.length === 0) return;
|
||||||
|
|
||||||
|
const count = findMatches.length;
|
||||||
|
const affectedIdxs = new Set(findMatches.map(m => m.idx));
|
||||||
|
|
||||||
|
affectedIdxs.forEach(idx => {
|
||||||
|
const item = reviewData[idx];
|
||||||
|
item.edited = item.edited.split(keyword).join(replacement);
|
||||||
|
item.confirmed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onFindInput();
|
||||||
|
autoSave();
|
||||||
|
document.getElementById('replace-match-info').textContent = `已替换 ${count} 处`;
|
||||||
|
document.getElementById('replace-match-info').style.color = 'var(--success)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFindHighlights() {
|
||||||
|
renderReview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToCurrentMatch() {
|
||||||
|
if (findCursor < 0 || findCursor >= findMatches.length) return;
|
||||||
|
const match = findMatches[findCursor];
|
||||||
|
const row = document.querySelector(`.review-item[data-index="${match.idx}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 renderReview 支持查找高亮
|
||||||
|
const _origRenderReview = renderReview;
|
||||||
|
renderReview = function() {
|
||||||
|
const list = document.getElementById('review-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
let totalChanged = 0;
|
||||||
|
let totalConfirmed = 0;
|
||||||
|
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
|
||||||
|
const currentMatch = findCursor >= 0 && findCursor < findMatches.length ? findMatches[findCursor] : null;
|
||||||
|
|
||||||
|
// 建一个快速查找表:哪些 (idx, pos) 是当前光标
|
||||||
|
let globalMatchIdx = 0;
|
||||||
|
|
||||||
|
reviewData.forEach((item, i) => {
|
||||||
|
if (item.has_change) totalChanged++;
|
||||||
|
if (item.confirmed) totalConfirmed++;
|
||||||
|
|
||||||
|
if (currentFilter === 'changed' && !item.has_change) return;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
|
||||||
|
row.dataset.index = i;
|
||||||
|
|
||||||
|
// 高亮匹配关键词
|
||||||
|
let editedDisplay = escHtml(item.edited);
|
||||||
|
if (keyword && item.edited.includes(keyword)) {
|
||||||
|
let result = '';
|
||||||
|
let searchPos = 0;
|
||||||
|
const text = item.edited;
|
||||||
|
while (true) {
|
||||||
|
const found = text.indexOf(keyword, searchPos);
|
||||||
|
if (found === -1) {
|
||||||
|
result += escHtml(text.substring(searchPos));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result += escHtml(text.substring(searchPos, found));
|
||||||
|
const isCurrent = currentMatch && currentMatch.idx === i && currentMatch.pos === found;
|
||||||
|
result += `<span class="${isCurrent ? 'replace-current' : 'replace-highlight'}">${escHtml(keyword)}</span>`;
|
||||||
|
searchPos = found + keyword.length;
|
||||||
|
}
|
||||||
|
editedDisplay = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="review-time">${msToTime(item.start_ms)}</div>
|
||||||
|
<div class="review-original">${escHtml(item.original)}</div>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<div class="review-edited-display" style="padding:4px 8px;font-size:0.9rem;min-height:1.5em;cursor:text;border:1px solid transparent;border-radius:4px;background:var(--bg-tertiary);"
|
||||||
|
onclick="startEdit(this)">${editedDisplay}</div>
|
||||||
|
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}" style="display:none;"
|
||||||
|
onblur="onEditBlur(this)">
|
||||||
|
</div>
|
||||||
|
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
|
||||||
|
onclick="toggleConfirm(${i}, this)" title="确认">
|
||||||
|
${item.confirmed ? '✓' : ''}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('stat-total').textContent = reviewData.length;
|
||||||
|
document.getElementById('stat-changed').textContent = totalChanged;
|
||||||
|
document.getElementById('stat-confirmed').textContent = totalConfirmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
function startEdit(displayDiv) {
|
||||||
|
displayDiv.style.display = 'none';
|
||||||
|
const input = displayDiv.nextElementSibling;
|
||||||
|
input.style.display = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CCA 唱词助手 — Flask 路由
|
||||||
|
POST /api/cca/upload 上传 A稿+音频,启动流水线
|
||||||
|
GET /api/cca/status/<id> 轮询任务状态
|
||||||
|
GET /api/cca/review/<id> 获取审稿数据
|
||||||
|
POST /api/cca/save/<id> 保存编导修改
|
||||||
|
POST /api/cca/generate/<id> 生成最终 SRT
|
||||||
|
GET /api/cca/download/<id> 下载 SRT zip
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
|
|
||||||
|
bp = Blueprint('cca', __name__, url_prefix='/api/cca')
|
||||||
|
|
||||||
|
# 运行时数据目录
|
||||||
|
CCA_DATA_DIR = Path('/workspace/military_tech_voice/backend/cca_data')
|
||||||
|
CCA_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# CCA 源码目录
|
||||||
|
CCA_SRC_DIR = Path('/workspace/military_tech_voice/backend/cca_src')
|
||||||
|
if str(CCA_SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(CCA_SRC_DIR))
|
||||||
|
|
||||||
|
# 任务状态存储(内存,重启丢失无所谓——编导重新上传即可)
|
||||||
|
tasks = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_task(task_id):
|
||||||
|
t = tasks.get(task_id)
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/upload', methods=['POST'])
|
||||||
|
def upload():
|
||||||
|
"""接收 A稿 + 音频,创建任务"""
|
||||||
|
if 'audio' not in request.files:
|
||||||
|
return jsonify({'error': '请上传音频文件'}), 400
|
||||||
|
|
||||||
|
audio_file = request.files['audio']
|
||||||
|
script_file = request.files.get('script')
|
||||||
|
|
||||||
|
if not audio_file.filename:
|
||||||
|
return jsonify({'error': '音频文件为空'}), 400
|
||||||
|
|
||||||
|
task_id = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:6]
|
||||||
|
task_dir = CCA_DATA_DIR / task_id
|
||||||
|
task_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 保存音频
|
||||||
|
audio_ext = os.path.splitext(audio_file.filename)[1] or '.mp3'
|
||||||
|
audio_path = task_dir / f'audio{audio_ext}'
|
||||||
|
audio_file.save(str(audio_path))
|
||||||
|
|
||||||
|
# 保存 A稿
|
||||||
|
script_path = None
|
||||||
|
if script_file and script_file.filename:
|
||||||
|
script_ext = os.path.splitext(script_file.filename)[1] or '.docx'
|
||||||
|
script_path = task_dir / f'script{script_ext}'
|
||||||
|
script_file.save(str(script_path))
|
||||||
|
|
||||||
|
tasks[task_id] = {
|
||||||
|
'id': task_id,
|
||||||
|
'status': 'uploaded',
|
||||||
|
'progress': '文件已上传,准备处理...',
|
||||||
|
'audio_path': str(audio_path),
|
||||||
|
'script_path': str(script_path) if script_path else None,
|
||||||
|
'created_at': datetime.now().isoformat(),
|
||||||
|
'error': None,
|
||||||
|
'asr_sentences': None,
|
||||||
|
'proofread_sentences': None,
|
||||||
|
'review_data': None,
|
||||||
|
'final_srt_dir': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动后台处理
|
||||||
|
thread = threading.Thread(target=_run_pipeline, args=(task_id,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({'task_id': task_id, 'status': 'processing'})
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pipeline(task_id):
|
||||||
|
"""后台运行 CCA 流水线"""
|
||||||
|
task = tasks[task_id]
|
||||||
|
try:
|
||||||
|
task['status'] = 'processing'
|
||||||
|
task['progress'] = '正在提取热词...'
|
||||||
|
|
||||||
|
audio_path = task['audio_path']
|
||||||
|
script_path = task['script_path']
|
||||||
|
|
||||||
|
# WAV/大文件 → MP3 压缩(讯飞上传大文件容易超时)
|
||||||
|
if audio_path.lower().endswith('.wav'):
|
||||||
|
import subprocess
|
||||||
|
mp3_path = audio_path.rsplit('.', 1)[0] + '.mp3'
|
||||||
|
task['progress'] = '正在压缩音频(WAV→MP3)...'
|
||||||
|
subprocess.run(
|
||||||
|
['ffmpeg', '-i', audio_path, '-b:a', '128k', '-y', mp3_path],
|
||||||
|
capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
if os.path.exists(mp3_path) and os.path.getsize(mp3_path) > 0:
|
||||||
|
audio_path = mp3_path
|
||||||
|
task['audio_path'] = mp3_path
|
||||||
|
|
||||||
|
from hotword_extractor import extract_hotwords, read_docx_text, read_text_file
|
||||||
|
from asr_client import transcribe, parse_result
|
||||||
|
from term_normalizer import normalize_terms
|
||||||
|
from ai_proofreader import proofread_batch
|
||||||
|
from segment_splitter import split_into_segments
|
||||||
|
|
||||||
|
# Step 0: 热词提取
|
||||||
|
hot_words = []
|
||||||
|
script_text = ""
|
||||||
|
if script_path:
|
||||||
|
hot_words = extract_hotwords(script_path, use_ai=True)
|
||||||
|
ext = os.path.splitext(script_path)[1].lower()
|
||||||
|
if ext == '.docx':
|
||||||
|
script_text = read_docx_text(script_path)
|
||||||
|
else:
|
||||||
|
script_text = read_text_file(script_path)
|
||||||
|
|
||||||
|
task['progress'] = f'热词提取完成({len(hot_words)}个),正在 ASR 转写...'
|
||||||
|
|
||||||
|
# Step 1: ASR
|
||||||
|
sentences, raw_json = transcribe(audio_path, hot_words=hot_words if hot_words else None)
|
||||||
|
|
||||||
|
# 缓存 ASR 原始结果
|
||||||
|
task_dir = Path(audio_path).parent
|
||||||
|
cache_path = task_dir / 'asr_raw.json'
|
||||||
|
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(raw_json)
|
||||||
|
|
||||||
|
task['progress'] = f'ASR 完成({len(sentences)}句),正在术语格式化...'
|
||||||
|
|
||||||
|
# 保存 ASR 原始句子(校对前,供 diff 对比)
|
||||||
|
asr_original = [(bg, ed, text, spk) for bg, ed, text, spk in sentences]
|
||||||
|
|
||||||
|
# Step 1.5: 术语格式化
|
||||||
|
if script_text:
|
||||||
|
sentences = normalize_terms(sentences, script_text)
|
||||||
|
|
||||||
|
task['progress'] = '术语格式化完成,正在 AI 校对...'
|
||||||
|
|
||||||
|
# Step 2: AI 校对
|
||||||
|
if script_text:
|
||||||
|
sentences = proofread_batch(sentences, script_text)
|
||||||
|
|
||||||
|
# Step 2.5: 校对后二次正则
|
||||||
|
if script_text:
|
||||||
|
sentences = normalize_terms(sentences, script_text)
|
||||||
|
|
||||||
|
task['progress'] = 'AI 校对完成,正在准备审稿数据...'
|
||||||
|
|
||||||
|
# 保存校对后的句子
|
||||||
|
task['asr_sentences'] = asr_original
|
||||||
|
task['proofread_sentences'] = sentences
|
||||||
|
|
||||||
|
# 构建审稿数据:逐句对比
|
||||||
|
review_items = []
|
||||||
|
for i, ((bg, ed, orig_text, spk), (_, _, proof_text, _)) in enumerate(
|
||||||
|
zip(asr_original, sentences)
|
||||||
|
):
|
||||||
|
has_change = orig_text != proof_text
|
||||||
|
review_items.append({
|
||||||
|
'index': i,
|
||||||
|
'start_ms': bg,
|
||||||
|
'end_ms': ed,
|
||||||
|
'speaker_id': spk,
|
||||||
|
'original': orig_text,
|
||||||
|
'corrected': proof_text,
|
||||||
|
'edited': proof_text,
|
||||||
|
'has_change': has_change,
|
||||||
|
'confirmed': not has_change,
|
||||||
|
})
|
||||||
|
|
||||||
|
task['review_data'] = review_items
|
||||||
|
task['status'] = 'review'
|
||||||
|
task['progress'] = '审稿数据就绪,请编导审阅确认'
|
||||||
|
|
||||||
|
# 同时保存到磁盘(防丢)
|
||||||
|
review_path = task_dir / 'review_data.json'
|
||||||
|
with open(review_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(review_items, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
task['status'] = 'error'
|
||||||
|
err_msg = str(e)
|
||||||
|
if '余额不足' in err_msg or 'insufficient' in err_msg.lower() or '10317' in err_msg:
|
||||||
|
task['error'] = '讯飞录音文件转写余额不足,请联系管理员充值'
|
||||||
|
else:
|
||||||
|
task['error'] = f'处理出错: {err_msg}'
|
||||||
|
task['progress'] = task['error']
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/status/<task_id>', methods=['GET'])
|
||||||
|
def status(task_id):
|
||||||
|
task = _get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return jsonify({'error': '任务不存在'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'task_id': task_id,
|
||||||
|
'status': task['status'],
|
||||||
|
'progress': task['progress'],
|
||||||
|
'error': task['error'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/review/<task_id>', methods=['GET'])
|
||||||
|
def review(task_id):
|
||||||
|
task = _get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return jsonify({'error': '任务不存在'}), 404
|
||||||
|
if task['status'] not in ('review', 'completed'):
|
||||||
|
return jsonify({'error': '任务尚未就绪', 'status': task['status']}), 400
|
||||||
|
return jsonify({
|
||||||
|
'task_id': task_id,
|
||||||
|
'items': task['review_data'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/save/<task_id>', methods=['POST'])
|
||||||
|
def save(task_id):
|
||||||
|
"""保存编导的修改(自动保存用)"""
|
||||||
|
task = _get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return jsonify({'error': '任务不存在'}), 404
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
edits = data.get('items', [])
|
||||||
|
|
||||||
|
for edit in edits:
|
||||||
|
idx = edit.get('index')
|
||||||
|
if idx is not None and 0 <= idx < len(task['review_data']):
|
||||||
|
task['review_data'][idx]['edited'] = edit.get('edited', task['review_data'][idx]['edited'])
|
||||||
|
task['review_data'][idx]['confirmed'] = edit.get('confirmed', True)
|
||||||
|
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/generate/<task_id>', methods=['POST'])
|
||||||
|
def generate(task_id):
|
||||||
|
"""用编导确认后的文本生成最终 SRT"""
|
||||||
|
task = _get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return jsonify({'error': '任务不存在'}), 404
|
||||||
|
if task['status'] not in ('review', 'completed'):
|
||||||
|
return jsonify({'error': '任务状态不对'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ai_line_breaker import process_sentences_with_ai
|
||||||
|
from srt_writer import write_srt, ms_to_srt_time
|
||||||
|
from segment_splitter import split_into_segments
|
||||||
|
|
||||||
|
# 用编导确认后的文本重建句子列表
|
||||||
|
confirmed_sentences = []
|
||||||
|
for item in task['review_data']:
|
||||||
|
text = item['edited']
|
||||||
|
confirmed_sentences.append((
|
||||||
|
item['start_ms'], item['end_ms'], text, item['speaker_id']
|
||||||
|
))
|
||||||
|
|
||||||
|
# 切分节目结构
|
||||||
|
segments = split_into_segments(confirmed_sentences)
|
||||||
|
|
||||||
|
# 折行 + 生成 SRT
|
||||||
|
task_dir = Path(task['audio_path']).parent
|
||||||
|
srt_dir = task_dir / 'srt_output'
|
||||||
|
srt_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
srt_files = []
|
||||||
|
for seg_name, seg_sentences in segments:
|
||||||
|
if not seg_sentences:
|
||||||
|
continue
|
||||||
|
subtitle_lines = process_sentences_with_ai(seg_sentences)
|
||||||
|
srt_path = srt_dir / f'{seg_name}.srt'
|
||||||
|
write_srt(subtitle_lines, str(srt_path))
|
||||||
|
srt_files.append(str(srt_path))
|
||||||
|
|
||||||
|
task['final_srt_dir'] = str(srt_dir)
|
||||||
|
task['status'] = 'completed'
|
||||||
|
task['progress'] = f'生成完成,共 {len(srt_files)} 个 SRT 文件'
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ok': True,
|
||||||
|
'srt_count': len(srt_files),
|
||||||
|
'message': task['progress'],
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'生成 SRT 出错: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/download/<task_id>', methods=['GET'])
|
||||||
|
def download(task_id):
|
||||||
|
"""下载 SRT zip 包"""
|
||||||
|
task = _get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return jsonify({'error': '任务不存在'}), 404
|
||||||
|
if not task.get('final_srt_dir'):
|
||||||
|
return jsonify({'error': 'SRT 尚未生成'}), 400
|
||||||
|
|
||||||
|
srt_dir = Path(task['final_srt_dir'])
|
||||||
|
srt_files = sorted(srt_dir.glob('*.srt'))
|
||||||
|
if not srt_files:
|
||||||
|
return jsonify({'error': '无 SRT 文件'}), 404
|
||||||
|
|
||||||
|
# 打包 zip
|
||||||
|
buf = BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for srt_file in srt_files:
|
||||||
|
zf.write(srt_file, srt_file.name)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
filename = f'唱词字幕_{task_id}.zip'
|
||||||
|
return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=filename)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
CCA 部署脚本 — 通过 paramiko 上传文件到腾讯云服务器
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
import os
|
||||||
|
import paramiko
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HOST = '101.42.29.217'
|
||||||
|
PORT = 22
|
||||||
|
USER = 'root'
|
||||||
|
PASS = 'liutong65'
|
||||||
|
|
||||||
|
CCA_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC_DIR = CCA_ROOT / 'src'
|
||||||
|
DEPLOY_DIR = CCA_ROOT / 'deploy'
|
||||||
|
|
||||||
|
# 服务器目标路径
|
||||||
|
SERVER_CCA_SRC = '/workspace/military_tech_voice/backend/cca_src'
|
||||||
|
SERVER_FRONTEND = '/workspace/military_tech_voice/frontend'
|
||||||
|
SERVER_WWW = '/var/www/voice'
|
||||||
|
SERVER_BACKEND = '/workspace/military_tech_voice/backend'
|
||||||
|
SERVER_ROUTES = f'{SERVER_BACKEND}/app/routes'
|
||||||
|
|
||||||
|
# 需要上传的 src 模块
|
||||||
|
SRC_MODULES = [
|
||||||
|
'asr_client.py',
|
||||||
|
'line_breaker.py',
|
||||||
|
'ai_line_breaker.py',
|
||||||
|
'ai_proofreader.py',
|
||||||
|
'term_normalizer.py',
|
||||||
|
'hotword_extractor.py',
|
||||||
|
'srt_writer.py',
|
||||||
|
'segment_splitter.py',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def connect():
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(HOST, PORT, USER, PASS)
|
||||||
|
sftp = ssh.open_sftp()
|
||||||
|
return ssh, sftp
|
||||||
|
|
||||||
|
|
||||||
|
def run(ssh, cmd):
|
||||||
|
print(f' $ {cmd}')
|
||||||
|
_, stdout, stderr = ssh.exec_command(cmd)
|
||||||
|
out = stdout.read().decode('utf-8', errors='replace').strip()
|
||||||
|
err = stderr.read().decode('utf-8', errors='replace').strip()
|
||||||
|
if out:
|
||||||
|
print(f' {out[:500]}')
|
||||||
|
if err:
|
||||||
|
print(f' [stderr] {err[:500]}')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def upload(sftp, local_path, remote_path):
|
||||||
|
print(f' ↑ {Path(local_path).name} → {remote_path}')
|
||||||
|
sftp.put(str(local_path), remote_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print('=== CCA 部署开始 ===\n')
|
||||||
|
ssh, sftp = connect()
|
||||||
|
print('[1/7] 连接成功\n')
|
||||||
|
|
||||||
|
# Step 2: 创建 cca_src 目录 + 上传源码
|
||||||
|
print('[2/7] 上传 CCA 源码模块...')
|
||||||
|
run(ssh, f'mkdir -p {SERVER_CCA_SRC}')
|
||||||
|
for module in SRC_MODULES:
|
||||||
|
local = SRC_DIR / module
|
||||||
|
if local.exists():
|
||||||
|
upload(sftp, local, f'{SERVER_CCA_SRC}/{module}')
|
||||||
|
else:
|
||||||
|
print(f' ⚠ 跳过(不存在): {module}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 3: 上传 cca_route.py 到 app/routes/
|
||||||
|
print('[3/7] 上传 cca_route.py...')
|
||||||
|
run(ssh, f'mkdir -p {SERVER_ROUTES}')
|
||||||
|
upload(sftp, DEPLOY_DIR / 'cca_route.py', f'{SERVER_ROUTES}/cca.py')
|
||||||
|
# 确保 __init__.py 存在
|
||||||
|
run(ssh, f'touch {SERVER_ROUTES}/__init__.py')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 4: 上传 cca.html 到前端目录
|
||||||
|
print('[4/7] 上传 cca.html...')
|
||||||
|
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_FRONTEND}/cca.html')
|
||||||
|
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_WWW}/cca.html')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 5: 配置 .env(追加 CCA 相关变量)
|
||||||
|
print('[5/7] 配置 .env...')
|
||||||
|
env_path = f'{SERVER_BACKEND}/.env'
|
||||||
|
existing_env = ''
|
||||||
|
try:
|
||||||
|
with sftp.open(env_path, 'r') as f:
|
||||||
|
existing_env = f.read().decode('utf-8', errors='replace')
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
env_additions = []
|
||||||
|
if 'XFYUN_APP_ID' not in existing_env:
|
||||||
|
env_additions.append('# === CCA 唱词助手凭证 ===')
|
||||||
|
env_additions.append('# 讯飞录音文件转写(账号1-默认)')
|
||||||
|
env_additions.append('XFYUN_APP_ID=4c423e35')
|
||||||
|
env_additions.append('XFYUN_SECRET_KEY=b9e0b97d5dda072c9b4b8fb59e7e3d22')
|
||||||
|
env_additions.append('# 讯飞备用账号2')
|
||||||
|
env_additions.append('# XFYUN_APP_ID=52ae3024')
|
||||||
|
env_additions.append('# XFYUN_SECRET_KEY=d65de0eb282a4339e2b1e14fd119e42e')
|
||||||
|
if 'DEEPSEEK_API_KEY' not in existing_env:
|
||||||
|
env_additions.append('# DeepSeek(校对+折行+热词)')
|
||||||
|
env_additions.append('DEEPSEEK_API_KEY=sk-01a7868a88a04ab494e4f05c1f3f06e2')
|
||||||
|
|
||||||
|
if env_additions:
|
||||||
|
with sftp.open(env_path, 'a') as f:
|
||||||
|
f.write('\n' + '\n'.join(env_additions) + '\n')
|
||||||
|
print(' .env 已追加 CCA 凭证')
|
||||||
|
else:
|
||||||
|
print(' .env 已有 CCA 凭证,跳过')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 6: 注册 CCA 蓝图到 Flask main.py
|
||||||
|
print('[6/7] 注册 CCA 蓝图...')
|
||||||
|
main_py_path = f'{SERVER_BACKEND}/app/main.py'
|
||||||
|
with sftp.open(main_py_path, 'r') as f:
|
||||||
|
main_content = f.read().decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
if 'cca' not in main_content.lower():
|
||||||
|
# 找到最后一个 register_blueprint 的位置,在其后追加
|
||||||
|
lines = main_content.split('\n')
|
||||||
|
insert_idx = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if 'register_blueprint' in line:
|
||||||
|
insert_idx = i
|
||||||
|
|
||||||
|
if insert_idx >= 0:
|
||||||
|
indent = ' ' # 匹配现有缩进
|
||||||
|
# 检查现有蓝图注册的缩进
|
||||||
|
existing_line = lines[insert_idx]
|
||||||
|
indent = existing_line[:len(existing_line) - len(existing_line.lstrip())]
|
||||||
|
|
||||||
|
cca_lines = [
|
||||||
|
'',
|
||||||
|
f'{indent}# CCA 唱词助手',
|
||||||
|
f'{indent}from app.routes.cca import bp as cca_bp',
|
||||||
|
f'{indent}app.register_blueprint(cca_bp)',
|
||||||
|
]
|
||||||
|
for j, cca_line in enumerate(cca_lines):
|
||||||
|
lines.insert(insert_idx + 1 + j, cca_line)
|
||||||
|
|
||||||
|
new_content = '\n'.join(lines)
|
||||||
|
with sftp.open(main_py_path, 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
print(' 已注册 CCA 蓝图')
|
||||||
|
else:
|
||||||
|
print(' ⚠ 未找到 register_blueprint,请手动注册')
|
||||||
|
else:
|
||||||
|
print(' CCA 蓝图已注册,跳过')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 7: 安装依赖 + 修改 index.html + 重启
|
||||||
|
print('[7/7] 安装依赖、修改首页、重启服务...')
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
run(ssh, f'{SERVER_BACKEND}/venv/bin/pip install openai python-docx 2>&1 | tail -5')
|
||||||
|
|
||||||
|
# 修改 index.html 添加 CCA 入口按钮
|
||||||
|
index_path = f'{SERVER_FRONTEND}/index.html'
|
||||||
|
with sftp.open(index_path, 'r') as f:
|
||||||
|
index_content = f.read().decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
if '唱词助手' not in index_content and 'cca.html' not in index_content:
|
||||||
|
# 在导航栏中找到合适位置插入按钮
|
||||||
|
# 典型位置:header 或 nav 区域的最后一个链接之后
|
||||||
|
if '<header' in index_content or '<nav' in index_content:
|
||||||
|
# 找 </header> 或 </nav> 前插入
|
||||||
|
for marker in ['</nav>', '</header>']:
|
||||||
|
if marker in index_content:
|
||||||
|
btn_html = ' <a href="cca.html" class="nav-link">唱词助手</a>\n'
|
||||||
|
index_content = index_content.replace(marker, btn_html + ' ' + marker)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 备用:在 body 开头加一个浮动按钮
|
||||||
|
btn_html = '<div style="position:fixed;top:10px;right:10px;z-index:9999"><a href="cca.html" style="background:#4a9eff;color:#fff;padding:8px 16px;border-radius:6px;text-decoration:none;font-size:14px">唱词助手</a></div>\n'
|
||||||
|
index_content = index_content.replace('<body>', '<body>\n' + btn_html)
|
||||||
|
|
||||||
|
with sftp.open(index_path, 'w') as f:
|
||||||
|
f.write(index_content)
|
||||||
|
|
||||||
|
# 同步到 /var/www/voice/
|
||||||
|
run(ssh, f'cp {SERVER_FRONTEND}/index.html {SERVER_WWW}/index.html')
|
||||||
|
print(' index.html 已添加唱词助手入口')
|
||||||
|
else:
|
||||||
|
print(' index.html 已有唱词助手入口,跳过')
|
||||||
|
|
||||||
|
# 增大 Nginx 上传限制(音频文件可能较大)
|
||||||
|
nginx_conf = '/etc/nginx/nginx.conf'
|
||||||
|
with sftp.open(nginx_conf, 'r') as f:
|
||||||
|
nginx_content = f.read().decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
if 'client_max_body_size' not in nginx_content:
|
||||||
|
nginx_content = nginx_content.replace(
|
||||||
|
'http {',
|
||||||
|
'http {\n client_max_body_size 200m;'
|
||||||
|
)
|
||||||
|
with sftp.open(nginx_conf, 'w') as f:
|
||||||
|
f.write(nginx_content)
|
||||||
|
run(ssh, 'nginx -t && nginx -s reload')
|
||||||
|
print(' Nginx 已增大上传限制至 200MB')
|
||||||
|
else:
|
||||||
|
print(' Nginx 上传限制已配置')
|
||||||
|
|
||||||
|
# 重启 Flask 服务
|
||||||
|
print('\n 重启 Flask...')
|
||||||
|
# 查找并重启 Flask 进程
|
||||||
|
run(ssh, 'pkill -f "flask run" || pkill -f "gunicorn" || pkill -f "python.*app" || true')
|
||||||
|
# 给进程时间退出
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# 检查服务启动方式
|
||||||
|
service_out = run(ssh, 'systemctl list-units --type=service | grep -i voice || systemctl list-units --type=service | grep -i flask || true')
|
||||||
|
if 'voice' in service_out.lower() or 'flask' in service_out.lower():
|
||||||
|
service_name = service_out.split()[0] if service_out else ''
|
||||||
|
if service_name:
|
||||||
|
run(ssh, f'systemctl restart {service_name}')
|
||||||
|
else:
|
||||||
|
# 直接后台启动
|
||||||
|
run(ssh, f'cd {SERVER_BACKEND} && source venv/bin/activate && nohup python -m flask run --host=0.0.0.0 --port=5000 > /tmp/flask_cca.log 2>&1 &')
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=== CCA 部署完成 ===')
|
||||||
|
print(f'访问地址: http://{HOST}/cca.html')
|
||||||
|
print(f'API 地址: http://{HOST}/api/cca/')
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
ssh.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# CCA 唱词助手子项目 Brief
|
||||||
|
|
||||||
|
> 主项目 → 子项目的"交接宪法":红线、技术栈、出入口接口
|
||||||
|
> 起草日期:2026-07-04
|
||||||
|
> 状态:主项目签发,子项目内部不修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、为什么做
|
||||||
|
|
||||||
|
**痛点**:《军事科技》每期节目剪辑完成后,编导需要制作一份唱词字幕(SRT 文件)交给责编拉到大洋系统上线。目前纯手工操作——对照 A 稿听音频逐句打字幕,费时且容易出错。
|
||||||
|
|
||||||
|
**升级目标**:编导只需提供 A 稿 + 粗编人声音频,系统自动走 ASR + AI 校对 + 编导审稿确认,最终输出大洋系统可直接使用的 SRT 文件。
|
||||||
|
|
||||||
|
**紧迫性**:切实解决当前痛点,功能不复杂,两天内出可用版本。先独立部署测试,成熟后并入 TPS 主项目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、做什么(功能边界)
|
||||||
|
|
||||||
|
| 模块 | 简述 |
|
||||||
|
|---|---|
|
||||||
|
| 专有名词提取 | AI 从 A 稿中摘取军事专有名词(型号、人名、机构等),形成热词词典 |
|
||||||
|
| 讯飞 ASR 转写 | 音频 + 词典 → 带时间戳的 ASR 稿 |
|
||||||
|
| AI 校对比对 | ASR 稿 ⊕ A 稿比对,自动修正明显错误,标记存疑差异 |
|
||||||
|
| 编导审稿台 | 双屏对比 UI,差异高亮,编导点击确认/手动修改 |
|
||||||
|
| SRT 生成 | B 稿按拍词规则折行 + 时间戳 → 大洋格式 SRT |
|
||||||
|
|
||||||
|
**具体怎么做,子项目内部讨论,本 Brief 不预设方案。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、怎么用(目标流程)
|
||||||
|
|
||||||
|
1. 编导剪完节目,手头有 A 稿(docx/txt)+ 粗编纯人声音频
|
||||||
|
2. 编导上传 A 稿和音频到 CCA 系统
|
||||||
|
3. 系统自动:提取专有名词 → 讯飞 ASR → AI 校对 → 生成初版对比
|
||||||
|
4. 编导进入审稿台:查看差异、确认/修改
|
||||||
|
5. 系统输出 SRT 文件
|
||||||
|
6. 责编拿 SRT 拉到大洋系统形成唱词字幕
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、不做什么(红线)
|
||||||
|
|
||||||
|
- ❌ **不替代编导审稿**:AI 拿不准的必须过编导确认,不全自动出 SRT
|
||||||
|
- ❌ **不改 A 稿内容**:A 稿只用于提取专有名词和提供参照,不回写
|
||||||
|
- ❌ **不动主项目 backend 代码 / schema**(在吸收进主项目之前)
|
||||||
|
- ❌ **不复用主项目 backend/.env 的凭证**(子项目自己 .env)
|
||||||
|
- ❌ **不用讯飞大模型版 ASR**(用录音文件转写标准版,与 doco 一致)
|
||||||
|
- ❌ **不做视频处理**(输入是已分离的纯人声音频,不是视频)
|
||||||
|
- ❌ **不做内容创作**(AI 只校对不创作,尊重 ASR 底稿 + 编导意见)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、技术栈约束
|
||||||
|
|
||||||
|
- **运行环境**:Python 3.x + Web 前端(审稿台)
|
||||||
|
- **ASR**:讯飞开放平台 录音文件转写标准版(热词偏置注入)
|
||||||
|
- **AI/LLM**:用于专有名词提取 + 校对(具体模型子项目讨论,省 token 优先)
|
||||||
|
- **输出格式**:SRT(大洋系统兼容,有样本)
|
||||||
|
- **部署**:先部署 lanhao 配音 2.0 网站测试,后续并入 TPS
|
||||||
|
- **美术风格**:参考 `ai-labeling/example/` 目录下的设计稿(`功能区划.jpg`、`幅面参考.jpg`、`页面风格.webp`),CCA 的 UI 风格与 TPS 主项目保持一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、交付什么(出口接口)
|
||||||
|
|
||||||
|
### 6.1 并入架构(同一服务器、同一进程)
|
||||||
|
|
||||||
|
CCA 最终**不是独立微服务**,而是代码合并进 TPS 同一套服务:
|
||||||
|
|
||||||
|
- **后端**:CCA 的 API 路由并入 `backend/app/api/`(如 `cca.py`),业务逻辑并入 `backend/app/services/`(如 `cca_service.py`),共用同一个 FastAPI 进程
|
||||||
|
- **前端**:CCA 页面并入 `frontend/src/pages/`(如 `CCA/` 目录),作为 TPS React 应用的一个路由页面(`/cca`)
|
||||||
|
- **同一台服务器**:不存在跨服务调用,共用 TPS 的 PostgreSQL、同一个 `.env`
|
||||||
|
|
||||||
|
### 6.2 开发阶段的独立形态
|
||||||
|
|
||||||
|
并入前的开发测试阶段,CCA 以独立可运行的 Web 应用存在:
|
||||||
|
- 独立的后端(FastAPI 或轻量框架)+ 前端(审稿台 UI)
|
||||||
|
- 可单独部署到 lanhao 配音 2.0 网站跑通验证
|
||||||
|
- 代码结构从一开始就按"能干净并入 TPS"设计(目录命名、API 风格、前端组件规范对齐主项目)
|
||||||
|
|
||||||
|
### 6.3 具体交付清单
|
||||||
|
|
||||||
|
| 交付物 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| CCA 后端 API | 文件上传(A稿+音频)、ASR 任务触发/轮询、校对结果、审稿确认、SRT 下载 |
|
||||||
|
| 讯飞 ASR 适配层 | 热词注入 + 转写 + 解析,可被主项目其他模块复用(与 doco 逻辑同源) |
|
||||||
|
| 专有名词提取服务 | AI 从 A 稿提取军事专名 → 热词词典 |
|
||||||
|
| AI 校对比对服务 | ASR 稿 ⊕ A 稿差异标记(自动修正 + 存疑清单) |
|
||||||
|
| 编导审稿台前端 | 双屏对比 UI + 差异高亮 + 确认/手改交互 |
|
||||||
|
| 拍词折行引擎 | 按规则把 B 稿折行 + 结合时间戳生成 SRT |
|
||||||
|
| SRT 输出 | 大洋系统兼容格式(有样本对标) |
|
||||||
|
|
||||||
|
### 6.4 配置与凭证
|
||||||
|
- 开发阶段:cca 自己的 `.env`(讯飞 Key + LLM Key)
|
||||||
|
- 并入后:凭证合并进 TPS 主项目 `backend/.env`,讯飞 Key 可与 doco 共用
|
||||||
|
- 主项目 `docs/api_credentials_inventory.md` 登记 CCA 凭证元信息
|
||||||
|
|
||||||
|
### 6.5 不接受
|
||||||
|
- 并入前,子项目自己改主项目 backend 代码、schema、迁移
|
||||||
|
- 未经主项目审批引入主项目没有的技术栈
|
||||||
|
- 并入时引入与主项目冲突的依赖版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、入口接口(子项目要知道的主项目现状)
|
||||||
|
|
||||||
|
- **主项目已有讯飞 ASR 经验**:doco 子项目已跑通讯飞录音文件转写标准版,签名/上传/轮询/解析全套逻辑可复用
|
||||||
|
- **主项目有 `api_credentials_inventory.md`**:凭证元信息登记表
|
||||||
|
- **主项目有跨子项目协作规则**:PRD 版本管理等,见 `跨子项目协作规则.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、制片人待提供
|
||||||
|
|
||||||
|
- [ ] 拍词规则(责编拍词的具体 markdown 规则文档)
|
||||||
|
- [ ] 大洋 SRT 样本文件(一份能用的真实样本)
|
||||||
|
- [ ] 粗编音频样本(一期的真实音频,用于开发测试)
|
||||||
|
- [ ] A 稿样本(对应的编导 A 稿)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、本 Brief 自身的修订规则
|
||||||
|
|
||||||
|
- 本 Brief 不在子项目内修改
|
||||||
|
- 红线/技术栈如有变更,**主项目这边发新版**
|
||||||
|
- 子项目可在自己的 chat 里建议调整,调整动作只能在主项目发生
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
requests>=2.28
|
||||||
|
python-dotenv>=1.0
|
||||||
|
mutagen>=1.47
|
||||||
|
openai>=1.0
|
||||||
|
python-docx>=1.0
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AI 折行引擎 — 用 DeepSeek 对 ASR 长句做语义折行
|
||||||
|
|
||||||
|
对于 ≤14 字的句子直接输出,>14 字的句子批量发给 AI 折行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if _env_path.exists():
|
||||||
|
load_dotenv(str(_env_path), override=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
||||||
|
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com").strip()
|
||||||
|
DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat").strip()
|
||||||
|
|
||||||
|
MAX_CHARS = 14
|
||||||
|
MAX_CHARS_SOFT = 16
|
||||||
|
SILENCE_THRESHOLD_MS = 2000
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """你是电视节目唱词字幕的折行助手。你的任务是将一段文字按照以下规则折成多行:
|
||||||
|
|
||||||
|
**基本规则:**
|
||||||
|
1. 每行最多14个字(中文字符、英文字母、数字各算1个字)
|
||||||
|
2. 去掉逗号、句号、感叹号、问号、分号、冒号、省略号等标点,只保留引号(""'')和书名号(《》)
|
||||||
|
3. 折行要符合语义和阅读习惯,不能把词语切断
|
||||||
|
4. 每行不一定要凑满14字,可以是5字、8字、10字等,关键是语义完整
|
||||||
|
5. 保持原文内容不变,不增不减不改字
|
||||||
|
|
||||||
|
**折行禁忌(硬规则,违反即错误):**
|
||||||
|
- 禁止把一个词语拆到两行("过程"不能变成"过"在行末、"程"在下行首;"实际上"不能拆开)
|
||||||
|
- 禁止"的""了""着""过""地""得"作为新行的第一个字
|
||||||
|
- 禁止"和""与""及""或"作为新行的第一个字
|
||||||
|
- 禁止拆断固定搭配(如"F-35A和F-35B"保持同行,"RQ-4全球鹰"保持同行)
|
||||||
|
- 禁止把动宾结构拆成:主语+动词(折行)宾语。应折成:主语(折行)动词+宾语
|
||||||
|
- **引号不跨屏**:当引号""内的内容≤6个字时,上引号和下引号必须在同一行,不允许拆到两行。例如"鱼鹰"、"日向"号 必须保持在同一行内
|
||||||
|
|
||||||
|
**折行优先级(按此顺序选择折点):**
|
||||||
|
1. 最优:在句子成分边界折(主语|谓语+宾语,状语|主句)
|
||||||
|
2. 次优:在并列分句之间折
|
||||||
|
3. 可接受:在长定语与中心词之间折(但"的"必须跟前面,不能落到下一行开头)
|
||||||
|
4. 最末:硬切词间(仅当以上都超14字时)
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
- 正确:"重塑自身军事力量版图的" / "野心与企图"
|
||||||
|
- 错误:"重塑自身军事力量版图" / "的野心与企图"("的"开头)
|
||||||
|
- 正确:"日本国会参议院" / "今天上午表决通过了" / "防卫省设置法修正案等法案"
|
||||||
|
- 错误:"日本国会参议院今天上午" / "表决通过" / "了防卫省设置法修正案等法案"("了"开头,且拆断动宾)
|
||||||
|
- 正确:"2015年美国国务院" / "批准向日本军售RQ-4全球鹰"
|
||||||
|
- 错误:"2015年美国国务院批准" / "向日本军售RQ-4全球鹰"(主+谓(折行)宾)
|
||||||
|
- 正确:"所以发展"日向"级" / "直升机护卫舰"(引号内容不拆开)
|
||||||
|
- 错误:"所以发展"日向" / "级直升机护卫舰"(引号被拆到两屏)
|
||||||
|
|
||||||
|
输出格式:每行一句,不加序号,不加标点(引号和书名号除外)。"""
|
||||||
|
|
||||||
|
USER_PROMPT_TEMPLATE = """请将以下文字折行(每行≤14字,去标点保引号,按语义断句):
|
||||||
|
|
||||||
|
{text}"""
|
||||||
|
|
||||||
|
BATCH_USER_PROMPT = """请将以下编号文字逐条折行(每行≤14字,去标点保引号,按语义断句)。
|
||||||
|
每条之间用空行分隔,保持编号对应。
|
||||||
|
|
||||||
|
{numbered_texts}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _create_client() -> OpenAI:
|
||||||
|
if not DEEPSEEK_API_KEY:
|
||||||
|
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
|
||||||
|
return OpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_break_single(text: str, client: OpenAI) -> List[str]:
|
||||||
|
"""单句 AI 折行"""
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=DEEPSEEK_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text)},
|
||||||
|
],
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=500,
|
||||||
|
)
|
||||||
|
result = resp.choices[0].message.content.strip()
|
||||||
|
lines = [l.strip() for l in result.split("\n") if l.strip()]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def ai_break_batch(texts: List[str], client: OpenAI) -> List[List[str]]:
|
||||||
|
"""
|
||||||
|
批量 AI 折行(减少 API 调用次数)
|
||||||
|
每批最多 20 条,避免输出过长出错
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
numbered = "\n".join(f"[{i+1}] {t}" for i, t in enumerate(texts))
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=DEEPSEEK_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": BATCH_USER_PROMPT.format(numbered_texts=numbered)},
|
||||||
|
],
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=3000,
|
||||||
|
)
|
||||||
|
result = resp.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
# 解析结果:按空行或编号分隔
|
||||||
|
all_results = []
|
||||||
|
current_lines = []
|
||||||
|
|
||||||
|
for line in result.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
# 检测新编号开头 [N] 或纯空行作为分隔
|
||||||
|
if not line:
|
||||||
|
if current_lines:
|
||||||
|
all_results.append(current_lines)
|
||||||
|
current_lines = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 去掉可能的编号前缀
|
||||||
|
import re
|
||||||
|
cleaned = re.sub(r'^\[\d+\]\s*', '', line)
|
||||||
|
if cleaned:
|
||||||
|
current_lines.append(cleaned)
|
||||||
|
|
||||||
|
if current_lines:
|
||||||
|
all_results.append(current_lines)
|
||||||
|
|
||||||
|
# 如果解析结果数量不匹配,回退到逐条处理
|
||||||
|
if len(all_results) != len(texts):
|
||||||
|
print(f"[AI折行] 批量解析不匹配 (期望{len(texts)}条,得到{len(all_results)}条),回退逐条处理")
|
||||||
|
all_results = []
|
||||||
|
for text in texts:
|
||||||
|
lines = ai_break_single(text, client)
|
||||||
|
all_results.append(lines)
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
|
||||||
|
# 常见不可拆分的双字词(高频,不求全,兜底关键场景)
|
||||||
|
# 这些词如果被折行拆到两屏,观众体验极差
|
||||||
|
_COMMON_WORDS = set([
|
||||||
|
"过程", "中间", "实际", "日本", "美国", "中国", "问题", "发展",
|
||||||
|
"军事", "武器", "装备", "能力", "力量", "防御", "进攻", "导弹",
|
||||||
|
"战斗", "战机", "战争", "国家", "历史", "世界", "方面", "系统",
|
||||||
|
"技术", "任务", "目标", "计划", "项目", "部署", "改装", "航母",
|
||||||
|
"自卫", "海军", "空军", "陆军", "预算", "宪法", "和平", "安全",
|
||||||
|
"基础", "措施", "结构", "性能", "速度", "射程", "重量", "面积",
|
||||||
|
"时候", "之后", "以后", "之前", "目前", "现在", "所以", "因此",
|
||||||
|
"但是", "虽然", "而且", "或者", "如果", "这个", "那个", "已经",
|
||||||
|
"可以", "应该", "需要", "能够", "开始", "成为", "通过", "进行",
|
||||||
|
"实现", "提升", "完成", "建设", "研发", "生产", "采购", "引进",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_split_words(lines: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
检测并修复被拆到两行的词语。
|
||||||
|
如果行末1字+下行首1字构成常见双字词,把末字移到下行。
|
||||||
|
"""
|
||||||
|
if len(lines) <= 1:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
fixed = list(lines)
|
||||||
|
changed = True
|
||||||
|
max_iterations = 3 # 防止无限循环
|
||||||
|
|
||||||
|
while changed and max_iterations > 0:
|
||||||
|
changed = False
|
||||||
|
max_iterations -= 1
|
||||||
|
new_fixed = [fixed[0]]
|
||||||
|
|
||||||
|
for j in range(1, len(fixed)):
|
||||||
|
prev_line = new_fixed[-1]
|
||||||
|
curr_line = fixed[j]
|
||||||
|
|
||||||
|
if not prev_line or not curr_line:
|
||||||
|
new_fixed.append(curr_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查行末字+下行首字是否构成词
|
||||||
|
pair = prev_line[-1] + curr_line[0]
|
||||||
|
if pair in _COMMON_WORDS:
|
||||||
|
# 把上一行末字移到当前行首
|
||||||
|
new_fixed[-1] = prev_line[:-1]
|
||||||
|
curr_line = prev_line[-1] + curr_line
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# 如果上一行变空了,删掉
|
||||||
|
if not new_fixed[-1].strip():
|
||||||
|
new_fixed.pop()
|
||||||
|
|
||||||
|
new_fixed.append(curr_line)
|
||||||
|
|
||||||
|
fixed = new_fixed
|
||||||
|
|
||||||
|
# 清理:过滤空行,检查是否有超长行需要重切
|
||||||
|
from line_breaker import break_sentence
|
||||||
|
result = []
|
||||||
|
for line in fixed:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if len(line) > MAX_CHARS_SOFT:
|
||||||
|
result.extend(break_sentence(line))
|
||||||
|
else:
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
return result if result else lines
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_quote_split(lines: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
修复引号被拆到两屏的问题。
|
||||||
|
当上引号"在一行、下引号"在下一行,且引号内内容≤6字时,合并到同一行。
|
||||||
|
"""
|
||||||
|
if len(lines) <= 1:
|
||||||
|
return lines
|
||||||
|
|
||||||
|
from line_breaker import break_sentence
|
||||||
|
|
||||||
|
fixed = [lines[0]]
|
||||||
|
i = 1
|
||||||
|
while i < len(lines):
|
||||||
|
prev = fixed[-1]
|
||||||
|
curr = lines[i]
|
||||||
|
|
||||||
|
# 检查:上一行有"但没有配对的",当前行有"
|
||||||
|
if "“" in prev and "”" not in prev and "”" in curr:
|
||||||
|
# 找到上引号位置,计算引号内内容长度
|
||||||
|
quote_start = prev.rfind("“")
|
||||||
|
# 引号内容 = 上一行从"开始的部分 + 当前行到"为止的部分
|
||||||
|
quote_end_in_curr = curr.index("”")
|
||||||
|
quoted_content = prev[quote_start+1:] + curr[:quote_end_in_curr]
|
||||||
|
if len(quoted_content) <= 6:
|
||||||
|
# 合并这两行
|
||||||
|
merged = prev + curr
|
||||||
|
if len(merged) <= MAX_CHARS_SOFT:
|
||||||
|
fixed[-1] = merged
|
||||||
|
else:
|
||||||
|
# 合并后超长,重新折行
|
||||||
|
fixed[-1:] = break_sentence(merged)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
fixed.append(curr)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tiny_subtitle(result: List[Tuple[int, int, str]]) -> List[Tuple[int, int, str]]:
|
||||||
|
"""
|
||||||
|
合并极短字幕行(≤3字且时长<1秒)到相邻行。
|
||||||
|
避免"东海"这种两个字单独闪一屏。
|
||||||
|
"""
|
||||||
|
if len(result) <= 1:
|
||||||
|
return result
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
skip_next = False
|
||||||
|
|
||||||
|
for i, (bg, ed, text) in enumerate(result):
|
||||||
|
if skip_next:
|
||||||
|
skip_next = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
duration_ms = ed - bg
|
||||||
|
is_tiny = len(text) <= 3 and duration_ms < 1000 and text.strip()
|
||||||
|
|
||||||
|
if is_tiny:
|
||||||
|
# 尝试与下一行合并
|
||||||
|
if i + 1 < len(result) and result[i+1][2].strip():
|
||||||
|
next_bg, next_ed, next_text = result[i+1]
|
||||||
|
combined = text + next_text
|
||||||
|
if len(combined) <= MAX_CHARS_SOFT:
|
||||||
|
merged.append((bg, next_ed, combined))
|
||||||
|
skip_next = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 尝试与上一行合并
|
||||||
|
if merged and merged[-1][2].strip():
|
||||||
|
prev_bg, prev_ed, prev_text = merged[-1]
|
||||||
|
combined = prev_text + text
|
||||||
|
if len(combined) <= MAX_CHARS_SOFT:
|
||||||
|
merged[-1] = (prev_bg, ed, combined)
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged.append((bg, ed, text))
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
MERGE_THRESHOLD_CHARS = 8 # ≤8字的句子考虑合并
|
||||||
|
MERGE_GAP_MS = 800 # 句间间隔<800ms才合并(>800ms视为有意停顿)
|
||||||
|
MERGE_GAP_TINY_MS = 1200 # 极短句(≤4字)放宽间隔阈值
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_short_sentences(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
) -> List[Tuple[int, int, str, int]]:
|
||||||
|
"""
|
||||||
|
合并碎片短句:专家气口造成的短碎 ASR 句,合并成完整语义单元再折行。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 连续的短句(≤8字)且间隔<800ms → 合并为一句
|
||||||
|
- 遇到 >2s 静音 → 不合并(是真正的话题停顿)
|
||||||
|
- 如果某句已经≥14字 → 作为独立单元不参与合并
|
||||||
|
- 合并后的句子时间戳取第一句起点到最后一句终点
|
||||||
|
"""
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from line_breaker import clean_punctuation
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
buffer = [] # [(bg, ed, text, spk), ...]
|
||||||
|
|
||||||
|
def flush_buffer():
|
||||||
|
if not buffer:
|
||||||
|
return
|
||||||
|
if len(buffer) == 1:
|
||||||
|
merged.append(buffer[0])
|
||||||
|
else:
|
||||||
|
# 合并 buffer 中所有句子
|
||||||
|
bg = buffer[0][0]
|
||||||
|
ed = buffer[-1][1]
|
||||||
|
text = "".join(item[2] for item in buffer)
|
||||||
|
spk = buffer[0][3]
|
||||||
|
merged.append((bg, ed, text, spk))
|
||||||
|
|
||||||
|
for i, (bg, ed, text, spk) in enumerate(sentences):
|
||||||
|
cleaned = clean_punctuation(text)
|
||||||
|
|
||||||
|
# 检查与 buffer 最后一句的间隔
|
||||||
|
if buffer:
|
||||||
|
gap = bg - buffer[-1][1]
|
||||||
|
# 极短句(≤4字)用更宽松的间隔阈值,让气口碎片更容易合并
|
||||||
|
threshold = MERGE_GAP_TINY_MS if len(cleaned) <= 4 else MERGE_GAP_MS
|
||||||
|
if gap > threshold:
|
||||||
|
flush_buffer()
|
||||||
|
buffer = []
|
||||||
|
|
||||||
|
# 如果当前句子很长(>28字),独立处理
|
||||||
|
if len(cleaned) > MAX_CHARS * 2:
|
||||||
|
flush_buffer()
|
||||||
|
buffer = []
|
||||||
|
merged.append((bg, ed, text, spk))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 如果当前句子中等偏长(15-28字)
|
||||||
|
if len(cleaned) > MAX_CHARS:
|
||||||
|
# 如果前面buffer里有极短句(≤5字),合并进来(专家气口碎片)
|
||||||
|
if buffer and all(len(clean_punctuation(item[2])) <= 5 for item in buffer):
|
||||||
|
buffer.append((bg, ed, text, spk))
|
||||||
|
else:
|
||||||
|
flush_buffer()
|
||||||
|
buffer = []
|
||||||
|
merged.append((bg, ed, text, spk))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 短句,看是否要合并
|
||||||
|
if len(cleaned) <= MERGE_THRESHOLD_CHARS:
|
||||||
|
# 检查合并后是否太长
|
||||||
|
buffer_text = "".join(clean_punctuation(item[2]) for item in buffer) + cleaned
|
||||||
|
if len(buffer_text) > MAX_CHARS * 3: # 合并后超过3行的量就太多了
|
||||||
|
flush_buffer()
|
||||||
|
buffer = [(bg, ed, text, spk)]
|
||||||
|
else:
|
||||||
|
buffer.append((bg, ed, text, spk))
|
||||||
|
else:
|
||||||
|
# 中等长度(9-14字),如果buffer有内容就合并进去,否则独立
|
||||||
|
if buffer:
|
||||||
|
buffer_text = "".join(clean_punctuation(item[2]) for item in buffer) + cleaned
|
||||||
|
if len(buffer_text) <= MAX_CHARS * 3:
|
||||||
|
buffer.append((bg, ed, text, spk))
|
||||||
|
else:
|
||||||
|
flush_buffer()
|
||||||
|
buffer = [(bg, ed, text, spk)]
|
||||||
|
else:
|
||||||
|
merged.append((bg, ed, text, spk))
|
||||||
|
|
||||||
|
flush_buffer()
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def process_sentences_with_ai(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
batch_size: int = 15,
|
||||||
|
) -> List[Tuple[int, int, str]]:
|
||||||
|
"""
|
||||||
|
用 AI 折行处理 ASR 句子列表。
|
||||||
|
|
||||||
|
输入: [(start_ms, end_ms, text, speaker_id), ...]
|
||||||
|
输出: [(start_ms, end_ms, text), ...]
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 先合并碎片短句(专家气口造成的短碎ASR句)
|
||||||
|
- ≤14 字:直接输出(去标点)
|
||||||
|
- >14 字:批量调 AI 折行
|
||||||
|
- 句间 >2秒:插入空白行
|
||||||
|
"""
|
||||||
|
from line_breaker import clean_punctuation
|
||||||
|
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 短句合并预处理
|
||||||
|
original_count = len(sentences)
|
||||||
|
sentences = _merge_short_sentences(sentences)
|
||||||
|
if len(sentences) != original_count:
|
||||||
|
print(f"[AI折行] 短句合并: {original_count} 句 → {len(sentences)} 句")
|
||||||
|
|
||||||
|
client = _create_client()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
# 先收集需要 AI 折行的句子索引
|
||||||
|
needs_ai = [] # (original_index, text)
|
||||||
|
for i, (bg, ed, text, spk) in enumerate(sentences):
|
||||||
|
cleaned = clean_punctuation(text)
|
||||||
|
if len(cleaned) > MAX_CHARS:
|
||||||
|
needs_ai.append((i, cleaned))
|
||||||
|
|
||||||
|
# 批量调 AI
|
||||||
|
ai_results = {} # index -> [lines]
|
||||||
|
if needs_ai:
|
||||||
|
print(f"[AI折行] 共 {len(needs_ai)} 句需要 AI 折行...")
|
||||||
|
for batch_start in range(0, len(needs_ai), batch_size):
|
||||||
|
batch = needs_ai[batch_start:batch_start + batch_size]
|
||||||
|
batch_texts = [t for _, t in batch]
|
||||||
|
batch_indices = [idx for idx, _ in batch]
|
||||||
|
|
||||||
|
print(f"[AI折行] 处理第 {batch_start+1}-{batch_start+len(batch)} 条...")
|
||||||
|
try:
|
||||||
|
broken = ai_break_batch(batch_texts, client)
|
||||||
|
for idx, lines in zip(batch_indices, broken):
|
||||||
|
ai_results[idx] = lines
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[AI折行] 批量失败: {e},回退逐条处理")
|
||||||
|
for idx, text in batch:
|
||||||
|
try:
|
||||||
|
lines = ai_break_single(text, client)
|
||||||
|
ai_results[idx] = lines
|
||||||
|
except Exception as e2:
|
||||||
|
print(f"[AI折行] 第{idx}句失败: {e2},使用机械切分")
|
||||||
|
from line_breaker import break_sentence
|
||||||
|
ai_results[idx] = break_sentence(text)
|
||||||
|
|
||||||
|
# 组装最终结果
|
||||||
|
for i, (bg, ed, text, spk) in enumerate(sentences):
|
||||||
|
# 检查空白
|
||||||
|
if i > 0:
|
||||||
|
prev_ed = sentences[i - 1][1]
|
||||||
|
gap = bg - prev_ed
|
||||||
|
if gap > SILENCE_THRESHOLD_MS:
|
||||||
|
result.append((prev_ed, bg, ""))
|
||||||
|
|
||||||
|
cleaned = clean_punctuation(text)
|
||||||
|
if not cleaned.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if i in ai_results:
|
||||||
|
lines = ai_results[i]
|
||||||
|
else:
|
||||||
|
lines = [cleaned]
|
||||||
|
|
||||||
|
# 后处理1:AI 偶尔返回超长行,强制二次切分
|
||||||
|
from line_breaker import break_sentence
|
||||||
|
final_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if len(line) > MAX_CHARS_SOFT:
|
||||||
|
final_lines.extend(break_sentence(line))
|
||||||
|
else:
|
||||||
|
final_lines.append(line)
|
||||||
|
lines = final_lines
|
||||||
|
|
||||||
|
# 后处理2:禁忌字开头修复(把禁忌字并入上一行)
|
||||||
|
FORBIDDEN_START = set("的了着过地得和与及或")
|
||||||
|
if len(lines) > 1:
|
||||||
|
fixed_lines = [lines[0]]
|
||||||
|
for ln in lines[1:]:
|
||||||
|
if ln and ln[0] in FORBIDDEN_START and fixed_lines:
|
||||||
|
# 把这个字并回上一行
|
||||||
|
fixed_lines[-1] = fixed_lines[-1] + ln[0]
|
||||||
|
remainder = ln[1:]
|
||||||
|
if remainder:
|
||||||
|
# 检查上一行是否超长了
|
||||||
|
if len(fixed_lines[-1]) > MAX_CHARS_SOFT:
|
||||||
|
# 需要重新切分合并后的文本
|
||||||
|
merged = fixed_lines[-1] + remainder
|
||||||
|
fixed_lines[-1:] = break_sentence(merged)
|
||||||
|
else:
|
||||||
|
fixed_lines.append(remainder)
|
||||||
|
else:
|
||||||
|
fixed_lines.append(ln)
|
||||||
|
lines = fixed_lines
|
||||||
|
|
||||||
|
# 后处理3:拆词检测(行末+下行首构成常见双字词 → 调整折点)
|
||||||
|
if len(lines) > 1:
|
||||||
|
lines = _fix_split_words(lines)
|
||||||
|
|
||||||
|
# 后处理4:引号不跨屏(≤6字的引号内容不拆到两行)
|
||||||
|
if len(lines) > 1:
|
||||||
|
lines = _fix_quote_split(lines)
|
||||||
|
|
||||||
|
# 为子行分配时间戳
|
||||||
|
total_chars = sum(len(l) for l in lines)
|
||||||
|
duration = ed - bg
|
||||||
|
current_ms = bg
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
line_duration = int(duration * len(line) / total_chars) if total_chars > 0 else 0
|
||||||
|
line_end = min(current_ms + line_duration, ed)
|
||||||
|
result.append((current_ms, line_end, line))
|
||||||
|
current_ms = line_end
|
||||||
|
|
||||||
|
# 全局后处理:合并极短字幕行(≤3字+时长<1秒→并入相邻行)
|
||||||
|
result = _merge_tiny_subtitle(result)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AI 校对器 — ASR 稿与 A 稿比对 + 上下文纠错
|
||||||
|
|
||||||
|
解决的核心问题:
|
||||||
|
- ASR 同音字误识别("建制"→"舰只"、"舰手"→"舰艏")
|
||||||
|
- 军事术语规范化("f15j"→"F-15J")
|
||||||
|
- 的/地/得纠错
|
||||||
|
- 去除口语填充词("嗯""那个""就是说")
|
||||||
|
- 专家采访段落强化去口头语
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 将 ASR 全文 + A 稿全文一起发给 DeepSeek
|
||||||
|
- AI 结合节目主题和上下文做纠错
|
||||||
|
- 返回修正后的句子列表 + 修改说明
|
||||||
|
- 专家采访段落用增强版 Prompt,更严格地删除口头语
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Dict
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if _env_path.exists():
|
||||||
|
load_dotenv(str(_env_path), override=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
PROOFREAD_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
|
||||||
|
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础
|
||||||
|
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访的具体内容)
|
||||||
|
|
||||||
|
你的任务是校对 ASR 稿中的**语音识别错误**。
|
||||||
|
|
||||||
|
**铁律(违反任何一条都算失败):**
|
||||||
|
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
|
||||||
|
- 只修下列允许的几类问题,除此之外一个字都不能动
|
||||||
|
- **A稿与ASR内容冲突时ASR优先**(配音员可能改过措辞),但专有名词的正确写法/格式按A稿
|
||||||
|
- **数字表达照抄ASR原文**:不要参考A稿调整数字的位置、格式或表述方式。ASR说"马赫数0.9"就保持"马赫数0.9",不要改成A稿的"0.9马赫"
|
||||||
|
|
||||||
|
**允许修的类别:**
|
||||||
|
1. **同音字/错别字**(ASR听错的字):如"建制"→"舰只"、"舰手"→"舰艏"、"继承"→"击沉"、"空花弹"→"滑翔弹"、"沉默"→"沉没"(指船只)
|
||||||
|
2. **代词纠错**:武器装备/导弹/飞机/舰艇等的代词应为"它"而非"他"。注意:指代国家时不改(国家口语中用"他"是可接受的)。只纠正明确指代物件(武器、军舰、飞机、导弹)的情况
|
||||||
|
3. **的/地/得纠错**(重要!ASR无法区分三个"de",你必须逐句检查并修正):
|
||||||
|
- **"的"用在名词前**(形容词/名词 + 的 + 名词):强大的性能、日本的军备、重要的舰只
|
||||||
|
- **"地"用在动词前**(副词 + 地 + 动词):不断地进行、持续地推动、快速地发展、正式地把、大规模地改装、积极地推进、不断地扩大、明确地表示
|
||||||
|
- **"得"用在补语前**(动词 + 得 + 补语):发展得很快、做得很好、打得很准
|
||||||
|
- 判断方法:看"de"后面跟的是名词还是动词——跟动词就用"地",跟名词就用"的",是评价/程度补语就用"得"
|
||||||
|
- 常见错误模式:"不断的进行"→"不断地进行"、"持续的推动"→"持续地推动"、"正式的把"→"正式地把"、"大力的发展"→"大力地发展"
|
||||||
|
4. **术语格式**:英文型号大小写+连字符("f15j"→"F-15J"、"v22"→"V-22"、"rq四"→"RQ-4")
|
||||||
|
5. **中文数字保留**:ASR可能把"数十"转成"数10"、"几百"转成"几100"——必须改回中文写法
|
||||||
|
6. **武器昵称引号**:如A稿中武器有引号昵称("鱼鹰""战斧""全球鹰"),ASR中同一武器无引号时补上中文双引号
|
||||||
|
7. **口语填充词删除**:只删"嗯""呃""唉""那个""就是说"这类纯填充词。"这个"后面紧跟名词作指示代词("这个导弹")时保留
|
||||||
|
|
||||||
|
**绝对不许做的(哪怕你觉得改了更好也不许):**
|
||||||
|
- 不许调整语序("它在性质上就是"不许改成"它本质上就是")
|
||||||
|
- 不许替换实词("不是那么特别的顺利"不许改成"不太顺利")
|
||||||
|
- 不许参考A稿的数字表达方式来改ASR的数字写法
|
||||||
|
- 不许增删标点来改变句子结构
|
||||||
|
- 不许把口语化表达改成书面语
|
||||||
|
- 不许根据A稿的措辞替换ASR中意思相同但用词不同的表达(如A稿"陆续订购",ASR说"先后采购"→保持"先后采购")
|
||||||
|
|
||||||
|
**输出格式:**
|
||||||
|
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
|
||||||
|
只输出JSON,不要其他内容。"""
|
||||||
|
|
||||||
|
|
||||||
|
PROOFREAD_EXPERT_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
|
||||||
|
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础。**本批全部来自专家采访段落**
|
||||||
|
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访内容——专家说的话A稿里没有)
|
||||||
|
|
||||||
|
你的任务是校对 ASR 稿中的**语音识别错误**,同时**严格清除专家的口头语**。
|
||||||
|
|
||||||
|
**铁律(违反任何一条都算失败):**
|
||||||
|
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
|
||||||
|
- 只修下列允许的几类问题,除此之外一个字都不能动
|
||||||
|
- 由于是专家采访,A稿中没有对应内容,所以**不要用A稿措辞替换专家的话**,A稿只用于确认专有名词写法
|
||||||
|
|
||||||
|
**允许修的类别:**
|
||||||
|
1. **同音字/错别字**(ASR听错的字):如"建制"→"舰只"、"舰手"→"舰艏"、"继承"→"击沉"、"沉默"→"沉没"(指船只)
|
||||||
|
2. **代词纠错**:武器装备/导弹/飞机/舰艇等的代词应为"它"而非"他"。指代国家时不改
|
||||||
|
3. **的/地/得纠错**(重要!ASR无法区分三个"de",你必须逐句检查并修正):
|
||||||
|
- **"的"用在名词前**(形容词/名词 + 的 + 名词)
|
||||||
|
- **"地"用在动词前**(副词 + 地 + 动词):不断地进行、持续地推动、正式地把、大规模地改装
|
||||||
|
- **"得"用在补语前**(动词 + 得 + 补语):发展得很快
|
||||||
|
- 常见错误:"不断的进行"→"不断地进行"、"持续的推动"→"持续地推动"
|
||||||
|
4. **术语格式**:英文型号大小写+连字符
|
||||||
|
5. **口语填充词删除(专家采访重点!必须严格执行)**:
|
||||||
|
- **必删**:嗯、呃、唉、啊(句首或句中作语气词时)、那个、这个(非指示代词时)、那么(非表示程度时)、就是说、应该说、可以说、怎么说呢、相对来讲、相对来说
|
||||||
|
- **判断"这个/那个"**:紧跟具体名词="指示代词"保留("这个导弹");单独出现或后面是虚词/停顿=口头语删除("这个呢它是"→删"这个"、"发展这个日向级"→删"这个")
|
||||||
|
- **判断"啊"**:句首"啊射程""啊这个"=口头语删除;"啊"在感叹句末尾=保留(极少出现在专家采访中)
|
||||||
|
- **判断"那么"**:"那么大""那么快"=程度副词保留;"那么它就是"=口头语删除
|
||||||
|
6. **数字表达照抄ASR原文**,不参考A稿
|
||||||
|
|
||||||
|
**绝对不许做的:**
|
||||||
|
- 不许调整语序、替换实词、把口语化改书面语
|
||||||
|
- 不许用A稿的措辞替换专家的话(专家说的内容A稿没有,不存在"参考"关系)
|
||||||
|
- 不许删除有意义的词(只删纯口头语填充词)
|
||||||
|
|
||||||
|
**输出格式:**
|
||||||
|
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
|
||||||
|
只输出JSON,不要其他内容。"""
|
||||||
|
|
||||||
|
|
||||||
|
PROOFREAD_USER_TEMPLATE = """**A稿(节目文稿,仅供参考):**
|
||||||
|
{script_text}
|
||||||
|
|
||||||
|
**ASR稿(需要校对,请逐条检查):**
|
||||||
|
{asr_text}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _create_client():
|
||||||
|
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
|
||||||
|
from openai import OpenAI
|
||||||
|
return OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def identify_speakers(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
) -> Dict[int, str]:
|
||||||
|
"""
|
||||||
|
识别每个 speaker_id 的角色。
|
||||||
|
|
||||||
|
规则(基于《军事科技》节目结构):
|
||||||
|
- 找到说"各位观众你们好"或"欢迎收看军事科技"的 speaker → 主持人(也是解说配音员)
|
||||||
|
- 导视段(最早出现的)speaker 如果和主持人不同 → 也是解说(录音环境不同导致分裂)
|
||||||
|
- 剩余的 speaker → 专家/其他(统一按"专家采访"对待,加强去口头语)
|
||||||
|
|
||||||
|
返回: {speaker_id: "narration"|"host"|"expert"}
|
||||||
|
"""
|
||||||
|
if not sentences:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
speaker_texts: Dict[int, str] = {}
|
||||||
|
speaker_first_appear: Dict[int, int] = {}
|
||||||
|
for i, (bg, ed, text, spk) in enumerate(sentences):
|
||||||
|
if spk not in speaker_texts:
|
||||||
|
speaker_texts[spk] = ""
|
||||||
|
speaker_first_appear[spk] = i
|
||||||
|
speaker_texts[spk] += text
|
||||||
|
|
||||||
|
roles: Dict[int, str] = {}
|
||||||
|
|
||||||
|
# 找主持人:说过"各位观众你们好"或"欢迎收看军事科技"
|
||||||
|
host_spk = None
|
||||||
|
for spk, text in speaker_texts.items():
|
||||||
|
if "各位观众" in text or "欢迎收看" in text or "主持人" in text:
|
||||||
|
host_spk = spk
|
||||||
|
roles[spk] = "host"
|
||||||
|
break
|
||||||
|
|
||||||
|
# 最早出现的 speaker 是解说(导视段配音员)
|
||||||
|
earliest_spk = min(speaker_first_appear, key=speaker_first_appear.get)
|
||||||
|
if earliest_spk not in roles:
|
||||||
|
roles[earliest_spk] = "narration"
|
||||||
|
|
||||||
|
# 如果主持人和解说是不同 speaker,两个都标记
|
||||||
|
# 如果相同,那就是同一个人(标为 narration 即可)
|
||||||
|
if host_spk is not None and host_spk == earliest_spk:
|
||||||
|
roles[host_spk] = "narration"
|
||||||
|
|
||||||
|
# 剩余的全部标为专家/其他
|
||||||
|
for spk in speaker_texts:
|
||||||
|
if spk not in roles:
|
||||||
|
roles[spk] = "expert"
|
||||||
|
|
||||||
|
role_summary = {spk: f"{role}({len([s for s in sentences if s[3]==spk])}句)"
|
||||||
|
for spk, role in roles.items()}
|
||||||
|
print(f"[校对] Speaker 角色识别: {role_summary}")
|
||||||
|
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
def proofread_batch(
|
||||||
|
asr_sentences: List[Tuple[int, int, str, int]],
|
||||||
|
script_text: str,
|
||||||
|
batch_size: int = 30,
|
||||||
|
) -> List[Tuple[int, int, str, int]]:
|
||||||
|
"""
|
||||||
|
对 ASR 句子列表做 AI 校对。
|
||||||
|
专家采访段落使用增强版 Prompt(更严格的口头语清除)。
|
||||||
|
"""
|
||||||
|
if not asr_sentences:
|
||||||
|
return []
|
||||||
|
|
||||||
|
client = _create_client()
|
||||||
|
script_truncated = script_text[:8000] if len(script_text) > 8000 else script_text
|
||||||
|
|
||||||
|
# 识别说话人角色
|
||||||
|
speaker_roles = identify_speakers(asr_sentences)
|
||||||
|
|
||||||
|
corrected_sentences = list(asr_sentences)
|
||||||
|
total_changes = 0
|
||||||
|
|
||||||
|
# 按角色分组处理:专家用增强 Prompt,其余用标准 Prompt
|
||||||
|
expert_indices = []
|
||||||
|
normal_indices = []
|
||||||
|
for i, (bg, ed, text, spk) in enumerate(asr_sentences):
|
||||||
|
if speaker_roles.get(spk) == "expert":
|
||||||
|
expert_indices.append(i)
|
||||||
|
else:
|
||||||
|
normal_indices.append(i)
|
||||||
|
|
||||||
|
print(f"[校对] 解说/主持 {len(normal_indices)} 句, 专家采访 {len(expert_indices)} 句")
|
||||||
|
|
||||||
|
def _process_batch(indices, system_prompt, label):
|
||||||
|
nonlocal total_changes
|
||||||
|
for batch_start in range(0, len(indices), batch_size):
|
||||||
|
batch_idx = indices[batch_start:batch_start + batch_size]
|
||||||
|
|
||||||
|
asr_lines = []
|
||||||
|
for seq, idx in enumerate(batch_idx):
|
||||||
|
asr_lines.append(f"[{seq+1}] {asr_sentences[idx][2]}")
|
||||||
|
asr_text = "\n".join(asr_lines)
|
||||||
|
|
||||||
|
print(f"[校对-{label}] 处理第 {batch_start+1}-{batch_start+len(batch_idx)} 句...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": PROOFREAD_USER_TEMPLATE.format(
|
||||||
|
script_text=script_truncated,
|
||||||
|
asr_text=asr_text,
|
||||||
|
)},
|
||||||
|
],
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=4000,
|
||||||
|
)
|
||||||
|
|
||||||
|
result_text = resp.choices[0].message.content.strip()
|
||||||
|
if result_text.startswith("```"):
|
||||||
|
result_text = result_text.split("\n", 1)[1]
|
||||||
|
if result_text.endswith("```"):
|
||||||
|
result_text = result_text[:-3]
|
||||||
|
result_text = result_text.strip()
|
||||||
|
|
||||||
|
corrections = json.loads(result_text)
|
||||||
|
|
||||||
|
for item in corrections:
|
||||||
|
seq = item.get("id", 0) - 1
|
||||||
|
corrected = item.get("corrected", "")
|
||||||
|
changes = item.get("changes", "")
|
||||||
|
|
||||||
|
if 0 <= seq < len(batch_idx) and corrected and changes:
|
||||||
|
original_idx = batch_idx[seq]
|
||||||
|
bg, ed, _, spk = corrected_sentences[original_idx]
|
||||||
|
corrected_sentences[original_idx] = (bg, ed, corrected, spk)
|
||||||
|
total_changes += 1
|
||||||
|
print(f" 修正: '{item.get('original','')}' → '{corrected}' ({changes})")
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[校对-{label}] JSON解析失败,跳过本批: {e}", file=sys.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[校对-{label}] 出错: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
if normal_indices:
|
||||||
|
_process_batch(normal_indices, PROOFREAD_SYSTEM_PROMPT, "解说")
|
||||||
|
if expert_indices:
|
||||||
|
_process_batch(expert_indices, PROOFREAD_EXPERT_SYSTEM_PROMPT, "专家")
|
||||||
|
|
||||||
|
print(f"[校对] 完成,共修正 {total_changes} 处")
|
||||||
|
return corrected_sentences
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
讯飞 ASR 客户端 — 适配自 doco/src/doco/asr_adapter.py
|
||||||
|
录音文件转写标准版: https://raasr.xfyun.cn/v2/api
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 凭证
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if _env_path.exists():
|
||||||
|
load_dotenv(str(_env_path), override=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
APP_ID = os.environ.get("XFYUN_APP_ID", "").strip()
|
||||||
|
SECRET_KEY = os.environ.get("XFYUN_SECRET_KEY", "").strip()
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 接口配置
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
HOST = "https://raasr.xfyun.cn/v2/api"
|
||||||
|
UPLOAD_URL = HOST + "/upload"
|
||||||
|
RESULT_URL = HOST + "/getResult"
|
||||||
|
|
||||||
|
LANGUAGE = "cn"
|
||||||
|
PD = "mil"
|
||||||
|
ENG_SMOOTHPROC = "true"
|
||||||
|
ENG_COLLOQPROC = "true"
|
||||||
|
ROLE_TYPE = "1"
|
||||||
|
ROLE_NUM = "0"
|
||||||
|
|
||||||
|
POLL_INTERVAL_SECONDS = 30
|
||||||
|
MAX_WAIT_MINUTES = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 签名
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def _make_signa(app_id: str, secret_key: str, ts: str) -> str:
|
||||||
|
base_string = (app_id + ts).encode("utf-8")
|
||||||
|
md5_str = hashlib.md5(base_string).hexdigest()
|
||||||
|
mac = hmac.new(
|
||||||
|
secret_key.encode("utf-8"),
|
||||||
|
md5_str.encode("utf-8"),
|
||||||
|
digestmod=hashlib.sha1,
|
||||||
|
)
|
||||||
|
return base64.b64encode(mac.digest()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_audio_duration_ms(filepath: str) -> int:
|
||||||
|
ext = os.path.splitext(filepath)[1].lower()
|
||||||
|
if ext == ".wav":
|
||||||
|
with wave.open(filepath, "rb") as wf:
|
||||||
|
return int(round(wf.getnframes() / wf.getframerate() * 1000))
|
||||||
|
if ext == ".mp3":
|
||||||
|
try:
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
return int(MP3(filepath).info.length * 1000)
|
||||||
|
except ImportError:
|
||||||
|
print("[警告] 需要 mutagen 库来读取 MP3 时长: pip install mutagen", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
raise ValueError(f"不支持的音频格式: {ext}")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 上传
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def upload_audio(filepath: str, hot_words: Optional[List[str]] = None) -> str:
|
||||||
|
if not APP_ID or not SECRET_KEY:
|
||||||
|
raise ValueError("请先在 .env 中设置 XFYUN_APP_ID 和 XFYUN_SECRET_KEY")
|
||||||
|
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
file_name = os.path.basename(filepath)
|
||||||
|
duration_ms = _get_audio_duration_ms(filepath)
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
signa = _make_signa(APP_ID, SECRET_KEY, ts)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"appId": APP_ID,
|
||||||
|
"signa": signa,
|
||||||
|
"ts": ts,
|
||||||
|
"fileSize": str(file_size),
|
||||||
|
"fileName": file_name,
|
||||||
|
"duration": str(duration_ms),
|
||||||
|
"language": LANGUAGE,
|
||||||
|
"pd": PD,
|
||||||
|
"eng_smoothproc": ENG_SMOOTHPROC,
|
||||||
|
"eng_colloqproc": ENG_COLLOQPROC,
|
||||||
|
"roleType": ROLE_TYPE,
|
||||||
|
"roleNum": ROLE_NUM,
|
||||||
|
}
|
||||||
|
|
||||||
|
if hot_words:
|
||||||
|
params["hotWord"] = "|".join(hot_words[:200])
|
||||||
|
|
||||||
|
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
|
||||||
|
url = f"{UPLOAD_URL}?{'&'.join(url_parts)}"
|
||||||
|
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
audio_bytes = f.read()
|
||||||
|
|
||||||
|
print(f"[ASR] 上传音频: {file_name} ({file_size/1024/1024:.1f}MB)")
|
||||||
|
resp = requests.post(url, headers={"Content-Type": "application/json"}, data=audio_bytes, timeout=300)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get("code") != "000000":
|
||||||
|
raise RuntimeError(f"上传失败: code={data.get('code')}, desc={data.get('descInfo')}")
|
||||||
|
|
||||||
|
order_id = data["content"]["orderId"]
|
||||||
|
print(f"[ASR] 上传成功, orderId={order_id}")
|
||||||
|
return order_id
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 轮询
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def poll_until_done(order_id: str) -> dict:
|
||||||
|
start_time = time.time()
|
||||||
|
print(f"[ASR] 开始轮询 (每{POLL_INTERVAL_SECONDS}秒)...")
|
||||||
|
while True:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed > MAX_WAIT_MINUTES * 60:
|
||||||
|
raise TimeoutError(f"超过 {MAX_WAIT_MINUTES} 分钟未完成")
|
||||||
|
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
signa = _make_signa(APP_ID, SECRET_KEY, ts)
|
||||||
|
params = {
|
||||||
|
"appId": APP_ID,
|
||||||
|
"signa": signa,
|
||||||
|
"ts": ts,
|
||||||
|
"orderId": order_id,
|
||||||
|
"resultType": "transfer",
|
||||||
|
}
|
||||||
|
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
|
||||||
|
url = f"{RESULT_URL}?{'&'.join(url_parts)}"
|
||||||
|
|
||||||
|
resp = requests.post(url, timeout=30)
|
||||||
|
data = resp.json()
|
||||||
|
order_info = data.get("content", {}).get("orderInfo", {})
|
||||||
|
status = order_info.get("status")
|
||||||
|
|
||||||
|
if status == 4:
|
||||||
|
print(f"[ASR] 转写完成 (耗时{int(elapsed)}秒)")
|
||||||
|
return data
|
||||||
|
if status == -1:
|
||||||
|
raise RuntimeError(f"转写失败: {data}")
|
||||||
|
|
||||||
|
print(f"[ASR] 等待中... ({int(elapsed)}秒)")
|
||||||
|
time.sleep(POLL_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 解析
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def parse_result(order_result_str: str) -> List[Tuple[int, int, str, int]]:
|
||||||
|
"""
|
||||||
|
解析讯飞返回结果
|
||||||
|
返回 [(start_ms, end_ms, text, speaker_id), ...]
|
||||||
|
"""
|
||||||
|
if not order_result_str:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cleaned = order_result_str.replace("\\\\", "\\")
|
||||||
|
outer = json.loads(cleaned)
|
||||||
|
|
||||||
|
sentences = []
|
||||||
|
for item in outer.get("lattice", []):
|
||||||
|
inner_str = item.get("json_1best", "")
|
||||||
|
if not inner_str:
|
||||||
|
continue
|
||||||
|
inner = json.loads(inner_str)
|
||||||
|
st = inner.get("st", {})
|
||||||
|
bg = int(st.get("bg", 0))
|
||||||
|
ed = int(st.get("ed", 0))
|
||||||
|
rl = int(st.get("rl", 0))
|
||||||
|
|
||||||
|
words = []
|
||||||
|
for rt in st.get("rt", []):
|
||||||
|
for ws in rt.get("ws", []):
|
||||||
|
for cw in ws.get("cw", []):
|
||||||
|
w = cw.get("w", "").strip()
|
||||||
|
wp = cw.get("wp", "n")
|
||||||
|
if w and wp != "g":
|
||||||
|
words.append(w)
|
||||||
|
sentence = "".join(words).strip()
|
||||||
|
if sentence:
|
||||||
|
sentences.append((bg, ed, sentence, rl))
|
||||||
|
|
||||||
|
return sentences
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 完整流程
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def transcribe(audio_path: str, hot_words: Optional[List[str]] = None) -> Tuple[List[Tuple[int, int, str, int]], str]:
|
||||||
|
"""
|
||||||
|
完整转写: 上传 → 轮询 → 解析
|
||||||
|
返回 (sentences, raw_json_str)
|
||||||
|
sentences: [(start_ms, end_ms, text, speaker_id), ...]
|
||||||
|
"""
|
||||||
|
order_id = upload_audio(audio_path, hot_words=hot_words)
|
||||||
|
result_data = poll_until_done(order_id)
|
||||||
|
order_result_str = result_data["content"]["orderResult"]
|
||||||
|
sentences = parse_result(order_result_str)
|
||||||
|
return sentences, order_result_str
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
热词提取器 — 从 A 稿中提取军事专有名词,注入讯飞 ASR 热词
|
||||||
|
|
||||||
|
两种模式:
|
||||||
|
1. 规则提取(不消耗 API): 匹配 A 稿中的武器型号、专有名词等
|
||||||
|
2. AI 提取(消耗 DeepSeek API): 让 AI 理解全文后提取专业术语
|
||||||
|
|
||||||
|
热词用途: 注入讯飞 ASR 的 hotWord 参数,提升领域识别准确率
|
||||||
|
讯飞限制: 最多 200 个热词,每个 2-16 字,用 | 分隔
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 规则提取
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 常见军事装备型号模式
|
||||||
|
MILITARY_PATTERNS = [
|
||||||
|
# 武器型号: F-35B, F-15J, RQ-4, V-22 等
|
||||||
|
re.compile(r'[A-Z]{1,3}-?\d{1,4}[A-Z]?(?:/[A-Z])?'),
|
||||||
|
# 中文+数字型号: 12式, 25式, 17式
|
||||||
|
re.compile(r'\d{1,3}式'),
|
||||||
|
# 导弹/武器名称中的英文缩写
|
||||||
|
re.compile(r'(?:MK|ESSM|LM|OPY|FCS)-?\d*[A-Z]*'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 军事领域高频词(手动维护,补充 ASR 容易错的同音词)
|
||||||
|
MILITARY_VOCAB = [
|
||||||
|
# 海军
|
||||||
|
"舰只", "舰艇", "舰载机", "护卫舰", "驱逐舰", "航空母舰", "航母",
|
||||||
|
"出云级", "出云号", "日向级", "日向号", "加贺号", "最上级",
|
||||||
|
"伊势号", "满载排水量", "飞行甲板", "舰艏", "舰艉", "舰宽",
|
||||||
|
"垂直发射系统", "近防系统", "反潜", "扫雷",
|
||||||
|
# 空军
|
||||||
|
"战斗机", "隐身战斗机", "舰载机", "无人机", "旋翼机",
|
||||||
|
"航空自卫队", "航空宇宙自卫队",
|
||||||
|
# 陆军/导弹
|
||||||
|
"巡航导弹", "反舰导弹", "高超音速", "滑翔弹",
|
||||||
|
"战斧", "陆上自卫队", "海上自卫队",
|
||||||
|
"岸基", "弹径", "弹体", "射程", "马赫数",
|
||||||
|
# 通用军事
|
||||||
|
"防卫省", "自卫队", "专守防卫", "和平宪法",
|
||||||
|
"军备", "军售", "军费", "军工", "军舰",
|
||||||
|
"进攻性", "防御性", "远程打击", "精确打击",
|
||||||
|
"作战编队", "态势感知", "火力演习",
|
||||||
|
# 人名/地名
|
||||||
|
"蓝皓", "熊本县", "南鸟岛", "东富士",
|
||||||
|
# 节目相关
|
||||||
|
"军事科技", "国防军事频道",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_by_rules(text: str) -> List[str]:
|
||||||
|
"""用正则从文本中提取军事术语"""
|
||||||
|
found = set()
|
||||||
|
|
||||||
|
# 正则匹配
|
||||||
|
for pattern in MILITARY_PATTERNS:
|
||||||
|
for match in pattern.finditer(text):
|
||||||
|
word = match.group().strip()
|
||||||
|
if 2 <= len(word) <= 16:
|
||||||
|
found.add(word)
|
||||||
|
|
||||||
|
# 固定词表匹配(只加文本中确实出现的词)
|
||||||
|
for word in MILITARY_VOCAB:
|
||||||
|
if word in text:
|
||||||
|
found.add(word)
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# AI 提取
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
AI_EXTRACT_PROMPT = """你是军事节目的专有名词提取助手。请从以下节目文稿中提取所有军事专有名词和术语。
|
||||||
|
|
||||||
|
提取范围:
|
||||||
|
1. 武器装备型号(如 F-35B、12式反舰导弹、战斧巡航导弹)
|
||||||
|
2. 军事单位/部队名称(如 航空自卫队、陆上自卫队)
|
||||||
|
3. 军舰/飞机名称(如 出云号、日向级)
|
||||||
|
4. 军事术语(如 垂直发射系统、高超音速滑翔弹、专守防卫)
|
||||||
|
5. 人名、地名(如 蓝皓、熊本县、南鸟岛)
|
||||||
|
6. 容易被语音识别混淆的词(如 "舰只"容易被识别为"建制","舰艏"容易被识别为"舰手")
|
||||||
|
|
||||||
|
输出格式:每行一个词,不加序号,不加解释。每个词 2-16 字。"""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_by_ai(text: str) -> List[str]:
|
||||||
|
"""用 DeepSeek 从文本中提取专有名词"""
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
if _env_path.exists():
|
||||||
|
load_dotenv(str(_env_path), override=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
||||||
|
if not api_key:
|
||||||
|
print("[热词] DeepSeek 未配置,跳过 AI 提取", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 文稿太长时截取前6000字
|
||||||
|
truncated = text[:6000] if len(text) > 6000 else text
|
||||||
|
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": AI_EXTRACT_PROMPT},
|
||||||
|
{"role": "user", "content": truncated},
|
||||||
|
],
|
||||||
|
temperature=0.1,
|
||||||
|
max_tokens=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resp.choices[0].message.content.strip()
|
||||||
|
words = []
|
||||||
|
for line in result.split("\n"):
|
||||||
|
word = line.strip().strip("-").strip("·").strip("•").strip()
|
||||||
|
if word and 2 <= len(word) <= 16:
|
||||||
|
words.append(word)
|
||||||
|
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 读取 A 稿
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def read_docx_text(docx_path: str) -> str:
|
||||||
|
"""从 docx 文件中提取纯文本"""
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
doc = Document(docx_path)
|
||||||
|
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||||||
|
except ImportError:
|
||||||
|
print("[热词] 需要 python-docx 库: pip install python-docx", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def read_text_file(path: str) -> str:
|
||||||
|
"""读取 txt 文件"""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 主入口
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def extract_hotwords(
|
||||||
|
script_path: str,
|
||||||
|
use_ai: bool = True,
|
||||||
|
max_words: int = 200,
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
从 A 稿提取热词列表
|
||||||
|
|
||||||
|
script_path: A 稿路径 (.docx 或 .txt)
|
||||||
|
use_ai: 是否使用 AI 提取(默认 True)
|
||||||
|
max_words: 最大热词数(讯飞限制 200)
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(script_path)[1].lower()
|
||||||
|
if ext == ".docx":
|
||||||
|
text = read_docx_text(script_path)
|
||||||
|
elif ext in (".txt", ".md"):
|
||||||
|
text = read_text_file(script_path)
|
||||||
|
else:
|
||||||
|
print(f"[热词] 不支持的文件格式: {ext}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 规则提取(免费)
|
||||||
|
rule_words = extract_by_rules(text)
|
||||||
|
print(f"[热词] 规则提取: {len(rule_words)} 个")
|
||||||
|
|
||||||
|
# AI 提取(可选)
|
||||||
|
ai_words = []
|
||||||
|
if use_ai:
|
||||||
|
print("[热词] AI 提取中...")
|
||||||
|
ai_words = extract_by_ai(text)
|
||||||
|
print(f"[热词] AI 提取: {len(ai_words)} 个")
|
||||||
|
|
||||||
|
# 合并去重
|
||||||
|
seen = set()
|
||||||
|
merged = []
|
||||||
|
for word in ai_words + rule_words: # AI 结果优先
|
||||||
|
if word not in seen:
|
||||||
|
seen.add(word)
|
||||||
|
merged.append(word)
|
||||||
|
|
||||||
|
# 截取前 max_words 个
|
||||||
|
result = merged[:max_words]
|
||||||
|
print(f"[热词] 最终热词: {len(result)} 个")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
折行引擎 — 将 ASR 句子按拍词规则折成 ≤14 字/行的字幕行
|
||||||
|
|
||||||
|
规则:
|
||||||
|
A. 每行 ≤ 14 字
|
||||||
|
B. ASR 中 >2 秒空白 → 插入空白行
|
||||||
|
C. 按语义断句(不机械凑满 14 字)
|
||||||
|
D. 去掉逗号/句号/感叹号/问号等标点,只保留引号
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
MAX_CHARS_PER_LINE = 14
|
||||||
|
MAX_CHARS_SOFT = 16 # 找不到好断点时允许的最大宽容值
|
||||||
|
SILENCE_THRESHOLD_MS = 2000 # >2秒空白插入空行
|
||||||
|
|
||||||
|
# 保留的标点(引号类)
|
||||||
|
KEEP_PUNCTUATION = set('""''「」『』《》')
|
||||||
|
|
||||||
|
# 需要去掉的标点
|
||||||
|
REMOVE_PUNCTUATION = re.compile(r'[,。!?、;:…—·\,\.\!\?\;\:]')
|
||||||
|
|
||||||
|
# 语义断句的优先切分点(按优先级排序)
|
||||||
|
BREAK_PATTERNS = [
|
||||||
|
re.compile(r'(?<=[。!?])'),
|
||||||
|
re.compile(r'(?<=[,、;:])'),
|
||||||
|
re.compile(r'(?<=》)'),
|
||||||
|
re.compile('(?<=[”’』」])'), # 右引号后: " ' 』 」
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_punctuation(text: str) -> str:
|
||||||
|
"""去掉标点,保留引号类。顿号替换为空格(唱词中并列词用空格分隔)。保留小数点。"""
|
||||||
|
result = []
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
if ch in KEEP_PUNCTUATION:
|
||||||
|
result.append(ch)
|
||||||
|
elif ch == '、':
|
||||||
|
result.append(' ')
|
||||||
|
elif ch == '.' or ch == '.':
|
||||||
|
# 保留小数点(前后都是数字)
|
||||||
|
if i > 0 and i < len(text) - 1 and text[i-1].isdigit() and text[i+1].isdigit():
|
||||||
|
result.append(ch)
|
||||||
|
# 其他句号/英文句点删掉
|
||||||
|
elif REMOVE_PUNCTUATION.match(ch):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
result.append(ch)
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def break_sentence(text: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
将一个句子按语义折行,每行 ≤ MAX_CHARS_PER_LINE 字。
|
||||||
|
先尝试在自然断句点切分,如果不行就硬切。
|
||||||
|
"""
|
||||||
|
if len(text) <= MAX_CHARS_PER_LINE:
|
||||||
|
return [text] if text.strip() else []
|
||||||
|
|
||||||
|
# 14-16字且找不到好断点时,允许不切(人工拍词也偶尔允许略超)
|
||||||
|
if len(text) <= MAX_CHARS_SOFT:
|
||||||
|
# 只有在有明显语义断点时才切
|
||||||
|
for pattern in BREAK_PATTERNS:
|
||||||
|
matches = list(pattern.finditer(text))
|
||||||
|
if matches:
|
||||||
|
pos = matches[-1].end()
|
||||||
|
if 3 <= pos <= len(text) - 3:
|
||||||
|
return [text[:pos].strip(), text[pos:].strip()]
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
remaining = text
|
||||||
|
|
||||||
|
while len(remaining) > MAX_CHARS_PER_LINE:
|
||||||
|
# 在前14字范围内找最佳切分点(从后往前找)
|
||||||
|
best_pos = -1
|
||||||
|
window = remaining[:MAX_CHARS_PER_LINE]
|
||||||
|
|
||||||
|
# 尝试在语义点切分
|
||||||
|
for pattern in BREAK_PATTERNS:
|
||||||
|
matches = list(pattern.finditer(window))
|
||||||
|
if matches:
|
||||||
|
pos = matches[-1].end()
|
||||||
|
if 3 <= pos <= MAX_CHARS_PER_LINE:
|
||||||
|
best_pos = pos
|
||||||
|
break
|
||||||
|
|
||||||
|
if best_pos == -1:
|
||||||
|
# 没有好的语义切分点,尝试在常见虚词前切
|
||||||
|
for i in range(min(MAX_CHARS_PER_LINE, len(remaining)) - 1, 2, -1):
|
||||||
|
ch = remaining[i]
|
||||||
|
if ch in "的了是在和与而但又或则也还却并且从向把被让给":
|
||||||
|
best_pos = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if best_pos == -1:
|
||||||
|
# 实在找不到,硬切在14字
|
||||||
|
best_pos = MAX_CHARS_PER_LINE
|
||||||
|
|
||||||
|
line = remaining[:best_pos].strip()
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
remaining = remaining[best_pos:].strip()
|
||||||
|
|
||||||
|
if remaining.strip():
|
||||||
|
lines.append(remaining.strip())
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def process_sentences(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
) -> List[Tuple[int, int, str]]:
|
||||||
|
"""
|
||||||
|
将 ASR 句子列表处理为折行后的字幕行列表。
|
||||||
|
|
||||||
|
输入: [(start_ms, end_ms, text, speaker_id), ...]
|
||||||
|
输出: [(start_ms, end_ms, text), ...] 其中 text="" 表示空白行
|
||||||
|
|
||||||
|
处理逻辑:
|
||||||
|
1. 检测句子间空白 >2秒 → 插入空白行
|
||||||
|
2. 清理标点
|
||||||
|
3. 按规则折行
|
||||||
|
4. 为折行后的子行分配时间戳(按字数比例)
|
||||||
|
"""
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for i, (bg, ed, text, _spk) in enumerate(sentences):
|
||||||
|
# 检查与前一句的空白
|
||||||
|
if i > 0:
|
||||||
|
prev_ed = sentences[i - 1][1]
|
||||||
|
gap = bg - prev_ed
|
||||||
|
if gap > SILENCE_THRESHOLD_MS:
|
||||||
|
# 插入空白行,占据空白时段
|
||||||
|
result.append((prev_ed, bg, ""))
|
||||||
|
|
||||||
|
# 清理标点
|
||||||
|
cleaned = clean_punctuation(text)
|
||||||
|
if not cleaned.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 折行
|
||||||
|
lines = break_sentence(cleaned)
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 为每个子行按字数比例分配时间戳
|
||||||
|
total_chars = sum(len(l) for l in lines)
|
||||||
|
duration = ed - bg
|
||||||
|
current_ms = bg
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line_duration = int(duration * len(line) / total_chars) if total_chars > 0 else 0
|
||||||
|
line_end = min(current_ms + line_duration, ed)
|
||||||
|
result.append((current_ms, line_end, line))
|
||||||
|
current_ms = line_end
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
节目结构切分器 — 将 ASR 结果按节目结构拆分为 5 段
|
||||||
|
|
||||||
|
结构: 导视 + 正片(3段) + 下期预告
|
||||||
|
标志词:
|
||||||
|
- 导视结束 / 正片开始: "各位观众你们好" 或 "我是主持人蓝皓"
|
||||||
|
- 正片结束: "好了观众朋友们" 或 "感谢您.*关注.*军事科技"
|
||||||
|
- 正片之后 = 下期预告
|
||||||
|
|
||||||
|
正片拆3段: 按时长大致均分,优先在角色转换处(speaker_id 变化)切分
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
# 标志词模式
|
||||||
|
PATTERN_SHOW_START = re.compile(r"各位观众你们好|我是主持人蓝皓")
|
||||||
|
PATTERN_SHOW_END = re.compile(r"好了观众朋友们|感谢您.*关注.*军事科技|感谢您持续关注")
|
||||||
|
|
||||||
|
|
||||||
|
def find_segment_boundaries(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
找到正片开始和结束的句子索引。
|
||||||
|
返回 (show_start_idx, show_end_idx)
|
||||||
|
- show_start_idx: "各位观众你们好"所在句子的索引
|
||||||
|
- show_end_idx: "好了观众朋友们"所在句子的索引
|
||||||
|
"""
|
||||||
|
show_start_idx = 0
|
||||||
|
show_end_idx = len(sentences) - 1
|
||||||
|
|
||||||
|
for i, (_, _, text, _) in enumerate(sentences):
|
||||||
|
if PATTERN_SHOW_START.search(text):
|
||||||
|
show_start_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
for i in range(len(sentences) - 1, -1, -1):
|
||||||
|
_, _, text, _ = sentences[i]
|
||||||
|
if PATTERN_SHOW_END.search(text):
|
||||||
|
show_end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
return show_start_idx, show_end_idx
|
||||||
|
|
||||||
|
|
||||||
|
def split_show_into_three(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
start_idx: int,
|
||||||
|
end_idx: int,
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
将正片(start_idx 到 end_idx)拆成 3 段。
|
||||||
|
返回两个切分点索引 (split1_idx, split2_idx)
|
||||||
|
|
||||||
|
策略: 按时长三等分,然后在附近找 speaker_id 变化的位置。
|
||||||
|
"""
|
||||||
|
if end_idx - start_idx < 6:
|
||||||
|
# 太短了,均分
|
||||||
|
third = (end_idx - start_idx) // 3
|
||||||
|
return start_idx + third, start_idx + 2 * third
|
||||||
|
|
||||||
|
show_start_ms = sentences[start_idx][0]
|
||||||
|
show_end_ms = sentences[end_idx][1]
|
||||||
|
total_duration = show_end_ms - show_start_ms
|
||||||
|
|
||||||
|
target1_ms = show_start_ms + total_duration // 3
|
||||||
|
target2_ms = show_start_ms + 2 * total_duration // 3
|
||||||
|
|
||||||
|
split1 = _find_best_split(sentences, start_idx, end_idx, target1_ms)
|
||||||
|
split2 = _find_best_split(sentences, split1 + 1, end_idx, target2_ms)
|
||||||
|
|
||||||
|
# 确保 split2 > split1
|
||||||
|
if split2 <= split1:
|
||||||
|
split2 = split1 + (end_idx - split1) // 2
|
||||||
|
|
||||||
|
return split1, split2
|
||||||
|
|
||||||
|
|
||||||
|
def _find_best_split(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
range_start: int,
|
||||||
|
range_end: int,
|
||||||
|
target_ms: int,
|
||||||
|
search_window: int = 15,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
在 target_ms 附近(±search_window 句)找最佳切分点。
|
||||||
|
优先找 speaker_id 变化的位置,其次找 >2秒 空白。
|
||||||
|
"""
|
||||||
|
# 先找到时间上最接近 target_ms 的句子
|
||||||
|
closest_idx = range_start
|
||||||
|
min_diff = abs(sentences[range_start][0] - target_ms)
|
||||||
|
for i in range(range_start, min(range_end + 1, len(sentences))):
|
||||||
|
diff = abs(sentences[i][0] - target_ms)
|
||||||
|
if diff < min_diff:
|
||||||
|
min_diff = diff
|
||||||
|
closest_idx = i
|
||||||
|
|
||||||
|
# 在附近找 speaker 变化点
|
||||||
|
search_lo = max(range_start + 1, closest_idx - search_window)
|
||||||
|
search_hi = min(range_end, closest_idx + search_window)
|
||||||
|
|
||||||
|
best_idx = closest_idx
|
||||||
|
best_score = 0
|
||||||
|
|
||||||
|
for i in range(search_lo, search_hi):
|
||||||
|
score = 0
|
||||||
|
# speaker 变化加分
|
||||||
|
if sentences[i][3] != sentences[i - 1][3] and sentences[i][3] != 0:
|
||||||
|
score += 10
|
||||||
|
# 空白间隔加分
|
||||||
|
gap = sentences[i][0] - sentences[i - 1][1]
|
||||||
|
if gap > 2000:
|
||||||
|
score += 5
|
||||||
|
elif gap > 1000:
|
||||||
|
score += 2
|
||||||
|
# 离目标越近加分
|
||||||
|
time_diff = abs(sentences[i][0] - target_ms)
|
||||||
|
time_score = max(0, 5 - time_diff / 10000)
|
||||||
|
score += time_score
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_idx = i
|
||||||
|
|
||||||
|
return best_idx
|
||||||
|
|
||||||
|
|
||||||
|
def split_into_segments(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
) -> List[Tuple[str, List[Tuple[int, int, str, int]]]]:
|
||||||
|
"""
|
||||||
|
将全部 ASR 句子拆分为 5 段。
|
||||||
|
返回: [("导视", [...]), ("正片1", [...]), ("正片2", [...]), ("正片3", [...]), ("预告", [...])]
|
||||||
|
|
||||||
|
如果找不到标志词,则只输出单段。
|
||||||
|
"""
|
||||||
|
if not sentences:
|
||||||
|
return [("正片1", [])]
|
||||||
|
|
||||||
|
show_start_idx, show_end_idx = find_segment_boundaries(sentences)
|
||||||
|
|
||||||
|
# 导视: 0 到 show_start_idx-1
|
||||||
|
intro = sentences[:show_start_idx] if show_start_idx > 0 else []
|
||||||
|
|
||||||
|
# 正片: show_start_idx 到 show_end_idx
|
||||||
|
show_sentences = sentences[show_start_idx:show_end_idx + 1]
|
||||||
|
|
||||||
|
# 预告: show_end_idx+1 到末尾
|
||||||
|
trailer = sentences[show_end_idx + 1:] if show_end_idx < len(sentences) - 1 else []
|
||||||
|
|
||||||
|
# 正片拆3段
|
||||||
|
if len(show_sentences) > 6:
|
||||||
|
split1, split2 = split_show_into_three(sentences, show_start_idx, show_end_idx)
|
||||||
|
# 转为相对于 show_sentences 的索引
|
||||||
|
rel_split1 = split1 - show_start_idx
|
||||||
|
rel_split2 = split2 - show_start_idx
|
||||||
|
show_part1 = show_sentences[:rel_split1]
|
||||||
|
show_part2 = show_sentences[rel_split1:rel_split2]
|
||||||
|
show_part3 = show_sentences[rel_split2:]
|
||||||
|
else:
|
||||||
|
show_part1 = show_sentences
|
||||||
|
show_part2 = []
|
||||||
|
show_part3 = []
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
if intro:
|
||||||
|
segments.append(("导视", intro))
|
||||||
|
segments.append(("正片1", show_part1))
|
||||||
|
if show_part2:
|
||||||
|
segments.append(("正片2", show_part2))
|
||||||
|
if show_part3:
|
||||||
|
segments.append(("正片3", show_part3))
|
||||||
|
if trailer:
|
||||||
|
segments.append(("预告", trailer))
|
||||||
|
|
||||||
|
return segments
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
SRT 生成器 — 将折行后的字幕行列表写成大洋系统兼容的 SRT 文件
|
||||||
|
|
||||||
|
格式参照 data/ 下的真实样本:
|
||||||
|
- 序号从 1 开始
|
||||||
|
- 时间格式: HH:MM:SS,mmm --> HH:MM:SS,mmm
|
||||||
|
- 每条字幕一行文字(不多行)
|
||||||
|
- 空白行(屏幕清字幕)写为空内容
|
||||||
|
- 条目之间用空行分隔
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def ms_to_srt_time(ms: int) -> str:
|
||||||
|
"""毫秒 → SRT 时间格式 HH:MM:SS,mmm"""
|
||||||
|
if ms < 0:
|
||||||
|
ms = 0
|
||||||
|
hours = ms // 3600000
|
||||||
|
minutes = (ms % 3600000) // 60000
|
||||||
|
seconds = (ms % 60000) // 1000
|
||||||
|
millis = ms % 1000
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_dayang_timing(
|
||||||
|
subtitle_lines: List[Tuple[int, int, str]],
|
||||||
|
) -> List[Tuple[int, int, str]]:
|
||||||
|
"""
|
||||||
|
大洋"单下单上":每条字幕的结束时间 = 下一条开始时间 - 40ms。
|
||||||
|
"""
|
||||||
|
if len(subtitle_lines) <= 1:
|
||||||
|
return subtitle_lines
|
||||||
|
|
||||||
|
GAP_MS = 40
|
||||||
|
adjusted = []
|
||||||
|
for i, (start_ms, end_ms, text) in enumerate(subtitle_lines):
|
||||||
|
if i < len(subtitle_lines) - 1:
|
||||||
|
next_start = subtitle_lines[i + 1][0]
|
||||||
|
end_ms = next_start - GAP_MS
|
||||||
|
if end_ms < start_ms:
|
||||||
|
end_ms = start_ms
|
||||||
|
adjusted.append((start_ms, end_ms, text))
|
||||||
|
return adjusted
|
||||||
|
|
||||||
|
|
||||||
|
def write_srt(
|
||||||
|
subtitle_lines: List[Tuple[int, int, str]],
|
||||||
|
output_path: str,
|
||||||
|
time_offset: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
写入 SRT 文件
|
||||||
|
|
||||||
|
subtitle_lines: [(start_ms, end_ms, text), ...] text="" 表示空白行
|
||||||
|
output_path: 输出文件路径
|
||||||
|
time_offset: 时间偏移(用于正片拆分后各段从0开始计时的情况,这里不用,保持绝对时间)
|
||||||
|
"""
|
||||||
|
subtitle_lines = _apply_dayang_timing(subtitle_lines)
|
||||||
|
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
for idx, (start_ms, end_ms, text) in enumerate(subtitle_lines, 1):
|
||||||
|
start = ms_to_srt_time(start_ms - time_offset)
|
||||||
|
end = ms_to_srt_time(end_ms - time_offset)
|
||||||
|
f.write(f"{idx}\n")
|
||||||
|
f.write(f"{start} --> {end}\n")
|
||||||
|
# 空白行写一个空格(参照样本中的做法)
|
||||||
|
f.write(f"{text if text else ' '}\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
print(f"[SRT] 已写入: {output_path} ({len(subtitle_lines)} 条)")
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
术语格式化器 — 正则后处理层(零 token 消耗)
|
||||||
|
|
||||||
|
在 ASR 结果出来后、AI 校对之前执行。
|
||||||
|
从 A 稿中提取正确的术语写法,构建映射表,对 ASR 文本做确定性替换。
|
||||||
|
|
||||||
|
解决的问题:
|
||||||
|
- 讯飞 ASR 丢失英文型号中的短横线(F-15J→F15J, V-22→V22)
|
||||||
|
- 武器昵称引号丢失(A稿有引号但ASR没带出来)
|
||||||
|
- 中文数字被转成阿拉伯数字(数十→数10)
|
||||||
|
- 数字范围符号(~→到)
|
||||||
|
- 顿号分隔词加空格
|
||||||
|
- 小数点丢失修复(09马赫→0.9马赫)
|
||||||
|
- 军事领域高频同音字修正(建制→舰只等)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import List, Tuple, Dict, Set
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 型号短横线修复
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
MODEL_PATTERN = re.compile(r'[A-Z]{1,4}-\d{1,4}[A-Z]?(?:/[A-Z])?')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_model_mapping(script_text: str) -> Dict[str, str]:
|
||||||
|
mapping = {}
|
||||||
|
models = set(MODEL_PATTERN.findall(script_text))
|
||||||
|
for model in models:
|
||||||
|
no_hyphen = model.replace("-", "")
|
||||||
|
if no_hyphen != model:
|
||||||
|
mapping[no_hyphen] = model
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_model_hyphens(text: str, mapping: Dict[str, str]) -> str:
|
||||||
|
if not mapping:
|
||||||
|
return text
|
||||||
|
for no_hyphen in sorted(mapping.keys(), key=len, reverse=True):
|
||||||
|
correct = mapping[no_hyphen]
|
||||||
|
pattern = re.compile(re.escape(no_hyphen) + r'(?![A-Za-z0-9])')
|
||||||
|
text = pattern.sub(correct, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 武器昵称引号修复(上下文感知版)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 匹配 A 稿中 "xxx"号 / "xxx"级 / "xxx"型 / 单独 "xxx" 的模式
|
||||||
|
QUOTED_WITH_SUFFIX = re.compile(r'“([^“”„‟""]{1,8})”([号级型式舰]?)')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_quote_mapping(script_text: str) -> Dict[str, Set[str]]:
|
||||||
|
"""
|
||||||
|
从 A 稿提取引号词及其后缀上下文。
|
||||||
|
返回 {词: {出现过的后缀集合}},后缀为空字符串表示单独使用。
|
||||||
|
例: {"日向": {"号"}, "鱼鹰": {""}} 表示 A 稿有"日向"号但没有"日向"级,有单独的"鱼鹰"
|
||||||
|
"""
|
||||||
|
mapping: Dict[str, Set[str]] = {}
|
||||||
|
for match in QUOTED_WITH_SUFFIX.finditer(script_text):
|
||||||
|
word = match.group(1).strip()
|
||||||
|
suffix = match.group(2)
|
||||||
|
if 2 <= len(word) <= 6:
|
||||||
|
if word not in mapping:
|
||||||
|
mapping[word] = set()
|
||||||
|
mapping[word].add(suffix)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bare_occurrences(script_text: str, word: str, suffixes: Set[str]) -> Set[str]:
|
||||||
|
"""
|
||||||
|
检查 A 稿中该词的无引号出现,看哪些后缀组合是不加引号的。
|
||||||
|
例如 A 稿有 "日向级"(无引号),说明"日向级"不该加引号。
|
||||||
|
"""
|
||||||
|
bare_suffixes = set()
|
||||||
|
for suffix in ["号", "级", "型", "式", "舰", ""]:
|
||||||
|
bare_pattern = word + suffix if suffix else word
|
||||||
|
quoted_pattern = f"“{word}”{suffix}"
|
||||||
|
# 在 A 稿中出现了无引号版本 且 没有对应的有引号版本
|
||||||
|
if bare_pattern in script_text and quoted_pattern not in script_text:
|
||||||
|
bare_suffixes.add(suffix)
|
||||||
|
return bare_suffixes
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_weapon_quotes(text: str, quote_mapping: Dict[str, Set[str]], script_text: str) -> str:
|
||||||
|
"""对文本中无引号的武器昵称补上引号(上下文感知)"""
|
||||||
|
if not quote_mapping:
|
||||||
|
return text
|
||||||
|
for word in sorted(quote_mapping.keys(), key=len, reverse=True):
|
||||||
|
quoted_suffixes = quote_mapping[word]
|
||||||
|
bare_suffixes = _check_bare_occurrences(script_text, word, quoted_suffixes)
|
||||||
|
|
||||||
|
# 对每个在 A 稿中确实带引号的后缀组合,在 ASR 文本中补引号
|
||||||
|
for suffix in quoted_suffixes:
|
||||||
|
if suffix and suffix not in bare_suffixes:
|
||||||
|
# 匹配 "word+suffix"(无引号),替换为 "word"+suffix
|
||||||
|
target = word + suffix
|
||||||
|
replacement = f"“{word}”{suffix}"
|
||||||
|
pattern = re.compile(
|
||||||
|
r'(?<!“)' + re.escape(target) + r'(?!”)'
|
||||||
|
)
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
elif not suffix:
|
||||||
|
# 单独出现(无后缀),但要避免替换那些在 A 稿中不带引号的后缀组合
|
||||||
|
# 用负向前瞻排除不该加引号的后缀
|
||||||
|
exclude_chars = "".join(bare_suffixes - {""}) if bare_suffixes else ""
|
||||||
|
if exclude_chars:
|
||||||
|
lookahead = f'(?![{re.escape(exclude_chars)}])'
|
||||||
|
else:
|
||||||
|
lookahead = ''
|
||||||
|
pattern = re.compile(
|
||||||
|
r'(?<!“)(?<!《)' + re.escape(word) + lookahead + r'(?!”)(?!》)'
|
||||||
|
)
|
||||||
|
text = pattern.sub(f'“{word}”', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 中文数字修复
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
CHINESE_NUM_FIXES = [
|
||||||
|
(re.compile(r'数10([年架艘枚门辆台套件个发种类])'), r'数十\1'),
|
||||||
|
(re.compile(r'数100([年架艘枚门辆台套件个发种类])'), r'数百\1'),
|
||||||
|
(re.compile(r'数1000([年架艘枚门辆台套件个发种类])'), r'数千\1'),
|
||||||
|
(re.compile(r'几10([年架艘枚门辆台套件个发种类])'), r'几十\1'),
|
||||||
|
(re.compile(r'几100([年架艘枚门辆台套件个发种类])'), r'几百\1'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_chinese_numbers(text: str) -> str:
|
||||||
|
for pattern, replacement in CHINESE_NUM_FIXES:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 数字范围符号修复:~ ~ → 到
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 匹配 数字~数字 或 数字~数字 的模式
|
||||||
|
RANGE_TILDE = re.compile(r'(\d)[~~](\d)')
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_range_symbol(text: str) -> str:
|
||||||
|
return RANGE_TILDE.sub(r'\1到\2', text)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 顿号→空格(唱词中并列词用空格分隔)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def _fix_enumeration_pause(text: str) -> str:
|
||||||
|
return text.replace("、", " ")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 节目名称书名号补全
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 需要带书名号的固定名称(节目名等)
|
||||||
|
# 格式: (裸名称, 带书名号版本)
|
||||||
|
BOOK_TITLE_NAMES = [
|
||||||
|
("军事科技", "《军事科技》"),
|
||||||
|
("军事报道", "《军事报道》"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_book_titles(text: str) -> str:
|
||||||
|
for bare, titled in BOOK_TITLE_NAMES:
|
||||||
|
# 只替换没有被书名号包围的裸名称
|
||||||
|
pattern = re.compile(r'(?<!《)' + re.escape(bare) + r'(?!》)')
|
||||||
|
text = pattern.sub(titled, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 小数点丢失修复(09马赫→0.9马赫 等)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 匹配丢失小数点的情况:
|
||||||
|
# 1. "09马赫" → "0.9马赫"(数字在单位前)
|
||||||
|
# 2. "马赫数09" → "马赫数0.9"(数字在单位后)
|
||||||
|
# 3. 通用:非正常的 0+单个数字 紧跟/紧接单位
|
||||||
|
LOST_DECIMAL_BEFORE_UNIT = re.compile(r'(?<!\d)0(\d)(\s*(?:马赫|倍|秒|米|千米|公里))')
|
||||||
|
LOST_DECIMAL_AFTER_UNIT = re.compile(r'(马赫数|倍数|速度约)0(\d)(?!\d)')
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_lost_decimal(text: str) -> str:
|
||||||
|
text = LOST_DECIMAL_BEFORE_UNIT.sub(r'0.\1\2', text)
|
||||||
|
text = LOST_DECIMAL_AFTER_UNIT.sub(r'\g<1>0.\2', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 军事领域高频同音字修正
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
# 格式: (错误写法正则, 正确写法, A稿中应有的验证词)
|
||||||
|
# 只有当 A 稿中存在正确写法时才替换,避免误改
|
||||||
|
HOMOPHONE_PAIRS = [
|
||||||
|
# 海军
|
||||||
|
("建制", "舰只", "舰只"),
|
||||||
|
("舰手", "舰艏", "舰艏"),
|
||||||
|
("舰位", "舰尾", "舰尾"),
|
||||||
|
("继承", "击沉", "击沉"),
|
||||||
|
("沉默", "沉没", "沉没"),
|
||||||
|
("空花弹", "滑翔弹", "滑翔弹"),
|
||||||
|
("建支", "舰只", "舰只"),
|
||||||
|
("坚支", "舰只", "舰只"),
|
||||||
|
# 其他
|
||||||
|
("符和", "符合", "符合"),
|
||||||
|
("决意", "决议", "决议"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_homophone_mapping(script_text: str) -> Dict[str, str]:
|
||||||
|
mapping = {}
|
||||||
|
for wrong, correct, verify_word in HOMOPHONE_PAIRS:
|
||||||
|
if verify_word in script_text:
|
||||||
|
mapping[wrong] = correct
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_homophones(text: str, mapping: Dict[str, str]) -> str:
|
||||||
|
if not mapping:
|
||||||
|
return text
|
||||||
|
for wrong, correct in mapping.items():
|
||||||
|
text = text.replace(wrong, correct)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 主入口
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def normalize_terms(
|
||||||
|
sentences: List[Tuple[int, int, str, int]],
|
||||||
|
script_text: str,
|
||||||
|
) -> List[Tuple[int, int, str, int]]:
|
||||||
|
"""
|
||||||
|
对 ASR 句子列表做术语格式化(确定性正则替换,不调 AI)。
|
||||||
|
在 ASR 结果出来后、AI 校对之前调用。
|
||||||
|
"""
|
||||||
|
if not sentences:
|
||||||
|
return []
|
||||||
|
if not script_text:
|
||||||
|
return list(sentences)
|
||||||
|
|
||||||
|
model_mapping = _build_model_mapping(script_text)
|
||||||
|
quote_mapping = _build_quote_mapping(script_text)
|
||||||
|
homophone_mapping = _build_homophone_mapping(script_text)
|
||||||
|
|
||||||
|
if model_mapping:
|
||||||
|
print(f"[术语格式化] 型号映射 {len(model_mapping)} 条: {list(model_mapping.items())[:5]}")
|
||||||
|
if quote_mapping:
|
||||||
|
print(f"[术语格式化] 引号昵称 {len(quote_mapping)} 个: {dict((k, list(v)) for k, v in list(quote_mapping.items())[:5])}")
|
||||||
|
if homophone_mapping:
|
||||||
|
print(f"[术语格式化] 同音字映射 {len(homophone_mapping)} 条: {list(homophone_mapping.items())[:5]}")
|
||||||
|
|
||||||
|
result = []
|
||||||
|
fix_count = 0
|
||||||
|
for bg, ed, text, spk in sentences:
|
||||||
|
original = text
|
||||||
|
text = _fix_model_hyphens(text, model_mapping)
|
||||||
|
text = _fix_weapon_quotes(text, quote_mapping, script_text)
|
||||||
|
text = _fix_chinese_numbers(text)
|
||||||
|
text = _fix_range_symbol(text)
|
||||||
|
text = _fix_enumeration_pause(text)
|
||||||
|
text = _fix_lost_decimal(text)
|
||||||
|
text = _fix_homophones(text, homophone_mapping)
|
||||||
|
text = _fix_book_titles(text)
|
||||||
|
if text != original:
|
||||||
|
fix_count += 1
|
||||||
|
result.append((bg, ed, text, spk))
|
||||||
|
|
||||||
|
print(f"[术语格式化] 完成,修正 {fix_count} 句")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
本地测试 — 不需要讯飞凭证
|
||||||
|
用模拟的 ASR 数据测试折行引擎和 SRT 生成
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from line_breaker import break_sentence, clean_punctuation, process_sentences
|
||||||
|
from srt_writer import write_srt, ms_to_srt_time
|
||||||
|
from segment_splitter import split_into_segments, find_segment_boundaries
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_punctuation():
|
||||||
|
print("=== 测试标点清理 ===")
|
||||||
|
cases = [
|
||||||
|
('对于战后日本而言,重新武装最大的限制不是工业能力,而是政治约束。', '对于战后日本而言重新武装最大的限制不是工业能力而是政治约束'),
|
||||||
|
('这是“和平宪法”的约束', '这是“和平宪法”的约束'),
|
||||||
|
('你好!世界?', '你好世界'),
|
||||||
|
]
|
||||||
|
for input_text, expected in cases:
|
||||||
|
result = clean_punctuation(input_text)
|
||||||
|
status = "✓" if result == expected else "✗"
|
||||||
|
print(f" {status} 输入: {input_text}")
|
||||||
|
print(f" 结果: {result}")
|
||||||
|
if result != expected:
|
||||||
|
print(f" 期望: {expected}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def test_break_sentence():
|
||||||
|
print("=== 测试折行 ===")
|
||||||
|
cases = [
|
||||||
|
"对于战后日本而言重新武装最大的限制不是工业能力而是政治约束",
|
||||||
|
"各位观众你们好欢迎收看军事科技我是主持人蓝皓",
|
||||||
|
"F-35B隐身战斗机",
|
||||||
|
"日本通过从美国引进F-35战机和战斧巡航导弹等先进装备迅速获得了第五代战机远程精确打击能力",
|
||||||
|
]
|
||||||
|
for text in cases:
|
||||||
|
lines = break_sentence(text)
|
||||||
|
print(f" 输入 ({len(text)}字): {text}")
|
||||||
|
for line in lines:
|
||||||
|
print(f" → [{len(line):2d}字] {line}")
|
||||||
|
# 验证每行不超过14字
|
||||||
|
for line in lines:
|
||||||
|
assert len(line) <= 14, f"超过14字: {line} ({len(line)}字)"
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_sentences():
|
||||||
|
print("=== 测试完整折行流程 ===")
|
||||||
|
# 模拟 ASR 句子(参照 data/1.srt 内容)
|
||||||
|
sentences = [
|
||||||
|
(8520, 8840, "", 0), # 空句
|
||||||
|
(8880, 9880, "各位观众你们好", 1),
|
||||||
|
(9920, 11320, "欢迎收看军事科技", 1),
|
||||||
|
(11360, 12280, "我是主持人蓝皓", 1),
|
||||||
|
(12320, 13120, " ", 0), # 空白
|
||||||
|
(13160, 14400, "日本国会参议院", 1),
|
||||||
|
(14440, 16000, "今天上午6月26日表决通过了", 1),
|
||||||
|
(16040, 18480, "防卫省设置法修正案等法案", 1),
|
||||||
|
# 模拟 >2秒空白
|
||||||
|
(22000, 24320, "这是日本自1954年", 1),
|
||||||
|
(24360, 29040, "成立陆上海上航空自卫队以来", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = process_sentences(sentences)
|
||||||
|
print(f" 输入 {len(sentences)} 句 → 输出 {len(result)} 行")
|
||||||
|
for start, end, text in result:
|
||||||
|
time_str = f"{ms_to_srt_time(start)} --> {ms_to_srt_time(end)}"
|
||||||
|
print(f" {time_str} {text if text else '[空白]'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_detection():
|
||||||
|
print("=== 测试节目结构识别 ===")
|
||||||
|
sentences = [
|
||||||
|
(0, 5000, "本期军事科技将为您介绍", 1),
|
||||||
|
(5000, 8000, "日本军备的发展历程", 1),
|
||||||
|
(8880, 9880, "各位观众你们好", 1),
|
||||||
|
(9920, 11320, "欢迎收看军事科技", 1),
|
||||||
|
(11360, 12280, "我是主持人蓝皓", 1),
|
||||||
|
(13000, 50000, "正片内容一", 1),
|
||||||
|
(50000, 100000, "正片内容二", 2),
|
||||||
|
(100000, 150000, "正片内容三", 1),
|
||||||
|
(150000, 200000, "正片内容四", 2),
|
||||||
|
(200000, 250000, "正片内容五", 1),
|
||||||
|
(250000, 300000, "正片内容六", 2),
|
||||||
|
(300000, 310000, "好了观众朋友们感谢您持续关注国防军事频道军事科技", 1),
|
||||||
|
(310000, 320000, "下期我们将继续关注", 1),
|
||||||
|
(320000, 330000, "某某话题", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
start_idx, end_idx = find_segment_boundaries(sentences)
|
||||||
|
print(f" 正片开始: 第{start_idx}句 = {sentences[start_idx][2]}")
|
||||||
|
print(f" 正片结束: 第{end_idx}句 = {sentences[end_idx][2]}")
|
||||||
|
|
||||||
|
segments = split_into_segments(sentences)
|
||||||
|
for name, seg in segments:
|
||||||
|
if seg:
|
||||||
|
print(f" [{name}] {len(seg)}句, 时间 {ms_to_srt_time(seg[0][0])} ~ {ms_to_srt_time(seg[-1][1])}")
|
||||||
|
else:
|
||||||
|
print(f" [{name}] 空")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def test_srt_output():
|
||||||
|
print("=== 测试 SRT 输出 ===")
|
||||||
|
subtitle_lines = [
|
||||||
|
(8880, 9880, "各位观众你们好"),
|
||||||
|
(9920, 11320, "欢迎收看《军事科技》"),
|
||||||
|
(11360, 12280, "我是主持人蓝皓"),
|
||||||
|
(12320, 13120, ""), # 空白行
|
||||||
|
(13160, 14400, "日本国会参议院"),
|
||||||
|
]
|
||||||
|
|
||||||
|
output_path = Path(__file__).parent / "output" / "test.srt"
|
||||||
|
output_path.parent.mkdir(exist_ok=True)
|
||||||
|
write_srt(subtitle_lines, str(output_path))
|
||||||
|
|
||||||
|
# 读回来检查
|
||||||
|
content = output_path.read_text(encoding="utf-8")
|
||||||
|
print(content[:500])
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_clean_punctuation()
|
||||||
|
test_break_sentence()
|
||||||
|
test_process_sentences()
|
||||||
|
test_segment_detection()
|
||||||
|
test_srt_output()
|
||||||
|
print("=== 全部测试完成 ===")
|
||||||
+4
-3
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
- **最后更新**:Claude Code | 2026-06-26
|
- **最后更新**:Claude Code | 2026-07-03
|
||||||
- **当前状态一句话**:**doco 子项目开发收尾。** 22 期融合A稿全部产出(含说话人区分重跑),已归拢至 `doco/deliverables/`。第01期(武器装备里的形状规律)缺A稿暂跳过。下一步:带 22 期成品回 TPS 主项目知识库批量导入。
|
- **当前状态一句话**:**doco 子项目阶段性收工。** 22 期融合A稿全部产出,成品在 `doco/deliverables/`。主项目寄存条交接核查已完成(2026-07-03):寄存条状态表已更新、api_credentials_inventory 已更新(MiMo/讯飞)。下一步由主项目侧执行批量导入知识库。
|
||||||
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
|
- **下一个动手的人从这里开始**:doco 开发完成,无待办。主项目侧批量导入走 Phase 3 上传/embedding 链路。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
|
|
||||||
## 4. 已完成(只追加,最新在最上)
|
## 4. 已完成(只追加,最新在最上)
|
||||||
|
|
||||||
|
- [2026-07-03 |Claude Code] **主项目寄存条交接核查完成。** 更新内容:① 主项目 `note/寄存条Doco文稿整理子项目已外迁.md` 底部状态表从"等子项目出 PRD v3 启动 P1"更新为"开发收尾(2026-07-03)";② `docs/api_credentials_inventory.md` doco 节更新——讯飞改已激活、DeepSeek Vision 改为 Ollama 本地 DeepSeek-OCR、新增小米 MiMo 2.5 Pro(替代原 Anthropic Claude API 条目);③ doco CLAUDE.md 状态栏更新。交付物核查:22 份 docx + 20 份 md 在 `deliverables/`、术语词典 `term_dict.json` 10600 行、测试 `test_video_split.py` 存在。
|
||||||
- [2026-06-26 |Claude Code] **doco 子项目收尾:ep021/022 补稿 + 说话人重跑 + 成品归拢。** 本 session 完成:① **ep021(海战颠覆者)、ep022(硬核脑洞)两期新补 A 稿全流程 P1→C4 跑通**——ep021 骨架 LLM 自动生成失败(`extract_a_paragraphs()` 过滤空段后重编号,LLM 用了 raw docx 下标),手写骨架 JSON(32 段=26 normal+6 ignore 粗体章节标题)通过校验;ep022 骨架 LLM 自动通过(38 段全 normal,inline header);ep021 C3 review 13 条(均 OCR 修正,专名零替换)、C4 punct_ok 25/26;ep022 C3 review 5 条、C4 punct_ok 全过;② **cli.py 路径 bug 修复**:`episode_dir = Path("programs")/episode_id` 相对路径在子进程中二次解析导致路径翻倍(`programs/ep021/programs/ep021/...`),加 `.resolve()` 修复——此 bug 从未触发因 ep004 用 `--skip-p1` 且 `_batch_run.py` 用绝对路径;③ **ep007、ep010 C4 说话人区分重跑**(06-24 批量重跑时这两期被遗漏,融合A稿时间戳仍为 06-23),重跑完成、产物已更新;④ **排播对照表** `doco/data/2026episode_list.xlsx`(25 期播出日期/期次/节目名/编导/收视)导入,建立 ep→播出顺序完整映射;⑤ **成品归拢** `doco/deliverables/` 文件夹:22 份融合A稿按播出顺序统一命名(`第XX期_YYYYMMDD_节目名_编导_融合A稿.docx`),缺第01期(武器装备里的形状规律,无A稿);⑥ 通哥人工核验 ep021/022 质量高,仅两处小毛刺(ep021 一段话归错段落、ep022 一个标签应为三维动画),确认技术路径有效。
|
- [2026-06-26 |Claude Code] **doco 子项目收尾:ep021/022 补稿 + 说话人重跑 + 成品归拢。** 本 session 完成:① **ep021(海战颠覆者)、ep022(硬核脑洞)两期新补 A 稿全流程 P1→C4 跑通**——ep021 骨架 LLM 自动生成失败(`extract_a_paragraphs()` 过滤空段后重编号,LLM 用了 raw docx 下标),手写骨架 JSON(32 段=26 normal+6 ignore 粗体章节标题)通过校验;ep022 骨架 LLM 自动通过(38 段全 normal,inline header);ep021 C3 review 13 条(均 OCR 修正,专名零替换)、C4 punct_ok 25/26;ep022 C3 review 5 条、C4 punct_ok 全过;② **cli.py 路径 bug 修复**:`episode_dir = Path("programs")/episode_id` 相对路径在子进程中二次解析导致路径翻倍(`programs/ep021/programs/ep021/...`),加 `.resolve()` 修复——此 bug 从未触发因 ep004 用 `--skip-p1` 且 `_batch_run.py` 用绝对路径;③ **ep007、ep010 C4 说话人区分重跑**(06-24 批量重跑时这两期被遗漏,融合A稿时间戳仍为 06-23),重跑完成、产物已更新;④ **排播对照表** `doco/data/2026episode_list.xlsx`(25 期播出日期/期次/节目名/编导/收视)导入,建立 ep→播出顺序完整映射;⑤ **成品归拢** `doco/deliverables/` 文件夹:22 份融合A稿按播出顺序统一命名(`第XX期_YYYYMMDD_节目名_编导_融合A稿.docx`),缺第01期(武器装备里的形状规律,无A稿);⑥ 通哥人工核验 ep021/022 质量高,仅两处小毛刺(ep021 一段话归错段落、ep022 一个标签应为三维动画),确认技术路径有效。
|
||||||
- [2026-06-23 |Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.80(batch_size=25 效果显著,无批次回退);② 新增 `doco run` 一键全流程命令(Cline 实现,Opus 审核),参数 `--episode-id / --a-script / --input-video / --batch-size / --skip-p1`,ep004 `--skip-p1` 验证通过;③ C3 SYSTEM_PROMPT 收紧专名铁律(防 ASR 同音字替换权威 B稿专名);④ Ollama 并发提至 16 路(`OLLAMA_NUM_PARALLEL=16`,8 路实测 GPU 96%、显存充裕);⑤ 16 期新目录建立 + 文件拷贝 + 骨架批量生成并集中核验(真名零泄露、隔断/ignore 正确);⑥ `_batch_run.py` 批量脚本启动跑 ep005-ep020。**doco PRD 完成标准已与通哥确定**:20 期全出稿 → 带成品回 TPS 主项目知识库批量导入。
|
- [2026-06-23 |Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.80(batch_size=25 效果显著,无批次回退);② 新增 `doco run` 一键全流程命令(Cline 实现,Opus 审核),参数 `--episode-id / --a-script / --input-video / --batch-size / --skip-p1`,ep004 `--skip-p1` 验证通过;③ C3 SYSTEM_PROMPT 收紧专名铁律(防 ASR 同音字替换权威 B稿专名);④ Ollama 并发提至 16 路(`OLLAMA_NUM_PARALLEL=16`,8 路实测 GPU 96%、显存充裕);⑤ 16 期新目录建立 + 文件拷贝 + 骨架批量生成并集中核验(真名零泄露、隔断/ignore 正确);⑥ `_batch_run.py` 批量脚本启动跑 ep005-ep020。**doco PRD 完成标准已与通哥确定**:20 期全出稿 → 带成品回 TPS 主项目知识库批量导入。
|
||||||
- [2026-06-22 晚|Claude Code] **ep002 C4 审核完毕,全流程收工**。Cline 跑完 C4 compose 出稿 `20260127潜艇的仿生之路_穆佩弦_融合A稿.docx`。Opus 审核核验:硬校验(汉字零改)全过、733 行全覆盖、标点回退 0 段。**但发现两个问题**:① Cline 自报空段名称错了两个(报"解说3/解说6",实际是"三维动画解说3/解说8")、隔断数也报错(报 3 个,实际 4 个)——再次印证不信 Cline 自检;② **9/19 批次(47%)LLM 对齐失败走回退**(全 confidence=0.30),本次因失败批次恰在大段中间未翻车,但属黄色预警。**分段偏差根因**:A 稿是拍摄前剧本,专家采访段与实际播出内容差异巨大(A 稿一句话提纲 vs 专家自由发挥两分钟),LLM 无法正确匹配——这是信息不对应,非算法问题。通哥手动批改分段(~10 处高亮标签),内容本身正确。**结论(通哥拍板)**:治本靠编导给贴近播出版的稿子,现阶段接受"程序保证文字零改 + 编导手调分段"的模式。
|
- [2026-06-22 晚|Claude Code] **ep002 C4 审核完毕,全流程收工**。Cline 跑完 C4 compose 出稿 `20260127潜艇的仿生之路_穆佩弦_融合A稿.docx`。Opus 审核核验:硬校验(汉字零改)全过、733 行全覆盖、标点回退 0 段。**但发现两个问题**:① Cline 自报空段名称错了两个(报"解说3/解说6",实际是"三维动画解说3/解说8")、隔断数也报错(报 3 个,实际 4 个)——再次印证不信 Cline 自检;② **9/19 批次(47%)LLM 对齐失败走回退**(全 confidence=0.30),本次因失败批次恰在大段中间未翻车,但属黄色预警。**分段偏差根因**:A 稿是拍摄前剧本,专家采访段与实际播出内容差异巨大(A 稿一句话提纲 vs 专家自由发挥两分钟),LLM 无法正确匹配——这是信息不对应,非算法问题。通哥手动批改分段(~10 处高亮标签),内容本身正确。**结论(通哥拍板)**:治本靠编导给贴近播出版的稿子,现阶段接受"程序保证文字零改 + 编导手调分段"的模式。
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 现代防空反导大对决
|
|
||||||
播出日期: 2026-06-12
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep001
|
|
||||||
---
|
|
||||||
|
|
||||||
# 现代防空反导大对决
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视】
|
|
||||||
|
|
||||||
导弹呼啸而过,战机低空穿梭,无人机如蜂群般袭来,这些空中威胁,正不断挑战防御体系的极限,它们是如何被精确拦截的?究竟是什么力量,能在遥远距离精准打击,在近在咫尺时,完成致命一击呢?本期《军事科技》,将带您逐步解析,现代防空网络的复杂机制,亲眼见证,科技与智慧打造的空中屏障,揭秘现代防空体系,御敌于空的终极密码。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众,你们好,欢迎收看《军事科技》,我是主持人蓝皓,从第二次世界大战时期的,战机轰炸,到冷战时期的导弹威慑,再到如今的无人机,高超声速武器的崛起,空中威胁的形态,不断地迭代,防御体系也在随之进化,而如今的现代化防空系统,早已不再是,单一武器的简单对抗了,而是构建了,以远、中、近衔接,高、中、低覆盖为一体的,立体化防御系统,仿佛一块层层镶嵌的空中盾牌,从千里之外的高空,到咫尺之间的低空,形成了无死角的防护,在与新型空中威胁持续对抗当中。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
上演着一场场激烈的,攻与防的较量。2026年2月28日,美以两国协同向伊朗发起军事打击,中东地区顿时战火纷飞,硝烟弥漫。随着冲突不断加剧,各方频繁动用弹道导弹、巡航导弹、高超声速武器,以及自杀式无人机等先进装备,展开密集的高强度空袭。波斯湾上空,随即呈现出一场,攻防交织的终极较量,涉及突破与拦截、毁灭与保卫,这场关乎生死存亡,与战局走向的空中攻防战,彻底爆发。
|
|
||||||
|
|
||||||
远程防空:弹道导弹与反导系统的巅峰对决
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
在涉及远、中、近程,和高、中、低空的,全方位空战对抗中,弹道导弹,与反导系统之间的对决,无疑是最具战略威慑力,且最检验技术水平的顶峰交锋,中段反导,作为现代战略防空的最高层级,更是左右战场局势的核心要素,这项技术,常被形容为,太空中以子弹击落子弹,它标志着,当今世界军事科技的极致成就,也是战略防空领域,真正的制高点。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画1】
|
|
||||||
|
|
||||||
一枚中远程弹道导弹的飞行,分为上升段、中段、末段,三者在时空、速度上的巨大差异,直接决定了拦截难度的天壤之别,上升段仅持续3至5分钟,时间窗口稍纵即逝,且需在敌国领土拦截,受部署限制难以实施,拦截成功率极低,末段导弹俯冲至几十公里至几公里高空,速度达马赫数10至20,仅留数秒至十几秒的反应时间,同时面临机动变轨、多弹头诱饵及低空探测盲区,拦截风险高且成功率有限,唯有中段,导弹在100公里以上大气层外无动力滑行,飞行时间长达10至20分钟,轨迹由万有引力决定极度稳定,并未释放弹头与诱饵,反导系统有足够的时间预警、测算轨迹并拦截,采用动能撞击可彻底消除地面威胁,是现代战略防空最理想、效果最佳的“黄金拦截期”,也是各国反导技术研发的核心方向。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
虽然说,中段反导拦截,在整个反导作战的流程当中,它是比较容易实现的,或者说,可以针对对方来袭的导弹,进行精准的摧毁,但是最大的问题,就是你的预警系统,需要看得远,反导拦截弹,需要飞得快,打得高、打得远,所以对于技术方面的要求,是比较高的,这就要求,大型的相控阵预警雷达,能够在极远的距离上,针对外大气层飞行的,这种小型、高速移动的目标,能够进行精准的锁定,而且要对它进行持续的跟踪,还要有相关的,这样的一个计算系统,针对它的飞行轨迹进行计算,这样的话,才能够有效地引导反导拦截弹,在外大气层,对它进行精准的这样的一个截杀,而且反导拦截弹的性能,要求非常高,不仅要飞得高,飞得远、飞得快,而且,甚至还要配备有动能的战斗部,要针对处于飞行状态的,弹道导弹,进行精准的撞击,确保它是被彻底摧毁。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
3月5日,伊朗伊斯兰革命卫队发布声明称,在“真实承诺-4”行动的第17轮中,伊朗动用高超声速导弹和攻击无人机,成功突破美国“萨德”防御系统,打击了以色列国防部大楼与本-古里安国际机场,并摧毁了7套雷达系统及核心配套设备。同期,美国内部评估报告透露,作为中东关键中段反导装备的以色列“箭式”系统,在拦截伊朗中程弹道导弹时显现重大短板:实际拦截率远未达到原有设计标准。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
那么在以色列的国土范围内,有“箭-2” “箭-3” 反导系统,也有美国部署的“萨德”反导系统,但是我们看,仍然有数量相当多的伊朗弹道导弹,成功突防,针对以色列区域内的目标,进行了毁伤,为什么说“箭-2”“箭-3”,以及“萨德”系统,它联起手来,也没有办法针对伊朗的弹道导弹,进行这种百分之百的防范和拦截,我认为,首先是伊朗弹道导弹的技术,有所提升,部分伊朗的弹道导弹,采用了这种高超音速的战斗部,在飞行的末段,会有突然的加速,或者说有急剧的,飞行轨迹的变化,这样的话,“萨德”系统,和“箭-2”“箭-3”反导系统,很难对它进行精准的拦截,此外,伊朗的一些弹道导弹,比如说像“霍拉姆沙赫尔-4”,那么它是一种重型的,这样的一个弹头的设计理念,1.5吨到2吨的战斗部,它可以采用这种集束弹头,这样的一个模式,飞到了目标区上空之后,打出了一个满天星的效果,数十枚子弹头飞向目标区域,所有的防空反导系统,都没有办法对它进行拦截,那么还有另外一种情况,美国的反导作战系统,和以色列的“箭-2” “箭-3” 反导系统,它没有办法进行联网作战,是各打各的,这样的话,在相关的区域内,没有办法进行目标信息的共享,那么也无法形成合力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
2026年3月15日,伊朗伊斯兰革命卫队,在“真实承诺-4”军事行动中,首次实战部署了泥石导弹,与此同时,美国从韩国,紧急调遣了萨德反导系统,以加强该地区的防御,应对伊朗导弹带来的持续威胁。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
若将远程反导,视作千里点穴的区域防空手段,中程防空,便是中空守护网,承担着衔接,远近防御的关键使命,它覆盖数十公里,乃至一百多公里的中高空区域,作为防空体系中的,中坚力量,承担起区域防空重任,护卫城市,军事要地等高价值目标,成为整个防空网络中,承上启下的核心组成部分。
|
|
||||||
|
|
||||||
中程防空:复合威胁下的区域空天防御攻坚战
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
中程防空,作为现代防空作战系统的关键环节,其防御半径一般在50到200公里之间,拦截高度重点覆盖10至20公里的中高空域。这片空域正是现代战争中空中威胁密集活动的核心地带,敌方战机若要突破外层防御、打击纵深关键目标,必须穿越此区域;巡航导弹虽常利用低空突防,但多数情况下仍需在中高空调整姿态、寻找防御薄弱点;而中近程导弹在俯冲末段同样会经过这一高度区间,其高速特性要求中程防空系统及早介入、精确拦截。可以说,这片中高空域是空中威胁渗向核心目标的必经通道,也是防空体系中承前启后的关键拦截区。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
在现代化的防空作战行动当中,中程防空的难度是非常大的,因为很多对方的这种空中的来袭目标,其实它都是在中程防空的任务范围内,目标的属性是非常多的。此外,在复杂的电磁环境的这样的一个状态下,中程的防空体系也可能会出现失灵的情况,因为对方可能会出动电子战飞机针对你的防空作战体系进行压制。此外,有的时候,面对各种各样的目标,数量众多的目标,中程防空系统它的这样的一个火力的密集程度可能会出现不足的情况,比如说一部发射器上它只有数量有限的中程防空导弹,打完了之后,对方的目标连续地持续地袭来,那中程防空体系可能也会面临弹药不足、火力不足的情况。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
在近期,美以联合对伊朗的军事行动中,中程导弹防御系统,成为防空体系中的核心作战力量,也是双方攻防较量最为激烈的领域。为巩固中程防御屏障,美国运用“爱国者”防空反导系统,结合天基预警卫星、陆基远程雷达及集成化指挥控制中心,打造了能够覆盖30公里至150公里空域的拦截体系,着重防护其军事基地与国防工业区域。面对伊朗高超音速武器配合无人机集群的非对称组合式打击,此前被美军公开宣称拦截成功率可达80%到97%的“爱国者”系统,实际拦截率已跌至5%以下,系统还频频出现拦截假目标、漏过真实弹头的失误。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
“爱国者”曾在海湾战争期间,一战成名,那当时,美军“爱国者”系统,它所拦截的导弹,都是伊拉克萨达姆政权军队,使用的比较老旧的,“飞毛腿”弹道导弹,它的飞行轨迹是一个典型的抛物线,不会有这种,机动变轨的这样的一些动作,所以,只要是探测到了它的飞行轨迹,多发射一些“爱国者”拦截弹,是可以对它进行有效拦截的,但是现代化的弹道导弹,突防能力都很强,飞行轨迹是变化的,甚至可以说是有一些打水漂的,这样的一个飞行轨迹,速度又很快,那么甚至可能会携带,更多的诱饵弹和干扰弹,所以“爱国者”系统,可能根本就看不清,可能也就追不上。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
“爱国者”导弹在部分实战场景中,拦截效果未达预期,这一表现,使得美以联合构建的,中程防空反导系统暴露出短板,也让多国对该系统,产生信任危机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
在现代防空系统中,低空与超低空,是远程及中程防空,存在的短板,受到地球曲率、地形掩蔽等影响,远程雷达难以追踪低空目标,而中程防空导弹,在低空条件下,制导系统易受地面杂波的干扰,致使拦截效能下降,因此,近程防空系统由此诞生,专门用于近距离的防护,弥补了防空网络,在低空领域的不足。
|
|
||||||
|
|
||||||
近程防空:低空突防与最后防线的生死较量
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
对普通公众而言,近程防空无疑是最常见,且视觉冲击力最强的防空形式。那些在网络短片,新闻报道和战场记录中,常见的导弹腾空而起,尾焰撕裂黑夜,目标在空中爆炸,火光刹那映亮天空的拦截画面,大多源自近程防空系统。这些系统射程较短,但部署便捷,响应迅速,通常在目标接近阵地,城市或重要设施的最后一刻,才发动攻击,因而更易被现场捕捉,媒体记录,并广泛传播。正是由于这种高频曝光,近程防空在许多人心中,几乎成了防空的代名词。然而实际上,近程防空在整体防空网络中,充当的是最终贴身防线的功能,远程和中程防空,无法覆盖的低空,及超低空漏洞。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
近程防空作战体系,在实际应用的过程当中,有的时候,可能会成为战场上的奇兵,因为对方的直升机,甚至包括战斗机,在低空飞行的时候,它可能会放松警惕,另外一个来不及反应,所以这个时候,近程防空火力,针对目标进行射击的时候,战斗机很难对它进行规避,有的时候甚至连,发射红外诱饵弹的时间都没有,近程防空也有很大的难度,因为很多近程防空作战体系,它的搜索系统是比较简单的,视野不够宽广,也无法针对远距离的目标,进行识别,所以,有的近程防空作战体系,它本质上来讲是“近视眼”,而且还涉及到一些,地形起伏的问题,如果对方利用这种地形起伏,那么去进行超低空的突防,在相关区域内部署的一些传感器,比如说低空搜索雷达,或者是光学红外传感器,可能没有办法,及时有效地识别目标,那么这些低空突防的,这样的一些空中作战平台,也可能会针对,近程的防空作战体系,包括相关的防空阵地,进行打击和压制。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
在近期,美以联合对伊朗的军事行动中,以色列拉斐尔公司研发的“铁穹”近程防空系统,经历了自2011年部署以来最激烈且最具转折性的实战检验。这款以往在冲突中以85%至90%拦截率享誉世界的明星武器,在伊朗“真实承诺-4”行动的饱和攻击下,其卓越声誉荡然无存。以色列国防部3月17日向国会递交的评估报告中指出,面对伊朗连续发动的六十多波攻势,“铁穹”系统的实际拦截率从历史标榜的90%以上骤降至约50%,而在集束弹头密集倾泻与无人机蜂群低空突袭的关键战斗阶段,其拦截成功率更是下滑至30%以下,刷新了服役以来的最低性能纪录。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
我们注意到,在此次军事行动的过程当中,“铁穹”系统表现平平,我认为,可能是有两方面的考虑,就是它配套的这种传感器,或者是预警体系,可能是遭到了对方的火力打击,有部分“铁穹”系统,那么针对空中来袭目标,它的反应速度不够快,它的这样的一个情报,和目标引导体系,不够完善,另外一个,就是“铁穹”系统,它所使用的是“塔米尔”拦截弹,这种拦截弹,由于它装备,和使用的数量比较多,所以,它必须要进行成本的控制,它的飞行速度,其实并没有那么快,有的时候,针对高速目标进行打击的时候,可能会出现速度跟不上的情况,现在伊朗主要是使用弹道导弹,针对以色列进行打击,面对高空高速飞行的弹道导弹,或者是,配备有,高超音速战斗部的弹道导弹,“铁穹”系统可能它的拦截范围,拦截的高度也都不足,伊朗也使用了大量的无人机,但是这一次,针对以色列进行后续的反击,伊朗更加注重使用,这种中程的弹道导弹,或者是突防能力很强的弹道导弹,面对这样的一种情况,它就会出现反应速度不够快,包括拦截的范围,拦截的高度,都存在不足的情况。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
近程防空系统,作为整个防空架构中,关键的最后屏障,肩负着坚守底线、护卫要害的核心职责,但它并非完美无缺,仍存在明显的短板。其防御范围,通常较为有限,探测与拦截能力,都集中在较小区域,大多只能执行小尺度、重点目标的局部防护任务,无法如同中远程防空系统那样,实现广大空域的大范围、远距离监控与狙击。因此,近程防空系统,不能独自支撑一国、一城,或整个战区的全面防空安全。它更像是一件,贴身的防弹衣,而非是能覆盖全域的保护罩。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人 4】
|
|
||||||
|
|
||||||
远程、中程与近程防空系统,相互配合,在现代战争中,构筑了层次分明、分工明确、紧密衔接的全空域、立体防空网络,三者各尽其责,彼此支援、逐层拦截,形成了一套由外至内、由高空到低空、由远及近的周密防御体系,不过,近年多场高强度、军事冲突的实际表现,这套看似牢不可破的防空体系,正持续受到、新兴战场模式的严峻挑战,其传统作战思路和防御能力,正在不断遭受冲击与重塑。
|
|
||||||
|
|
||||||
体系防空:全域防御与精准拦截的新困境
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
在美以联合对伊朗发动的,大规模军事行动中,可以清晰看到,美军,以色列军队,以及伊朗的防空系统,都遭遇了前所未有的考验,在以往严密的多层防护网络,在实战中屡次被突破,撕裂,甚至彻底崩溃,美军采用了,以“爱国者”和“萨德”反导系统,中远程导弹防御体系,以色列则调动了由“铁穹”,“大卫投石索”,和“箭式”系统组成的,全域防空反导系统,然而,针对高超声速武器穿透,无人机群饱和攻击,复合诱饵欺骗,与强电磁干扰等策略,拦截失败的情况,依然频繁发生。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
在此次行动的过程当中,美以的防空反导系统,频频被洞穿,也就是说,伊朗使用了,一系列的突防手段,最终打穿了,美国和以色列的一些,防空反导的保护伞,那么针对相关的区域,构成了更为猛烈的火力打击,我认为,就是存在一些问题,比如说,它系统之间,没有进行有效的联合,存在一些兼容的问题,各自为战的状态下,肯定会出现漏洞,另外一个,伊朗也是采取了一些灵活多变的,这样的一些战术,多种火力 多种模式联合运用,也让美国和以色列的,防空反导系统。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
在现代军事冲突中,高超声速武器的穿透,与无人机蜂群的饱和攻击,虽是关键威胁,但更具深远颠覆性的,是战场成本效益失衡,对防空体系带来的,系统性难题。这已超越了,单一装备的性能竞争,演变为从经济原理,持久作战能力,到战略自信的全方位压制,引发了拦截不起,消耗不起,补充不上的三重困局。以伊朗主力攻击武器,“见证者-136” 自杀式无人机为例,这款被戏称作,飞行摩托车的装备,彻底打破了传统军工的,精密制造范式:机身使用民用级玻璃纤维,和泡沫塑料拼装,动力系统,直接借用普通摩托车的,二冲程发动机,导航依靠,民用GPS模块与简易惯性制导,甚至部分核心零件,来自拆解的汽车和家电,从而极大压低了制造成本。凭借民用技术军用化,复杂系统简易化的理念,其批量生产单价,稳定在2万至5万美元之间,某些简化版本,甚至降至1.5万美元,仅相当于高端防空导弹成本的,极小一部分。美以用于对抗的防空武器,却如同天价奢侈品。以色列“铁穹”系统的拦截弹,作为近程防空核心弹药,每枚成本,达5万到10万美元,这意味着,即便成功拦截,防御方的经济损失,也远超进攻方。更甚者,美国“爱国者-3”拦截弹,作为中程防空主力,单枚成本飙升至,400万至500万美元,堪比用超级跑车,撞击普通代步车——无论胜负如何,防御方已在战略层面,沦为输家。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家8】
|
|
||||||
|
|
||||||
那么在效费比方面,如果说对方在进攻的过程当中,采用了很多廉价的方式,或者说很多省钱的这样的一些技术手段,像“见证者-136”,其实它就是飞行的小摩托,那么它的成本控制得很低,可能要用先进的防空反导系统对它进行拦截,而伊朗的一些地区盟友,包括黎巴嫩真主党,会使用大量的“卡桑”火箭弹,也就是说,更低成本的作坊工厂当中生产出来的这种火箭弹,针对以色列进行打击,那么色列方面使用的是什么,是这种“塔米尔”拦截弹,也就是“铁穹”系统的拦截弹,在这样的 一种相互的消耗战的过程当中,那么位列或者是美国,他所面临的这种昂贵弹药的快速消耗和短缺的情况,也会变得越来越多,也会变得越来越明显。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
由于战场上,攻击和防御的效费比例,严重失衡,防空弹药库存告急,形势十分紧迫,3月18日,以色列国防部火速向美国求援,美军立刻出动C-17运输机,紧急空运了近千枚拦截导弹,为以色列的库存注入强心针,与此同时,美军从韩国,亚太到欧洲多个据点,迅速调集防空系统,全力缓解战场上,防空装备和弹药,捉襟见肘的困境。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人 5】
|
|
||||||
|
|
||||||
现代防空系统,打造了一个从远程中段反导、近程区域防空,到近程末段拦截的完整链条,构建了远近交错、高低兼顾的立体防御天网,然而,高超声速武器如闪电般突破、无人机群的压制,以及饱和式攻击的浪潮不断袭来,让传统防空体系面临着严峻的考验,攻防成本的天平正在严重倾斜,威胁形势日新月异,如何在提升拦截效果的同时,勒紧成本的缰绳,变得至关重要,让防空网看得远、拦得准、扛得住、耗得起,成为了左右未来防空安全和战争胜负的关键棋局,好了观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 潜艇的仿生之路
|
|
||||||
编导: 穆佩弦
|
|
||||||
播出日期: 2026-01-27
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep002
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《潜艇的仿生之路》
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
在浩瀚海洋深处,数亿年的进化,塑造了无数神奇的攻防利器。如今的人类,用钢铁和电路,重新发明了这一切。当潜艇披上鲨鱼皮消声瓦,当声呐模仿海豚看穿黑暗,当乌贼的墨汁化作潜艇的电子替身,自然早已写好了深海中生存与猎杀的终极教科书。本期《军事科技》,带您见证茫茫深海中潜艇的仿生之路。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是蓝皓。在人类出现之前,深海就是地球上最残酷的竞技场。在这里,生存的法则只有两条:隐藏自己,发现敌人。深海依旧是地球上最后的黑暗疆域,潜伏着人类工程学的杰作,也是终极的刺杀兵器——潜艇。提到潜艇,想必大家并不陌生。这样一头深海巨兽,从诞生至今,都借鉴了大自然中的哪些动物的绝招呢?
|
|
||||||
|
|
||||||
从鹦鹉螺到“潜艇压载水舱系统”
|
|
||||||
|
|
||||||
|
|
||||||
## 【二维动画解说1】
|
|
||||||
|
|
||||||
1776年9月7日,历史上第一次潜艇攻击开始了。这次攻击,是由埃兹拉·李来执行。他驾驶着“海龟”号,成功潜到英国战舰“鹰”号的尾部。接下去的工作,就是用钻头在敌舰上穿孔,以便固定炸药包。他打钻的地方,正好是一块金属板。半个小时之后,他仍然没有钻透敌舰,只好上浮返回。虽然“海龟”号,没有成功取得战果,但它拉开了潜艇实战的序幕。从此,人类的战场,也随之从陆地、水面,发展到了水下。“海龟”号,也凭借与现代潜艇相通的设计原理,获得了世界上第一艘军用潜艇的称号,在世界潜艇发展史,占据了一席之地。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
“海龟”号潜艇,顾名思义,它的外形很像海龟。舱内空气可供驾驶员呼吸半个小时。在潜艇的上部,还装有两根通气管,以便上浮时打开,下沉时关闭,从而补充新鲜的空气。“海龟”号潜艇之所以能够成功地上浮下沉,离不开对一种美丽动物的深刻学习。它就是鹦鹉螺。鹦鹉螺,它是一种海洋中的动物,非常地漂亮,外壳非常漂亮。但是你要到鹦鹉螺的内部转一圈的话,它里边有很多的腔,就像我们在陆地上看到的一个一个的小房子。在它的腔里边,人类正是学习了鹦鹉螺的这种功能。在制造潜艇的过程中,就采用了水柜。水柜就是在这个潜艇的艇壳的里边,筑成一个一个一个方块的,就是类似于储水的柜子。那么需要沉底的时候,就向水柜里边注水,水多了,沉了,潜艇就下沉到海底。在亿万年的进化中,鹦鹉螺掌握了一种堪称完美的浮力控制艺术。它的螺旋形外壳,被一系列隔膜分隔成数十个独立的气室,如同一个精密的多舱式潜水器。鹦鹉螺能精确调节每个气室内液体与气体的比例。分泌液体进入气室时下沉,排出液体让位给气体时上浮。这种精妙的生理机制,让它能在数百米的深海与浅层水域之间自如升降,几乎不消耗任何额外能量。人类潜艇的压载水舱系统,正是对这一自然智慧的完美复刻。当潜艇需要下潜时,阀门打开,海水涌入压载舱,取代舱内空气,艇体重量增加。当需要上浮时,高压气体将海水强行排出,艇体重量减轻。1897年,一艘划时代的潜艇悄然下水,这就是约翰·霍兰以自己名字命名的霍兰潜艇。这艘潜艇汇集了众多开创式的设计。以往的潜艇,要么动力不足,要么水下姿态不稳,很难在实战中发挥较大的作用。霍兰首次将压载舱拆分为主舱与辅助舱。主压载舱负责核心沉浮,而辅助舱专攻姿态校准,能通过精准补水。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
维持平衡,精准调节潜深,解决了早期潜艇纵倾失控的难题。这套系统,是现代潜艇操控系统的源头。这一设计,让潜艇真正具备了实战价值,为深海作战开辟了道路。从霍兰潜艇压载舱的手动阀门,到现代潜艇压载舱的计算机控制高压气系统,其核心原理始终未变。正如鹦鹉螺用气体取代液体,潜艇用海水取代空气。这种能力,鹦鹉螺用了数亿年进化完成,而人类只用了不到一百年。
|
|
||||||
|
|
||||||
鲨鱼皮肤与潜艇的外形设计
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
在广袤的海洋中,生存,是一场关乎看见,与不被看见的永恒竞赛。为了在这场竞赛中胜出,无数的海洋生物,进化出了一套精妙绝伦的,色彩伪装策略,这便是隐蔽色。像虎鲸,鲨鱼,这样一生与海洋打交道的生物,其背部往往呈现深蓝,深灰色,而其腹部通常是白色,或者是银白色。我们经常能够看到,一只从深海上浮的虎鲸,其白色的胸膛,在来自下方的视线中,几乎消失在明亮的天光里;而一只从上方,发起攻击的鲨鱼,其深色的背部,直到最后一刻,才会被发现。这套系统简单而高效,本质上是一种光学隐身衣。它抹平了生物体,在三维空间中的立体感,极大缩短了,不被天敌或者是猎物,发现的距离。海军工程师很快意识到,这正是潜艇在海洋中生存,所需要的第一层能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
一战时期,潜艇多活跃于浅海区域,主要面临目视侦察的威胁。各国率先借鉴海洋生物的双色伪装逻辑,尝试用浅灰、绿蓝等颜色涂装艇身,试图让潜艇融入近海的蓝灰色海水。模仿的正是虎鲸腹部适配天光的浅色隐身思路。但早期设计未能兼顾深浅海环境差异,效果寥寥。到了二战,德军和英军则试验新的组合涂装。这些色彩既延续了上部深色、下部浅色的仿生核心,又试图贴合浅海藻类与海床环境。可一旦潜艇下潜深度增加,这套设计便迅速失效。阳光穿透海水层层衰减,深海本就是一片幽暗,浅色系艇身反而成为显眼目标。更致命的是,早期涂料易被海水冲刷氧化,很快褪成乳白色,让仿生伪装彻底失效。真正的转折来自人类对深海环境的深刻洞察。科学家发现,当深度超过200米,阳光完全消失,黑色成为海洋的天然底色。这与鲨鱼深色背部在深海中的隐身逻辑不谋而合。于是纯黑色涂层应运而生。它不仅能让潜艇在幽暗深海中完美融入环境,消除视觉轮廓,更凭借改良配方解决了褪色难题,成为潜艇光学隐身的终极方案。因为我们讲,涂层主要是为了颜色,也就是视觉上不被对手发现。那么很多的动物都有一层保护层。我们在建造潜艇的时候,也是从自然界或仿生学里边学到了如何变化颜色。因为颜色如果涂在潜艇上,是很难随着环境变化的。所以我们就要考虑潜艇它生存在一个什么样的环境里边。所以随着潜艇以后,也就是越来越先进,它下潜的深度越来越深,我们发现黑颜色在下潜以后不容易被发现,可以把自己隐蔽得很好。所以后来的潜艇,也就是在二次大战以后的潜艇,逐步逐步地就往黑色上靠近。而现在,世界各国的潜艇基本上全都涂成了黑颜色。然而在深海,仅仅看不见还远远不够。随着潜艇潜得更深,航行时间更长,真正致命的威胁开始从光线转移到声音。我听到它了,找到它了,目标很近,正右舷083。声音是深海中传播最远、最难隐蔽的信号。一丝多余的噪声就可能暴露位置,招来猎杀。于是,潜艇的隐身逻辑也从颜色升级到了形态。潜艇的外形并非一开始就是今天的样子。很长一段时间里,它更像是一艘能够下潜的水面舰艇。二战时期的潜艇需要频繁浮出水面航行,水下只是暂时藏身之所。因此艇体高耸,结构复杂,水下阻力大,噪声高。真正的转折发生在核动力出现之后。当潜艇可以长期潜航,人类第一次开始只为水下生存来设计它的形状。工程师将目光投向自然界。鲸类、海豚等高速游泳动物几乎都拥有同一种外形:前圆后尖的水滴形。1950年,美国“大青花鱼”号实验潜艇验证了这一答案。水滴型艇体显著降低阻力和流噪,让潜艇更快、更安静,也难被发现。从此,现代潜艇的外形几乎统一。当潜艇的外形已经足够像一条鱼,真正的较量才刚刚开始。接下来要隐藏的不再是轮廓,而是声音本身。生物界,鲨鱼的速度非常快,因为在水中。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
它可以以非常高的速度前进。实际上,它除了形体的这种流线型以外,还有就是它的皮肤。为什么我们很多的游泳运动员,所采用的游泳衣或游泳裤,采用的是叫所谓鲨鱼皮呢?就是借鉴了鲨鱼的,它皮肤上的这种V字形。也就是它在前进的时候,水流是顺着箭头的方向向后流的,减少了阻力,减少了就是水与鲨鱼皮肤的摩擦的声音,然后使它快速地前行。既减少了阻力,又降低了声音。所以我们在建造潜艇的时候,也借鉴了这样的情况,或者是说借鉴了这种功能。我们在材料上,采取一些特殊的材料。因为在深海大洋里边,这种材料,就是我们讲的这种消声瓦。因为消声瓦既要保证它在水中,能够把噪音裹在潜艇里边,不被对方探测到,同时它也要像鲨鱼的皮肤一样,有一些减少与水的这种摩擦和阻力,来使这个声音降低。也就是潜艇在高速航行的时候,要降低它与水的摩擦的声音。这样的话可以进一步地,使我们的潜艇,就是像鲨鱼一样,悄然无声地在水中行进。海豚的皮肤,能够分泌一种特殊黏液,使水流紧贴体表滑行,从而减少湍流与阻力。而更为人熟知的,是鲨鱼的皮肤结构。鲨鱼体表,覆盖着无数微小的V型盾鳞。这些看似粗糙的纹理,反而能打散涡流,抑制水流紊乱,大幅降低摩擦阻力。潜艇工程师,从中得到启发。在现代潜艇的消声瓦表面,常会设计出类似的微沟槽,或者细微纹理。这种被称为沟槽面,或仿鲨鱼皮的结构,并不仅仅是为了让潜艇航行得更省力,更重要的是,它能够有效降低水流掠过艇体时产生的流噪声,让潜艇在深海中,变得更安静,也更加致命。海豚在浑浊的海水中,能够准确地找到小鱼,还能分辨出哪条更加肥美。这种能力来自它们独特的水下视觉生物声呐。当海豚发出咔嗒咔嗒的声音时,这些声波,就像无形的探照灯向前发射。声音遇到物体产生回声,海豚通过下颚。
|
|
||||||
|
|
||||||
海豚声呐与潜艇综合声呐系统
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
接收这些回声,就能在脑海中,描绘出周围环境的立体图像。它们能够听出,藏在泥沙里的两条鱼,哪条更加肥美,前方是岩石还是海草。这种能力,让任何人工声呐都望尘莫及。人类从海豚身上,获得了灵感,发明了声呐技术。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
二战爆发后,德国U型潜艇,再次成为海战中的核心力量。在广阔的大西洋上,它们潜伏在航道附近,利用夜色和水下掩护,对盟军运输船队,发动突然袭击。单艘潜艇就足以瘫痪,一整条补给线。对英国而言,这不仅是军事问题,更是生存危机。装备,军需,几乎全部依赖海上运输,而水下的敌人却始终看不见。面对潜艇带来的巨大压力,英国海军被迫改变思路,不再依赖目视搜索,而是尝试在深海中,倾听敌人的存在。以水听器为基础,配合主动声波探测,声呐系统,逐步成型,并率先装备在,驱逐舰和护卫舰上。在护航战例中,第一次实现了对潜艇的,稳定定位,使深水炸弹不再是盲目投掷,而成为有目标的打击。随着反潜技术的成熟,曾经游刃有余的U型潜艇,开始频繁暴露行踪,损失不断增加。大西洋的海战,从潜艇的单方面猎杀,逐渐演变成一场,围绕隐蔽与探测的,技术对抗。而这一技术,正是源自大自然中的,生物声呐。那么说到声呐,就涉及到一个有趣的知识。潜艇在水下航行时,由于水的密度,是空气密度的大约800倍,导致雷达电磁波,通讯电磁波,无法穿透海水。严谨地来说,就是被海水快速地吸收,传不远。因此,声呐就成为了潜艇的主要探测工具。声呐的本质,是一个经过微调的耳朵。它的工作原理,类似于鲸鱼或海豚的回声定位。根据工作类型,声呐可分为,主动声呐和被动声呐。主动声呐模式,需要潜艇主动发射声呐信号,声呐信号接触到目标后会反射回来。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
再被潜艇捕捉,但在战争中,这不是一个明智的决策。因为这种探索,就像黑夜中打开手电筒,照亮目标的同时,也暴露了自己。而被动声纳模式,是静默航行,利用被动声呐基阵,接收对方的主动声呐探测信号,来确定对手位置。被动声纳模式下,潜艇不会暴露目标,隐蔽性好。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
声呐,作为动物界里边,海豚,是有它特异的功能。因为海豚,它是从嘴里,发出不同的声响,然后声响传出去以后,如果撞在礁石上,就是海底的礁石上,会反射回来一个回波。它用嘴巴下面的接收器,去把这个声音接收到,然后判定这是石头还是鱼类。如果它的发出去的声音,撞上的是鱼类,它的回声,是和撞在岩石上的声音不同的。而且它收到的回波,也就是声音传播,回波在水里传的,因为很远,它接收下来就可以分辨出来,这是它捕食的猎物了。那么既然,海豚有这么好的这种功能,也就类似于声呐的功能,人类就向它们学习,就仿制了这种声响,这就是主动声响。也就是当我的潜艇,航行的时候,我不断地通过,我的声呐向外释放声波。当这些声波,放到海洋里边去之后,它会一去不复返。但是如果这些声波,撞击到了,对方的潜艇或者是舰艇,或者是海底的这些礁石,它反射回来的声波是不一样的。这样就凭借我们声呐兵,来收到的回波,判断前面是金属,还是石头还是洋流。这是一艘,弗吉尼亚级攻击核潜艇。潜艇的艇首,布置着大型声呐阵列。在早期批次上,它呈现为球形结构。它始终对前方海域,进行持续探测。通过分析回波的时间与特征,潜艇,能够判断目标的距离,方位,并对水下接触进行初步识别。沿着艇体左右两侧,分布着舷侧声呐阵列。海豚身体两侧的侧线系统,负责监听来自各个方向的,细微声学变化。即使潜艇保持静默状态,不主动发声,这些阵列依然能够捕捉,远处螺旋桨的低频噪声,以及水流扰动形成的异常信号。在潜艇尾部,一条细长的,拖曳声呐阵列被放出。这是弗吉尼亚级,最重要的远程感知手段之一。它远离艇体自身的噪声干扰,将潜艇的声觉,延伸到更远的海域,用于发现极其微弱,距离极远的水下目标。艇舯声呐,舷侧阵列,与拖曳声呐协同工作,使这艘潜艇,即使在完全隐蔽的状态下,也能构建起一幅,连续立体的水下态势图。当生存受到直接威胁时,顶尖的猎手,与最先进的武器,都诉诸同一种策略,制造混乱,隐藏本体。当乌贼遇到危险时,会迅速喷出浓密的墨汁。它并非简单的黑色液体,它在海水中会迅速扩散,形成一个与自身形态相似的,浓密的墨汁伪影。这个深色轮廓能够立即吸引,并且锁定捕食者的,视觉注意力,为真身的逃离,创造关键的时间窗口。潜艇工程师,完美地借鉴了这套。
|
|
||||||
|
|
||||||
乌贼墨汁与声学诱饵
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
复杂的求生系统,并将其转化为防御装备:声学诱饵。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
潜艇感知到来袭的声自导鱼雷,或者敌方主动声呐锁定时,会从发射管快速射出声学诱饵。它是潜艇的一种高电子替身。声学诱饵通常是一个自航式的水下航行器。被发射后,它会精确模仿母艇的发动机噪声、螺旋桨节拍等独特的声学指纹。还会主动航行,制造出比气幕弹更像一艘真实潜艇的移动声学目标。来袭的智能鱼雷能够区分简单的气泡幕和复杂的潜艇噪声。声学诱饵通过发出更具吸引力的听觉陷阱,引诱鱼雷偏离航道,转而追踪并攻击这个替身。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
而这些诱饵的发现,实际上就是根据海洋动物,它一些保护自己的这些方式,来学习的,因为它们要么金蝉脱壳,要么以假乱真。所以我们看,随着人工智能的发现,随着我们无人装备的发现,我们就制造了,不同的这种诱饵,主动的,被动的。总之当发现潜艇,被攻击或被人发觉的时候,就扔出一个假目标,向不同的方向,甚至是相反的方向来行进,让它的鱼雷,让它的追踪的设备,去跟踪假目标,而真正的潜艇,悄悄地就逃生了。控制室,这里是声响室,水中有鱼雷,方位241,高速前进,右满舵,找到它了,目标很近,正右舷083,发射5英寸的反鱼雷诱饵,发射反鱼雷诱饵。这套系统,在实战与对抗演练中,屡建奇功,其核心战术,都与数百万年前的乌贼,如出一辙。乌贼用一团墨汁,换取生存的时间,人类则用电子替身,把这一秒无限放大。当深海的幽灵收起獠牙,当猎杀者的声呐归于沉寂,这片蔚蓝疆域的博弈,却从未停止。从鹦鹉螺的浮力控制,到核潜艇的无限续航,从乌贼的墨汁,到潜艇的电子诱饵,我们见证了一场,跨越数亿年的,军事科技进化。自然用漫长的时光,雕琢出生存的智慧。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
人类用智慧在钢铁中复刻这些法则。这不仅是矛与盾的较量,更是对生命本身最深刻的学习。下一场改变游戏规则的技术革命,或许正在随着某只深海生物的游弋,在黑暗中悄然酝酿。感谢您持续关注国防军事频道《军事科技》,我们下周同一时间再见。
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 枪械设计中的小尺寸大讲究
|
|
||||||
编导: 孙逸昊
|
|
||||||
播出日期: 2026-03-31
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep003
|
|
||||||
---
|
|
||||||
|
|
||||||
# 枪械设计中的长短智慧
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
藏于方寸的敌后利器,锋芒毕露的火力先锋,穿梭街巷的近战精灵,主宰阵地的压制王者,同为枪械家族成员,为何长度取舍各不相同?每一寸伸缩,都是对实战需求的精准回应;每一款设计,都凝结着军工领域的深邃考量。《军事科技》带您解码枪械设计背后的长短智慧。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。提到枪械,威力、精度,往往是我们关注的焦点,但是,有一个看似基础,却至关重要的设计,始终左右着枪械的实战性能,它就是长度。今天要聊的枪械,每一款的长度都大不相同,有的枪械要极致缩短,甘愿舍弃部分舒适与功能,有的却要刻意加长,坦然放弃部分便携性。首先我们来看手枪。首先出场的是,短到可以塞进火柴盒里的,美国FP-45解放者手枪。我手里的这款模型,是为了让大家看得更加清楚,所以做了放大。据资料显示,它的实际长度只有141毫米,比我手掌的宽度还要短。大家仔细来看它的结构,握把的下半部分是中空的,打开滑动挡板,能够装10发.45 ACP备用弹。瞄准具也非常的简单,只有在枪管的前端,有一个小小的片状准星,在尾端挡板上,有一个切削的简易缺口,不仔细看几乎发现不了。当然,不能说它没有瞄准具,做到了极致的简单。更特别的是,它没有半自动的功能,每打一发,都得手动把尾部的待击块,然后再向左旋转,才能装下一发弹。那么如此短小的设计,却让这款不起眼的小手枪,成为了敌后斗争的特殊利器。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
这是一把用来换取更好枪支的武器,一个构思独特、简单,却又精妙绝伦的设计。FP-45,绰号解放者,是一款外形笨拙的手枪,却从未真正投入过实战。解放者算是最酷的未投入使用枪支之一。该枪诞生于二战时期,是针对德国和日本开展的一项秘密心理战行动的产物。其设计理念是制造一种小巧廉价的枪械,让盟军可以成千上万地空投给欧洲和亚洲的抵抗战士。设想让当地人捡到这种枪后,能悄悄接近德国或日本军官,扣动扳机将其击毙,然后夺走对方的枪支。每把FP-45解放者会连同10发.45 ACP弹装在纸盒里,盒内的绘画说明书就算不识字的人也能看懂操作步骤。这些“火柴盒杀手”被大量投放到敌后,抵抗组织成员用它近距离突袭德国哨岗后就随手丢弃,即便被缴获,也能对对方产生极大的心理威慑效果——当成千上万的FP-45被德军发现,他们便会心生疑惑:天知道当地人手里到底还有多少把?它是在二战期间比较特殊的这样的一款手枪,主要是秘密携带,秘密使用。它完全是采用这种单发的这样的一个设计。这个退壳也没有特别的这样的一个设备,必须要单独配一个小木棍,把使用之后的这个弹壳再把它给捅出来,所以操作起来是比较复杂的。但是,解放者这款手枪最大的优势就是结构简单,不需要经过专门的培训,任何人都可以对它进行使用。甚至这个枪管非常的短,连这个膛线都没有,在近距离可以针对人员构成比较大的杀伤。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
如今FP-45,成了收藏家眼中的珍品,它短小的尺寸,不是偷工减料,而是战略需求优先的必然结果,少一毫米,都可能影响空投效率,多一毫米,都可能失去隐蔽性。接下来我们看一看手枪中的,大家伙,它就是沙漠之鹰.50AE,它全长约273毫米,几乎是FP-45的两倍,仅枪管长152毫米,比FP-45的整枪还长11毫米,握在手里能明显感觉到它的分量,它完全放弃了手枪的便携性,所有的设计都为了适配,.50AE大口径弹药的,极致火力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
沙漠之鹰,是当今世界上最知名的,大口径手枪之一,从射击游戏到电影银幕,沙漠之鹰表现的强大威力,让其成为许多人的最爱。20世纪80年代,欧州狩猎运动兴起,面对熊、野猪等大型猛兽,传统9毫米,或.45口径的威力,根本不足以致命。在反恐作战中,警方和特种部队,也需要一种,能击穿轻型防弹衣,车辆挡风玻璃的便携武器。随着.50AE弹药的出现,让这些需求,有了实现的可能,这种弹药的弹头重量,超过15克,动能,是普通9毫米手枪弹的3倍以上,但是,也对枪械设计,提出了严峻挑战。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画1】
|
|
||||||
|
|
||||||
大口径弹药燃烧时,会产生巨大的高温高压,必须有足够长的枪管,才能让火药充分燃烧。沙漠之鹰152毫米的枪管,能让弹头初速,达到每秒402米,比短枪管版本提升15%以上,有效射程,也从50米延伸到100米。随之而来的是巨大后坐力,需要273毫米的枪身,和两千克的重量来平衡。如果枪身过短,射击时枪口会剧烈上跳,甚至可能伤到射手的手腕。生产商还提供,254毫米枪管的加长版本,全长会达到374毫米,但因便携性太差,只有少数狩猎爱好者才会选择。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
如今,沙漠之鹰虽未大规模列装军队,却因独特的外形,和强大的威力,成为影视剧中的常客,用极致的长度,诠释了,火力至上的设计理念,也成为枪械文化中,力量象征的代名词,为什么说要把它的枪管,设计得比较长,因为它弹药的尺寸比较大,另外一个本身,在射击的过程当中,为了保证精度,为了保证可靠性,所以它整体的这个(重量),是比较重的,重达两公斤,正常人对它进行持握射击,确实是很费劲儿的,沙漠之鹰它是采用导气结构,这一点和步枪是非常相似,能够保证,它发射大威力的弹药,但是在实战的过程当中,要进行短时间内的快速射击,这个就很难打得准,因为它的后坐力,是比较大的,所以从它的设计理念来看,它更像是靶场手枪,或者是狩猎手枪,在军警部队当中进行使用,首先是不便于携带,另外一个枪体太重了,那么在操作使用的过程中,会带来一系列的麻烦。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
一款手枪能藏进火柴盒,一款堪称手枪巨无霸,FP-45和沙漠之鹰,用132毫米的长度差,精准命中了两种极端需求。它们的设计看似矛盾,却都在各自的场景下,实现了长度与功能的最优匹配。接下来,我们把目光转向冲锋枪家族,看一看它们的长度差,又藏着怎样的战场逻辑?先看我手里的这把PP-2000冲锋枪,这是我见过最“露骨”的一款冲锋枪了。同样,为了让大家看清楚,这款模型也做了一点放大。可以看到,它的枪机前半部分,完全是裸露在外的。根据设计方的官方参数,它的全长仅340毫米,比常见的500毫升矿泉水瓶,就长那么一点,刚好能够放进战术背心的口袋里,单手就能握住操作,兼顾近战火力和一定的有效射程,不会因为追求短尺寸而牺牲核心威力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
PP-2000的设计,源于俄罗斯在城市反恐作战中遇到的实战痛点。20世纪90年代,车臣战争中的巷战让俄军深刻体会到:传统冲锋枪过长的枪身在狭窄的楼道、房间中根本无法灵活转向,士兵在破门后,常常因枪身剐蹭门框而延误举枪时机,甚至被敌方近距离突袭。为了解决这一问题,图拉仪器设计局从1993年开始研发紧凑式冲锋枪,核心目标就是在保证火力的前提下,把长度控制在350毫米以内。为此,设计师采用了多项针对性设计。PP-2000冲锋枪的设计非常小巧精妙,整枪上下两个部分可以快速拆开,整体部件非常少,便于维护保养。PP-2000冲锋枪的枪机前部分暴露在机匣外,并兼作上膛拉机柄,在枪机连杆的前方设有一个铰接手柄,当需要拉动的时候可以扳起向左或向右旋转,然后再拉动连杆进行上膛操作。这种枪机外露的方式,不仅能够减少整枪的长度,还可以让枪机和枪管的一部分直接暴露在外面,有利于整枪的散热。PP-2000冲锋枪配有两种容量的弹匣,一种是20发,另一种是44发,供弹弹匣插在握把内,而在枪尾还有一个弹匣插口,把不用的弹匣插上去,便可以当作枪托来使用。量产型版本则增加了折叠式枪托,这个可选配件也是像弹匣一样插到枪尾上使用。全枪的重量非常低,而且非常的短,特别适合特种部队或者是强力部门的执法人员去使用,可以在近距离的过程当中针对对方进行密集的火力压制。由于它的枪身比较短,其实也适合乘车作战的部队,比如说坦克装甲车的这个车组乘员进行佩戴和使用,作为一种自卫武器。当然了战斗机的飞行员和直升机的机组乘员也可以把它作为一种大威力的自卫武器,因为它的持续的火力压制能力要比一般的手枪要强很多,而且和这种短的冲锋枪相比,它的枪身确实更短,实现了最大程度地减重。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
用340毫米的紧凑长度,加上暴露枪机设计,把巷战灵活做到了极致,短身带来的机动优势,在狭小空间里无可替代。接下来我们看一把长尺寸的冲锋枪,它就是MP40冲锋枪。根据二战时期的德军技术手册记载,它折叠后全枪长630毫米,比前面讲到的PP-2000长近100毫米,当它展开之后,长833毫米。我抵肩握住它,能明显感觉到枪身特别舒展,枪托与肩部的贴合度很好,250毫米的枪管让枪口在射击时跳动很小,在二战的平原和阵地战中特别实用,士兵在冲锋的时候能够稳定抵肩射击,精准压制200米内的敌方目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
MP40是二战期间产量较大、影响力较广的一款冲锋枪,其因结构相对简单、稳定性强,成为当时多个作战单位的装备。二战前,德军步兵班组的火力核心是毛瑟98k栓动步枪和MG34重机枪,冲锋枪仅作为辅助武器。第二次世界大战爆发后,纳粹德军强调快速推进、近距离火力压制的理念,这就需要一种能伴随步兵冲锋、兼顾射程与精度的武器。而传统冲锋枪,如MP18,尺寸虽短,但枪管仅198毫米,有效射程仅100米,无法满足压制需求,而MP40很好地解决了这一问题。MP40冲锋枪的设计围绕中近距离持续压制展开:250毫米的枪管采用镀铬工艺,能让枪口初速提升至每秒380米,有效射程达到200米,比MP18提升一倍;833毫米的枪身长度,让士兵在抵肩射击时能更好地控制后坐力,配合32发弹匣,能形成持续的火力输出。更值得一提的是,它的生产工艺特别适合战时量产:枪身采用冲压和焊接技术,零件数量比MP18减少40%,一个熟练工人每天能组装10支,到1944年累计生产了120万支。833毫米的长度,适配了二战时期开阔战场的作战需求,也成为了二战中纳粹德军步兵火力支柱的标志性武器之一。MP40是在MP38的基础上改进升级而来的。可以说在二战期间,在德国军队的武器装备当中,它是一款设计比较优秀的冲锋枪。优先装备的是德军的装甲部队和它的空降部队,也就是伞兵。装甲部队和伞兵在使用的过程中,就是要求在近距离针对对方的作战人员短时间内倾泻大量的弹药。所以我们看这个MP40,它的弹匣的长度是比较长,所以能够在近距离,比如说100到150米的这样的一个距离上,针对对方进行这种持续的这样的一个射击。那么采用这种比较长的枪身和枪管,就是要保证射击的精度。所以,在近距离射击的过程当中,MP40它的精准程度也是比较高的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
从340毫米的紧凑灵活,到833毫米的稳定压制,冲锋枪的长度选择,本质上是对作战空间和战术节奏的精准回应。短有短的便捷,长有长的稳定,这正是枪械设计中长短智慧的核心之一。接下来,我们看一看步枪家族。先来看我手中的这把AUG突击步枪。大家有没有发现,它的外形非常的独特?没有传统步枪的枪托,弹匣直接插在了扳机后方。根据奥地利斯泰尔公司的官方数据,它的全长只有665毫米,比折叠后的MP40冲锋枪长不到40毫米。大家可别被它的短身所迷惑了,它的枪管长度足足有508毫米,和传统M16A4步枪的枪管几乎是一样长的。设计师用无托结构,硬生生在不缩短枪管的前提下,把枪身缩短了将近340毫米,真正实现了短身不短能。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
AUG的出现,掀起了步枪设计的一场结构革命。随着城市化进程加快,传统长枪管步枪在城市作战、装甲车内部操作时的弊端日益凸显:在楼宇作战中,过长的枪身导致转向不便,甚至影响战术动作。各国都在寻找一种能兼顾射击精度与机动灵活的步枪,但传统有托结构的设计逻辑是枪管长则枪身长,似乎无法突破这一局限。奥地利斯泰尔公司的设计师大胆创新,提出了无托结构的理念。AUG突击步枪将枪托与机匣融为一体,让射手的肩部直接抵在机匣尾部,这样一来,枪管的长度不再受枪托位置的限制——508毫米的枪管能保证5.56×45毫米弹药的初速达到每秒970米,有效射程600米,与传统步枪持平;而665毫米的全长,让士兵在装甲车内能轻松操作,在楼宇中能快速转向。更创新的是,AUG采用了模块化设计,枪管可以快速更换,能在几分钟内切换成步枪、卡宾枪、轻机枪甚至狙击枪,适配不同作战场景。首先我们看它是大量采用了这种塑料和合成材料,所以全枪可以有效地实现减重。士兵在携带和使用的过程当中,可以最大程度地节省体力。由于采用了这种无托化的设计理念,它全枪的重量和长度是可以进行进一步优化。但是,由于采用无托的设计理念,也可以保证它的枪管比较长,这样的话无论是有效的射击距离和它的这样的一个火力的持续性,其实是达到了一种均衡的状态。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人6】
|
|
||||||
|
|
||||||
AUG用无托枪身的设计,真正做到,短小精悍。接下来我们看一看,长家伙。下面要讲到的这款步枪,就是大名鼎鼎的。根据德国黑克勒-科赫公司的官方参数,HK416在配备20英寸枪管之后,全枪长可达1030毫米,比AUG足足长了365毫米。长长的枪身,给可伸缩枪托、全长度的皮卡汀尼导轨和活塞导气系统,留出了适配的空间。大家看它的顶部导轨,能从头到尾加装瞄准镜、激光指示器,护木两侧还能扩展战术握把、手电,完全是为现代特种作战多任务、多环境,量身打造的长枪身设计。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
如果你曾接触过枪战类游戏,那么你一定知道,这把突击步枪。在各种枪战游戏中,这把枪的性能排名,都是稳居前列。近几年爆火的吃鸡游戏玩家,更是将其称之为“吃鸡神器”。在游戏中,只要能装备上M416,便能无往不利,所向披靡。而现实中,这把枪的原型便是HK416。HK416的诞生,源于现代特种作战对可靠性,与多场景适配的极致追求。21世纪初,美军特种部队在全球反恐作战中发现,M4A1卡宾枪的气吹式导气结构,在沙漠、湿热等恶劣环境下,容易积碳故障,且短枪管在中距离压制时,精度不足。于是,黑克勒-科赫公司在M4A1基础上,重新设计改造出了HK416。HK416自动步枪长805毫米,既保证了抵肩射击的稳定性,又能通过伸缩枪托适配不同身高士兵;5.56×45毫米北约弹初速达到每秒900米,有效射程600米,比M4A1提升10%。其最新改型HK416 A5提供5种枪管选择,分别是11英寸、12.5英寸、14.5英寸、16.5英寸和20英寸。这让HK416既能在城市反恐中灵活操作,又能在沙漠、平原等开阔地形提供远距离火力支援。HK416自动步枪最经典的实战案例,是在2011年“海神之矛”行动,海豹突击队队员正是用HK416击毙本·拉登。当时,队员在狭小房间内完成近距离突袭,后续撤离时,又在开阔地带用其压制可能的追兵,证明其能同时适配近距离突袭和中距离压制双重需求。如今,被三十多个国家的特种部队列装,法国、挪威等国更将其定为全军制式步枪,成为现代步枪的代表之一。和AUG相比,HK416最大的优势就是它的适应性比较好,可以衍生出很多的这种型号。HK416它的皮卡汀尼导轨的这样的一个安装也比较方便,在枪管外面的护套上,它可以安装四条皮卡汀尼导轨。也就是说,不仅可以加装战术手电,或者是这种激光的指示系统,还可以加装外挂的榴弹发射器,所以HK416既可以作为普通士兵所使用的突击步枪,也可以作为精准射手步枪,进行这种精准的火力支援。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人7】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人9】
|
|
||||||
|
|
||||||
今天我们展示的每一款枪械,它们没有绝对的优劣,只有是否贴合使命的区别,这正是枪械设计中,最珍贵的长短智慧。长度从来不是孤立的数字,而是设计师平衡使命需求、核心功能、使用环境的结果,每一寸增减,都凝结着对战场的深刻理解,和对功能的极致追求。好了,观众朋友们,感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 枪王对决_卡拉什尼科夫与尤金斯通纳的设计传奇
|
|
||||||
编导: 左鑫
|
|
||||||
播出日期: 2026-05-26
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep004
|
|
||||||
---
|
|
||||||
|
|
||||||
# 枪王对决:卡拉什尼科夫与尤金·斯通纳的设计传奇
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
他们的一生是创造的一生,他们的灵感,或许来自不经意间的触动,他们的成就,却改变了战争形态,甚至改变了历史。《军事科技》,就让我们借助AI时空复原技术,走进两位枪械大师,不为人知的人生故事,触摸藏在钢铁枪械背后的,柔软与坚守。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。回望轻武器的发展史,有两个名字,注定被世人永远铭记。一位是从二战硝烟中走来,心怀悲悯的苏联老兵,米哈伊尔·季莫费耶维奇·卡拉什尼科夫。另一位,是深耕航空机械领域,以极致突破传统桎梏的工程师,尤金·斯通纳。他们中一位设计了皮实耐用,风靡全球的AK枪族,一位开创了模块化,小口径先河的枪械体系。那么,是什么样的原因,让两位天才设计师,走上了枪械研发之路呢?在战火洪流,与时代浪潮的裹挟之下,他们的设计理念,又有何区别呢?本期节目,我们依托AI数字技术,穿越时空,回溯历史。循着两位大师的自传手迹,与生前专访,还原真实的枪械宗师人生,解锁两种风格迥异,却同样著名的,工业设计。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
2013年12月23日,俄罗斯枪械设计大师,AK-47步枪之父,卡拉什尼科夫,在刚度过94岁生日后病逝。鉴于他对俄罗斯,乃至世界武器设计领域的卓越贡献,俄罗斯为其举行了国葬,总统普京亲自出席。前总理梅德韦杰夫,沉痛表示,卡拉什尼科夫的离世,是整个国家难以弥补的巨大损失。卡拉什尼科夫设计的,AK系列步枪,是全球最广为人知,且使用最普遍的武器。其核心优势在于操作简便,可靠耐用,加之价格低廉,在不少国家,甚至低于一只活鸡的售价,仍被众多国家和地区,广泛使用。这是2009年11月10日留存下的一段,珍贵历史影像。既是卡拉什尼科夫90岁寿辰,也是AK-47,诞生62周年纪念日。俄罗斯政府,在克里姆林宫,为他举办了盛大的生日招待会。时任总统梅德韦杰夫,亲手将代表国家最高荣誉的,俄罗斯英雄金质奖章,授予卡拉什尼科夫,并盛赞他,创造了令全体俄罗斯人,引以为傲的民族品牌。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
卡拉什尼科夫,于1919年11月10日,出生在苏联阿尔泰边疆区的一个普通农家。1930年前后,苏联开展农业集体化运动,并且推行消灭富农政策,他们家被定性为富农,年仅11岁的卡拉什尼科夫,随家人强制流放。1936年毕业后,卡拉什尼科夫在铁路系统找到了工作。正是在这段时间,他逐渐对机械构造,产生了浓厚的兴趣。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
少年时期的卡拉什尼科夫,最大的梦想,并非设计枪械,而是制造农用机械。在自传《我的枪中人生》里,他曾写道:“我小时候最大的愿望,就是造出一台更高效的割草机、一台更省力的播种机,让乡亲们不用再顶着烈日在田里劳作,让土地长出更多粮食,让大家都能吃饱饭。”
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-主持人1】
|
|
||||||
|
|
||||||
卡拉什尼科夫先生,您年少时,一直想要钻研农用机械,当时有没有便利条件呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-卡1】
|
|
||||||
|
|
||||||
当时是没有的,我的家庭是比较贫穷,家里面的孩子也比较多。你可以看到,远处那几间简陋的房子,当时是我的家。虽然这里的环境优雅,但是相对于来说,比较偏僻闭塞。直到1936年,我才第一次见到了蒸汽机车。读到了七年级的时候,我就被迫辍学了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-主持人2】
|
|
||||||
|
|
||||||
辍学之后,您依旧没有放弃,对机械的研究,对吗?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-卡2】
|
|
||||||
|
|
||||||
是的,其实我打心里,非常地喜欢机械。这个时候,我就自己开始学习,机械的相关知识。平时,我也会把别人,丢掉的一些机械零件,捡回来,然后一点一点地拆解,反复去研究里面的结构。我还亲自动手,制作了一些简单的小型农具,小型的机械,一点一点地摸索机械的结构原理,为我后来的设计之路,打下了基础。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
1938年,19岁的卡拉什尼科夫应征入伍,成为苏联红军的一名坦克兵。凭借对机械构造的熟悉,他常帮战友修理坦克、枪械等装备,很快便成了部队里的机械能手。然而,平静的生活在1941年被彻底打破。这一年,纳粹德国突然入侵苏联,卫国战争全面爆发,残酷的东线战场彻底改写了卡拉什尼科夫的人生轨迹。作为坦克兵,他随部队奔赴前线,参与了无数次激烈战斗。1942年10月的一场激战中,卡拉什尼科夫所在的坦克被德军炮弹击中,他的肩部受了重伤。更让他痛心的是,他发现许多战友并非败于敌人的炮火,而是栽在了手中劣质且故障频发的制式枪械上。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-主持人3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-卡3】
|
|
||||||
|
|
||||||
当时战场的情况,可以说是糟透了,我们根本没有还手之力,只能硬扛。德军的士兵,普遍装备了自动步枪,火力是又连贯又凶猛,我们前线的战士是,完全被压制的。当时我躺在病床上疗伤的时候,战场上的画面,就不断在我脑海当中回闪,战友们牺牲的场景,枪械卡壳时候的无助,日夜煎熬着我。我每天都在反思,那些英勇的战友,不应该因为武器的不可靠,而白白丧命。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-主持人4】
|
|
||||||
|
|
||||||
也就是在那个时候,您下定决心放弃了农机梦想,转而研发枪械的吗?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1-卡4】
|
|
||||||
|
|
||||||
这个念头一旦产生,就永远没有办法消散。我变得越来越着迷,伤病还没有痊愈,我就找来笔记本、铅笔、橡皮,一笔一画勾勒出,我想象当中步枪的草图。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
经过刻苦钻研,卡拉什尼科夫先后设计出冲锋枪和轻机枪,并于1946年设计出一款新式突击步枪,并将其送往国家靶场参加选型竞赛。这款步枪一路过关斩将,最终赢得了竞赛胜利。仅为上士军衔的卡拉什尼科夫一举成名,那一年他27岁。1947年,这款步枪被正式定为苏军制式装备,命名为AK-47突击步枪。AK-47问世之初,苏联军方在生产过程中对其设计严格保密,就连用过的弹壳都必须回收。直到上世纪60年代,它的设计秘密才公之于众。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
在卡拉什尼科夫3岁那年,大洋彼岸的美国,一位天才枪械设计师悄然降生。伴随着他的成长,一场足以颠覆全球轻武器发展格局的行业变革悄然酝酿。这位引领时代风潮,被誉为现代突击步枪之父的传奇巨匠,就是尤金·斯通纳。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
1922年11月,尤金·斯通纳出生于美国印第安纳州的一个普通家庭,随家人迁居加利福尼亚州。高中毕业后,因父亲无力供他继续深造,斯通纳只能辍学打工。幸运的是,他被当时的维加飞行公司,即后来的洛克希德·马丁公司录取。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-主持人1】
|
|
||||||
|
|
||||||
您刚进入航空企业的时候,从事的是什么工作?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-斯1】
|
|
||||||
|
|
||||||
我刚刚入职的时候,还是做一些非常基础的工作,比如说像航空机械装配,飞机结构加工,航空零部件钳工一类的工作。当时我非常知道自己,学历不是很高,所以,一旦有学习的机会,就格外地珍惜。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-主持人2】
|
|
||||||
|
|
||||||
那想必过程是非常艰辛的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-斯2】
|
|
||||||
|
|
||||||
对,当时确实非常辛苦。只要有业余的时间,我就会去上夜校,去航空学院旁听专业的课程。当时的经济条件也不是很好,只能一边打工,一边自费求学。为了节省一些通勤时间,能够获得更多的学习机会,我干脆就在学校附近,搭了一间非常简易的房子去居住。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
日本海军,突然地攻击了夏威夷群岛。1941年12月7日,日本海军舰队偷袭珍珠港,美国对法西斯国家宣战。获得这一消息的斯通纳,怀揣着一腔热血报名参军,成为了一名海军陆战队队员。由于他在维加飞行公司,学习了许多有关飞行机械的技术,被分配到海军陆战队加利福尼亚埃尔托罗航空站,负责维护螺旋桨战机。斯通纳先后在冲绳岛、菲律宾群岛以及中国北部参与作战。在这个过程中,他接触并熟悉了战斗机上配备的12.7毫米口径机枪和20毫米口径机关炮,为日后投身枪械设计,埋下了伏笔。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-斯3】
|
|
||||||
|
|
||||||
战争结束后,我就光荣退伍了,先后在多家知名的航空企业工作,深度参与到各类航空部件的设计与研发当中。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
但二战结束后,战争形态发生了彻底变革,堑壕战成为历史,丛林作战、机械化机动与快速反应作战成为主流。为适应新时代作战需求,美军方提出了全新的轻武器研发诉求,需要一款轻量化、高精度、人机适配性好、可多功能改装的新型单兵武器。在这样的背景下,深耕精密机械设计、拥有丰富航空制造经验的斯通纳,承接了这一时代课题,正式跨界投身枪械设计领域。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-斯4】
|
|
||||||
|
|
||||||
当时我亲眼目睹了我们的士兵,在作战当中的种种困难。当时给他们列装的武器,非常地笨重烦琐,并且操作也不方便,负重非常大。这让他们的作战灵活性,是大打折扣,完全跟不上现代战争的节奏。身为一名工程师,我非常清楚自己有能力,可以优化这些难题。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-主持人3】
|
|
||||||
|
|
||||||
所以说航空技术,成为了您设计M16的独家优势吗?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2-斯5】
|
|
||||||
|
|
||||||
是的,航空制造领域,追求的是轻量化、高精度,以及高稳定性。这和现代枪械的改良需求,是一致的。所以,我就把航空轻量化材料,气动优化原理,精密结构设计,全部融入到枪械的研发当中。我想打造一款,更轻便、更精准,并且更可靠的单兵武器。我毫不夸张地说,如果没有这么多年,在航空领域积攒的经验,就没有M16系列步枪的诞生。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
M16步枪,因枪身通体呈黑色,被人们戏称为“黑枪”。它是世界上第一款列装的小口径军用自动步枪,也是二战后美国换装的第二代制式步枪。其列装标志着军用枪械进入崭新的发展时期,也宣告着一代名枪正式登上历史舞台。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
一位是战火幸存者的自救式设计,一位是工业体系下的专业化革新。成长经历的不同,目的不同,两位枪械大师,会造出截然不同的经典武器。1990年5月16日,应美国史密森学会邀请,在华盛顿,尤金·斯通纳第一次见到了他的老对手,卡拉什尼科夫。他们共处了一个多星期,围绕两款枪械进行了深入交流。那么接下来,我们就和两位枪械大师,从枪械结构、设计逻辑等方面,聊一聊他们的核心设计思想。首先,AK-47采用冲压焊接、简易铆接工艺,枪身线条硬朗,没有精致修饰,木质配件也打磨素朴,质感粗犷。与此同时,为了适应沙尘、严寒等恶劣环境,特意放大零件配合公差,留有较大间隙,枪机晃动明显。正是因为这些特点,它可靠性极强,环境适应性拉满。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-主持人1】
|
|
||||||
|
|
||||||
卡拉什尼科夫先生,对于AK-47,外界有人评价它做工粗糙,设计简陋。面对这样的评价,您怎么看?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-卡1】
|
|
||||||
|
|
||||||
我从来不认为,它是一把粗糙的枪,反倒是一种非常实用化的设计。因为当初,我设计这把枪的时候,就是为了让他们在前线,能够更好地作战。我们知道士兵们在前线作战,经常会碰到泥泞、严寒等等的恶劣条件。我给他们设计的不是一个工艺品,而是一把能真正拿出去,去作战的武器。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-主持人2】
|
|
||||||
|
|
||||||
所以说您的设计都是围绕可靠,实战性开展的吗?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-卡2】
|
|
||||||
|
|
||||||
对,没错。我认为一把好的枪,应该是作为战士身体的一部分,而不是一种负担。在战场上,战士是没有时间,去维修这种复杂的枪械的。我简化这个枪械的结构,就是为了减少零件的数量,也是为了在战场上,降低这个故障率。比如说使用这种,高强度结构的钢,就是为了适配,极其恶劣的战场环境。优化这个供弹系统,也是为了在战场上,杜绝这种卡壳的存在。这就是我的设计准则。实践证明,我的设计是正确的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
枪长870毫米,枪管长415毫米,保险、快慢机柄在机匣右侧。全枪主体采用钢木结构,采用容量30发子弹的弧形弹匣供弹。实战状态下,全枪重5.2千克,有效射程300到400米。采用导气式自动原理,回转式闭锁枪机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-卡3】
|
|
||||||
|
|
||||||
其实我的这把枪设计结构,并不算是复杂。当击锤打击针,引燃底火和发射药,子弹就会发射出去。这时候枪管里面,会产生高压高热的这个热气。我在枪管上开了一个小孔,这个时候就可以,推动枪械里面的结构进行滑动。这股力量会推开枪膛,将里面的弹壳弹出去。之后会一颗子弹重新上膛,这样就可以进行连续射击。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-主持人3】
|
|
||||||
|
|
||||||
那么斯通纳先生,相较于卡拉什尼科夫的AK-47的皮实,您的M16,它的核心竞争力是什么呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-斯4】
|
|
||||||
|
|
||||||
在我的想法里面,单纯的皮实耐用,还不能适配到现代战场当中。所以,我在M16设计上只聚焦于三点,一个就是精准度,还有一个是轻量化,最后一个就是模块化,可以适配多元的现代战场。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-主持人4】
|
|
||||||
|
|
||||||
您提到的模块化,在当今也是非常创新新颖的设计,在当时您为什么会有这样的想法?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-斯5】
|
|
||||||
|
|
||||||
其实,这现代战争场景,是越来越复杂,所以作战的任务灵活多变,单一功能的枪械,始终会被淘汰掉的。所以我想打造的,是一款万能的枪械,同一个枪身,就可以迅速更换其他的配件,比如说切换成步枪,狙击枪,还有轻机枪多种作战形态。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
M16轻便且质感高级,加工工艺精密,零件公差控制严格,拼接缝隙规整严密,外表打磨光滑,线条流畅简约。整枪修长,枪长986毫米,枪管长508毫米。全枪采用航空铝合金、工程塑料轻量化材质,搭配钢制核心构件。实战满弹状态,全枪重量只有3.6千克。有效射程400至600米。采用吹气式导气自动原理。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-斯6】
|
|
||||||
|
|
||||||
我设计的导气方式,和卡拉什尼科夫先生是完全不同的。我采用的是,吹气式导气原理,由高压气体直接进入,枪栓后方机框内的一个内室,机框带动机枪后退。这使得,单独的活塞室和活塞,就没有什么必要了,从而减少了移动部件的数量。这种原理可以让枪,更加地紧凑,重量也更轻,从而让后坐力也变小,使得射击精度大大地提高。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
M16还十分注重枪械的人机工学设计,优化了枪械的重心分布,枪托、握把的角度和形状,贴合士兵的持握习惯,减少长时间射击的疲劳感。与此同时,两款枪也采用截然不同的弹药规格,发射7.62X39毫米中间威力枪弹,弹头质量大,动能充足,枪口初速每秒710米。则使用5.56×45毫米小口径高速枪弹,弹头轻巧修长,初速可达每秒975米。口径的不同,二者杀伤机理差异也显著,AK-47穿透力强劲,子弹击中人体,多为规整贯穿伤,依靠动能造成机体冲击。而M16的小口径子弹,初速极高,进入人体后极易翻滚碎裂,形成严重空腔创伤,毁伤效果更为惨烈。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-主持人5】
|
|
||||||
|
|
||||||
1990年,两位首次碰面,而且相互试射了对方设计的步枪,近距离体验了彼此的作品。让我们一起回顾一下那时的画面。那么试射之后,两位对对方的枪械有怎样的评价呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-卡8】
|
|
||||||
|
|
||||||
斯通纳先生设计的这款枪械,精准度非常高,子弹初速度也很快,设计得十分精巧。不过这把M16,我个人认为是更偏向于,这种比较良好的作战环境,以及经过专业训练的士兵使用。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-斯9】
|
|
||||||
|
|
||||||
我是十分认可卡拉什尼科夫先生的设计。因为AK-47这把枪,纯粹就是为战争而生的完美战斗工具。结构简单到任何人都可以直接上手使用,而且在恶劣的环境下,可靠性更是令人惊叹。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-主持人6】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3-斯通纳10】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
两款经典步枪问世后,在越南战争的战场上迎来了巅峰对决。北越军队大量列装AK-47,凭借极致可靠的性能,在越南热带丛林、泥泞沼泽的恶劣环境中稳定输出,表现出众。而美军列装的M16,则依托高精度与轻量化的先天优势,实现了对目标的精准压制打击。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-主持人1】
|
|
||||||
|
|
||||||
没有优劣之分,只有适配之别。不论是AK-47还是M16,都在轻武器发展史上,成为了经典之作。全球轻武器发展的进程,那么我想请问两位,在您的心中,一把好枪的终极标准,到底是什么呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-卡1】
|
|
||||||
|
|
||||||
在我看来,一把好的枪最重要的标准,就是稳定可靠,是一定要排在第一位的。同时结构一定要简单,是可以进行大批量的生产的,适配于各种各样的战争场景。枪械毕竟不是一个精致的工艺品,在战争之中,多余的花里胡哨的设计,实际上没有任何的价值。可靠、简单、实用,是永远的标准。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-主持人2】
|
|
||||||
|
|
||||||
那么斯通纳先生,您怎么认为?
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-斯2】
|
|
||||||
|
|
||||||
我的评判标准,更加贴合现代战术。优秀的单兵武器,必须贴合人体,贴合战术。轻量化、高精度,人机工学一定是基础,同时还要有持续迭代升级的能力,可以适配不断变革的战争模式。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-主持人3】
|
|
||||||
|
|
||||||
二位的设计理念不同,但是都保持着自己的初衷。但不可否认的是,轻武器的滥用,造成了大量的无辜伤亡。接下来我们看一个短片。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说13】
|
|
||||||
|
|
||||||
2025年11月10日,联合国安理会就小武器和轻武器问题举行公开辩论。其中有代表指出,目前全球枪支已超十亿支,而非法小武器在部分冲突地区造成的平民死亡比例高达三成。尤其是突击步枪问世后,凭借造价低、易上手、火力猛的优势,迅速被多国列装。同时因获取渠道便捷,也遭到武装人员、游击势力与不法分子的大肆使用。而AK-47更是其中极具代表性的枪械。这款武器既助力众多亚非拉国家实现民族独立,也成了极端分子和武装暴徒们的杀人凶器。在有组织的暴力活动中,可以说人人手中都有把AK-47和它的“子嗣”。正因如此,晚年的卡拉什尼科夫深感痛心,曾呼吁订立国际条约,管控含AK-47在内的轻武器跨国交易。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-卡3】
|
|
||||||
|
|
||||||
我为我这把枪能在战场上,守护战士的生命,其实是感到骄傲的。我的初衷,从来不是制造一款杀戮的武器。我原本只想设计一些,农用机械用来造福百姓。是战争逼迫我,转向枪械设计的研发。我万万没有想到,我设计的这款枪,会成为极端势力和犯罪分子,杀戮平民的工具。那些倒在枪口下的老人和孩子,是我这辈子,都无法释怀的梦魇。罪孽从来不源自于步枪本身,而源自于扣动扳机的人。可我依旧终生反思,渴望能减少它所带来的伤害。AK-47是守护国土的防御盾牌,而不是侵略的利刃。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-斯5】
|
|
||||||
|
|
||||||
技术本身并不会制造杀戮,而决定善恶的,永远是使用它的人。站在设计师的角度,我们的核心职责就是优化装备,弥补缺陷,最大限度地保护士兵的生命安全。武器本身是中性的,并没有善恶之分。战争的正义与否,武器的使用方向,从来不是设计师能够决定的,而这,是国家和政治家们的选择。我们只负责打磨技术,优化装备。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4-主持人6】
|
|
||||||
|
|
||||||
我相信二位绝非战争的狂热者,只是顺应时代的武器设计者。卡拉什尼科夫先生一生呼吁和平,斯通纳先生精通设计,是一个典型的理性思维。那么可以说,武器是顺应时代而生的,设计是回应实战所需。技术本无善恶,恪守底线,正是两位枪械大师留给世人最深刻的启示。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
好了观众朋友们,感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 当武器装备长出“硅基大脑”
|
|
||||||
编导: 孙逸昊
|
|
||||||
播出日期: 2026-01-20
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep005
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《当武器装备长出“硅基大脑”》
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
当战斗机拥有思考能力,当坦克集群学会自主协同,人工智能正在重塑武器装备的神经中枢。从有人装备改装到无人系统创新,本期《军事科技》带您拆解AI如何为传统装备注入智能基因,为您揭秘全球AI军事化浪潮下的尖端装备。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》。2025年4月,伴随第六架改装的F-16,抵达佛罗里达州,埃格林空军基地,美军“毒液”项目,完成了机队组建。这个类似,漫威超级英雄名字的项目,其背后,实则是一项把人工智能,引入空战领域的计划。那么,“毒液”项目究竟是什么呢?这一波操作,到底是噱头,还是真的能够改变,未来空战的格局呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
2024年,美国启动“毒液”项目,为现役F-16 Block70机型加装AI自主决策系统。这套系统是由洛马公司研发,核心是将传统火控计算机升级为具备机器学习能力的战术中枢,目标是验证有人无人协同作战模式。为了检验人工智能在真实环境中的表现,美国军方选择了广泛服役且技术成熟的F-16战斗机。与F-16改装为QF-16无人靶机不同,“毒液”计划核心目标是评估人工智能在复杂空战环境下的自主作战效能,而非仅仅扮演被动挨打的角色。是在真实的战斗机上安装人工智能驾驶员。截至目前,已经有6架F-16被改装并组成“毒液”测试中队。那么融入了人工智能的这个战斗机,在作战的过程当中,它的这种反应速度也会变得更快,此外信息的处理能力会变得更强。也就是说,美国空军是要利用这个F-16战斗机作为人工智能这种AI飞行员的这样的一个验证平台。那么在实际测试的过程当中,F-16战斗机融入了人工智能,也就是AI系统之后,无论是在对抗性的训练的过程当中,还是在联合执行任务的过程当中,它都表现出了强大的技术优势。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
作为全球装备数量最大的四代机,机长15.03米,最大速度马赫数1.8,载弹量7.8吨,配备AN/APG-83有源相控阵雷达。其经典设计历经40年迭代,此次改装并未改变机身结构,而是通过软件定义实现战力倍增。据悉,AI系统使F-16的目标处理能力从同时跟踪四个目标提升至16个,威胁排序耗时从2.3秒压缩至0.1秒。在演习中,装机首次实现双机编队对抗六机集群,交换比达到1:3.2,展现出以少胜多的智能优势。从目前来看就是,“毒液”项目主要是要让美国的这个空中的作战体系变得更加地智能化。不过现在只是在模拟的环境当中,针对人工智能的操控系统进行验证。那么还是按照美军的训练模式,以及美军飞行员的这样的一个操作战斗机的这样的一种样本模式,去进行这种融合的这样的一个作战。所以说,它的学习目标以及样本库和数据库相对而言是比较单一的。那如果说美国空军要把“毒液”计划作为一种实战化的软件系统,和它现有的战斗机进行融合,当它面对对手的时候,可能就会出现不适应,因为都是利用美军的这种训练成果以及相关的这个数据。那么我认为它的指向性目前还是无法满足实战需求,还是处于测试,或者说处于数据搜集和这种样本学习的状态。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
从“毒液”项目中的F-16改装方案,我们已经看到,AI如何让传统战机,突破人类决策的瓶颈,将战术响应,压缩至毫秒级别。当AI从辅助工具,进化为决策中枢,空战正在经历从飞行员主导,到算法博弈的转移。世界各国的科研人员,都在试图给武器装备,装上智慧大脑。接下来,我们就去看一看,这场硅基大脑,与钢铁身躯的融合,正在打开怎样的,战争新维度。
|
|
||||||
|
|
||||||
隔断1:空战革命之自主空战先锋——X-62A VISTA验证机
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
美军“毒液”计划的背后,离不开一个关键的技术验证平台,X-62A战斗机,与“毒液”项目同步进行。“毒液”项目主要聚焦于人机协作,而X-62A计划则专注于AI独立作战。这架由F-16D改装的飞行实验室,凭借开放式架构和洛马公司的模型跟踪算法,成为破解AI空战控制难题的先锋。大家都知道以前人工智能,它主要是在无人机的这个平台上,去进行融合使用,主要执行的是简单的空中侦察,或者说针对地面目标进行打击的任务。但是和F-16这个平台进行融合之后,美国空军主要是想利用人工智能系统,也就是AI的这样的一个控制系统,和具备很强空战能力的这种主力的战斗机进行结合。那么之所以要进行这样的一个验证,就是要利用美国空军大批量装备的F-16,去验证人工智能飞行员,在近距离空中格斗的这个过程当中它的价值。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
2024年,美国空军部长弗兰克·肯德尔,亲自试驾了美国国防部正在研制的X-62A人工智能战斗机。在一小时的飞行过程中,肯德尔全程未干预,所有动作都由AI自主完成。X-62A人工智能战斗机,又被称为VISTA,是可变稳定性飞行模拟器测试飞机的缩写,是由F-16D战机经过大量改装而来。相比常规的F-16D战斗机,X-62A技术验证机最大的不同是,它使用了空战人工智能软件,来操纵飞机的飞控计算机,控制飞机在空中做出各种机动动作。此次,由美国空军部长亲自搭乘的X-62A人工智能战斗机,与另一架由人类飞行员驾驶的战斗机,进行了一场一对一模拟空战演练。最接近的时候,仅相距大约三百米。在飞行结束的采访中,肯德尔表示,经过飞行,他愿意相信这个仍在学习的AI,有能力决定是否在空战中发射武器。人工智能操控的F-16战斗机,它的技术层面具有优势性,那么这主要是人类飞行员的生理承受能力所导致的。我们讲,像F-16的王牌飞行员,在做出大过载机动动作的时候,他会面临6到7个G的这样的一个过载。但是如果说长时间连续地做出大过载的机动动作,战斗机可能还是可以承受的。但是飞行员的生理的这样的一个人体的承受极限,其实是已经到达了红线的这样的一个标准。如果说飞得更快一些,或者说做出了更大过载的这个机动动作,那飞行员可能就晕过去了,就无法正常地执行作战任务。人工智能的F-16不存在这个问题,它打破了人的生理的这种承受的能力,对于战斗机,也就是空中作战平台的限制。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
X-62A验证机的出现,标志着AI从空战辅助,迈向自主决策的关键一步。当美国在验证机领域,探索AI边界时,英国选择在六代机“暴风雨”身上,植入智能基因。作为全球空战系统的核心,该机从设计图纸开始,就将AI列为第五大系统,其协同作战管理器,可同时指挥8架忠诚僚机无人机,形成覆盖数百公里的分布式侦察打击网络。
|
|
||||||
|
|
||||||
隔断2:空战革命之集群指挥官——暴风雨战斗机
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
与X-62A的后天升级不同,“暴风雨”的AI,更像是天生的决策者,重点实现无人机协同与电子战自主化。该机型被定义为具备无人驾驶能力的多任务作战平台,气动结构兼顾隐身性能与超音速巡航需求。能够进行空对空、空对地打击、侦察电子战及战场指挥控制等复合任务。该机采用革命性的模块化架构设计,可通过快速更换业务模块实现功能重构。新武器系统整合了定向能武器与高超音速导弹等前沿装备,配合虚拟信息头盔显控系统,显著提升作战效率。这个项目非常地复杂,目前来看已经是进入到了这种基础的研发阶段,但是困难重重,甚至可以说它面临巨大的风险。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
这个“暴风雨”项目,究竟能不能造出真正的,具有实战功能的,这个所谓的第六代战斗机,我个人是要打上一个大大的问号了。因为这个项目是英国主导,我们客观来说,英国在战斗机的,飞行控制系统,包括在发动机方面,它是有独特的技术优势的。大家都知道大名鼎鼎的,这个罗尔斯·罗伊斯发动机公司,它就是属于这个英国的。英国牵头,但是它自身的资金,和技术研发能力,还是存在不足的情况,所以就开始找国际的合作伙伴。目前现在已经确定的是两家,分别是意大利和日本。所以它是,欧洲和亚洲国家联合的,这种战斗机的研发项目。虽然说,英国 意大利和日本,把“暴风雨”定位为第六代战斗机,但是我个人认为,它充其量就是,五代机或者是五代半。因为此前欧洲国家也好,包括日本也好,它都没有独立自主地研制,这个第五代战斗机。所以在隐身战斗机,在这种先进空中作战平台方面,他们都是缺乏经验的。那么现在想要赶鸭子上架,直接从四代机,迈入到六代机的,这样的一个序列当中,我认为这个项目,确实是困难重重。“暴风雨”的核心技术亮点,在于深度融合。人工智能,不仅实现自主飞行决策,还能通过蜂群技术,指挥无人机编队协同作战。“暴风雨”战机的,AI协同作战管理器,可同时控制4种型号,共8架无人机。这些忠诚僚机,既可为战机充当外置传感器,也能作为自杀式诱饵,消耗敌方弹药。我认为,这个“暴风雨”项目当中的,人工智能系统,它更像是一种,辅助的这样的一个,智能化的操控体系。也就是说,在“暴风雨”的这个战斗机的,这个驾驶舱当中,会融入一个AI助手。那么人工智能,它可以利用自身的分析能力,和独特的逻辑运算能力,把最重要的,甚至关乎飞行员性命,战斗机安全的,这样的一个重要信息,进行重点展示,进行提前展示。那么在作战信息的筛选方面,在战场环境的感知能力方面,它有很快的,这样的一个运算能力,也可以进行辅助决策。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
在刚才的介绍当中,我们见证了AI如何将天空变为算力对抗的新战场。从自主生成非常规战术的智能验证机,到指挥无人机群编织察打网络的六代机,人工智能正在把空战的胜负从飞行员的操纵杆,转向AI的代码库。就在天空的智能博弈日趋激烈的同时,陆地战场的钢铁洪流也在经历一场静默的蜕变。让我们把视角从云端拉回地面,看一看AI对地面战场又会带来哪些新的改变。
|
|
||||||
|
|
||||||
隔断3:地面战颠覆之集群大脑——MCGS地面作战系统
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
MGCS项目于2017年,与未来作战航空系统同步启动。其目标,不仅是替换老旧的德国“豹”2,与法国“勒克莱尔”主战坦克,而是要通过一个多平台、高度集成的系统,彻底改变陆地战争。从目前来看,这个坦克的定位,应该是未来多功能的地面主战系统,已经不叫这个主战坦克了。那么这样的一个装甲作战平台,首先,它的火炮威力进一步地提升。目前有两种方案,就是德国提出来的,换装130毫米口径的滑膛炮。那么由于火炮的口径变得更大,身管变得更长,那么肯定是可以有效地延伸这个装甲作战平台主炮它的射击距离,也可以发射尺寸更大的炮弹,也可以发射个头更大、射程更远的炮射的导弹。所以德国方面给出的这种火力增强方案,就是让滑膛炮的口径进一步地提升。此外,这样的一种装甲作战平台,它的防御能力全方位地提升,不仅会有各种各样的这种附加装甲、复合装甲,包括爆炸反应装甲,包括隔栅装甲,那么也会加装更加先进的这种主动防御系统,针对战场上的比如说无人机,甚至包括慢速来袭的弹药,进行有效的防范。当然了,它的战场感知能力会全方位地提升。那么坦克的车组乘员,可以利用多块的显示屏,有效地感知它周边更广阔区域内的这样的一个作战环境,也有可能会采用无人化的炮塔。炮塔上不仅仅会有主炮,也会有这个遥控武器站,上面可以集成机枪,也可以发射巡飞弹,甚至可以携带无人机。它已经不是传统意义上的主战坦克,是一个地面的综合的装甲火力打击系统。既可以作为主战坦克使用,那么在一定程度上,也可以扮演移动的装甲炮兵,利用比如说曲射火力,包括巡飞弹,针对远距离的目标进行打击。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
被设想为一个网络化生态系统,而非单一的装甲车辆。该系统将包括一个配备大口径火炮的主坦克平台,以及一系列配套装备:配备强大反坦克导弹的重型装甲车辆、配备定向能武器的无人机器人车辆、战术无人机和其它创新武器系统。这些模块将通过数字方式相互连接,在一个共享的作战云环境下运行,以最大化作战协同和实时响应能力。那么AI系统和主战坦克进行结合,最大的优势就是它可以实现更好的联网作战,能够把其它作战平台和侦察平台的信息,集成到这个未来地面的主战系统的内部。这样的话,这个坦克的车组乘员就可以了解到更为准确更为精准的这种战场的作战环境。那么哪些目标应该优先打击,哪些目标要尽快地对它进行消灭,可能是AI要辅助地进行决策。那么AI利用这种大数据,利用其它平台的这种传感器的视野,马上就可以做出决策,可以在触碰的显示屏上进行标注。车组乘员所做出的这样的一个攻击动作,非常地简单,用手指轻触一下屏幕,就可以完成一击必杀。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
隔断4:地面战颠覆之地空协同——格里芬装甲车
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
除了MGCS,这种全新架构的地面AI系统,在传统装备的身上,进行AI化升级,也是各国对地面武器,进行升级的一种尝试。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
法国在其格里芬装甲车上,加装“天蝎”作战系统,就是一个典型的例子。格里芬多用途装甲车,是一款6×6轮式装甲运兵车,战斗全重25吨,根据法国“天蝎”计划研制,旨在取代法国陆军现役的VAB装甲运兵车。该车安装一台400马力雷诺卡车公司6缸涡轮增压柴油发动机,和ZF七挡自动变速箱,最大公路速度每小时90千米,续航里程800千米。格里芬多用途装甲车,能够防御14.5×114毫米穿甲弹、炮弹破片和地雷简易爆炸装置的打击,并配有模块化装甲套件,以增强防护能力。格里芬装甲车,是法国陆军的一款主战装备。我们看它的外形,和传统的装甲车不太一样,有点像一个加强版的六轮的装甲卡车。它的全向的防御能力,是比较强的。不仅法国陆军会装备,你像比利时、卢森堡这些国家的军队,可能也会采购,因为他们所使用的主战装备,都是这个法系的。格里芬装甲车最大的优势,就是防护能力非常地全面。首先我们可以看一下它的底盘,和相关的这个悬挂系统,即使遭遇到了这个反坦克地雷,包括路边炸弹,也可以保证车体结构的这样的一个可靠性,和它的这个完整性。包括它的载员舱,也是经过了特殊设计,即使装甲车被炸翻了,或者说面临大威力的路边炸弹的袭击,也要最大程度地保证它的这种载员舱的这样的一个整体性,最大程度要保障士兵的生命安全。那除此之外,我们也注意到,格里芬它的主要功能还是运输,而且也可以作为一个多功能的装甲作战平台。在车体前部靠上这个位置,会配备有多功能的遥控武器站,可以集成轻机枪、重机枪和自动榴弹发射器,能够针对下车作战的步兵,进行一定程度的火力支援。而且它还有衍生的型号,比如说为它加装大口径的迫击炮,它就成了机动部署的这种迫击炮车,可以针对作战的步兵,进行精准的曲射的火力支援。也有工程车的变型,可能还有装甲救护车、装甲抢救车的这样的一个变型。从目前法军部队使用的反馈来看,它的机动性、防护能力和联网作战能力,是可以达到要求的。格里芬多用途装甲车,配备了“天蝎”计划的作战信息系统和泰雷兹战术电台,用于实时战场协调。衍生型号,包括装甲运兵车、指挥车、炮兵侦察车、医疗后送车和战斗工程车,所有这些型号,都旨在确保配备“天蝎”系统的部队之间协同作战能力。它是一套非常完善的这样的一个一体化的指挥控制系统,那么也是在大范围内,能够进行信息和情报共享的这样的一个系统。通过终端设备的加装,像格里芬装甲车,包括勒克莱尔主战坦克,以及其他欧洲国家的主战装备,一旦融入了“蝎子”系统,或者说我们叫“毒蝎”系统,就可以形成合力,因为信息和情报,可以在最大的范围内进行这种共享。也可以把每个作战平台,连接到这种作战的指挥网络和体系当中。这样的话,整个的地面装甲作战单位,包括像凯撒自行火炮,就可以构成合力,更好地感知战场环境,更好地进行目标的分发,而且不同的作战单位之间,可以打出组合拳。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说13】
|
|
||||||
|
|
||||||
依托“蝎子”信息与战斗系统,格里芬多用途装甲车,与美洲豹侦察车、凯撒自行火炮等装备,形成数据互联。AI算法可自动分析战场态势,动态调整火力分配策略。例如,当发现敌方装甲集群时,系统会优先调度射程最远的凯撒火炮进行打击,同时指挥狮鹫机动至侧翼实施伏击,实现探测、定位、打击链路的秒级闭环。这种协同模式,使传统线性推进战术,演变为动态猎杀矩阵。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
在地面战变革当中,我们看到了AI如何让钢铁洪流拥有群体智慧。那么,有着地面装备克星之称的无人机,在近几年的局部冲突中,可谓是出尽了风头。当无人机蜂群战术成为现代战场的灰色威胁,传统防空体系正在面临发现难、跟踪难、拦截难的挑战。而AI技术的注入,正在将被动防御转化为智能防御。
|
|
||||||
|
|
||||||
隔断5:AI编织的光速拦截网——卢卡斯激光武器
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说14】
|
|
||||||
|
|
||||||
2024年10月,美国莱昂纳多DRS公司与蓝晕公司,展示了斯特赖克8×8步兵战车的最新变体。该版本被称为定向能反无人机系统斯特赖克,配备蝗虫激光定向能武器系统,及各种雷达和传感器。蝗虫激光系统,结合了高速计算机处理,先进算法,和新一代激光武器,能够在几毫秒内识别、区分,并焚毁敌方无人机目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说15】
|
|
||||||
|
|
||||||
卢卡斯车载激光系统,主要是和斯特赖克,八轮的装甲车进行结合。之所以要融入AI系统,其实主要是针对目标进行分析和判断。激光武器针对低慢小目标,它具备瞬间的秒杀能力。但是它有一个技术的问题,必须要进行一个很好的解决,也就是在低空空域的目标识别和分析的问题。因为在这种八轮的装甲车上,不可能加装特别复杂的光学传感器,或者说太多的、太大个头的这个雷达。那么针对空中的目标,进行快速的分析,进行快速的这个识别,必须要和这个激光器进行一个紧密的联动。也就是说,激光器它的光学和红外的瞄准设备,直接融入了AI的这种分析软件。蝗虫激光武器系统,旨在以人工智能技术实现即时识别,并消除无人机威胁。蝗虫激光武器系统采用托盘式设计,由两部分组成,左侧是激光器,右侧是跟踪系统。该系统将精密光学和激光硬件与先进的软件、人工智能和处理技术相结合,可识别、跟踪以及利用高能激光攻击目标。这种AI的分析软件,也就是一种智能化的算法。这个智能化的算法,起到什么样的作用呢?比如说天空有一群飞鸟,天空当中又有一群无人机,如何根据它的飞行特征、飞行速度以及这个外观和相关的这种影像的这样的一个图像,要对它进行马上的判断。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说16】
|
|
||||||
|
|
||||||
在这样的一个决策的过程当中,是不需要人力介入的。精准快速地识别目标,完成激光束的攻击。人工智能系统,能够接收传感器数据,并以比人类快得多的速度,执行分析,组织数据和识别相关项目。这意味着,可以在几秒钟内,确定最佳解决方案或对策。这大大提高了反无人机的速度,并更好地实现目标甄别。只要敌方无人机被发现,识别和验证的速度越快,蝗虫就能在越远的地方,越安全的地方摧毁它们。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
从F-16的AI魔改,到“暴风雨”战机的原生智能,从MGCS的无人集群,到防空体系的智能化升级,AI的加入让武器装备有了质的飞跃。当硅基大脑与钢铁之躯完成融合,科幻电影当中的那些作战场景正在逐步走进现实。好了观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间再见。
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
---
|
|
||||||
标题: “舰” 证不凡
|
|
||||||
编导: 左鑫
|
|
||||||
播出日期: 2026-02-03
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep006
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《“舰”证不凡》
|
|
||||||
|
|
||||||
|
|
||||||
## 【宣传词1】
|
|
||||||
|
|
||||||
从万吨大驱,到两栖攻击舰,再到航空母舰,大国巨舰,勾勒人民海军深蓝征程。本期《军事科技》,走进055咸阳舰,075湖北舰,山东舰,解码国产战舰,体形大的硬核底气,揭秘拳头硬的体系战力,感悟责任大的使命担当。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是蓝皓。人民海军的装备发展,堪称一部波澜壮阔的崛起史诗。我们用数十年的时间,完成了从近海防御,到远海防卫的跨越式发展。那么,提起人民海军的装备实力,今天的节目,我们想用几个关键词,国之重器的硬核密码。第一个关键词就是体形大。此刻,我们正走在军港的码头上。大家顺着镜头望去,我身旁的这艘巨舰,就是055型驱逐舰,咸阳舰。当我们第一次接触它的时候,能够充分感受到它的力量感,与美感,是一艘美丽而威武的海上利刃。当然,镜头里呈现的震撼,远不及亲眼看到它的万分之一。当您真正地站在它的甲板之下,仰望这艘钢铁巨舰的时候,才能够真正地感受到,万吨大驱这四个字,沉甸甸的体量,与一往无前的底气。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
咸阳舰,是055型驱逐舰其中的一艘。它的诞生,突破了大型舰艇总体设计、信息集成、总装建造等一系列关键技术,满载排水量达到1.2万吨。可能有观众,对1.2万吨没有直观概念,不妨做一组对比。建国之初,我国自主研制的第一款驱逐舰,051型驱逐舰,满载排水量约3600吨,055的体量是它的三倍之多。即便是与现役被誉为“中华神盾”的052D型驱逐舰相比,055的体量也几乎是它的两倍。站在甲板下仰望,高耸的舰桥、修长的舰体,搭配流畅的隐身设计,既有钢铁巨兽的威严,又不失现代军工的精致。这种体量的提升,绝非简单的放大,而是综合作战能力的全面跃升。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
此刻,我们已经登上了咸阳舰的甲板。我发现这个甲板,比我们之前看到的驱逐舰,或者护卫舰的甲板都要大。这样的设计有怎样的特别之处呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾1】
|
|
||||||
|
|
||||||
第一个,大家现在所处的,我们的起降平台在海上,主要用于直升机的起降,和这个临时的停放。大家可以往这边看,这个就是我们的双机库。一般的驱护舰艇,只有我们的单机库,所以说,对比它们,我们的搭载能力更强了。我们可以搭载多型的直升机及无人机,单机库的话就只能搭载一架,但是双机库我们就可以搭载两架。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾2】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
咸阳舰的外形设计,充满了未来感。舰体采用了大量平滑的斜面,减少了棱角和突出物。这种设计大幅降低了雷达反射截面积,让它在敌方雷达面前,如同一个隐形战士。与此同时,它采用典型的全封闭飞剪式舰首。它并非二战前后战列舰的飞剪式设计,而是融合中国创新智慧的升级成果。从正面看,舰艏折线在顶部开始内倾,到主炮位置前方又稍稍外倾,随后再次内倾。最前端与主甲板构成尖锐的多面体全封闭结构,与前甲板齐平过渡。整体外形简洁现代,兼具美观与实战效能。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
现在我们来到了咸阳舰的前甲板。相比我之前看到的其他战舰来说,咸阳舰的甲板会更加地干净整洁一些。那些系留装置,锚点都去哪儿了呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾3】
|
|
||||||
|
|
||||||
这个主要是为了突出我们的隐形化设计。我们将这些所有的舱面设备,我们的锚机,系统柱都置于我们的甲板之下,提高我们舰艇的这个隐蔽效果。第一个,可以(增加)我们人员,在舰艇大幅度摇摆的过程中,在舱面作业的安全系数。第二个,减小敌方的雷达发现我的概率,提高我的舰艇的防护能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
咸阳舰的主桅杆,相比于其他军舰来说,变得更加粗壮,外观也会更加简洁。这样的细节变化有怎样的深意吗?
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾4】
|
|
||||||
|
|
||||||
我们055的桅杆,采用一体化主桅设计,不但看起来简洁美观,它实际上更内藏乾坤。我们将所有的雷达、电子战及通信天线,整合于我们的主桅之内。它可以同时跟踪数百批海面、空中目标。它不但可以引导导弹更精准地打击目标,也可以提升我们舰艇的战场态势感知能力,是我们决胜战场的“千里眼”与“顺风耳”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
如果说055驱逐舰的“大”,是“标杆级”的突破,眼前这艘075型两栖攻击舰,湖北舰的“大”,就是“平台级”的跨越。就像网友调侃的那样,湖北舰之大,连镜头都快装不下了。湖北舰,舷号34,是中国自主研制的075型两栖攻击舰中,最新的一艘。2025年7月,湖北舰正式入列人民海军。它的到来,对提升中国两栖作战能力,完善远海作战体系,具有里程碑式的意义。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾5】
|
|
||||||
|
|
||||||
在我身后这艘舰艇,是我们075型两栖攻击舰,第四艘舰艇,舷号是34,舰名是湖北。大家可以看到,我站在它下面,显得非常渺小。我们湖北舰的长度,超过200米,高度有15层楼那么高。它从外观来看,与我们的航母有点相似。当然,我们的湖北舰,也是内含乾坤,它既能容纳两栖车辆,又能装载直升机。它的一个主要的使命任务,就是遂行两栖作战。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
拥有巨大的直通甲板,面积相当于20个篮球场,可同时快速起降多款舰载直升机。它的入列,加强了我国“平面登陆加立体登陆”的综合两栖作战能力。它既能通过舰载直升机实现垂直投送,又能利用坞舱释放登陆装备展开平面突击,可快速投送两栖作战力量,应对远海岛屿防御、人道主义救援等多种任务,是人民海军维护国家主权、海洋权益的两栖先锋。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人6】
|
|
||||||
|
|
||||||
说到体形大,人民海军的装备,还有比海南舰更大的存在。此时,我就站在这艘巨舰的身旁。通过结构,大家可能已经猜出了,它就是我们的首艘国产航母山东舰。站在平台上去仰望它的时候,它就像是一座高楼;而当它在大海上全速奔跑起来的时候,那就是一座劈波斩浪的海上高山。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
山东舰的满载排水量,约6万多吨,舰长300米左右,是我国首艘完全自主设计、自主建造、自主配套的国产航母。从055的1.2万吨,到湖北舰的4万吨,再到山东舰的6万多吨,这种体量的递增,清晰勾勒出人民海军装备发展的战略轨迹。每一次放大,都对应着战略需求的升级;每一次体量的突破,都意味着远海作战能力的跃升。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾6】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
山东舰,于2019年12月17日入列。它一入列就备受关注。全舰高度相当于十几层楼,内部舱室密布,宛如迷宫。6万多吨的排水量,赋予了它远洋航行的充沛底气。现在我们就从我们的主船体,来到了我们舰岛。我们主要是分为这两个部分。我们船的总的这个排水量,达到了六万多吨,也就相当于三个埃菲尔铁塔的重量。在海上航行,我们的总的发电量,相当于一个中型的一个城市。所以这个体量还是特别大的。我们的全船的舱室,据我们初步统计都有三千多个。网上有一个流传这么一个说法,就是一个婴儿从刚出生,一直到12岁的时候,才能把我们全船每天住一个舱室,才能都住个遍。现在来到是我们的舰上的图书馆。我们舰上遵循了一个社区化的设计的理念,保证舰员在生活的一个区段之内,保证了基本的工作、学习、生活的这种状态。然后我们现在图书馆藏了,收藏了能有七千多本书。我们船上还有(很多)像这类生活舱室,还有我们的健身房,我们的医疗区,甚至我们有自己的超市,还有邮局。以上种种说明,我们航母确实是一个体量庞大、功能强大的海上巨系统。航空母舰,是一种供舰载机起飞和降落的大型舰艇。换句话说,航母就是一座海上机场,是目前世界上最庞大、最复杂、威力最强的武器平台之一,是现代战争中名副其实的海上重器。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾7】
|
|
||||||
|
|
||||||
从我们舰岛的前部走出来,就来到了我们的飞行甲板。我们整个飞行甲板,可以说一下视野就开阔了,没错。整个飞行甲板,有一万五千多平方米,相当于三个标准的足球场,还要再大。我们分了三个区域,现在我们脚下的这个区域,是停机区。可以看到好多黄色的标志线,就是我们飞机停放的位置。然后在这些停放的位置,我们可以进行各类的加油、加气、挂弹,各类的保障作业。在我们正前方,舰船的正前方,是我们的起飞区,有设置了两个短距的起飞位。在我们的左边、左手边,设置了是我们的着舰区。在舰艇的方向有4道拦阻索。我们飞行甲板的右边,布设的是我们岛式上层建筑。整个上层建筑,有大概十余层楼那么高。以中间的这个格栅为分界,前半部分,是我们主要掌管航行安全的驾驶室,以及我们舰岛上面,各类的预警探测、我们的雷达等设备。在我们的混合室后边,是我们的塔台,主要掌管我们飞行安全。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
从1.2万吨,到4万吨,再到6万多吨,人民海军舰艇的体形大,从来不是盲目追求规模,而是战略需求与工业实力的精准匹配。这种递进式的体量突破,为后续的拳头硬,也就是强大的打击能力,奠定了坚实基础。接下来,我们进入第二个关键词,拳头大。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人7】
|
|
||||||
|
|
||||||
我们都说舰艇的体形大是基础,但是火力系统,拳头硬才是核心。那么对于055驱逐舰而言,它的火力系统,有什么特别之处呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾8】
|
|
||||||
|
|
||||||
目前飞机和这个,空中来袭的导弹,是对我们最大的威胁。我们055型舰,构建了远、中、近三层防空抗击体系。现在映入眼帘,就是我们的一个近程防空导弹,主要担负我们对近距离来袭的导弹或飞机进行一个抗击和拦截,精度高、反应时间短。这个就是我们所说的副炮系统,也是咱们所说的万发炮。万发炮,顾名思义也就是射速高,依靠它强大的射速,来提供弹幕,对末端的导弹,进行一个有效地拦截。同时,它上面具有这个雷达及光电系统,为它来抗击导弹,提供一些更精确地指引。我们刚才看的就是我们远、中、近三层防空体系。这个里边就可以搭载我们的远程防空武器。从它的外形上我们看到,它像一块一块的巧克力,虽然看着像巧克力,可不要小觑它的威力,这个就是我们的通用垂直发射系统。我们刚才提到的远、中、近三层防空体系的远程作战能力,它可以装载各型的导弹,为我们打击空中、岸上以及这个海面的目标。然后同时还具有这个冷热兼容发射,大大提高了我舰的综合作战能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
既然说到了垂直发射系统,我们就来科普一下,导弹飞出发射筒的两种主要方式:热发射和冷发射。两者的核心区别在于,弹出筒的动力来源不同,发射筒的内部结构也因此存在差异。热发射,是导弹在发射筒内直接点火,依靠自身发动机的推力飞出发射筒,这种方式反应速度快,发射效率高。冷发射,则是先利用高压气体,将导弹弹出发射筒,待导弹上升到一定高度后,再启动发动机飞向目标。简单来说,热发射是一步到位,冷发射则需要两步走。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾9】
|
|
||||||
|
|
||||||
这个就是我们的主炮系统,近距离地接触它,才能感受到它的威严的。它主要担负我们一些对岸火力支援任务,或者说一些对海打击任务。具有两个特点:一个是我们口径大,也是目前国内舰炮搭载口径最大的,所以说我们的火力就很强;再有一个,我们依托它的平台的自身的优势,我们平台大,所以说它就有更稳定的射击稳定性,然后更精确地命中目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
强大的武器配置,让055型驱逐舰不仅能独立执行防空、反舰、反潜等作战任务,还能在航母编队中承担“带刀护卫”的核心角色,是人民海军远海作战的核心战力之一。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人9】
|
|
||||||
|
|
||||||
如果说055的拳头是精准打击,那么两栖攻击舰的拳头就是立体投送加协同打击,是一套威力更加强大的组合拳。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
作为两栖攻击舰,它的核心打击能力体现在搭载能力上,它所搭载的直升机、登陆装备和陆战队员,就是它最强大的拳头。可搭载几十架各型直升机,能快速投送陆战队员和轻型装备,实现昼夜立体登陆。在平面登陆方面,湖北舰的坞舱可容纳多艘气垫登陆艇,这种气垫登陆艇速度快、适应性强,能够在浅滩、礁石等复杂地形登陆。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾10】
|
|
||||||
|
|
||||||
那么我现在所在这个位置,是属于湖北舰,驾驶室平台的外侧。在我们湖北舰设计理念上,跟航母有着异曲同工之妙。我们采用的也是,边岛式上层建筑,和这种全纵通式的飞行甲板。站在我这个位置,可以清晰地看到我们整个,飞行甲板的全部概况。从前至后,分别有6个起降点。在整个飞行甲板的后侧,还设置了一个7号点,也就是一个小停机坪。这个停机坪,主要是用来保障无人机的起降。在我们机库的下方,是我们的车辆库和坞舱。它主要是用来存放气垫艇,两栖装甲车,坦装以及自行榴弹炮。当我们进行,登陆投送任务的时候,我们的坞舱门会打开。我们的气垫艇,会带着我们的坦装,我们两栖装甲车,一块儿进入到水中,进行登陆作战。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
当然,作为一款大型作战平台,湖北舰也配备了自卫武器。分布在舰艇四周的万发炮,和舰空导弹系统,构成了它的近程防御圈。这些武器虽然看似不多,却能在关键时刻,拦截来袭目标,为湖北舰,撑起全向防御的保护伞。而作为人民海军的核心作战平台,山东舰的拳头则更为强硬,它体现的是,远洋制空加制海的,体系作战能力,而这个拳头的核心,就是舰载机部队。歼-15舰载战斗机,就是山东舰最锋利的尖刀。山东舰目前搭载的,是歼-15舰载战斗机,这是我国自主研发的,重型舰载战斗机,具备制空、制海、对陆攻击等,多种作战能力,它的最大起飞重量超过三十吨。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾11】
|
|
||||||
|
|
||||||
我们现在所在的这个位置,就是我们3号起飞位。在我们舰上,像这样的起飞位我们一共有三个,另外的两个是在舰艏的位置。舰载机就位了以后,我们会有三种工作人员:一种是机务检查人员,他们会检查飞机放飞前的这个准备状态,舰载机的状态是否良好,它发动机状态是否良好;还有一类就是我们止动轮挡的检查员,主要是检查我们止动轮挡和飞机主轮的啮合程度,轮挡是否顶在中间,它没有完全顶上的话是不合格的;还有一类是我们偏流板的检查人员,他主要是检查我们偏流板的冷却系统还有支撑系统,是否是处于一个正常的状态。在完成这些一系列检查之后,起飞助理会到飞机的右侧,确认好各位置的状态之后,完成放飞动作,也就是我们熟知的航母style的动作。之后,舰载机会在一个短的滑跑距离之内完成放飞。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
除了歼-15舰载战斗机,山东舰还搭载了预警直升机、反潜直升机等辅助作战机型。这些舰载机构成了一个完整的海空一体作战体系,让山东舰具备了遂行远洋作战任务的能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾12】
|
|
||||||
|
|
||||||
山东舰出海执行任务,通常不是只有单舰的出海,通常是组成编队去执行任务。我们的编队,还会有驱逐舰、护卫舰,以及补给舰等等。这些附属舰艇,也都会搭载各型武器,来完成我们编队体系化构建,让我们山东舰编队的拳头,越来越硬。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人10】
|
|
||||||
|
|
||||||
从055驱逐舰的全方位精准打击,到两栖舰的立体投送协同打击,再到山东舰的远洋体系作战,人民海军的拳头大,指的是从单点打击,到体系作战的能力跃升。而这些强大的装备,都服务于一个核心的使命,那就是守护祖国主权、安全和发展利益。就引出了我们今天的第三个关键词。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说13】
|
|
||||||
|
|
||||||
近年来,人民海军在实战实训中,持续向远海远域拓展。实战化训练的规模、强度和体系化水平显著提升,标志着海军走向深蓝的能力不断增强。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾13】
|
|
||||||
|
|
||||||
我来到咸阳舰已经第三年了。当我第一次抬头,看见咸阳舰的时候,如此的宏伟和巨大。后面当我可以独立执勤的时候,每次双手握住舵轮,眼前看着现代化的操作界面,都能感受到这艘先进舰艇蕴含的强大力量。我心里明白,我以后的责任、荣光和青春,都将与它不可分割。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾14】
|
|
||||||
|
|
||||||
我们咸阳舰就是,自从我们入列以后,执行了很多战备和训练任务。其实说通俗一点,我们这个作战任务,其实主要就两项。第一个,就是通过我们的舰艇的军事外交,向全世界,包括我们到达的国家,传达我们的善意和诚意。另外,通过展示我们的能力,让对方能够感受到,我们是有能力保护和维护,自由交往和贸易。我们这型舰,就像我们是一个武林高手一样的,但是我们是爱好和平的,但是我们绝对不会畏惧武力。就是需要的时候,该出手时就出手,让那些想到我们周边,来耀武扬威的敌人,让他们知道,我们虽然以德服人,但是也可以以力服人。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾15】
|
|
||||||
|
|
||||||
湖北舰交舰入列的时候,我们作为舰员,站在湖北舰下面,第一个感受就是震撼,第二感受就是激动、自豪。每名军人都希望自己手中,有一把钢枪。我当时看到这艘舰艇,我当时就想,湖北舰就是我们,所有舰员的钢枪,是我们手中的利刃。通过我们的刻苦训练,湖北舰,已经是一把锋利的,更是有生命力的利刃。这把利刃交到我们手里,我们作为光荣的湖北舰舰员,丝毫没有懈怠过。有了这把利刃,我们在海上遇到强敌对手,我们有信心有能力,消灭一切来犯之敌。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾16】
|
|
||||||
|
|
||||||
航母这样一个巨系统工程,不仅代表了国家的综合实力,更代表着科技水平,和军事水平的一个集中体现。从2017年辽宁舰访问香港,全体舰员在甲板摆出了“香港你好”,到今年7月份我们舰访问香港,我们摆出了“国安家好”。这种转变体现了我们一个海军强国,海军大国,一种负责任的态度。同时,我们也有能力有信心,维护国家利益,保护人民安全。近些年来,我们演训任务也是逐渐增多,我们航母也是从近海走向远海,双航母编队在西太平洋部署。今年11月份,我们也是在码头,有幸见证到18舰交接入列,我们的海军实力进一步增强。我们始终相信,国家利益所至,航母航迹必达。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说14】
|
|
||||||
|
|
||||||
舰艇的吨位有大小,武器的威力有强弱,但人民海军的责任与担当,始终如一。从近海到远海,从维护主权到国际救援,055驱逐舰、湖北舰、山东舰,用一次次行动,践行着人民海军为人民的誓言,诠释着大国海军的责任与担当。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人11】
|
|
||||||
|
|
||||||
深蓝砺剑,逐梦海天,人民海军装备的发展之路,是一条自主创新,砥砺前行的奋斗之路,从无到有,从弱到强,从近海走向深蓝,每一次巨舰的下水,每一次编队的远航,都见证着中华民族,向海图强的百年夙愿,随着更多先进装备的入列,人民海军必将以更强大的姿态,守卫国家主权,安全和发展利益,当然,我们也会总结更多的关键词,带您揭秘国之重器的硬核密码,好了观众朋友们,感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 大国巨舰上的奇妙反差
|
|
||||||
编导: 左鑫
|
|
||||||
播出日期: 2026-02-10
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep007
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《大国巨舰上的奇妙反差》
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
万吨航母的“方向盘”,竟比家用轿车的还小巧。几十吨重的舰载机,全靠呆萌小车灵活挪位。数字化战舰,为何执着于手绘海图?古老旗语与黑科技同框,又藏何玄机?本期《军事科技》,带您解锁大国巨舰的反差彩蛋,探寻这些奇妙细节背后的硬核故事。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是蓝皓。提起大国巨舰,大家脑海中往往浮现的是,万吨体量的钢铁巨兽,劈波斩浪的远洋航迹,先进雷达的敏锐探测,精确导弹的雷霆打击。正是这些宏大的画面,勾勒出人民海军装备的硬核轮廓,使我们对这些国之重器充满了敬畏与自豪。但很少有人知道,在这些钢铁巨舰的身体当中,藏着一些让人意想不到的反差细节,它们看似矛盾,但却是促使它们驶向深蓝的底层逻辑。本期节目,就让我们走进这些国产战舰,揭秘大国巨舰的反差密码,从细节之处读懂国之重器的硬核实力。
|
|
||||||
|
|
||||||
信息化战场上的 “古老对话密码”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
2024年5月1日,福建舰从上海江南造船厂启航,开展首次海试。当时消息一出,瞬间点燃全网热情。不少网友逐帧欣赏福建舰海试的报道与视频,却不约而同生出一个疑问:在我国航母海试这一重要时刻,舰上为何醒目地悬挂着外国国旗呢?其实,这些旗帜并非某国国旗,而是国际信号旗。顾名思义,国际信号旗是全球通用的舰船通信工具,专供舰船之间、舰岸之间快速联络。其起源可追溯至17世纪的欧洲航海时代,当时的舰船通过悬挂不同组合的信号旗,就能让其他船只快速明晰自身意图。有人追问,如今一艘现代顶尖战舰,正朝着信息化、智能化、体系化深度演进。以人民海军咸阳舰为例,作为世界现役先进驱逐舰之一,其搭载的一体化桅杆,将多种探测、通信、电子对抗设备融为一体,实现了信息的高效融合和精准传递,代表着世界舰艇电子装备的顶尖水平。可是,就是这样一艘汇集多种黑科技的万吨驱逐舰,现代化桅杆之上,却依然保留着醒目的信号旗。这一延续数百年的古老通信方式,与先进电子系统同框,一今一古,形成了极强的视觉与认知反差。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
我们知道现在的科技,日新月异。那么对于055这种智能化,数字化的战舰来说,为什么还需要古老的旗语呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
首先就是因为我们是,旗语通信和旗号通信,它在海军通信中,占据了不可替代作用。首先第一点,就是它的抗干扰性,还有它的隐蔽性,因为它是不依靠,各类电子设备进行发波,所以敌方就不会收到,我们的各种电磁信号。第二点就是它的快速性,就比如说我们在舰艇,航行过程中,受到了敌方的干扰,我们无法使用我们的,其他的通信设备,那么我们旗号通信,和旗语通信,就发挥到作用,它可以进行快速地,点对点进行通信。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
陆地上,用特定信号传递信息,有着悠久历史。烽火台传递警报,战场击鼓吹号调度,传令兵奔走传讯,手段丰富多样。而海上,因物理空间隔绝,每艘船都是相对独立的个体,信息传递更为艰难。17世纪起,各国商船为规避冲突,传递警示,逐渐形成统一的旗帜通信规则,用旗帜代表数字,再通过数字传译具体信息。1799年,波帕姆爵士,公布了首份,可通过数字引用单词,句子的列表,即“波帕姆旗语系统”,较以往通信效率大幅提升。1805年10月21日,特拉法尔加海战前夕,英国海军胜利号上的,纳尔逊勋爵,便是通过波帕姆信号旗,发出了闻名后世的,著名决战旗语,意思是,英格兰希望每一位官兵,恪尽职守。这一段对话,共计花了四分钟发出,当舰队相继收到,这份决战旗语后,每位官兵欢欣鼓舞,士气大增。而对于我国而言,同样承载着厚重的历史记忆。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾1】
|
|
||||||
|
|
||||||
挂冲锋旗!是,挂冲锋旗!是,挂冲锋旗!
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
甲午海战中,北洋水师致远舰,在通信受阻时,曾靠悬挂信号旗,指挥编队冲锋,用热血诠释了中国人的风骨。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾2】
|
|
||||||
|
|
||||||
我等从军卫国,今日一死,也要撞沉吉野。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
19世纪中叶,国际信号规则正式成型,体系更趋规范、全面,并沿用至今。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
我们海军信号旗,一共分为两种,一种是信号旗,—(种)是手旗。那么信号旗又分为三类,就比如说我们的字母旗、数字旗,还有特种旗。那我今天就一共带来了四面旗帜,分别它们不同的意思。首先我先介绍,为大家介绍的是数字旗。数字旗一共有10面,而我们手中的这一面旗,现在是2。然后不同的数字旗,有不同的样式。这就是我们的字母旗,它在海军通信中代表着A。在不同的字母,其中它们的样式都不一样。像这种旗,这个旗帜确实像某一世国家的国旗,但它并不是。各种字母,它的旗帜样式都是不一样的,也代表着不同的含义和意思,有些就代表着航向,有些是代表着我在进行危险作业。好,接下来介绍就是我们的特种旗。特种旗在海军旗号通信中,各种特种旗有不同的意思。我们特种旗一共有8面,它就相当于是海上,我们挂出旗号的时候,要进行某一种,同时执行的这种(行动),就会挂出这一类旗型,表示大家一起进行行动或者运动。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
一套完整的国际信号旗,由26面字母旗、10面数字旗、3面代旗和1面回答旗组成。多数国家海军信号旗的样式、数量与国际信号旗基本相同。除各国专属含义外,国际通用场景中,每面旗帜要么代表特定字母、数字,要么单独承载固定含义,部分复杂信息则需多面旗帜组合表达。了解了这一规则,依据国际信号手册,我们便能读懂福建舰海试时悬挂旗帜的深意。当时舰岛右侧悬挂的三面旗帜,分别对应字母R、U和数字1,组合为“RU1”,其释义为“我正在试航”。即便如此,舰载无线电技术已经十分成熟,旗帜信号作为基础通信手段,依然被各国商船、民船及军舰广泛采用。无论是欧美海军,还是亚洲军舰,均严格遵守这一传统。而与旗语一同保留下来的,还有另一种古老的通信工具——传声筒。传声筒大家肯定不陌生,在抗日影片《地道战》里,就有用它互通消息的桥段。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾3】
|
|
||||||
|
|
||||||
各小组注意!你们各自为战!各小组注意!你们各自为战!打一枪换一个地方!不准放空枪!不准放空枪!开火!
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
可别小瞧这看似“土得掉渣”的简陋装备,它不仅没被时代淘汰,反而在历代战舰的装备清单上,稳稳占据着一席之地。传声筒又称扩音筒,外形简约,其桶身采用耐腐蚀金属材质,依托物理声学原理传导声音,无需依赖电力。海图室 作战室,作战室 海图室。检查雷达工作情况,明白。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾4】
|
|
||||||
|
|
||||||
军舰上的传声筒,是一个古老设备。在两次世界大战期间,几乎所有主力舰,都配备传声筒,作为核心通信手段。其核心作用是实现舰内不同舱室、甲板间的通信。管路贯穿多个甲板,进行点对点信息传递。比如传输距离较短,为了让对方听清,必须非常大声。但是当军舰遭受攻击,致使电子通信、电力系统损坏,或是遇到电磁脉冲干扰时,能成为最后的通信手段,避免舱室间失联。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
传声筒,曾是舰艇上的主流通信工具,下达操舵指令,舱室间联络协调,都离不开它。如今,即便无线对讲,内置通信耳机,舰内电话等先进设备广泛应用,传声筒依然被保留下来,成为电子通信失效时的应急生命线。
|
|
||||||
|
|
||||||
数字化战舰上的 “手绘图纸”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
在多数人的认知中,咸阳舰、山东舰这类现代化战舰,理应是全数字化、无纸化运作,指挥系统、武器系统、导航系统的数据,全靠电脑屏幕显示。但当我们走进咸阳舰内部,却看到了令人意外的场景,先进的液晶显示屏旁,整齐摆放着纸质版的舰操绘算图、铅笔和比例尺。而这并非咸阳舰独有,而是现代战舰的标准配置。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
在舰艇的机动之前,以及舰艇机动的过程中,我们需要对舰艇的运动要素,进行一个汇算。在这过程中我们就要用到,舰操绘算图。这个舰操绘算图,是各国海军,通用的一个汇算手段。它是由两个圆组成,外面的数字代表的是航向,里面的数字代表的是方位。我们假设目标舰航向是,340 18节,我们在图上标绘一下。假设目标舰艇在我舰的,方位是090 10海里,我们在图上标绘一下。我们将方位与圆心,做一个连线,将这个连线平移至,平移至我们的航向线上。假设说我舰以32节的速度,我们在32节的速度上。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
这番讲解听起来很复杂,但核心原理,却是基于四则运算。已知我舰航向、速度,及敌舰相对位置等数据,通过手绘标注与比例尺测算,就能推断出敌舰的航行方向、速度,进而判断其战术意图。舰操绘算图,几乎是我们值更官人手必备的一项技能。在我们与目标舰艇进行交锋、博弈的时候,我们彼此之间都在进行一个绘算。那么谁进行一个更加快速的绘算,谁就会占领一个先机和主动权。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
舰上航海部门每天都会安排舰员手绘海图,用铅笔仔细标注舰艇实时位置、预设航线,每一笔都工整清晰、精准无误。明明有先进的电子海图系统,为何还要坚持手绘呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
电子海图目前来说,固然是比较先进和发达了,但是我觉得我们,执行任务的过程中,是需要有一个底线思维的,是需要有保底手段的。电子产品很有可能,比如说像全舰失电了,或者说受到干扰了,那电子海图就没有办法使用了,这个时候就需要,我们手动进行标绘,这相当于说,一个双备份一个手段。更重要的是,手绘海图的过程,也是舰员熟悉海况的过程。不仅如此,在一些特殊的通信受限场景下,绘算的结果还能通过灯光、旗语,传递给编队的其他舰艇。可以说先进的技术,是战斗力的倍增器,但传统的技能,也是保底的压舱石。在风帆战舰时期,我们常常看到水手,握着巨大的舵轮,驾驶木质帆船驰骋在大海上。而现代战舰,体形远比风帆战舰更庞大,尤其是航空母舰,以绝对体量,堪称海上“巨无霸”,也正因这份庞然。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
“海上巨无霸” 的 “迷你方向盘”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
不少观众心生好奇,驾驭这样一艘巨舰,它的“方向盘”,会不会也是庞然大物呢?答案或许会颠覆你的想象。走进山东舰驾驶室,映入眼帘的“方向盘”,竟比家用轿车的还要小一圈,样式简约,手感轻盈,单手就能轻松转动。要知道,山东舰的体量,堪比一座20层楼高的海上城市,舰体拥有三千多个舱室,满载重量,相当于数万辆家用轿车。如此庞大的身躯,竟靠一个小小的“方向盘”,掌控方向,这份反差令人直呼意外。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
我们很难想象,如此规模庞大的航空母舰,依靠一个小小的舵轮,就可以实现轻松地转向。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
是的没错,这个就是我们这个日常操作的舵轮。它在上面有小的刻度尺,当我们值更官给我们下达这个指令,比如说右舵10的时候,我们只需要把这个舵轮打到10度。您看,这地方有这个刻度,那么我们的舰艇就会逐渐地向右转向。如果是我们要把这个航向操作得更准的话,比如说我们在放那个舰载机的时候,它这个舰载机是有放飞条件的,比如说航向航速之类的,这方面就要考验到我们这个操舵员的技术了。所以说我们这个操舵员就必须要很精确地去把这个航向给稳定住。但是如何去稳定这个航向呢?那么我们需将舵轮反压,舰艇在这个海上滑行的时候,它是有一个惯性的,不像我们日常生活中的车一样。所以说我们反压之后,是为了去抵消这个舰体的一个惯性,然后才能够稳定到我们那个航向上面。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说13】
|
|
||||||
|
|
||||||
与此同时,山东舰采用先进的电传操控系统,舵轮本质上只是一个信号输入装置,转动舵轮的力度、角度,会被传感器精准转化为电信号,传递至舰尾舵机。可以说,小小的舵轮,哪怕是一度的微调,都能让万吨巨舰在海上完成灵活转向,适配复杂海域航行需求。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
我们操作兵在操作航母的时候,不是靠的蛮力,而是靠着我们平时积累的经验,以及对值更官口令的,一个精准地执行,这样我们在海上,才能够精确地去操纵,六万多吨的航母,能够安全地去航行。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说14】
|
|
||||||
|
|
||||||
不过,航母还有一个极具辨识度的设计特征,它采用全通式飞行甲板,舰桥被巧妙地布置在甲板的一侧。这一设计能最大限度释放飞行甲板空间,满足舰载机起降调度的核心需求,但也让航母的操控中枢随之偏向一侧。不同于常规船舶的居中驾驶布局,航母驾驶员的操作席位设置在右侧舰桥内,视野和操控参照系都与传统舰船截然不同。这种非对称的操控环境,无疑给驾驶带来了更高的技术挑战。由于我们要放飞舰载机,所以说我们的舰岛,以及我们这个操作的,都是在靠右舷。在我们实际上进行执行的时候,我们前面有一根那个天线,有一个口令,叫作跟前舰走的一个口令。当我们值更官给我们下达这个口令的时候,我们会以前面的这个天线为参照物,前面的天线对准前面舰体的主桅,然后由主桅往后延伸,到我们这个天线,到人眼这个位置,三点一线,这样我们就可以把这个航向给走直了。更有意思的是,这个迷你舵轮还配备了多种模式。在开阔海域航行时,可以切换到自动导航模式,舰艇会按照预设航线行驶;在复杂海域或者靠港时,又能切换到手动模式,进行巨舰的“绣花式”操作。
|
|
||||||
|
|
||||||
甲板上的 “小个子 大力士”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说15】
|
|
||||||
|
|
||||||
提及山东舰甲板,大家脑海中浮现的,往往是歼-15舰载机腾空而起的雄姿。这些舰载机每架重达三十多吨,堪称空中巨兽。但鲜有人知,在航母甲板上移动这些“巨兽”的,竟是一些身材低矮、体重不足一吨的“小个子”——无杆牵引车。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
这台小装备是无杆牵引车,它的整体很小,而且无杆牵引车一共有四个轮胎。这是无杆牵引车的驱动轮,它的驱动轮只有我膝盖一边高,而且无杆牵引车的底盘很低,在满足甲板通过性的同时,确保舰载机的转运安全,保护舰载机的前起落架。这是无杆牵引车的转向轮,那么无杆牵引车是如何实现转运作业的呢?请看我身后的这个装置,是将无杆牵引车和飞机连接,通过它将舰载机的前轮抱起,进一步实现转运作业。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说16】
|
|
||||||
|
|
||||||
别看它们个头小,力气却大得惊人。航母空间寸土寸金,舰载机停放间距仅数米,要将几十吨重的战机,精准停放到指定位置,难度堪比在针尖上跳舞。而这些“小个子”,能轻松拉动歼-15舰载机,在狭窄的甲板空间灵活转弯。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家8】
|
|
||||||
|
|
||||||
无杆牵引车的操作很简单,如果向前拉时,驾驶员可以正坐在驾驶舱内;如果向后拉,可以将座椅180度旋转后,向后进行顶推舰载机。大家看我们车,有两套刹车和油门装置,可以实现驾驶员从前拉,和向后推的过程。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说17】
|
|
||||||
|
|
||||||
与航母上的歼-15舰载机体形不同,两栖攻击舰搭载的直升机体形更为低矮,机身腹部与甲板间隙更小,常规无杆牵引车很难抱住起落架。在湖北舰飞行甲板上,我们见到了一种外形更呆萌的专用装备——舰载直升机牵引车,因身形方正,宛如一个四四方方的移动托盘,被舰员们亲切地称为“甲板小黄车”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家9】
|
|
||||||
|
|
||||||
在我们的舰上,如何调运十几吨的直升机?就是我们眼前这辆,075型舰载直升机的牵引车。用几句话来讲,它的整个特点就是体积小巧、力量大、操作便捷。第一,体积小巧,因为我们的直升机,它的底盘比较低,直升机的机型不同,它每个机型的底盘高低也不同,为了适配较低底盘的直升机,它设计得就比较小巧。然后我们说这个牵引车力量大,就是因为我们前面这个举升机构,它可以轻而易举地将我们十几吨的直升机举起,然后完成我们的直升机的转运。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说18】
|
|
||||||
|
|
||||||
因为对牵引车的整体高度有很大限制,那么传统的内燃机显然不适合作为配套,毕竟大功率的发动机体积更大。而小功率的内燃机虽然体积小,但是输出功率也小,无力牵引直升机,所以采用电动驱动更为合适。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家10】
|
|
||||||
|
|
||||||
说它操作方便,就是用我们这个遥控盒,就像家里的玩具遥控车一样,简单的几个按钮,就可以轻松地操纵我们的牵引车,完成对飞机的一系列的操作。像这样子简单地往前、往后,同时它可以原地左右进行旋转。可以看出,我们的牵引车,它的操作是十分方便的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说19】
|
|
||||||
|
|
||||||
大国巨舰上的每一处反差,都不是技术的取舍,而是互补共生。先进技术让巨舰拥有了驰骋深蓝的硬实力,传统技能与基础装备则守住了安全底线,二者相辅相成,共同铸就了人民海军的钢铁长城。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
在今天的节目当中,我们总结了大国巨舰的几个反差:数字化战舰与手绘图纸,信息化战场与旗语密码,巨无霸舰体与迷你“方向盘”,还有舰载机与“小个子大力士”。这些反差看似矛盾,实则是统一的。在这些细节的设计当中,我们看到了设计师的巧思,也感受到了舰员们日常的坚守,还有人民海军从近海走向深蓝的底气。将会有更多先进装备入列,当然也会有更多更有趣的细节反差,等着我们去发现。好了观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 马年装备图鉴
|
|
||||||
编导: 张彤枫
|
|
||||||
播出日期: 2026-02-17
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep008
|
|
||||||
---
|
|
||||||
|
|
||||||
# 导视:
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视】
|
|
||||||
|
|
||||||
金戈铁马,龙马精神。2026年,是农历马年。那么在武器装备领域中,有哪些与马相关,或以马命名的经典装备?它们是驰骋大洋的海上“野马”,是纵横疆场的陆战“骏马”,抑或是翱翔天际的空中“飞马”?值此新春佳节之际,《军事科技》带您走近那些骏马疾驰的武器世界。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。今天是农历大年初一,万家灯火,共庆新春。在此,我谨代表《军事科技》栏目,向全国观众,致以最诚挚的新春祝福,愿您在新的一年里,如骏马奔腾,勇往直前。2026年是农历马年,马象征着力量、速度与进取。在现代军事装备中,马元素也屡见不鲜。本期节目,我们就来回顾一下,那些以马命名的武器装备。就是从海平面,踏浪而来的,国产726型气垫登陆艇,它被军迷亲切地称为野马。这款装备航速快,载重大,可搭载主战坦克,跨越波涛直抵滩头,正如古代战马驰骋沙场。那么这款踏浪而来的战马,有着怎样的非凡性能呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
当舰队逼近海岸,如何在敌方火力封锁下,快速完成兵力投送,是两栖突击的核心难题。此时,数艘726型“野马”气垫登陆艇,从母舰舱内缓缓驶出,如海上奔腾的骏马般,划破浪涛,搭载着主战坦克与陆战队员,以极高的速度冲向滩头,完成登陆的“最后一跃”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
早期进行大规模的两栖作战,主要是使用机械化登陆艇。机械化登陆艇,它在靠近岸滩的时候,容易受到障碍物的阻挡。也就是说,它无法让士兵直接实现登陆作战。参与两栖作战的这些士兵,携带的武器和装备是比较重的。所以如果相关的区域内水比较深,那么可能就会出现溺亡的情况。而且在行动的过程当中,容易遭到对方的这种火力打击。有的时候由于相关的沿海和近岸区域,防守的兵力设置了一些拒马,包括其他的这样的一些障碍物,它只能是在靠近岸滩的区域进行这种兵员的投放。但是气垫登陆艇,是完全不同的一种装备。本质上来讲,它并不是传统意义上的这种船艇类的登陆平台,而是一种采用特殊气垫设计理念的这样的一种登陆平台。可以越过滩头阵地上的很多的障碍物,直接把步兵投送到岸滩上,可以让步兵实现鞋不沾水的登陆夺岛作战。而且大型化的气垫登陆艇,运载能力比较强,可以搭载主战坦克装甲车辆,直接把陆军的主战装备投送到滩头阵地。这样的话,在己方火力的掩护和支援之下,能够用最短的时间,把最多的作战人员和作战装备,投送到两栖登陆作战和夺岛作战的最前沿。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画1】
|
|
||||||
|
|
||||||
野马气垫登陆艇的外形,看起来就像是一个巨大的扁平盒子。中空的船体中,可以容纳主战坦克、装甲车辆或大量士兵。而气垫登陆艇最大的特点,就是在于“气垫”二字。它们不像传统水面舰艇一样,采用全金属或硬质材料的外壳,而是在底部,采用柔性围裙结构的充气气垫。就好像是我们游泳时使用的救生圈一样。为了给这个气垫充气,野马气垫登陆艇的动力系统,也不同于传统舰艇的螺旋桨推进。首先由燃气轮机带动的,不是螺旋桨,而是艇体的升力风扇。升力风扇将大量空气压入艇体底部,使艇身脱离水面或地面约10厘米。而这层柔软的气垫,不仅让“野马”摆脱了水的阻力,还能在松软沙滩、沼泽甚至礁石地形上平稳行驶。就像给登陆艇垫上了一层“空气棉花”,完全不惧怕沙滩上的复杂地形。由于气垫登陆艇是悬浮于水面之上,因此,前进的动力也不能依靠水下螺旋桨,而是依靠艇尾两台巨大的推进风扇产生强大推力,推动登陆艇在海面高速航行。总而言之,野马气垫登陆艇通过升力风扇加推进风扇的组合,实现了武侠世界中的轻功绝学“水上漂”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
726型气垫登陆艇,是我国两栖作战体系的核心投送装备,堪称“超地平线登陆”的踏浪利器。其50至60吨的载重,可灵活搭载一辆99A主战坦克,或两辆两栖步兵战车,也可搭载80名全副武装士兵,实现重型装备与人员的快速输送。依托60至80节高速性能,它能在敌方火力射程外发起突击,两小时内完成近距离海峡跨越,大幅缩短暴露时间。轻松突破沙滩、沼泽等复杂地形,彻底摆脱传统登陆艇的地形与潮汐限制。作为071、075型两栖舰的关键配套,它构建起立体投送网络,同时可承担岛礁补给、应急救援等任务,是攻防兼备的两栖作战中坚力量。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
726型野马气垫登陆艇,它的研制成功,尤其是装备相关的部队,能够让我们体系化的这种登陆作战能力,以及岛屿环境当中的作战能力大幅度地提升。为什么这么说?因为我们是采用了这种大小组合的方式,先由大型的海上兵力投送平台,无论是两栖攻击舰,还是船坞登陆舰,把更多的人员装备转运到相关的区域进行集结待命。然后在坞舱当中的野马级的气垫登陆艇,也就是726型的气垫登陆艇,可以迅速地把装备和人员转运到滩头阵地。那么通过这种以大带小的方式,能够实现兵力的快速集结,也可以实现兵力的快速转运,所以这个快字非常重要。也就是说,726型的气垫登陆艇,它和现代化的两栖作战体系它是融为一体的。看完了拥有绝世武功“水上漂”的野马气垫登陆艇,我们再将目光投向万米高空。有一匹会飞的马正在翱翔,它就是安-2运输机。可能您对这个名字有点陌生,但是在我国,它还有一个更广为人知的名字,运-5。运-5运输机的外形短小精悍,仿佛一匹灵巧的草原骏马,所以也被称为马驹。它和我们常见的大型运输机相比,显得小巧许多,造型也有许多不同之处,看起来没有什么科技感。但是,自它1957年首飞以来,始终坚守在军事和民用的航空领域,这款“空中小马驹”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
有着怎样的独特魅力,能让它在近七十年的岁月中,依旧驰骋于蓝天之上呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
相较于目前,可以运载数十吨货物的大型运输机,看起来就像是一个没长大的孩子。所以,“马驹”这个称号,生动地描绘了它娇小的身形。但其实,这些新生代的庞然大物,却要称运-5一声前辈。运-5运输机,是中国航空工业史上的标志性机型。1954年,还处在起步阶段的新中国航空工业,从苏联引进了首批9架安-2轻型运输机,随即展开仿制攻关,仅用三年便完成首飞。这款机型,便扎根祖国广袤疆域,在高原、边疆、海岛及偏远山区,持续服役。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
运-5它是仿制而来的运输机,可以说皮实耐用,结构简单,非常地靠谱。那么之所以采用仿制的技术,就是早期,我们在运输机的研发方面,缺乏这种足够多的经验,所以我们选择最简单的,最可靠的,或者说在此前应用过程当中,它的表现是非常好的,这样的一个平台进行了仿制,很快就实现了批量列装。为什么说运-5非常地重要,因为它的功能实在太多了,既可以满足日常的人员,物资的运输需求,同样也可以用于训练伞兵,这对于伞兵部队,也就是空降兵部队,来讲是非常重要的。它的勤务保障要求是非常低,而且是一台发动机,那么通过大批量地列装和使用,大家认为,运-5它是一款非常优秀的,这种单发的运输机,那么既可以执行运输任务,也可以用于伞兵的训练。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画2】
|
|
||||||
|
|
||||||
运-5运输机,和目前主流的大型运输机,有着很多截然不同的设计理念。采用活塞式发动机的它,可以说是一台空中“拖拉机”。这种设计,相比采用涡轮发动机的现代运输机,虽然在飞行速度和载重能力上都处于劣势,但却在维护成本和燃油经济性上,有着巨大的优势。采用双机翼设计的运-5,在飞行时,上下两个机翼都能够产生升力。这种牺牲飞行速度换来的,是极佳的低速飞行性能和短距起降能力。在极限状态下,运-5仅需180米的跑道,即可完成起降。如果说起运-5身上最老派的设计,一定是它的固定式起落架。这种二战时期就已经被淘汰的设计方式,为运-5带来了极高的结构可靠性。同时,不需考虑收起起落架所占用的空间,为本就不大的机体内部,腾出了更多的载物空间。正因如此,运-5可轻松承载1.5吨货物,穿梭于简易跑道之间,成为偏远地区空中运输的可靠选择。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
目前运-5,仍是我国通用航空领域的,重要辅助装备,同时在军事训练中,持续发挥价值,凭借180米短距起飞,低速稳定飞行的特性,在多场景中不可替代,在农业领域,它作为绿色战鹰,承担飞播造林与病虫害防治,从内蒙古草原到秦岭山区,精准播撒籽种,或喷施药剂,完成大面积生态作业,军用领域,运-5是空降兵基础跳伞训练的,核心机型,低速稳定的飞行特性,为新兵提供安全的跳伞环境,累计培养数万空降兵骨干,其简易场地适配性,也可满足,边防物资快速投送,应急机动支援等军事需求,运-5服役的数量比较大,另外一个操作非常地简单,不需要,特别复杂的,这样的一个技术保障条件,那么由于,还有一部分运-5,它的发动机,还是处于可靠的状态,所以在它的寿命周期内,还是可以用的,为什么说现在仍然有,一部分运-5,还是可以执行任务的,用于日常的飞行训练任务,包括伞兵的日常训练,它还是合格的,还是能够胜任的,除此之外,它也可以实现军转民,比如说,转为这个农用飞机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
进行飞播造林,或者说进行喷洒农药,所以说目前仍然有一部分运-5,它可以继续地发挥余热。那么如果利用可靠的平台,改成这种运输型的无人机,也是一个很好的选择。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
运-5这款“空中小马驹”,在数十年的服役生涯中,凭借出色的低空飞行性能,极强的短距起降能力,以及极高的可靠性,成为一款老而弥坚的经典机型,在一众大型运输机中,仍然发挥着不可替代的作用。但是,并不是所有以“马”命名的飞机都像“小马驹”一样温顺实用,它们也可以充满野性与力量。如果在空中,有哪一匹“马”称得上是力量担当,那非美国的CH-53直升机莫属了,并且它也有一个霸气的名字——“海上种马”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
20世纪60年代,美军部队面对越南战场上,大量覆盖丛林的山地战场,大量需要依赖地面跑道才能起降的运输机,无法有效投送重型装备与部队。面对前线胶着的战事,直接催生了直升机这种不依赖地形,可以垂直起降的航空装备的发展。此时,美国陆军已经拥有了以CH-47支奴干双旋翼运输直升机等为代表的重型运输直升机,而海军陆战队在这一方面还是空白。当时的海军陆战队决定,自行研制一款属于自己的重型运输直升机,而且性能必须超越陆军现有机型,能吊运火炮、车辆等超重装备。在几番竞标与技术攻关后,西科斯基公司最终中标,由此便开启了属于海军陆战队的“海上种马”时代。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
我们客观来讲,CH-47也可以满足美国海军陆战队的这种任务需求,因为它的载重能力,和针对一些轻型火炮的吊运能力是比较强的。如果美国海军陆战队直接使用CH-47,没有任何问题。但是为什么说美国海军陆战队还要发展CH-53?美军的内部它是有军种之争的。那么海军陆战队,虽然说在军迷和网友的眼中被称为“四等人”,但是美国海军陆战队它的作战环境比较特殊,尤其是执行夺岛作战和两栖作战的过程当中,它必须有一个独立的这样的装备体系。这样的话,它可以争取到更多的经费,保证它自身的任务需求,关键的时候是不求人的。那么为什么“超级种马”这款重型的直升机设计是比较独特的呢?就是因为海军陆战队它要求装备在海上长时间部署的时候,有非常强的可靠性,而且对于重型直升机它的长度,以及它所占的两栖攻击舰内部的机库空间的这样的一个面积,要做到一个最小值。所以“超级种马”和CH-47相比,更能满足海军陆战队的需求。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画3】
|
|
||||||
|
|
||||||
CH-53 “种马” 重型运输机家族,最大的本事就是,力气大,我们要如何量化它的力气呢,“种马”家族中,最强的CH-53K,“超级种马”,配备三台发动机,——共可以输出,约1.8万匹马力,要知道,普通家用轿车的动力,一般在150至200匹马力,也就是说,一架“超级种马”大约相当于,100辆家用轿车的力气,“超级种马”的极限,因为它还预留出了,第四台发动机的位置,所以1.8万匹,其实并不是它的上限,这种超标的动力,使它可以一次搭载,55名全副武装的陆战队员,进行机降作战,如果使用机体上的,货物挂钩,它可以拉起一架,并不满载的F-35C,固定翼战斗机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
自CH-53“种马”家族的老大哥“海上种马”服役以来,进行了多个型号的更迭。从CH-53A“海上种马”,到CH-53E“超级种马”,再到如今的CH-53K“种马王”。“种马”家族的每一代成员,都以更强的运载能力,更高的可靠性,与更远的航程,持续刷新着重型运输直升机的性能标杆。CH-53K“种马王”作为家族巅峰之作,搭载3台GE T408涡轴发动机,单台功率突破7500轴马力。相较CH-53E,运力提升近一倍,可轻松吊运12吨级装备,和37名全副武装的士兵,完全适配两栖攻击舰,与偏远战场的投送需求。每一次型号迭代,都紧扣实战需求,在载重、防护,三大核心指标上持续突破,巩固了其全球重型运输直升机的领先地位。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
军事强国、大国,必须要拥有重型的运输直升机。为什么?因为重型的运输直升机,它针对陆军航空兵,针对海军陆战队的支援保障能力是比较强的。因为重型直升机,它内部的这个舱室的空间,也就是货舱载员舱的空间比较大,一次性投送的兵力比较多。另外一个,它也可以携带一些突击车辆。那么通过这种快速低空投送的方式,把人员和装备都投送到战场上的这样的一个重要的区域去发挥作用。也可以采用这种吊运的方式,去向一线部队去提供轻型火炮,包括一些轻型的这种运输车辆,甚至是一些轻型的装甲车。它也像是一个会飞的起重机,针对一线的作战部队进行物资补给、弹药补给和装备的这样的一个运输,它也是一个不可或缺的平台。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
“种马”家族,自诞生以来,一直在直升机运载能力的榜单上名列前茅。它的外形看起来算不上英俊,甚至有些粗犷。但是,它就像驰骋在沙场上的战马一样,充满力量与野性。看完了翱翔在蓝天的“飞马”,我们再将视线转向陆地,这里才是战马最熟悉的战场。在古代,马匹作为最重要的运输和作战工具,主宰着战争的节奏与走向。如今,陆地上的钢铁战马,早已取代了血肉之躯。如果哪款以“马”命名的陆上装备最为人所熟知,那无疑是“悍马”车了。这种外形方正的军用越野车,自问世以来,便以卓越的机动性和强悍的通过性著称。无论是在沙漠戈壁,还是密林山地,都能如骏马般纵横驰骋。其军民两用特性,更是成为了无数男生心中的梦想座驾。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
“悍马”车,是无数男生梦想中可以上山下海的硬核座驾。硕大的车身和粗犷的轮眉下,是能征服一切地形的全时四驱系统与中央轮胎充放气装置。能在零下46摄氏度极寒与55度酷热中稳定运行,是极限越野爱好者的最佳选择。而它,却有着真正的军用血统。军用的“悍马”车全称是高机动性多用途轮式车辆。之所以后来叫作“悍马”车,则是因为它英文名字的发音听起来像是。作为一种可以在低烈度场景下进行人员运输的车辆,悍马车可以说是参与了上世纪80年代后美军参与的所有战争。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
“悍马”它应该是一种通用型的军用车辆,本质上来讲比吉普车大,但是比重型卡车小。那么这种车辆最大的优势,就是它可以防弹,因为它是有装甲板的,针对轻武器,比如说步枪、轻机枪的一些射击,它可以起到一个足够的防护作用。与此同时,“悍马”车也可以架设武器装备,像重机枪、自动榴弹发射器、反坦克导弹,都可以安装和架设,可以协助和支援坦克和装甲车进行作战,同样也可以边快速行进,边进行这种火力压制。从(上世纪)80年代之后,可以说一直到现在,美军仍然装备大量的“悍马”车,在部队进行大纲深区域内进行机动的时候,它可以发挥重要的运输和保障作用。当然在一些低烈度的这样的一个巡逻任务当中,它也可以有效地掩护步兵,也可以通过安装附加装甲的方式,强化自身针对轻武器的防御能力。所以不仅美军用,很多其他国家也是引进装备和采购。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画4】
|
|
||||||
|
|
||||||
“悍马”车最大的特点,就是全能。首先,作为一款可以在复杂路况下进行人员运输的车辆,它除了不能飞,不能潜水,几乎做到了全地形。非承载式底盘和大行程独立悬挂,使它的离地间隙达到了40厘米,可以跨越0.6米高的障碍,或者接近一米的水域。其次就是功能全。宽大的车体可以针对不同任务进行模块化改装,配备电子系统可以作为战术指挥车辆,宽大的后排空间可以改造为战场救护车。最重要的是,可以更换武器站,可以在重机枪、榴弹发射器等单兵武器间进行更换,甚至可以加装反坦克导弹,客串一把坦克杀手。加上全车覆盖的轻型装甲,让“悍马”车成为低烈度地面作战的最佳选择,成为廉价版本的多功能装甲车。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
“悍马”车并非完美无缺。虽然相较于其他车辆,“悍马”车的防护能力已属上乘,但其本质上还是一款轻型运输车辆,在面对RPG火箭弹或路边炸弹时仍显单薄。并且为了在极端复杂战场环境中保持高机动性,导致“悍马”车的油耗居高不下,又与它经常参与的远距离奔袭和城市作战需求形成矛盾。尤其是在2003年伊拉克战争后,大量的“悍马”车因防护不足而遭袭损毁,从而促使美军加速推进联合轻型战术车辆项目的研发。“悍马”车逐步退出一线作战序列,转而承担后勤运输、基地巡逻等低风险任务。其经典形象则通过民用版H1,化作了一个时代的标志。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
看完了这么多,以“马”命名的装备,不难发现,它们虽然形态各异,却都承载着人类对速度、力量与自由的永恒追求。无论是运-5的坚韧执着,还是CH-53的雄健霸气,还是“悍马”的无畏前行,这些机械身躯,都延续了马这种动物的特点。那么在节目的最后,《军事科技》栏目全体成员,武器装备中的“骏马”,祝您在新的一年,一马当先,马到成功,马年吉祥。好了观众朋友们,感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 重金造“神器”还是造“摆设”
|
|
||||||
编导: 付天雨
|
|
||||||
播出日期: 2026-02-24
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep009
|
|
||||||
---
|
|
||||||
|
|
||||||
# 军武“翻车”记
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
在全球军工范畴内,那些造价不菲,技术炫酷的武器装备,本应是纵横战场的硬核强者。偏偏存在那么一批天价装备,它们没有在对手面前展现过几次风采,反倒先让自身陷入了困境。有些服役数年,却无法投入实战;有些经历数十年研发,最终沦为摆设。这些荒诞的失误表现,堪称军工史上的尴尬时刻。本期《军事科技》,将与您一同盘点那些“出道即折戟”的天价装备,揭开它们光鲜外表下的致命硬伤。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。在人类军工发展的历史长河中,从不缺少叱咤风云,威震八方的传奇装备。但是与此同时,也有一批天价装备,它们带着黑科技,划时代的耀眼光环诞生,花费数以亿计的资金,凝聚着数年乃至数十年的心血,最终,却因种种荒诞的设计缺陷,决策失误,沦为军工史上,令人哭笑不得的“翻车”典型。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
我们首先来看,当今世界上,造价最为高昂的核动力航母,美国福特号,作为美国海军,下一代航母的标杆之作,福特号被寄予了,领跑全球航母技术,巩固海上霸权的厚望。舰上汇集了电磁弹射,新一代核反应堆,综合电力系统等多项前沿技术。美国海军希望,它能全面超越尼米兹级,成为未来远洋作战的核心力量。可是,就是这样一款,被捧上神坛的超级航母,却在服役之后,彻底走向了预期的反面。福特号航母的设计架构,可追溯到,1988年启动的,CVN-21未来航母计划,其核心目标在于,打造一款全方位超越尼米兹级的,新一代核动力航母,以达成战力跃升,成本可控,寿命延长这三大诉求。为实现这一战略目标,新一代核动力航母,明确将电磁弹射系统,先进阻拦装置,双波段雷达,全电推进系统,列为核心技术标配。这四大关键技术,共同构建起核心战力。其中,电磁弹射系统,是新航母的关键创新所在。与尼米兹级的蒸汽弹射器相比,它无需提前储备蒸汽,能够通过电力精确调节力度,以适配轻重不同的舰载机,与无人机。同时,它还能大幅提高舰载机的,日均出动量,使效率得到显著提升。此外,该系统结构更为简单,零部件减少一半以上,这将大幅降低维护成本,和人力需求,使航母操作人员,缩减至两千人左右。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
福特号核动力航母,也就是福特级核动力航母,确实是当时美国海军求新求变的一种大型的海上主力作战平台。除了电磁弹射器之外,它也设计了电磁弹药升降机和电磁的拦阻装置。除此之外,它的舰岛进行了进一步地优化,个头变得更小,采用了隐身化的设计理念。而且,内部结构其实也是出现了很大的变化,因为这个福特级核动力航母,它所搭载的舰载机的种类会变得更多。除了超级大黄蜂之外,还有F-35C,针对不同的这种舰载机和无人机,进行这种勤务保障。从航母的研发和制造来讲,当时福特号航母提出的这样的一些技术标准和设计标准,可以说是比较领先的。那么对于美国的造船体系,对于美国的设计单位来讲,其实也是一个难题。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
2017年5月31日,福特号举办了一场盛大的服役仪式。当时的媒体盛赞它是工程与军事领域的巅峰之作。这份光辉消逝得也很迅速。随着海上测试和部署工作的推进,这艘耗费130多亿美元打造的“海上巨无霸”,彻底沦为“问题航母”。其核心系统频繁出现故障,实战能力严重下滑,甚至遭到了美国国会的质疑,认为它“不具备完整作战能力”。首先是电磁弹射系统。美国海军设定的设计指标为平均弹射4166次才出现一次严重故障。然而,在2018年海试期间,仅弹射747次便出现了10次故障,平均故障间隔仅有181次,这还不及设计标准的1/23。更为关键的是,电磁弹射系统实际最大弹射重量仅为25吨,远远达不到设计的41吨标准。这就导致F-35C无法在满油满弹的情况下使用,只能勉强让超级大黄蜂凑数。其次,与电磁弹射系统配套的阻拦装置,情况也不乐观。按照设计,该阻拦装置能够实现16500次阻拦无故障。但实际测试时,仅拦阻48次就出现问题,故障率是预期的三百多倍。即便到了2025年,最新数据依旧不乐观,平均每拦阻460次就会出现状况,其性能远不如老款液压挡阻索可靠。除此之外,升降机、动力也频繁出现故障。截至2026年初,福特号核动力航空母舰已服役近九年。服役期间,该舰多次遭遇设备故障与系统问题,不得不提前中断部署任务,返回诺福克海军造船厂实施大修与整改。福特号累计停场维修时长已突破20个月,整改费用更是超过15亿美元。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
从设计的理念来看,福特号核动力航母,或者说同一级别航母是没有问题的。那么之所以出现了各种各样不靠谱的情况,是由于美国造船厂整体的这种制造能力出现了严重的下滑。也就是说,技术并没有按照它的设计标准进行同步提升,甚至出现了一些比较严重的人才流失。像一些专业的技术人员,或者说有经验的这种造船工人,已经是严重不足。所以在制造的过程当中,并没有达到它预想的标准,电磁弹射器频繁地出故障,包括一些供电的设备,以及其他的一些子系统,也出现了不靠谱的情况。所以,福特级核动力航母这个标准提得很高,而且也是想要最大程度地求新求变。但是美国制造业整体的这种实力的下滑,和造船体系的空心化,导致它的这样的一个工业基础和造船体系,无法支撑起福特号的这种高标准,出现了各种各样不靠谱的情况。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
如今的福特号,成了诺福克海军基地的常驻嘉宾,日常状态不是在维修,就是在等待维修零件,百亿美金下去,只换来一个港口“吉祥物”。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
如果说“福特”号航母,是因技术难题,而频繁受挫的典型案例,那么印度的“阿琼”主战坦克,则是一部“三十年磨一剑,终究磨出一把钝刀”的军工辛酸史。20世纪80年代,壮志满怀的印度,立志打造一款能比肩,甚至超越美苏主流型号的国产王牌主战坦克,决心在陆战装备领域崭露头角。无奈理想很丰满,现实却很骨感,这款承载着印度军工殷切期望的“国产神车”,其研发过程,堪称一部高开低走的窘迫实录。从项目立项到列装部队,整整耗费了30年时间,投入了45亿美元巨资,硬生生创下了全球主战坦克研发周期最长的尴尬纪录。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
这是一场自1974年开启的豪赌。其核心目标在于打造一款第三代主战坦克。此坦克需全面超越苏制T-72,且能与美制M1艾布拉姆斯相媲美,以达成火力强劲、防护出众、完全国产这三大诉求。为实现这一宏伟愿景,这款承载着印度军工自主梦想的坦克,明确将国产120毫米线膛炮、自研复合装甲、大功率柴油发动机、数字化火控系统列为核心技术的标配。这四项关键技术,共同被给予了构筑印度陆军下一代核心战力的厚望。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
阿琼主战坦克的创新,体现在印度陆军,要用这种所谓的国产坦克,要替代它以前,大批量装备的苏系主战坦克。也就是说,要实现针对战场目标的快速识别,要使用更先进的火炮系统,针对目标进行快速的打击,而且要提升这个防护能力。所以阿琼坦克,是印度陆军试图摆脱,苏系俄系坦克,转而追求欧洲标准的一个产物。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
2004年8月,阿琼主战坦克,正式被引进印度陆军编制。彼时媒体纷纷盛赞它为“南亚陆战之王”,视作印度军工,自主研发领域的巅峰之作。然而,这份荣耀如昙花一现,随着列装进程的推进,与训练的深入,这款耗费30年研发,投入超过45亿美元打造的国产王牌,彻底沦为了问题坦克。问题出在,其核心120毫米线膛炮。印度军工设定的设计指标,为炮管寿命达到1000发,实际测试时,仅发射200发炮弹,就出现了炮管线膛磨损、炮口炸裂等故障,寿命不及设计标准的1/5。更要命的是,这款主炮配备的自研穿甲弹,实际穿深仅为350毫米,均质钢装甲,远远达不到设计的500毫米标准。这就导致阿琼坦克,面对主流主战坦克时,根本不具备正面击穿能力,只能勉强用高爆弹,执行支援任务。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
阿琼主战坦克的终极改进型号,也就是印度陆军能够拿到的最先进的型号,它的全车的重量接近69吨。那么,对于保障体系而言,它会变成一个很笨重的钢铁怪物。此外,阿琼主战坦克现在已经出现了发动机功率无法满足它笨重车体的这样的一个情况。如果是在高温环境下长时间的运行,可能也会出现整个的车体出现过热的情况,发动机的可靠性是下降的。虽然说它是采用了德国设计的这种先进的柴油机,但是和车体的适配性一直有问题。此外就是它的装甲,并不像印度陆军所吹嘘的那么先进。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
如今这款坦克,大多时候仅作为阅兵式上的“花瓶”存在。鉴于其维护难度大,且实用性欠佳,它被长期搁置在仓库里。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
如果说印度的军工发展之路,万国牌拼凑的“翻车”闹剧,那么韩国的KF-21“猎鹰”战斗机,无疑是将打肿脸充胖子的戏码演绎到了极致。韩国喊出了自研五代机的口号,其不仅立志打造一款能与F-22、歼-20相媲美的战斗机,还拉拢印尼共同分担高达60亿美元的研发费用。这款被寄予厚望的国产神机,最终却沦为了航空史上伪五代机的典型案例。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
2022年7月19日,一架与美军F-22隐身战机颇为相似的战机,从位于韩国庆尚南道泗川市的一座空军机场起飞。战机在空中飞行33分钟后成功降落。飞行结束后,这架战机获得韩国总统称赞。韩方认为,这是韩国国防独立取得的重大成就。同时,韩媒宣称,此次飞行成功,意味着韩国成为继美国、中国和俄罗斯之后,全球第四个能制造超音速隐形战斗机的国家。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
韩国之所以要研制KF-21,这种双发中型的隐身战斗机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
就是要在航空工业体系方面,要有一个比较过硬的产品。当然了,它这个技术指标,定得不是特别高。韩国对于KF-21,它的定位是一种中低端的隐身战斗机,能够满足它自身的这种使用需求。那么面对对方不太先进的防空预警体系,对方不太先进的战斗机,让它们看不见就可以了。此外对外出口的时候,它的价格非常低,要比现在其他国家对外推销和出售的隐身战斗机,这个价格都要低很多。自认为可以采用低价的方式,能够找到更多的潜在客户和这种国际合作伙伴。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
三年多的时光已然流逝,6架原型机历经42个月,圆满完成1600余架次试飞,以及1.3万余项试验科目,并且计划于今年下半年开始量产。这看似成功迈出了,从原型机,到列装部队的关键一步。当初被韩媒大肆吹嘘的,隐身五代机定位,早已被韩国官方,悄然修正为4.5代战机。这款外形酷似F-22的战机,终究未能突破核心的,隐身技术壁垒。真正的隐形战斗机,是对空气动力学,与电磁学的双重极致挑战。首先在外形设计方面,必须犹如经过精密计算的,偏转镜一般,将敌方雷达波,巧妙地散射至非探测方向,使电磁波无法形成有效反射。这种对机身线条的严格打磨,乃是隐身的第一道防线。然而相较于外形设计,更为困难的是“五脏六腑”的布局。内置弹舱,这堪称五代机的身份证。一旦导弹采用外挂方式,巨大的雷达反射截面,便会瞬间戳破隐身伪装。F-22与歼-20均拥有经典的,一主两侧弹舱布局,而俄制苏-57,则采用独立的串列主弹舱,配合茧包式格斗弹舱。反观韩国的KF-21,它在这一核心指标上,做出了妥协。它并未采用内置弹舱的设计,而是直接在翼下,设置了6个翼下挂点,以及4个机腹半埋式挂点。这种设计让它从隐身刺客,摇身一变成了,半遮半掩的弓箭手。那么KF-21,它最大的问题,就是设计思路比较保守。我们可以看到,F-22猛禽隐身战斗机的影子,还可以看到,KF-21借鉴,F-35隐身战斗机的,这样的一些特征。但是,韩国他的航空工业体系,并不是特别完善。采用的是两台,这种中等推力的发动机。而且,KF-21这个机身的尺寸,也并不是特别小。在这样的一种状态下,KF-21 它服役之后,就已经不是特别先进了。在此基础上,其实对它进行升级,完善和改进,并不能很大程度上地,提升作战能力。甚至想要改出一个内置弹舱,韩国都需要更多的,过渡时间。现在韩国对于KF-21的定义,是4.5代战斗机。但是由于韩国,在隐身战斗机的设计方面,以前是缺乏基础的,那么很多的体系都是要从零起步,进行配套研发和构建。所以他相当于花了五代机的,一个研发和制造经费,最后搞出了一个4.5代的,半吊子隐身战斗机。尽管KF-21,已经变更成为了4.5代战机,先天的设计缺陷,与核心技术的缺失,让它即便在四代半战机的阵营里,也难言出众。无内置弹舱的隐身硬伤,高度依赖海外的供应链,高成本低性能的尴尬,终究让这款战机,成了一款既达不到五代机标准,又在四代半领域,缺乏竞争力的平庸之作。如果说KF-21的失利,是源于技术底气匮乏,那么法国。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
勒克莱尔坦克面临的困境,则是财大气粗、肆意而为的体现。20世纪80年代,凭借深厚军工底蕴的法国,立志要摆脱AMX-30坦克“皮薄馅大”的指责,决心打造一款在全球领先的新一代主战坦克。然而,最终耗费百亿资金打造的“法兰西骄傲”,却一直问题频发。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
1986年,一场围绕坦克的革新,在法国正式拉开帷幕。其目标,是打造一款超越德国豹-2,媲美美国M1的坦克,重塑欧洲陆战装备的实力标准。作为全球首款,真正意义上的全数字化主战坦克,勒克莱尔的信息大脑,堪称划时代的技术突破。它开创性地集成了,战场数据链,实时导航,敌我识别,故障诊断等多项核心功能,能够将自身侦察获取的精确数据,与友军单位,指挥中枢,实现无缝对接与实时共享。这一革命性的设计,使乘员,能够实时掌握全域战场态势,彻底突破传统坦克,孤军奋战的战术局限,成功引领主战坦克,步入体系化协同作战的,全新时代。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
设计勒克莱尔主战坦克的时候,法国是吸取了,二战期间的一些教训,认为主战坦克不能太重,一定要强调它自身的机动性,所以勒克莱尔主战坦克,在当时有两方面的,这样的一些比较先进的设计理念,第一方面,就是车体比较轻,而且车体比较短,无论是运输还是作战使用,那么对它进行保障,和维护比较简单,机动性比较强,在战场上跑得很快,而且法国的一些火控系统,当时也是比较先进的,可以实现,针对对方的坦克和装甲车辆,进行快速射击,然后快打快撤,此外勒克莱尔主战坦克,它在后续发展的过程当中,在升级的过程当中,非常注重,这种信息化和数字化的,这样的一些系统的融入,它可以更好地去接收,不同作战平台的信息,那么针对战场上的,数据的处理能力比较强。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
1993年,勒克莱尔主战坦克,正式装备法国陆军。凭借全球首款,数字化坦克的荣耀光环,它被媒体盛赞为,第三代坦克先驱。阿联酋,以单价超过1000万美元的高价,一口气订购了388辆,让这款坦克一时间风头无两,俨然成为欧洲军工的骄傲。这份高光时刻并未持续太久。随着实战部署,以及时间的推移,这款造价是德国豹-2两倍的天价坦克,开始暴露出层出不穷的问题,辉煌背后的隐患彻底爆发。首先崩溃的,是核心动力系统。它搭载的,新型柴油机组组合结构过于复杂,故障频发。实际发动机无故障间隔,仅为设计值的60%,多次在军演中突然抛锚。尽管法国,早在1995年,就启动了首批改进计划,但却未能触及结构缺陷的根源。紧接着,曾经引以为傲的数字化大脑,也出现了问题。早期火控计算机,战场数据链频繁死机,尤其在沙漠环境中,沙尘极易导致电路板短路,平均每10小时,就出现一次电子故障。更致命的是,数字化系统维护,需要专用设备与技术,基层维修单位,根本无法独立处理,故障坦克只能长期搁置。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家8】
|
|
||||||
|
|
||||||
法国陆军装备的勒克莱尔主战坦克,以及中东地区用户所使用的勒克莱尔主战坦克,也爆出了很多的问题。比如说,空调性能无法满足炎热环境的这样的一个使用需求,一些动力系统也经常地出故障。我认为,这是勒克莱尔主战坦克,它在后续升级,或者说它在完善的过程当中,很多核心系统,并没有进行有效的匹配,它导致的问题。那么后续无论是动力系统,还是其他的一些方面出现的问题,主要是这个主战坦克的升级和改造,没有跟上它的这种使用环境的这种变化的这样的一个需求。也就是说,如果没有办法对它进行优化,对它进行完善,又出现了一些全新的使用环境,和不同的这样的一个任务需求,那么就会出现平台无法适应任务环境的情况。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
如今,勒克莱尔已彻底告别了昔日的高光时刻。多数坦克静置于库房之中,尘封已久,积满灰尘。法国更计划于2030年前,将其现役数量削减至200辆以下,加快其退出主力序列的步伐。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
从美军的福特号航母,到韩国的KF-21战斗机,这些曾经头顶黑科技、划时代耀眼光环的天价装备,如今早已褪去往昔荣光,沦为被各类问题紧紧纠缠的麻烦制造者。维护费用如流水般持续消耗,更是如同家常便饭一般,拖慢进程。当初投入的数十亿,乃至上百亿美元真金白银,反倒成了为华而不实付出的一笔笔冤枉钱。而这,也恰好揭示了一个朴素,却无可辩驳的道理:武器装备的核心价值,永远在于实战。再炫酷的纸面数据,再高昂离谱的造价,一旦脱离了战场需求本质,背离了实战的基本准则,终究只会沦为持续吞噬资金的怪兽。好了,观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 枪械射速决定论
|
|
||||||
编导: 张彤枫
|
|
||||||
播出日期: 2026-03-10
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep010
|
|
||||||
---
|
|
||||||
|
|
||||||
# 导视:
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
对于枪械而言,很多设计思路,都可以影响枪械的性能。但其中非常重要的一个指标,就是射速。在众多类型的枪械中,射速最快的枪械都是哪些?它们是如何实现更快的射速?更高的射速对它们来说,又有着怎样的意义呢?《军事科技》带您探究,枪械射速决定论。各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。在瞬息万变的战场上,火力的压制与覆盖,往往能决定战斗的走向。枪械射速的快慢,直接关系到,单位时间内,弹药投送的密度,和对目标的打击效率。究竟哪些枪械,能够称得上,是射速领域的佼佼者呢?它们又凭借着怎样的设计奥秘,实现了令人惊叹的高射速呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
首先我们来看看手枪,手枪作为近距离自卫和辅助作战的武器,并不会像步枪和机枪一样,追求极高的射速。尤其是早期的左轮手枪,其射速,很大程度上,依赖于射手的熟练度。那么,现代的手枪,是通过什么方式,去提升射速呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
在早期热武器时代,无论是燃发枪还是火绳枪,都因为需要将火药和弹丸,通过枪口进行装填,所以导致它们的射速,受到极大的限制。在这一时期,前膛枪的射速,一般在每分钟三发,极其熟练的射手,也仅能做到每分钟五发。所以我们会看到,在两军对垒时,双方都会排着整齐的密集阵型,进行射击,通过密集的齐射,弥补单支枪械的射速劣势。随着枪械技术的发展,后装枪的出现,使得装填速度,有了一定提升,同时定装弹药的普及,也让装填流程大幅缩减。作为这一时期的代表武器,左轮手枪,可以一次性装填6发弹药,极大地缩短了射手,在射击间隙的装填时间。但同时我们会发现,这一时期的左轮手枪,在射击时,会有一个奇怪的动作。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画解说1】
|
|
||||||
|
|
||||||
很多人以为,枪械开火只需要扣动扳机。实际上,扣动扳机瞬间要完成两件事:扣动扳机释放击锤,撞击底火,引燃发射药完成开火。若要再次击发,仅扣动扳机是无效的,需像西部牛仔一样,手动扳回击锤,才能再次射击。这种打一枪扳一下的模式,叫单动式击发,即扳机只释放击锤。所以,早期左轮手枪提升射速,全靠射手双手配合。若想提升射速,则需要将“扳”和“击”连接起来,扣扳机时,同时完成击锤复位与释放,这就是双动式击发。现代多数左轮手枪,和部分半自动手枪,采用此设计。射击前,无需手动扳击锤,扣扳机时,击锤自动复位与释放,利用套筒后坐力,完成击锤复位,省去手动操作,显著提升连续射击速度,将手枪带入半自动时代。手枪在很多的使用场景当中,它是作为自卫武器,去进行使用的。那么很多这种使用手枪的,这样的一些人员,可能并没有经过,长时间专业化的训练。所以手枪在一定距离上,进行射击的时候,精准程度没有办法保障。在自卫的这种状态下,可能就要求它有很高的射速。也就是说,它必须要在短时间内,清空弹匣,采用这种,快速连续射击的方式,提升它的命中率,让对方在短时间内失能,不具备这种反击的能力,才可以有效地保护自己。所以说,手枪的这种快速的射击能力,是比较重要的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
当手枪进入半自动时代后,射速也有了明显的提升。一般情况下,清空一个15发弹匣,仅需4到5秒。半自动手枪的理论射速,每分钟600到800发。但是,这并非手枪射速的极限。部分特制的手枪,不断将射速推向了新的高度。其中最具代表性的,就是奥地利格洛克公司推出的格洛克18。您猜猜,这把枪的射速,能够达到多少呢?让我们一起来看一下。您没有看错,这种超越常规手枪认知的射速,就是格洛克18在现实状态下达到的射速。所以,格洛克18也区别于半自动手枪,成为了一种全新的全自动冲锋手枪。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
格洛克18的诞生,源于对极致火力的追求。20世纪80年代,全球反恐形势日益严峻,执法部门和特种部队急需一种能在近距离突发冲突中提供强大火力压制的便携武器。当时主流的半自动手枪在面对多名武装分子时,单发射击的火力密度已显不足。奥地利格洛克公司敏锐捕捉到这一需求,决定在其经典的格洛克17基础上开发一款具备全自动射击能力的新型手枪。它在格洛克17的基础上进行了关键性改进,其理论射速可以达到每分钟千发以上。这意味着一个标准的17发弹匣,在不到两秒的时间内就会被打空。如此恐怖的射速,使得格洛克18在近距离遭遇战中能够瞬间形成强大的火力压制。这种强大的火力输出使其区别于半自动手枪,自成一派。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画解说2】
|
|
||||||
|
|
||||||
格洛克18,与同时期其他半自动手枪,最大的不同,就是它完全取消了击锤设计,转而采用了,击针平移式击发机构,这种设计,不仅简化了内部结构,减少了运动部件的数量,更重要的是,缩短了击发动作的行程,相比传统击锤式设计,在动作连贯性,和响应速度上,都有显著提升,更为关键的是,格洛克18创新性地,引入了一个快慢机选择杆,当快慢机拨至全自动模式时,射速最高可以达到,每分钟1300发,这种射速,已经超越了大部分全自动步枪,使其在近距离内,能够爆发出,接近冲锋枪的火力密度,同时,为了防止全自动模式下,枪械走火误伤,格洛克18在扳机处,设计了一个独特的,双扳机结构,在主扳机前方,设有一个小型的辅助扳机,只有当手指,同时按压辅助扳机,和主扳机时,枪械才能进入全自动发射状态,这一设计,极大地提升了使用时的安全性,格洛克手枪,很多国家的,警方的执法人员是大量地配发,它最大的优势就是,操作性是比较好的,而且,整体上来讲也不是特别重,那么经过简单的培训之后,就可以进行精准的射击,如果是警方使用这种枪械,就会面临一个问题,很多情况下都是遭遇战,针对车辆进行检查的时候,比如说进入室内,针对嫌疑人进行抓捕的,这样的一个过程,在这种状态下,一旦对方使用武器进行射击,那执法人员,或者是警员,所以这个时候,就要求手枪它有极高的射速,短时间内大量地倾泻弹药,针对目标进行压制,让对方失去反抗的能力,让对方没有机会开枪,所以这是在一种危险环境当中,对于手枪性能的,这样的一个要求,因为格洛克这种手枪,可能在军队当中,使用得并不是特别多,主要是在执法的过程中,能够快速射击,精准射击,针对嫌疑人,和持枪的人员,进行杀伤和压制。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
因其独特的全自动模式,成为手枪家族射速的担当。最高每分钟1300发的射速,让众多步枪和机枪都甘拜下风。手枪是近距离自卫武器,无法远距离作战,步枪的作用就凸显出来了。步枪射速的进化,大体也是经历了单发、半自动、全自动三个阶段,但起点是栓动式步枪。若在单发栓动式步枪中,选射速最快的代表,当数李·恩菲尔德步枪了。它不像左轮手枪,靠射手双手配合,而是在结构设计上就有独特之处。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
在栓动式步枪被大量使用的二战期间,由于机枪和步枪的使用方式有着明显的界限,以毛瑟98K步枪和莫辛-纳甘步枪为代表的一众栓动式步枪,并没有针对射速提出更高的需求。就有一把特殊的栓动式步枪,在射速层面玩出了不同的花样。李·恩菲尔德步枪的高射速秘诀,首先体现在其独特的后端闭锁枪机设计。与当时许多步枪采用的前端闭锁枪机不同,李·恩菲尔德的枪机闭锁突榫位于枪机后部,这使得枪机的旋转角度仅需60度,远小于毛瑟98K等步枪的90度甚至更多。更小的旋转角度意味着更短的操作行程和更快的开锁、闭锁速度。同时,其枪机的设计也更为平滑,拉机柄的位置和形状经过优化,便于射手快速操作。熟练的射手可以利用“疯狂一分钟”的射击技巧,在一分钟内发射超过30发子弹,甚至有记录显示,顶尖射手能达到每分钟40发以上的惊人射速。在手动栓动步枪时代,是极为罕见的,远超同时期其他同类武器。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
李·恩菲尔德步枪,凭借与同时期其他栓动步枪不同的结构设计,获得了更高的射速,但终究还是无法克服栓动步枪的天然劣势。如果想要获得更高的射速,依旧还是要走上半自动甚至是全自动的道路。现代化的突击步枪,基本上都是依靠快慢机,在半自动和全自动之间进行切换。我们以AK-47为例,其理论射速约为每分钟600发左右,而M16突击步枪的理论射速则在700到950发之间。为什么同样是具备全自动模式的突击步枪,却有着截然不同的射速上限呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
当步枪进入到自动化时代,射速的重要性,开始与精准度和稳定性平起平坐。弹匣供弹,为提高射速,提供了弹药保障。那么想要提高射速,唯一需要解决的,就是如何在射击时,实现连贯的自动循环系统。所谓的自动循环系统,其实就是通过多种机械结构之间的配合,使两次步枪射击之间,省略掉手动拉栓这一费时的步骤,从而提高射速。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画解说3】
|
|
||||||
|
|
||||||
目前步枪家族,实现自动循环的主要方式,分为活塞式和吹气式两大类。活塞式原理为,枪弹击发后,部分火药燃气推动活塞后移,活塞通过连杆带动枪机,完成开锁、抽壳、抛壳等动作,再在复进簧作用下复位推弹上膛。这种方式将燃气能量经活塞间接传递给枪机,典型代表是AK-47,因其活塞运动行程长,被细分为长行程活塞。而吹气式则省略活塞环节,让火药燃气经枪管导气孔直接进入机匣,推动枪机框和枪机后坐,典型代表是M16步枪。随着战场实践深入,在两者基础上取长补短,演化出短行程活塞式。其工作原理是,枪弹击发后,火药燃气推动活塞后移较短距离后停止,再靠惯性或连杆传递能量,带动枪机完成后续动作。它保留了活塞式可靠性高的优点,避免了长行程活塞射速受限问题,相比直接导气式,减少了燃气对机匣内部零件的污染和灼烧,使枪械在保持高射速的同时,有更好的可靠性和维护性,代表是HK-416。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
无论是哪一种自动循环系统,都是希望在射速和稳定性这两者之间找到一个平衡点。毕竟射速并非越高越好,过高的射速会导致弹药消耗过快,射手需要频繁更换弹匣,反而可能在实战中出现火力中断的空档。高射速产生的后坐力叠加,也会严重影响枪械的射击精度和操控性,让子弹难以命中目标。因此,现代突击步枪在设计时,会根据其作战定位和使用场景,对射速进行合理的调控,而最终得到的结果,就是大家默契地将射速控制在每分钟600到800发。首先,单兵所携带的弹药量,它一定是有一个上限的,所以,即使把突击步枪的射速提升到了一个更高的水平,也会面临弹药不足的情况。而且枪械整体的重量,必须要控制在一个合理的范围内,步兵在携带和使用的过程中,就会大量地消耗体能,甚至会出现携带不方便的情况。所以我们看,突击步枪所使用的弹匣,它的长度和它的尺寸,和弹药的容量,基本上大家都是在一个合理的范围。单兵在使用突击步枪的时候,主要还是在中近距离上进行这种点射,或者说在近距离上进行这种连发的这样的一个射击。无论是哪种作战模式,那么它都不需要有特别高的这样的一个射速,只要能够保证在一定的距离上能够持续地压制对方,能够保证火力的密度,其实就可以了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
当然,总会有一些特殊的枪械,会打破对于射速的限制。既然长时间的快速射击,会大量消耗弹药,并且会影响枪械的精准度,那么,是不是可以追求一个更短时间的射速爆发,让枪械在一瞬间获得极高的射速,但又不牺牲稳定性。这种想法的结果就是三连发射击模式。这种射击模式的代表就是M16突击步枪。M16步枪的三连发模式,通过内部的棘轮机构或电子控制模块,实现扣动一次扳机,即可以连续发射三发子弹的功能。在这种模式下,枪械的射速在极短时间内可以达到一个峰值。三发子弹的发射时间可能仅需0.2秒左右,换算成瞬时射速可达每分钟900发以上,远高于其全自动模式下的理论射速。射手在使用三连发模式时,每扣动一次扳机,就能获得一个短暂而猛烈的火力点射。因此,三连发模式成为了平衡射速、精度与弹药消耗的一种有效解决方案,在现代突击步枪设计中被广泛采用。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
无论是全自动的火力压制,还是三连发所追求的精准度,都是为了应对不同的场景。所以我们能看到,步枪家族在射速上,达成了一个默契,并且以不同的方式寻求突破。看完了步枪射速的多种多样,我们就来到了枪械射速的顶点。当射速迈过了每分钟千发这个门槛后,射速能起到的作用,基本上就是进行火力压制了。而执行火力压制最合适的枪械,就是机枪。当机枪的枪口开始喷吐火焰,雨点一般的子弹,就会形成致命的弹幕。那么,机枪的射速,又将走出怎样的道路呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
机枪的诞生,本身就是为了追求极致的射速,用连续不断的射击形成的弹幕去压制敌人。在追求这种更高射速的过程中,最大的难点就是连续的射击会导致枪管过热。所以,为了解决热量问题,设计师给出了两种解决方式:水冷式散热和风冷式散热。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画解说4】
|
|
||||||
|
|
||||||
水冷式机枪和风冷式机枪最大区别,在于枪管散热方式不同。水冷式机枪在枪管外包装满水的套筒,利用水蒸发吸收和带走枪管持续射击产生的热量,使枪管长时间保持低温,可持续连发射击,提升射速和火力持续性。如马克沁重机枪,理论射速约每分钟600发。水冷式机枪水套筒重,增加枪械整体重量,机动性差,且依赖水源,在缺水或需快速转移的战场受限大。风冷式机枪依靠枪管自身散热及更换枪管解决过热问题,取消水套筒,通过设置大量散热片增加与空气接触面积、加快散热。枪管温度过高时,射手可以迅速更换备用枪管恢复射击能力。比如MG42通用机枪,采用风冷式设计,理论射速高达每分钟1200发,兼具高射速和良好机动性。在二战期间,机枪可以说是针对对方步兵进行火力压制的一款主要的武器。那么制约机枪射速的方式,可能和冷却的这样的一个模式和设计理念存在密切关联。像马克沁机枪,它是采用水冷的模式,所以长时间连续射击的时候,枪管不会因为过热问题而出现故障,导致它无法开火。针对对方的冲锋的兵力或者是进攻的兵力进行压制,不需要频繁地更换枪管,这个优势是非常明显的。但是像MG42,它是采用这种风冷的方式,射速又非常地快。所以在运动的过程当中,或者说在进攻作战的过程当中,它可以扮演非常重要的角色。而且MG42虽然会容易出现枪管过热的问题,但是更换枪管速度比较快,操作起来也比较容易。一般这种德军它在操作和使用机枪的过程当中,都是有其他的士兵去进行配合的,帮他去搬运弹药或者说携带枪架。当然了也配备有更换的这样的一个枪管。所以风冷机枪它在使用的过程中,就是要看更换枪管的速度究竟有多快,操作使用是否方便。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
无论是风冷式散热,还是水冷式散热,都可以有效地解决连续射击带来的热量问题。通过这种方式,也将机枪的射速推进到每分钟千发以上。但这并不是机枪所追求的射速极限。如果想在每分钟千发的基础上再次提高射速,就需要有更加激进的设计。在种种需求的推动下,枪械射速的巅峰——转管机枪出现了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【动画解说5】
|
|
||||||
|
|
||||||
转管机枪提升射速的方式,简单粗暴。因为单管机枪,连续射击容易过热,设计师便将多枪管捆绑,让其轮流射击,如同机枪手,同时用数挺机枪进行轮换。不过,这种简单逻辑下的产物,是转管机枪体积普遍较大。早期手摇式转管机枪,需要马匹运输。射击时,射手要手摇把,驱动枪管旋转和供弹,射速受摇动速度限制,通常每分钟200至300发。而现代转管机枪,普遍以电动机,或液压马达为动力源,通过齿轮传动系统。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
带动多根枪管,绕中心轴高速旋转。每根枪管旋转到特定位置,完成装弹、击发、抛壳等动作。由于多枪管交替工作,每根枪管有足够时间自然冷却,可以使机枪以极高射速持续射击。以M134转管机枪为代表的射速王者,依靠6根枪管循环射击的叠加效果,将机枪的射速推进到每分钟6000发。这意味着在一秒钟内,就足足有100发子弹倾泻而出,形成一道几乎无法逾越的金属弹幕。如此恐怖的射速,使其在对付集群目标、低空慢速目标或进行区域压制时,展现出无与伦比的威力。无论是在直升机舱门、舰艇甲板,还是车载平台上,M134转管机枪都能凭借其火神炮般的咆哮,瞬间覆盖目标区域,给予敌人毁灭性的打击,成为现代战场上令人胆寒的火力象征。那么这种机枪,单兵是没有办法携带的,因为实在是太重了,而且需要有外部的这种供电的设备。往往是在车辆上,比如说在悍马车上,架设这种6管的这样的一个转膛机枪、速射机枪,可以针对对方的步兵进行密集的火力压制,短时间内让对方的步兵出现大量的伤亡。所以,这种利用电力驱动的速射机枪,或者说我们叫它多管机枪,往往是在装甲车上、在直升机上,或者说在一些特种作战的这种冲锋舟和快艇上进行使用,可以在突击作战任务的过程当中,在特种作战行动的过程当中,提供近距离的火力压制能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人7】
|
|
||||||
|
|
||||||
不同枪型在设计之初,都会将射速作为一个关键要素。手枪提升射速,极大提升了单兵的自卫火力。步枪的不同射击模式,用以适应不同的作战场景。机枪的火力压制,将射速推向了顶点。所以,射速的提升,始终是枪械发展历程中,贯穿始终的核心追求之一。射速的不断突破,不仅是枪械技术进步的直观体现,更深刻地影响着战术思想的演变,和战场格局的塑造。它使得枪械,在不同作战环境下,能够更高效地完成其使命任务,成为士兵手中可靠的战力倍增器。好了观众朋友,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 武器进化论:空战格局的演进之路
|
|
||||||
编导: 付天雨
|
|
||||||
播出日期: 2026-03-17
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep011
|
|
||||||
---
|
|
||||||
|
|
||||||
# 武器进化论:空战颠覆者
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视】
|
|
||||||
|
|
||||||
从螺旋桨战机的低空缠斗,到无人机的智能作战,空战武器的每一次革命性突破,都在重塑天空战场的制胜逻辑。当一件装备,彻底打破速度、隐蔽的三角平衡,当它的出现,让延续数十年的空战范式,轰然倒塌,就是天空的规则改写者。这不是装备的简单升级,而是空战法则的,彻底重构。本期《军事科技》,带您翱翔苍穹,揭开空战史上,最震撼的转折点,看这些颠覆性装备,如何定义制空权的归属。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。从螺旋桨战机时代,飞行员凭借驾驶技巧,与临场应变展开的近距离空中缠斗、航炮对射,通过智能巡飞与协同作战,实现的零伤亡、全范围精准打击,空战武器的演进,始终围绕着如何在广袤天空中高效打击敌人、保护自己这一核心议题。每一款革命性装备的诞生,并非是简单的性能提升,而是对空战形态的根本变革。
|
|
||||||
|
|
||||||
螺旋桨飞机:空战时代的“开拓者”,近距离缠斗的“定调者”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
20世纪初期,飞机的问世,首次把人类的战场,拓展到了天空。起初,飞机只负责执行侦察任务,并未配备专门的作战武器。一战爆发之后,各国着手对飞机,进行武器改装,将机枪和轻型炸弹,安置在机身上。以活塞式螺旋桨为动力的,螺旋桨飞机,就此诞生。真正意义上的空战时代,也正式拉开帷幕。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
螺旋桨飞机,之所以被人们称为,真正意义上的空战的到来,其实主要在于它,摆脱了战场辅助作用。在螺旋桨飞机出现之前,人类尝试的航空载体,比如像飞艇、热气球,能承载战场的侦察、通信,传递的一些辅助的任务,本质上也就是,空中的一个观察哨,不存在真正的空战行为。而螺旋桨飞机,依靠活塞式发动机,和螺旋桨推进系统,才真正实现了,真正意义上的空中对抗。同时螺旋桨飞机发展过程中,机枪同步射击装置的发明,是个关键的转折点,它解决了机枪射击,不会击中自身螺旋桨的难题,让飞机拥有了,可实战的机载攻击武器,直接赋予了螺旋桨飞机,空中作战的能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
第一次世界大战的天空,成为螺旋桨飞机的练兵场。机载机枪的射击精度与射速持续提升,战机的机动性能也在不断优化。横滚、俯冲、筋斗等专属空战动作,成为飞行员的必备技能。同时,人们发现,将机枪安装在飞行员座舱前,射击方向与飞行方向一致时,能获得最佳瞄准精度和火力效果,且便于飞行员操作与排除故障。但是问题随之出现,由于螺旋桨的阻挡,每次射击都有击中自身桨叶的风险。直至枪口协调器的出现,才成功突破了战机机枪射击与螺旋桨旋转的技术瓶颈,让机枪子弹可精准穿过螺旋桨旋转面,无需担心击伤桨叶。1916年,索姆河战役期间,德国福克E.III战斗机凭借其搭载的核心装备射击协调器,该型飞机在短短数周内便击落协约国战机六十余架,创下了震惊战场的空战纪录,后世也将其称之为“福克灾难”。这场战争让各国逐渐意识到制空权的重要性,空战也从单纯的侦察护航,发展为独立的作战单元。螺旋桨飞机不仅开创了近距离缠斗的经典空战模式,更推动人类形成制空权影响战场胜负的核心军事理念,为后续战机动力升级、武器革新及战术体系构建,奠定了坚实的实践与理论基础。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
螺旋桨飞机,将天空开辟为全新战场,以航炮的轰鸣,开启了空战的新纪元。却因速度缓慢,火力薄弱,探测范围有限,使空战始终局限于,近距离的贴身较量。随着战争需求的不断升级,人们开始追求更快的飞行速度,更高的作战高度,更远的打击距离。一种采用全新动力的战机,应运而生,彻底终结了螺旋桨时代,将空战推向,高空高速的新维度。
|
|
||||||
|
|
||||||
喷气式飞机:螺旋桨时代的“终结者”,高空高速空战的“开启者”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
第二次世界大战中后期,航空发动机技术迎来革命性突破。喷气式发动机的诞生,彻底打破了活塞式螺旋桨的性能瓶颈。喷气式战机由此登上历史舞台。1944年,德国Me-262喷气式战机首次投入实战,其最大飞行速度突破900公里每小时,远超当时最先进的螺旋桨战机,让各国看到了空战的全新可能。喷气式战机一登场,空战直接换了个时代!它的革命性突破,全靠两大硬核升级:第一,动力彻底革命!喷气发动机不靠螺旋桨,直接靠燃气猛喷往前冲!一下子挣脱了老式飞机的物理天花板,速度狂飙,爬升像火箭,轻松冲破音障,一头扎进超音速世界!直接飞到万米高空的平流层,把螺旋桨战机远远甩在身后。第二,火力与“眼睛”全面进化!大威力航炮取代小机枪,打得更狠、更猛;简易雷达测距仪装上战机,飞行员不再只靠肉眼盲打,看得更远,瞄得更准!空战的精度和范围,直接翻倍升级!这两大突破的合体,使得人类空战正式从螺旋桨时代,冲进了喷气超音速时代!
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
喷气式飞机的诞生,彻底改变了螺旋桨飞机以近距离的缠斗为主要形式的空战逻辑,迈进了高空高速空战的新时期。第一代喷气式飞机一出现,就达到了高亚音速。螺旋桨飞机,在第一代喷气战机的眼里,还是一个低速的目标,极其好打,战损比也极其高。喷气式飞机的真正出现,就使螺旋桨飞机在争夺制空权的斗争中被淘汰了。随着军事科技,特别是材料工艺、发动机的发展,喷气式飞机基本都达到了高空、高速。高空、高速带来的作战样式的变化,取代了缠斗,变成了高速截击、高空截击、高速突防,使作战样式发生了深刻的变化。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
二战落幕之后,喷气式战机进入高速发展的阶段,渐渐代替螺旋桨飞机。1950年朝鲜战争爆发,米格-15战斗机跟随志愿军入朝作战,其出色的性能让美军在朝鲜半岛上空的制空权遭遇了极为严峻的挑战。美国远东空军只能承认,在清川江和鸭绿江之间的空域,米格战机拥有绝对的优势,并把这片区域叫作“米格走廊”。在美国空军的营地里,甚至竖起了一块显眼的标识牌,上面清楚地写着“距离‘米格走廊’200英里”,以此来时刻提醒每一位曾经自视技术高超的美军飞行员。为了改变战场空中形势的不利局面,美国紧急大批量装备新型F-86战斗机,希望与米格-15相抗衡。于是,两款战后第一代经典喷气式战斗机在朝鲜上空不期而遇,正式拉开了人类历史上后掠翼喷气式战斗机空中对决的崭新时代。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
喷气式战机,突破了螺旋桨时代空战的限制,把空战的领域,延伸到了万米高空。超音速飞行速度,和初步的雷达探测功能,使得空中骑士,近距离缠斗的时代,一去不复返了。不过,随着各国对,喷气式战机的不断升级,战机在速度,火力以及升限方面,持续突破,相互之间的性能差异,也慢慢减小,仅仅依赖平台性能的优势,变得越来越不明显。这个时候,一种全新制导武器的成熟运用,完全改变了空战的打击思路,让超视距空战从雏形,变为了现实。
|
|
||||||
|
|
||||||
空空导弹:航炮主导的“破局者”,超视距空战的“核心利器”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
20世纪60年代,制导技术与火箭发动机技术迅猛发展,空空导弹完成了从试验品到主战武器的转变,完全打破了航炮主导空战的局面。早期的空空导弹主要是红外制导的近距格斗弹,通过追踪战机尾焰的红外信号来锁定并打击目标,具备了锁定后发射、发射后不用管的作战能力。空空导弹的核心技术突破,集中体现在三大关键跨越。第一,射程实现质的飞跃。从早期仅有数公里的近程格斗,一路拓展到如今数百公里的超视距打击,真正覆盖近距离格斗、中距拦截、远程猎杀全空域,让战机先敌发现、先敌发射、先敌命中。第二,制导精度与抗干扰能力全面升级。红外成像、主动雷达制导等尖端技术的应用,让导弹看得更清、辨得更准、抗干扰更强,哪怕面对复杂电磁环境,依然能够精准锁定、一击制敌。机动性能突破有人机极限。凭借超大过载设计,导弹可以完成飞行员无法承受的高难度机动,灵活追击、快速转向,让空中目标无处可逃。与此同时,空空导弹不断向小型化、轻量化、集成化发展,单架战机可同时挂载多枚,实现多目标跟踪、多方向打击,大幅提升空中作战的持续火力与体系优势。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
空空导弹的出现,使以航炮为主要兵器的空战形式,发生了深刻的变化。以往的是视距内的作战,变成了超视距的,航炮成为了一个武器中的配角。航炮的有效射程仅有数百米,传统空战的所有战术,均是围绕着视距内的咬尾攻击展开。飞行员需通过精确的瞄准,抢占敌机的阵位,胜负完全依靠于飞行员的技战术水平。导弹的出现就不一样了,空战的胜负,不再决定于飞行员的技战术水平,其实更重要的,取决于飞行员对座舱设备的掌握,对态势感知的判断,对时机的把握上。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
越南战争,成为了检验空空导弹实战能力的关键战场。早期的空空导弹,由于技术尚未成熟,存在一定的脱靶情况,但其打击优势已远超航炮。经过后续的技术改良,空空导弹逐步发展为空战中的主要作战武器。发现即锁定,锁定即摧毁,成为空战的新准则。超视距空战,正式确立为现代空战的主流形式。空战也从单一平台间的对抗,转变为武器与平台协同作战的模式。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
空空导弹,使超视距空战变为了现实,完全重塑了空战的打击思路。然而,伴随着雷达技术,与防空体系的持续进步,战机的探测与反探测,干扰与反干扰,成为了空战新的关键所在。仅仅依赖战机自身的电子设备,已经无法适应,复杂多变的战场电磁环境。一种专为电磁对抗打造的装备,顺势出现,它虽然不具备,直接的火力攻击能力,但却可以成为空战中的,隐形撒手锏,促使空战,迈向体系化作战的新时期。
|
|
||||||
|
|
||||||
电子战飞机:单一平台的“补位者”,体系化空战的“隐形支柱”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
20世纪70年代,电子技术迅猛发展,以现役战机为基础,改装而成的电子战飞机,正式亮相历史舞台。它虽未配备传统的火力打击武器,却装配了电子侦察、电子干扰以及电子压制等专业设备,化身为空天战场中的电磁掌控者。从早期专门用于电子战的改装机,到如今多功能的电子战飞机,其作战性能不断提升,能够实现对远、中、近程电磁目标的全方位覆盖。同时,它还配备了反干扰和隐身防护系统,从而确保在复杂电磁环境中的生存能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
电子战飞机之所以成为,体系作战的隐性支柱,核心是在于它不是以战斗机,轰炸机的直接的火力支援,来作为后盾。它是通过对电磁频谱的掌握,成为空中作战的神经中枢,和保障屏障,悄无声息地,掌握战场的制电磁权,为整个战场体系的感知,打击,生存,提供底层支持。制电磁权,是现代体系空战的一个核心基础。失去了电磁优势,战机的雷达,通信,导弹制导,统统都沦为了瞎子,聋子,哑巴。电子战机的出现,使空战的攻防逻辑,发生了巨大的变化,从以往的火力比拼,平台对抗,发展成为频谱先行,体系作战,电子频谱的争夺,成为了空战的首要目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
海湾战争,铸就了电子战飞机的巅峰时刻。美军派遣EA-6B、EF-111等多种电子战飞机,针对伊拉克的雷达、通信以及防空系统,展开全频段、且高强度的电子压制。刹那间,伊拉克整个防空体系陷入瘫痪,为后续战机的渗透与打击清除障碍。在整个战争进程中,电子战飞机始终跟随作战编队飞行,化作空中电磁屏障,极大地减少了战机的战损率。电子战飞机的诞生,开启了空战隐形对抗的新领域,使得电子压制成为空战的前置步骤与关键战术。缺乏电子战掩护的战机,在现代空战里几乎毫无胜算。电子战能力,已成为评判一支空军战斗力的核心标准。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
体系化空战,依靠电子战飞机,构建起电磁骨架,使空战的对抗层面,从单一的火力攻击,拓展到了无形的电磁较量。然而,随着各国防空系统的逐步完善,传统战机,雷达反射截面积大,容易被探测锁定的问题,愈加明显。即使有电子战飞机的保护,也难以攻破现代化的,多层次防空系统。于是,一种融合隐身性、超视距、高机动特点的新型战机,诞生了。它完全打破了,常规战机的作战方式,把空战带入了,隐形对抗的新阶段。
|
|
||||||
|
|
||||||
隐身战机:常规战机的“颠覆者”,隐形对抗时代的“核心主力”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
20世纪70年代,为了应对现代化的防空体系,美国率先开展隐形战机的研发工作。1981年,世界首款隐形战机F-117A成功完成首飞。1997年,首款五代隐形战机F-22实现首飞,这标志着空战正式迈入隐形时代。隐身战机的关键革新,在于全面的隐身设计,借助机身外形的流线型优化,吸波材料的使用,以及红外隐身技术的支持,显著减少战机的雷达反射截面积与红外信号特征,使传统雷达难以探测和锁定,从而达成来无影去无踪的空中突防效果。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
隐身战机的隐身性,之所以成为,空战装备的革命性突破,其核心就是打破了,现代空战的,发现即摧毁的核心法则,颠覆了以雷达探测为基础的,传统防空与空战体系,让空战制胜逻辑,从比拼火力、速度、机动,转向争夺探测,和反探测的主动权,实现了从装备性能提升到,作战维度,降维打击的本质跨越。隐身战机的出现,彻底改变了攻防的逻辑。隐身战机能够做到先发现,先攻击、先脱离,它做到悄无声息地,进入攻击地段,对它的目标展开攻击,敌方还没有发觉,甚至发觉了也没有反应时间,做不好反击准备。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
1991年海湾战争期间,F-117A执行了1300多次任务,摧毁伊拉克四成高价值目标,自身却毫发无损,向全球展示了,隐形战机的强大作战潜力。伴随F-35,苏-57等五代隐形战机陆续服役,隐形对抗,已然成为空战现代化的,核心模式。隐身战机间的较量,不再单纯是平台性能的比试,而是航电装置,隐身科技,制导兵器,电子干扰,战术协作的全面竞争。制空权的角逐也迈入了,隐形时代。隐身战机因具备隐身,超视距,高机动的显著优势,已然成为现代空战的,关键主力。然而,其高昂的研发成本,巨大的维护难度,以及严格的人员培训需求,也是相当明显的。随着反隐身技术的持续进步,隐身战机的作战长处,正在逐渐被削弱。当有人空战的成本与风险,日益增长时,一种全新的智能作战装备,顺势出现了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人6】
|
|
||||||
|
|
||||||
它凭借低成本、零伤亡、高性价比的特点,打破了有人机掌控空战的传统格局,促使着空战朝着智能化、无人化的新阶段迈进。
|
|
||||||
|
|
||||||
无人机:有人空战的“革新者”,智能无人空战的“引领者”
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
进入21世纪后,伴随人工智能、导航定位以及小型化制导武器技术的深度结合,无人机完成了从侦察支援到实战攻击的飞跃,化身为现代空战中的新兴力量。从小型单兵携带的无人机,到中型的侦察无人机,再到察打一体的大型无人机,无人机的作战性能不断提升,成为非对称作战和体系化空战的关键组成。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
无人机之所以成为现代空战的新质力量,核心就是突破了有人飞机的飞行员的生理极限,作战成本,战场风险的多重束缚,实现了零伤亡,低成本,长航时,高适配的独特优势,成为衔接有人作战与智慧化空战的关键载体,为体系化空战注入了全新的效能增量。随着智能化的发展,可能人机协同,慢慢就变成了一种无人机单独执行任务,飞行员从一个最初的执行者,变成了一个现在,甚至不远的将来的一个决策者。再后来的发展,随着高科技的发展,随着智能化的发展,他可能成为一个监督者。这是飞行员任务的变化,也是军事科技向前发展的一个必然趋势。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
在体系化空战中,无人机可与有人机,组成忠诚僚机搭档,由有人机指挥,无人机负责侦察,诱敌 护航和打击等任务,显著降低有人机的作战风险,并提升整体作战效率,无人机的崛起,彻底颠覆了,强者恒强的传统空战理念,使中小国家,也能具备制衡大国空军的能力,同时推动空战,从有人化,迈向有人加无人协同的,智能化阶段,无人集群作战与自主空战,成为未来空战的,重要发展趋势。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人7】
|
|
||||||
|
|
||||||
空战时代,由螺旋桨飞机拉开帷幕。智能无人空战,则由无人机引领。空战武器的演变,始终伴随着攻与防的长久较量,而每次较量的结局,都是作战理念的提升。当前,智能化、无人化、体系化、空天一体化,已经成为空战发展的主要方向。高超音速无人机、空天飞机、无人作战集群等新式装备与战术,正在快速研发。未来的空战,必定朝着无接触、超视距、智能制胜、空天一体的趋势不断演进。到底还有哪些突破性的技术,会融入空战体系呢?制空权的争夺,又会展现怎样的新形态呢?这个问题,只有时间能够回答。感谢您持续关注国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 逆袭战局的集成武器
|
|
||||||
编导: 穆佩弦
|
|
||||||
播出日期: 2026-03-24
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep012
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《逆袭战局的组装武器》
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
在复杂多变的战场上,真正改变战斗的,并不一定是预先设计好的完美武器。能够撬动战局的,往往是那些原本平凡的装备,它们被人们在战场环境下,重新组合,变为全新的战斗形态。当运输直升机挂上机枪,化身空中火力平台;当榴弹炮装在坦克底盘上,跃升为精准打击利器。本期《军事科技》,带您走进武器装备,从平平无奇,到制霸战局的关键瞬间,见证这些装备,在战场上的高光时刻。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。谈到战争与科技,我们总是习惯将目光,投向那些耀眼的装备,比如说航母、坦克,导弹、隐身战机等等。它们像是一幕宏大戏剧中的主角,光彩夺目。我们总是习惯性地认为,每一次战局的转折,战史的翻篇,都必然来自于某项划时代的、横空出世的发明创造。历史并非总是如此运转的。当我们把目光投向历史的深处,或许应该尝试着,将视线从耀眼的聚光灯下,稍稍移开那么一些。很多时候,真正改变战场的,不是全新的科技,而是两件原本普通的装备,在某个绝境的时刻,被迫结合在一起,并且制霸战场。这其中,最经典的一个案例,便是空中炮艇的诞生。
|
|
||||||
|
|
||||||
|
|
||||||
## 【二维动画解说1】
|
|
||||||
|
|
||||||
1964年12月,越南南部。一个美军营地在夜色中遭到包围。越军数量远超美军。丛林遮蔽了视野。地面火炮无法及时支援。美国空军紧急出动喷气式攻击机。但问题很快暴露。喷气机速度快,油耗高。一次俯冲攻击后就必须返航。夜间目标难以锁定。营地指挥官请求持续火力压制。空中却只剩下一架老式运输机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
这架飞机诞生于二战,设计目的只有一个:运送人员和物资。在“市场花园”行动中,数百架C-47组成空中梯队,持续完成兵力与补给输送。它没有重型武装,没有厚重装甲。由民用客机改进而来,机体结构经过加固,地板承重增强,能够承载三吨物资,或一次性运送28名全副武装的伞兵。侧舱门加宽设计,使伞兵可以在数秒内连续跳伞。动力方面,它搭载两台普惠活塞发动机,巡航速度约每小时370公里,航程超过2000公里。性能并不激进,但机械可靠性高,维护简便,即使在简陋机场也能快速起降。更重要的是,它具备出色的低空稳定性,在复杂气流和防空火力干扰下,飞行姿态依然可控。
|
|
||||||
|
|
||||||
|
|
||||||
## 【二维动画解说2】
|
|
||||||
|
|
||||||
面对无线电中,营地指挥官的求助,空中的这架老式运输机,拿出了绝招。它的机舱侧面,被设计人员,安装了三挺机枪。这是它第一次,在战场上,露出尖锐的獠牙。飞机绕圆飞行,机枪固定角度射击,子弹落点,形成稳定火力圆环。这便是日后被称作,支点盘旋的飞行方式。地面形成持续的火网,敌军被压制。几个月内,这种经过特别改装的C-47,被正式命名为AC-47。它成为第一种正式编号的,炮艇机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
越南战争爆发之后,美军陷入了丛林作战的泥潭。虽然说,美军可以派出地面作战部队,和对方进行交战,或者说,也有火炮进行支援。但是最重要的是,当时的战术飞机,也就是一些喷气式战斗机,飞行速度太快,那么很难在相关的区域内,长时间地,针对地面作战部队,进行精准的火力支援。所以,美军是需要一种大型的,这种低空慢速,飞行性能比较好的,这样的一个作战装备。就用C-47,也就是二战遗留下来的,剩余物资进行技术改造。是在机身的一侧,加装这种速射的机枪,或者是机炮。那么最大的优势,就是它的低空的,盘旋性能比较好。作战的模式是空中,绕圈子 兜圈子,针对地面作战部队,所遭遇的对方步兵,丛林区域进行长时间的,弹幕射击和弹雨射击,倾泻火力的时间比较长。而且AC-47,是在C-47运输机的基础上,改造而来的。它内部是有,比较大的货运空间,在加装了速射机炮之后,也可以携带更多的弹药,保证火力的密集程度,和这样的一个持续性。除此之外,它的这样的一个平台,最大的优势,就是可靠性比较高,而且战场生存能力比较强。即使对方的步兵,使用轻武器,比如说步枪,包括小口径的高射武器,针对AC-47进行打击,机翼 机身打出几个窟窿,并不影响它的使用。所以皮实耐用,也是它的一个重要的,这样的一个优势。通过技术改造之后,C-47成为了AC-47。那么在作战使用的过程当中,确实针对美军的作战部队,提供了强有力的火力支援。可以针对对方的步兵,进行猛烈的火力压制。在前期,取得了相当的作战效果。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
在AC-47验证“空中持续火力平台”概念之后,美军研究人员并没有止步。相反,他开始系统化推进炮艇机的发展,使其从临时改装,走向专业型号。在越南战争中期,美国推出了AC-119。它改装自C-119运输机,机身加长,续航延长,搭载20毫米机关炮,并配备夜视与早期电子侦察系统。在夜间巡逻时,AC-119通过红外传感器,锁定卡车发动机的热源,侧向机炮连续射击,形成稳定持续的火力覆盖。补给线几乎没有安全间隙,每一辆运输车都在火力压制下寸步难行。而后,炮艇机概念进一步成熟,诞生了AC-130系列。AC-130的身影经常出现在各类影视剧中。它凭借着极强的空中火力支援能力,给广大观众留下了深刻的印象。AC-130系列炮艇机是现代空中火力支援平台的代表。它基于C-130运输机改装,它滞空时间长,可连续盘旋6小时以上,最大航程超过2000公里,能够快速部署至远程战区。搭载多种口径火炮,这种火炮沿机身侧向安装,可以实现灵活打击。在面对轻型车辆和人员目标时,20毫米机关炮。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
可以快速压制。在面对集群目标或防御阵地时,40毫米博福斯炮可以实施有效覆盖。而它搭载的105毫米榴弹炮,能够精确摧毁坚固建筑或装甲目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
在现代战场上,AC-130的核心优势,不在于单纯火力,而在于精确支援地面作战的能力。它能够实时锁定移动目标,将传感器捕捉的信息,转化为精准射击指令。红外与多光谱传感器,使它在夜间或复杂环境中,仍能发现敌方阵地和运输车辆。通过与地面指挥系统的数据联动,能够根据地面部队的需求,调整火力重点和攻击节奏,实现及时支援。在美军阿富汗和伊拉克的行动中,它长时间滞空,连续压制敌方火力点,保护前沿部队推进,同时切断敌方增援路线。这种持续、可控、精准的空中支援,让地面单位能够在复杂战场环境下保持主动权,而不是被动应对。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人2】
|
|
||||||
|
|
||||||
从装备演进的角度看,空中炮艇的出现,不是一次技术革命,而是一种功能的重构。在过去的战场上,发现目标和摧毁目标,是两个分开的环节。而空中炮艇的出现,彻底打破了这种模式。设计人员,没有发明任何一门新的火炮,也没有突破任何空气动力学的极限。但是,它只是把制空和打击,这两个要素重新组合,就创造出了全新的作战逻辑。它证明了一点,当成熟平台与战场需求重新匹配的时候,一款平凡的装备,就可能衍生出全新的战斗形态。残酷的战场,从来不是单纯地追求尖端的技术,而是追求适配性。当空中炮艇,正在战场上空,依靠着长时间的盘旋,进行火力压制的时候,在丛林的上空,另一场为了机动灵活而拼接的艺术,正在上演着,它就是武装直升机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
从20世纪40年代,一架直升机在战场上,成功救下两名受困士兵开始,美国军方便意识到,直升机,可以成为战场上的新型机动力量。直升机的发展,被纳入正式军方计划,军方对它寄予厚望,希望它能在运输、救援、和支援作战中,发挥独特作用。直升机的发展之路,并非一帆风顺。早期的直升机,虽然设计上,预留了武器挂载接口,但结构轻便,载重有限,机枪、火箭,或轻型火炮的安装,非常不便。在实际战场中,它们仍主要承担,运输士兵和物资的任务,无法直接参与火力压制。1962年3月,这款传奇通用直升机的,拼接改装之路,正式拉开了序幕。休伊直升机,其实也是在越战期间,开始大量应用的。UH-1休伊直升机,主要执行的是,兵员投送、物资运输、和伤员的这样的一个,后撤的任务。在越南的低空区域进行使用,可以说它成为了,美国军队的,空中“出租车”。因为当时已经有了,蛙跳作战的战术,针对对方的,游击区域进行扫荡。休伊直升机,投送机降作战部队,那么针对某个村庄,进行这种,机降兵力的快速投送,可以让部队,快速越过水网、稻田区域,或者说一些丛林区域,那么尽快地,抵达作战位置。完成了突袭行动之后,也可以快速地撤离。可以运送弹药、运送补给品、运送作战人员,也可以尽快地,把伤员给撤出来。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
后续美国军队为休伊,也就是UH-1型的运输直升机,加装了武器。主要是在舱门这个位置,加装机枪,也可以挂装小型的火箭弹发射巢。那么在机降的过程当中,武装版本的休伊,可以针对对方的步兵进行压制,掩护机降部队,迅速地到达着陆场。使用火箭弹发射巢,也可以针对对方的行军队队,包括一些房屋,进行火力打击。休伊直升机在越南战场,也是导致了很多无辜平民,也就是越南民众的这样一个伤亡。当休伊直升机上的美军机枪手针对地面进行扫射的时候,当火箭弹进行这种密集的,它其实是一种无差别攻击。美军也不会分辨哪些是民众,哪些是对方的作战人员,进行机枪扫射,进行大面积的火力覆盖,确实导致当地民众大量出现伤亡的情况。随着越南战争的深入,美军时常面临着丛林密布、伏击频繁的战场环境。步兵需要及时火力支援,而传统运输直升机缺乏火力保护。在这种迫切需求下,工程师和飞行员将UH-1休伊两侧加装机枪、火箭发射架,甚至轻型火炮。第一次实战投入,就显示出惊人的效果。直升机可以低空悬停,伴随地面部队推进,同时压制敌方火力点,为步兵开辟通路,或掩护撤退。这次临时拼装,开创了武装直升机的雏形。由于美军之前就曾有过改装传统直升机的设想,设计人员在设计休伊直升机的时候,就为它的各种型号预留了武器安装的接口。理论上,休伊直升机的A至D型,甚至之后的后续型号,都可以改装成武装型。但美国陆军还是根据每款型号的性能,进行了更加细化的分工。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
A、B型休伊直升机的机身较短,载员能力一般。在越南战场上,陆续被改装成武装型。长机身的D型休伊直升机,则被用为运载和救援直升机。由于各型号休伊直升机武器挂载的方案不同,这种武装型的休伊直升机,还有着不同,诸如野猪、炮艇等不同的绰号。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
这是一架加装上两挺电力驱动的M60C机枪的武装型休伊直升机。它的副驾驶可以直接通过驾驶室安装的X-M60反射式瞄具控制这两组双联装机枪。这套武器系统的每挺机枪都由三个弹药箱供弹,总弹药容量为6000发。当士兵跳下直升机时,射手可以提供强大的火力掩护。为了进一步加强直升机的强大火力压制,继续给武装型休伊增加机翼短挂架,用以挂载火箭巢。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
当越南战场的硝烟逐渐笼罩丛林,美军意识到一个现实问题:临时武装的UH-1休伊虽然有效,但终究不是为战斗而生。它的机身宽大,气动不优。于是美军决定专门研发一种攻击直升机。1967年,AH-1眼镜蛇正式投入越南战场,它是世界上第一种专用攻击直升机,武装直升机这一装备类别正式诞生。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说3】
|
|
||||||
|
|
||||||
眼镜蛇采用串列式双座布局,机身细长,正面投影面积小,目标更难被击中。机体经过强化,具备更好的防护能力和更高的速度。动力系统采用改进型涡轴发动机,使其在低空具备出色的机动性能。它可以贴地飞行、快速跃升、突然转向,在丛林与山地环境中进行突袭。在机头下方安装可旋转炮塔,装备7.62毫米或20毫米机炮,能够灵活指向目标,实施精准压制。机翼短翼下可挂载2.75英寸火箭弹发射巢,用于打击敌方阵地与轻型装甲目标。后期型号还具备发射反坦克导弹的能力,使其拥有对装甲单位的直接打击能力。眼镜蛇是美国军队第一种专用的武装直升机,应该是在1967年的下半年投入到越战的战场。这款直升机最大的优势就是它的动力系统,主螺旋桨包括一些尾梁的结构和尾桨是可以和UH-1休伊运输型的直升机去通用的。那么投入战场之后,在后勤保障以及维护方面,但是它的火力更强。它上面是配备有专门的光学的瞄准系统,而且在直升机前部靠下的有小型的机枪塔,或者是叫小型的炮塔,上面配备有迷你炮,也就是速射机枪,同样也可以加装自动榴弹发射器。采用串列式的这样的一个飞行座舱的布局,就可以让飞行员更好地观察地面的目标,有很好的视野,也可以使用迷你炮针对地面目标进行扫射。武装直升机的概念并非凭空而来的,它的血统来自运输直升机与各类武器挂架的组装。当直升机从运输转向战斗空中支援模式,才发生了实质性的变化。接下来,让我们将目光投向地面战场上的另一个明星,它就是自行火炮。我们看到现在典型的自行火炮就像是一个大的坦克,它们都有履带式的底盘,都有一个显眼的炮塔,而且炮塔同样有一个封闭式的装甲壳体。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人3】
|
|
||||||
|
|
||||||
那么它和坦克,到底有什么区别呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
20世纪40年代,北非战场上,沙漠上空烈日炙烤,大规模机械化部队,正在展开一场,前所未有的机动作战。无论是德军非洲军团,还是英军装甲部队,战场的主角,都是坦克与装甲车辆。在广袤无垠的沙漠地带,没有山川阻隔,没有密林掩护,战斗节奏,完全取决于机动速度。谁的装甲部队推进更快,谁就能抢占阵地,包抄侧翼,切断补给线。但问题很快暴露出来,装甲部队可以快速前出,可身后的炮兵却难以跟上。当时主力火炮,仍是牵引式榴弹炮,它们必须由卡车,或半履带车拖曳前行。一旦抵达射击位置,炮兵还要下车卸炮,展开炮架,校准射向,构筑阵地。在风沙弥漫的沙漠中,这个过程,往往需要十几分钟,甚至更长时间。而战场态势,可能在几分钟内,就发生改变。坦克已经冲过前线,而炮兵还在后方布置阵地。当敌方装甲部队,突然反击时,缺乏及时火力支援的装甲部队,只能独自承受压力。已经进入装甲机动的时代,但炮兵,还停留在马匹牵引的时代。工程师,便将榴弹炮,直接安装在坦克底盘上,让榴弹炮,获得了动力系统。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
北非战场的教训,已经十分清晰。盟军意识到,必须让火炮拥有与坦克相同的机动能力。于是,一种全新的装备开始登场,M7自行榴弹炮。由于其机枪座高耸,外形类似教堂讲坛,于是,北非战场上,率先列装M7自行榴弹炮的英军士兵,给它起了一个绰号,牧师。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说4】
|
|
||||||
|
|
||||||
M7自行榴弹炮,它基于M3,李氏中型坦克底盘改造,后期型号,则采用M4谢尔曼底盘,整车战斗全重,约23至24吨,乘员编制,通常为7人,包括车长,炮手,装填手,以及弹药手,M7在公路上的最高时速,可达到每小时40公里,越野速度,约每小时20公里左右,对于一门搭载105毫米榴弹炮的,火炮平台而言,这种机动能力,已经能够与装甲部队,保持同步推进,M7的核心武器,是一门105毫米,M2A1榴弹炮,最大射程,约11000至12000米,初速约每秒470米,可发射高爆弹,照明弹等多种弹药,火炮安装在,开放式战斗舱内,虽然不具备全向旋转炮塔,但在战术部署中,主要依赖间接射击,通过前线观察员引导,修正弹着点,在车体右前方,高耸的机枪座上,安装一挺,12.7毫米M2重机枪,M7自行榴弹炮的,装甲防护厚度,约12至50毫米之间,能够抵御轻武器,和破片打击,在地面的,装甲作战平台当中,坦克和自行火炮,是非常具有辨识度的,主战坦克,可以说它是,冲锋陷阵打头阵的,这样的一个披坚执锐的,重甲骑士,因为主战坦克,一般都是30吨以上,配备有大口径的滑膛炮,当然它此前也配备过,这个线膛炮,它最重要的功能是,突破对方的防线,掩护己方的步兵,和履带式的步战车,控制相关的作战区域,而自行火炮,是把大口径的榴弹炮,和传统意义上的,履带式装甲车进行结合,那么自行火炮最大的优势,它是可以针对,一线冲锋作战的部队,进行曲射的火力支援,打十几公里,二十公里左右,所以主战坦克,是直瞄火力,是抵近攻击,而自行火炮,自行榴弹炮,它是曲射火力,是在较远的距离上,进行这样的一个火力支援,在俄乌冲突中,我们看到了一个,显而易见的变化,无人机,彻底改变了战场侦察与打击方式,这些无人机体型小巧,成本低廉,却具备极高的灵活性,它们能够快速飞过战场,探测敌方阵地,补给线,甚至可以与简单的,爆炸装置组装,通过投放小型炸弹,实施打击,这些装备,虽然都缺乏标准化的体系支撑,但在前线却发挥了,令人惊艳的效果,它们的存在,完全源自战场的迫切需求,通过组合手头的平凡武器,发挥出,远超单件装备的,战术价值,从空中炮艇的持续压制,到武装直升机的灵活支援,从自行火炮的机动超车,到无人机,与临时防空系统的精彩对抗,战场一次次证明,装备的真正力量,不在于先进与否,而在于如何使用,如何组合,现代战争,已不再只是技术的比拼,而是智慧,创新,与临机决策的舞台,下一款能够改变战局的装备,或许正静静地,躺在某个角落,等待被发现,等待被赋予新的使命,好了观众朋友们。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人4】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人5】
|
|
||||||
|
|
||||||
感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
---
|
|
||||||
标题: “X”系列新成员:X-76飞机
|
|
||||||
编导: 左鑫
|
|
||||||
播出日期: 2026-04-07
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep013
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《“X”系列新成员:X-76飞行器》
|
|
||||||
|
|
||||||
|
|
||||||
## 【宣传词】
|
|
||||||
|
|
||||||
数学领域,X为待求解的未知数;科幻范畴,X是象征未来的科技标志。近期,美军X系列又加入了重要新成员,代号X-76的折叠旋翼机。它声称解决了航空界几十年的难题,可垂直悬停,且能达到喷气式战机的速度。那么,这款空中新物种,究竟蕴含哪些突破性黑科技?是否能重塑未来空战规则?本期《军事科技》,为您深入解读X家族最新成员X-76。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》。在现代战争里,飞行器,无疑扮演着关键战力的角色。然而速度与灵活,却始终是摆在军事工程师面前的,一对难题。固定翼飞机虽然快,但需要依赖长长的跑道。直升机虽然能够垂直起降,但是速度却难以提升。难道真的没有一种飞行器,能够同时融合两者的优点,既能急速飞驰,又能随时悬停吗?2026年3月,美国国防高级研究计划局,正式公布了一款全新装备,贝尔公司研发的,新型折叠旋翼机。作为X系列家族的新成员,立即吸引了,全球军事爱好者的目光。那么这款号称要突破固定翼飞机,与直升机性能界限,解决美军数十年来困扰的飞行器,究竟有何独特之处呢?今天就让我们一起揭开,X-76的神秘面纱。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
要认识X-76,我们得先搞清楚一个关键问题:美军为何要投入巨大精力,去开发这样一款全新的飞行器呢?实际上,这完全来自于现代战争中的现实难题。大家可以思考一下,在紧张的对抗中,飞机与跑道,通常是敌方首轮打击的核心目标。一旦跑道被破坏,那些需要跑道才能起飞的固定翼飞机,无论多么先进,最终只能变成地面上的靶子。而传统的直升机,虽然具备垂直起降能力,无需依赖跑道,但其速度过于缓慢,在战场环境中,极易成为敌方攻击的目标,生存能力大幅降低。是否存在一种办法,能让飞行器,既摆脱对跑道的依赖,又具备喷气式飞机的速度?这正是美军提出“速度与跑道独立技术”项目的核心目标。而X-76,则是贝尔公司为这一项目,提交的答卷。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾1】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
X-76它诞生的背景,是源于2023年,美国国防部提出了一种高速多元化飞行器的,这样的一个技术要求。贝尔公司经过技术研发,而且依托于丰富的经验,最终是在2026年拿下的项目,可以说就正式立项了。为什么说这个项目被称为X-76呢?主要是由于五角大楼和美军高层,对于这个项目非常地关注,而且美国的这个建国时间是1776年,那么到2026年,正好是这个250周年,所以把它命名为X-76,足见美国军方对这一款先进的倾转旋翼飞机,它是非常地重视。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
那么,这款被美军寄予厚望的X-76,究竟有何独特之处呢?咱们不妨从它的“外观”和“能力”谈起。首先,最引人注目的便是它的“可折叠旋翼系统”。这并非普通的旋翼,而是贝尔公司研发的独有“停止/折叠”技术。通俗来讲,它犹如“变形金刚”般,能在两种飞行模式间流畅转换。或许有人会问,这种“模式切换”究竟有何独特之处呢?咱们来打个比方,当X-76准备起飞时,其翼尖旋翼会正常运转,像直升机那样提供垂直升力,无论是在复杂地形、未经平整的地面,还是海上的小型浮动平台上,都能轻松实现起降。而当它加速到约每小时278公里至370公里的关键速度时,奇妙的现象便出现了,翼间的旋翼会逐渐停止转动,并向后折叠,紧密贴合在机翼短舱上。此时,飞机便瞬间化身为一架喷气式飞机,依靠独立的涡扇发动机推动,全速前进。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾2】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
X-76应该是一种先进的概念,未来成熟之后,会根据美国军方的需求,衍生出一系列的产品。那么个头小的,可能还不到两吨多,也就是说,它既成为一种先进高速飞行的,倾转旋翼无人机。如果是把它的尺寸进一步地放大,那么它的重量,可以达到四十多吨,就可以作为一种大型的,高速的空中的运输平台。和美军现役的,鱼鹰倾转旋翼机相比,X-76它最大的优势,就是把这种倾转旋翼机,它的性能优势,和高速飞行的,这样的一个需求,进行了有效的结合。所以我们注意到X-76,它既可以像鱼鹰一样,垂直起飞,垂直降落。但是当它进入到,水平飞行的状态,它可以达到喷气式飞机,飞行的这样的一种速度。那么这样的一种设计理念,可以说把现有的倾转旋翼飞机,它的技术层面的这样的一些欠缺,和性能有待提升的,这样的一些方面,其实都考虑到了。让我们来关注一则官方消息,X-76最大亮点无疑是速度,其巡航时速,大约在740公里,至833公里之间。这是个怎样的概念呢,我们可以,与V-22鱼鹰倾转旋翼机做个比较,后者的巡航时速,约为518公里,X-76比它快了43%至61%。这一差距在战场上,有何意义呢,举个例子,在执行战斗搜救任务时,鱼鹰需消耗两小时,才能到达目的地,而X-76仅需约一小时,便可完成闪电救援,显著提高搜救效率。未来X-76它成熟之后,会衍生出一系列的,这种部署方便灵活,而且飞行速度比较快的,这样的一些作战平台。首先它可以执行侦察任务,比如说在水面舰艇上,进行部署,那么完全可以替代舰载直升机,既可以针对广阔的区域,尤其是海面目标进行侦察。当然它也可以执行反潜任务。除此之外,它也可以作为一种,察打一体平台,配备给陆军的作战部队。一方面可以在前沿的,这样的一个区域,进行野战化的部署。同样也可以利用,它自身所携带的光学传感器,红外传感器,针对地面的目标进行确认。同样也可以携带一些,精确制导弹药,针对目标进行精准的火力打击。所以它既可以执行侦察任务,也可以执行察打一体任务。在水面战舰上进行部署,也可以替代,部分舰载直升机的职能。看到此处,或许有观众会产生好奇,这款全能型飞行器,如今发展到了何种地步呢。依据官方信息,X-76已顺利通过关键设计评审,并且全部关键风洞测试,也已完成,当下处于,原型机制造的进程当中。根据计划安排,将于2027年,完成原型机总装工作,在2028年初,达成首飞目标。说起旋翼机,人们脑海中首先浮现的,想必是V-22鱼鹰,这是由贝尔公司,与波音公司联手研发的装备,已经服役了多年之久,是美军特种作战中的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
主力机型。鉴于X-76同样出自贝尔公司之手,那么,它是否仅仅是“鱼鹰”的简单升级版本呢?二者之间,究竟存在着怎样的差异呢?我们就来进行一场同台比拼,一次性将它们的不同点阐述明白。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
首先,最为根本的区别,在于飞行模式,以及动力系统的差异,这也是X-76最具突破性的部分。我们先来谈谈鱼鹰,鱼鹰采用的是倾转旋翼的设计方式,其翼尖短舱,能够整体进行倾转。在起飞时,短舱垂直朝上,旋翼旋转,以提供升力,和直升机无异。在水平状态下,短舱水平朝前,旋翼旋转提供拉力,如同固定翼飞机一般。但这里有一个重要的问题,不管是起飞、悬停,还是高速平飞,鱼鹰的翼尖旋翼,都必须持续旋转。大家千万不要轻视这个持续旋转,它所带来的问题可不少。首先是空气阻力方面,高速平飞时,旋转着的旋翼,会产生极大的空气阻力,这也是鱼鹰巡航速度,难以提高的关键因素。其次是安全性方面,处于低空飞行状态时,旋转的旋翼容易与地面物体、树木发生碰撞,并且一旦旋翼出现故障,飞机极易失去平衡,从而引发事故。自鱼鹰服役以来,已经发生了多起坠机事故,都与这一设计,有着千丝万缕的联系。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾4】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
和鱼鹰相比,X-76最大的优势,主要是体现在两方面。第一个优势,就是它采用了复合的动力系统。其实它翼梢的两台发动机,主要提供的是升力。也就是说在垂直起飞,和垂直降落的过程当中,当它进入到,水平飞行状态的过程当中,翼梢的发动机,本质上来讲是不工作的。主要是利用机尾的这种,大功率的喷气式发动机,让它进行高速飞行。所以在它进入到平飞状态之后,那么翼梢的两台发动机,它的叶片,其实是进行了折叠和收拢。这样的话,可以最大程度地减少阻力。其实它是在鱼鹰,这样的一个设计理念上,进行进一步的升级和完善。第二个是应用范围的差异。鱼鹰主要以有人驾驶为特点,尺寸较为固定,重点用于人员与物资的输送,任务形式相对单一。而X-76包含无人,和有人两种模式,技术具备高度的可延展性,既能充当有人驾驶的,核心运输载体,也能作为无人机执行危险任务,其灵活性远胜鱼鹰。第三个是隐身能力的不同。X-76设计中,引入了低可探测性理念,机体采用隐身优化外形,结合红外与噪声抑制技术。特别是在旋翼折叠后,进入平飞状态时,其雷达反射截面,和噪声明显小于鱼鹰,更不易被侦察和拦截。而鱼鹰由于旋翼持续旋转。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
噪声显著,雷达反射面积也比较大,在高强度对抗场景下,极易被敌方防空系统捕捉。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾5】
|
|
||||||
|
|
||||||
或许有观众会有疑问:既然X-76比鱼鹰先进如此之多,它是否将直接代替鱼鹰呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
我认为X-76它成熟之后,尤其是它的这样的一个设计理念,走向大型化之后,肯定会取代鱼鹰倾转旋翼机。因为鱼鹰倾转旋翼机,是美军第一代的倾转旋翼的,空中的飞行器。那么当X-76它成熟之后,在部署的灵活性方面有优势,而且在这个性能的层面,它更加地有优势。更重要的是它的飞行速度比较快,甚至还可以采用一定程度的,隐身化的设计理念。所以和鱼鹰相比,它的可靠性更高,飞行速度更快,通用性更好。那未来一旦它广泛地装备,美国陆军、空军和海军陆战队,相信到那个时候,鱼鹰倾转旋翼机,可能已经到达了,使用寿命的中后期。那么X-76它的成熟型号,会逐步地替代鱼鹰成为一种,多军兵种的一种通用的装备。看到此处,大家或许会有疑问,贝尔公司,怎么能研发出如此奇特的装备呢?实际上X-76的问世,并非是一蹴而就的,而是从早期的实验机型,逐步发展到如今的X-76。在垂直起降领域的探索,已历时近七十年。现在让我们一同回顾一下,贝尔公司的垂直起降发展之路,同时再来瞧一瞧,除了美国之外,还有哪些国家,曾在此领域。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
留下了探索的足迹。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
首先,从贝尔公司早期的经典机型,D-188A讲起。上世纪五十年代,美苏在军事科技方面,竞争十分激烈。无需跑道的垂直起降飞机,成为两国研发的重点。诸多类似的想法不断涌现,例如,把F-104改装为火箭,或配备四台涵道风扇的,巨型垂直起降飞机,还有有着猪鼻子的X-14。这时,美海军通过非官方渠道,得知贝尔公司,正在研发新型垂直起降飞机。随后,美海空军联合向贝尔公司提出要求,希望研发一款,拥有垂直起降能力的超音速战斗机,既能执行全天候作战任务,又能进行防空拦截。D-188A项目便应运而生。贝尔给出的设计十分大胆。它配备了八台通用电气涡喷发动机,其中四台,位于翼尖的可旋转短舱内,另外四台,分别置于机身尾部,和驾驶舱后部。起飞时,翼尖短舱转至垂直方向,八台发动机,共同提供垂直升力,达成垂直起降。平飞时,翼尖短舱转至水平方向,发动机提供向前的推力,设计速度能够达到马赫数2。1958年初,飞机模型制作完成,设计方案也已确定。但可惜的是,这款看似无懈可击的机型,最终却没能走出实验室。这架战斗机设计理念,可以说是非常地前卫。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾6】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
甚至可以说是超出了,当时的这样的一个技术水平。那么这个项目失败的原因之一,就是因为采用了八台发动机,技术非常地复杂。而且八台发动机,都是以不同的安装模式,和这种运转模式,集成到一个平台上,这个技术非常地复杂。而且八台发动机,也大幅度地增加了,飞机本身的重量,导致它的飞行的性能,可能会非常差。外形非常地科幻,贝尔公司也造了模型。但是美国空军和美国海军,在后续进行评估的过程当中,发现这一架战斗机的成本,实在是太昂贵了。大家可以设想一下,一架战斗机,或者说一架战斗轰炸机,它就有八台发动机,这个成本,在当时可以说是天文数字。维修起来也非常地麻烦,而且需要非常复杂的,这种操控系统。在当时也就是(上世纪)50年代的,这样的一个背景下,飞行控制系统,根本无法保证八台发动机,进行一个精准的操控。一旦出现了,某一台发动机出故障的,这样的一个情况,那么这架昂贵的战斗机,可能就会面临坠毁,或者是严重故障的风险。所以最终美国空军和美国海军,都放弃了这个项目。不过这个思路,还是非常的先进的,为以后美军研制,垂直起降的飞行器,包括倾转旋翼飞机,提供了思路基础。尽管D-188A项目未能成功。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
贝尔公司却从未停止,对垂直起降技术的钻研,众多珍贵的经验,在这些经验的基础上,鱼鹰倾转旋翼机,得以问世,随后又进一步推出,V-280英勇倾转旋翼机,旨在替代美军日渐老旧的,UH-60黑鹰直升机,其巡航时速处于426公里,到574公里之间,转场航程,高达3889公里,它运用了倾转旋翼的设计理念,不过对机械构造进行了改良,安全与可靠性,较鱼鹰有了显著提高,并且安装了和F-35战斗机相近的,分布式孔径系统,飞行员能够实现全方位观测,作战实力大大加强,2025年5月,美陆军将V-280正式命名为,并计划在2027年,向部队交付首批作战飞机,这一时间点比最初规划,提前了约5年,其实,不单单是美国,苏联同样在垂直倾转旋翼机方面,开展过积极的研究,其中最具典型的,便是卡莫夫设计局,于1967年提出的,KA-35重型运输旋翼机方案,或许有观众会有疑惑,苏联的KA-35,和美国的倾转旋翼机,存在哪些差异呢,KA-35可以说它的设计理念,还是有创新的,那如果说它能够研制成功,而且交付部队,那(苏军)就会拥有一种,高速低空飞行的,这样的一个作战平台,既可以有效地执行侦察任务,也可以把它作为一种突击平台,根据公开资料显示。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾7】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
KA-35的设计定位,是重型的运输平台。安-12运输机执行任务,它最大的起飞重量,可以达到71.5吨,最大的载荷可以达到20吨。巡航速度可以达到,500公里每小时,最大的航程为800公里。这样的性能参数,在当时来说是非常先进的。KA-35采用的是,重叠旋翼的设计理念,所以它的这个,有效载荷的搭载能力,是比较强的。既可以进行高速飞行,可以携带更多的有效载荷。同样它也可以配备武器系统,比如说在这个飞机的前部靠下,这个位置也可以安装,相关的机炮,也可以针对目标进行扫射。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
但可惜的是,KA-35最后也仅仅停留在设计方案,与模型阶段,并未制造出原型机。主要原因,在于当时苏联的工业水准,无法攻克重叠旋翼的,气动稳定性难题,且双涡喷发动机燃油效率过低,航程难以达到军方要求。此外,当时苏联,将军费重点投入到战略导弹,和核潜艇方面,最终使该项目被搁置。看到此处,我们不难发现,垂直起降飞机的研发,属于难度极高的系统工程,其需要雄厚的航空工业基础,尖端的发动机技术,以及精细的机械制造能力。不管是美国的D-188A,还是苏联的KA-35,都因技术成本等问题,未能达成量产。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
通过前面的讲述,大家已经认识到,垂直起降飞行器的设计理念,相当先进,纸面性能也极为出色,甚至被不少人誉为,改写军事飞行规则的装备。一款先进装备的研发,从来不是一帆风顺的。那么眼下让美军寄予厚望的X-76,未来究竟会顺利落地,大放异彩,还是会重蹈覆辙,无疾而终呢?X-76它的设计理念,确实非常地超前,但是同样也是有难点。我们看X-76它的机体,是采用流线型的设计理念,采用的是V字型的,这样的一个尾翼。但是这些都不是难点,因为机身的这个设计,已经比较成熟。那么机翼,包括动力吊舱的这个倾转系统。
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾8】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家8】
|
|
||||||
|
|
||||||
都不存在这个技术难点,最大的技术难点,就是发动机叶片,采用折叠收拢的这样的一个模式,它的可靠性一定要非常强。为什么这么说?如果说从平飞,要切换到,垂直起飞和降落的,这样的一个状态,可能会出现机身抖动的情况,或者说,受到一些复杂气流的影响,可能它的飞行状态,就无法保证这样的一个平稳可靠。一旦出现这个情况,它就会面临非常大的风险。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾9】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家9】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【嘉宾10】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家10】
|
|
||||||
|
|
||||||
现在从技术角度来讲,从它的结构来讲,X-76可能比鱼鹰还复杂。那么现在,鱼鹰已经是故障频出,多次地出现了机毁人亡的,这样的一些情况。那未来如果X-76,它在成熟之后,无法解决可靠性问题,在高速飞行的状态下,一旦出现了故障,那么它所面临的危险,可能要比鱼鹰还要多。或许有观众会有疑惑,贝尔公司,不是已经在风洞测试里,证实了旋翼折叠的可行性吗?为何这一难点仍然存在呢?事实上,风洞测试仅是模拟条件,真实飞行中的状况,要复杂得多。例如高空中的气流波动,温度变化,都会对旋翼的折叠和动力切换,产生影响。尽管贝尔公司已验证了,旋翼折叠的可行性,但在实际飞行中,如何保证过渡阶段的平稳性与安全性,仍需要进行大量的飞行试验,和技术改进。这也是X-76研发过程中,亟待解决的关键难题。X-76还有一个问题,就是它配备了,其实是两套这个动力系统。那么有两套,完全不同属性的这个动力系统,集成到一个平台上,也会导致它的重量大幅度地增加。而且,一旦在水平飞行的过程中,它的喷气式发动机出现了故障,相关的垂直飞行的,这样的一套动力系统,能否在短时间内切换和救急,我认为还是未知数,因此还是存在隐患的。除了这些技术难题,X-76还遭遇成本,与可靠性的挑战。鱼鹰单价已超一亿美元,而X-76,运用了更为先进的技术,成本必然更高。即便美军军费充裕,也得权衡一下经济负担。X-76它的设计理念,美国军方已经认可,但是在动力系统的研发,在整体的设计方面,包括解决可靠性问题方面,贝尔公司还要面临,很复杂很棘手的一些问题。所以它未来能否真正地成熟,能否成为一款,实实在在性能先进的,这样的一个作战装备,我认为还是要打上一个大大的问号。美军在研制,武器装备的过程当中,有的时候就是要贪大求全,既要也要。这样的一种思维和逻辑,和这种装备的发展,进行这个结合的过程当中,可能就会出现期望值比较高,或者说提出的,这样的一个技术要求比较高,但是平台实现起来,难度较大。所以从目前来看,它的设计理念是没有问题的,但是能否在工程技术的,这样的一个状态下,能够实现,变成真正可靠的作战装备,我认为还是一个未知数。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
X-76力求突破性能限制,达成速度与灵活的精准平衡。尽管当下,存在着不少的技术难题,未来的发展也充满未知,但是我们必须承认,其设计思路与技术革新,为军事航空领域的发展,带来了新的启发。日后伴随技术的持续发展,相信会有更多更先进的装备诞生,重塑战争的模式。感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 启程远航
|
|
||||||
编导: 张彤枫
|
|
||||||
播出日期: 2026-04-21
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep014
|
|
||||||
---
|
|
||||||
|
|
||||||
# 导视:
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
77载深蓝砺剑,77载向海图强。在人民海军的序列中,有这样一些特殊的存在,它们不担负作战任务,而是青年官兵驶向大洋的第一课堂。看戚继光舰启程远航,听郑和舰续写传奇。值此人民海军成立77周年之际,《军事科技》与您和海军青年官兵一起,启程远航,乘风破浪。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。就是人民海军,77周岁的生日了。77年风雨兼程,人民海军从无到有,从小到大,从弱到强。在此之前,我们关注过很多巨舰。我们要看一些,比较特别的战舰。它们虽然不像驱逐舰,护卫舰那样全副武装,但是却承载着海军,未来的希望。它们虽然不直接参与海战,但是,每一次远行,却为海军,播下了勇毅前行的种子。它们,就是被称为“海上流动大学”的,训练舰。本期节目,就让我们一起走进它们。此刻我就站在,郑和舰的后甲板上。很少出现在公众的视野当中,但是我相信,很多海军官兵都在此处,留下了自己成长的足迹。那么这艘舰艇,有怎样的特别之处。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
而此处,又发生了怎样让人印象深刻的故事呢?让我们一起来听一听。
|
|
||||||
|
|
||||||
|
|
||||||
## 【学员1】
|
|
||||||
|
|
||||||
主持人好,大家好,欢迎大家来到海军郑和舰。海军郑和舰是我国自行设计建造的第一艘远洋航海训练舰,舷号81,也被誉为中国军校第一舰,承载着传承和平、耕海育人的双重使命。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
81郑和舰,是我国第一艘自行设计建造的远洋航海训练舰,舷号81,以明代航海家郑和命名,1987年4月27日正式加入海军服役,隶属于大连舰艇学院,被誉为“军校第一舰”。该舰长约132米、宽约16米,正常排水量约6000吨,最大航速21节,续航力一万海里,可在除极区外所有海区航行。虽然作为训练舰不担负作战任务,但为了实战化训练的需求,郑和舰也配备了一定的基础武器系统。主要包括两座57毫米双管舰炮、两座30毫米双管副炮以及两座81式五管反潜火箭发射器,尾部飞行甲板虽然没有设置机库,但也可以起降一架直-8舰载直升机。舰上还配备完善的教学和生活设施,可保障两百多名学员开展四十多个课目的实习训练。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
郑和舰紧贴舰艇人才的能力需求,为学员提供了体系化的海上实习内容。学员在舰上可以系统地开展海图作业、目标定位、航海仪器使用等航海专业训练,掌握航海保证的基本技能。实习学员上舰以后,会被我们分配到不同的部门,实现舰员与学员的混编统管。这样既有利于教学活动的顺利开展,又可以使学员充分代入舰员角色,助力学员实现从学员到舰员、指挥员的身份跨越。整个实习过程中,把理论知识和海上实操紧密结合,让学员在真实海况下适应舰艇生活,提升岗位履职能力,为今后走向第一任职岗位打下坚实基础。训练舰不同于驰骋海疆的作战舰艇,而是一个技能传授的平台。作为陆上到海上的关键桥梁,它不追求火力与速度,而以稳定、适航与教学功能为核心。作为海上移动军校,训练舰就是一本会移动的教科书。报告指挥官,目标方位070,距离5海里,航向000,航速15节。驾驶战舰行驶在茫茫大海之上,最重要的一点就是认清航向。毕竟一望无际的大海,没有陆标,没有参照,唯有罗经指针的微光与北斗星图的坐标。所以。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
作为一名刚刚登上郑和舰的学员,第一个任务,就是学会海图绘制。
|
|
||||||
|
|
||||||
绘制海图:
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
任务我们已经收到了,在陆地上看导航,大家看得很清楚,但是如果在茫茫的大海之上,如何进行海上自我定位呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【学员2】
|
|
||||||
|
|
||||||
海上定位方式通常有三种,第一种叫陆标定位,第二种叫无线电定位,第三种叫天文定位。在我们沿岸航行时,最常采用的就是陆标定位。我们以现在这张海图为例,我看某一个岛屿的方位,假如说是200,我沿200的方向画一条线,再看这个岛屿的方位,假如说是260,同样也画一条线,两个线的交点,就是我们现在所在的位置。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
这就是刚才提到的,两边定位法。如果我们在茫茫的大海上,或者是只能看到一个岛礁,这个时候又该如何自我定位呢?如果岛礁的距离,还有岛屿的形状,都非常合适的话,非常推荐采用的就是,单标方位法进行定位。而单标方位法,就离不开一个,重要的航海仪器,就是六分仪。六分仪,是航海天文定位的核心工具,通过测量天体,与海平线之间的夹角,即可推算出,舰船所处的经纬度。它不依赖电子信号,不受电磁干扰,在远洋无参照环境下,仍能保持高精度。在现代导航系统,高度发达的今天,六分仪。
|
|
||||||
|
|
||||||
|
|
||||||
## 【学员3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
依然是郑和舰学员必修的压舱石课目。
|
|
||||||
|
|
||||||
六分仪使用
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
现在我手里拿的,就是我们航海上常用的六分仪。它主要是由三大部分组成,首先是架体,其次是光学系统,它主要是包括动镜、固定镜、滤光片以及望远镜。最后一部分是测角读数装置,它是由指标杆、分度弧、鼓形轮以及游标尺组成。那么我们在观测天体时,尤其是测量高度角,我们就需要垂直拿六分仪。这个时候我们观测太阳,由于太阳比较刺眼,所以我们需要选择一个合适的滤光片,选择一合适的滤光片去进行观测。当我们要测量高度角时,我们在望远镜里,可以看到太阳的直视影像和反射影像。接下来我们拉动指标杆,将太阳反射影像的上边缘,与太阳直视影像的下边缘进行相切。最后转动鼓形轮进行微调。这个时候,我们会测出一个读数,现在就可以获得我们距离太阳的方位和高度角是多少。后期这个数据,我们进行计算,可以用来进行定位。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
除了六分仪的使用,当学员登上郑和舰的那一刻,如何将这样一艘庞然大物,顺利驶离码头,穿越狭窄水道,规避暗礁浅滩,便成为每名学员,必须直面的实战课题,而这种实操能力,并不是靠书本就可以学会的,为此,以郑和舰为代表的,远洋训练舰上,全任务模拟航海驾驶教学系统,模拟驾驶,是训练舰的特色课目之一,对于刚上舰的学员来说,直接实操驾驶战舰,是非常容易出事故的,因此,学院教室,远洋训练舰上,都配备了,全任务模拟航海驾驶教学系统,让学员在零风险环境中,锤炼应变能力与操作精度,通过全景视角,与实时数据交互,学员能同步感知风浪力矩,航向偏移及敌情动态,操作台上,还原了真实舰桥的每一块仪表,使学员能更快适应真实作战节奏,每一次推杆,每一道口令,都精准对应物理反馈,与系统响应。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
郑和舰,作为中国海军首批远洋训练舰之一,是无数海军军官成长的摇篮。在这艘舰上,不但拥有宽敞明亮的教室和先进的教学设施,更可以为学员提供真实海况下的航行训练机会。从黄海到南海,从西太平洋到印度洋,郑和舰每年执行数次远航实习任务,将课堂延伸至万里海疆。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
看完了经历数代海军成长的郑和舰,接下来我们将目光转向另一艘功勋卓著的战舰邓世昌舰。相比于郑和舰,它的外观完全不一样。此刻我就站在邓世昌舰的甲板之上,为了更直观地感受它的甲板有多大,接下来跟随镜头一起欣赏一下。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
82邓世昌舰,是中国唯一的国防动员舰,隶属海军大连舰艇学院,1996年12月正式列装服役,以民族英雄邓世昌命名。其外形相对独特,只保留了高耸的舰桥和一个巨大的飞行甲板,让它的外形看起来更像是一个巨大的货轮,在外形上明显区别于其他军舰。其核心功能是为海军学员提供航海实习,同时可通过模块化改装,实现医疗救护、直升机训练、集装箱运输等任务,承担国防动员演练,是平战结合、寓军于民的典范。服役以来,累计保障百余批学员实习,总航程达四十多万海里。当你第一次看到邓世昌舰时,一定会被它那宽阔的飞行甲板与极具辨识度的双直升机起降平台所震撼。这个巨大的甲板不仅是舰载直升机起降的核心区域,更是学员开展海上立体作战保障训练的重要平台。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
位于我身后的是海军邓世昌舰的医疗模块,主要用于海军医疗队和医学院校学员海上实习保障。主要由18个标准集装箱组成,具有手术室、分级病房、X光室、检验室以及各类医疗检验设备,可以同时进行三台手术以及其他伤病员救治。作为海上移动医院,与一般医院最大的区别是,可以根据任务所需要,在短时间内增配不同的医疗模块,完成功能拓展。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
海上医疗任务是邓世昌舰平战转换能力的核心体现,其集装箱式医疗模块可在48小时内完成布设与联调,实现从训练舰到野战医院的快速切换。在这些集装箱中,分设了包括手术室、重症监护室、检验科及卫生防疫单元在内的全功能医疗体系,几乎是将一座现代化野战医院完整搬到了海上,真正实现了舰行万里、医随舰走。同时,邓世昌舰还可以为众多海军官兵提供海上医疗救援课目的训练,涵盖海上批量伤员转运、战创伤紧急处置、远程会诊联动等实战化课目,曾参加全国多个省份的医疗救援队集训拉动演练,先后培养出两千余名卫勤保障人员,并多次在台风抢险、远洋护航等重大任务中完成实战化卫勤保障,成为海军官兵信赖的海上生命方舟。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
医疗模块,在军医大学学员,和卫生专业学兵,实习训练中起到重要的作用,在实习中,我舰充分发挥医疗模块功能,以反恐 反海盗等,等战斗部署操演为背景,使学员和学兵,全流程全要素,参与到医疗 救护,伤病员转运等训练环节中,使学员和学兵,在课堂上所学到的知识,在实战环境中得以应用。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
宽大的飞行甲板,不但可以让邓世昌舰化身海上立体救护平台,更可以成为海军官兵开展舰载机起降协同训练、垂直补给演练和伤员空中后送的关键枢纽。当然,随着现代武器装备的发展,无人作战系统日益融入海上卫勤体系,这也为邓世昌舰赋予了新的任务。在气候变化复杂的海上,无人机的使用成为了一个全新的课题,但常规舰艇的飞行甲板相对较小,难以满足多型医疗无人机起降与协同作业需求。邓世昌舰凭借巨大的飞行甲板,成为海军官兵无人机装备训练的首选平台。
|
|
||||||
|
|
||||||
无人机:
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
随着科技发展和战争演变,我们也在探索将无人设备融入学员的实习体系中,可以让学员学会无人装备、战术编组、协同指挥、态势研判,培养联合作战思维。同时,我们也会用无人装备模拟高危复杂场景,构设逼真战场环境,以此提升实习质效。此外,结合无人机后送、无人补给、远海搜救等场景,提升海上应急救援和保障能力。
|
|
||||||
|
|
||||||
无人艇:
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
接下来我为大家介绍,我院投入教学训练使用的两类无人艇。这也是近两年来,支队官兵参与学员专业学习,海上实习的新型实训设备,在支队教学训练提质增效方面,发挥着重要作用。第一类是挂载声呐的水下探测目标无人艇,主要用于模拟水下目标探测。在相关课程的学习中,它将抽象的水下探测理论、声呐工作原理、目标识别技巧等知识具象化,让学员更加直观地看到声呐工作流程、水下信号反馈。数据采集、目标研判等实操训练,让学员在真实海况中掌握装备操作技能,积累水下探测实战经验。第二类是三米级无人艇原型机,它具备自主划定航线作业的能力。在课堂教学中,它主要用于无人装备操控、航线规划。学员通过实操设定航行路线,掌握无人艇操控逻辑,开展编队航行、协同作业等实训科目,锻炼学员的海上装备协同指挥。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
相较于无人机,无人艇的训练,则更依赖邓世昌舰的巨大甲板。它为无人艇的布放、编队协同及复杂海况适应性训练,提供了充足空间与稳定平台,使学员能在真实风浪中,反复锤炼无人系统海上作业全流程能力。邓世昌舰甲板上,浪花飞溅,无人艇编队正依指令破浪前行;无人机腾空而起,医疗模块遍布甲板,红十字标志在阳光下格外醒目。让邓世昌舰成为了海上全能课堂。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家8】
|
|
||||||
|
|
||||||
对于我们支队这样,以教战、研战、能战为发展核心的单位,将这两类无人艇投入到教学训练中,创新了教学模式,实现了理论教学具象化,实操训练实战化,人才培养专业化。既有效还原了海上实战环境,又全方位提升学员的专业实操与综合应用能力,补齐了无人装备实操教学的短板,助力支队打造更加贴合实战需求的海上教学实习体系,为培养高素质、专业化的海军人才,筑牢了装备根基,推动教学训练工作迈上新台阶。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
作为我军唯一的一艘国防动员舰,平时主要承担实习训练、国防动员、卫勤演练、医疗救护、直升机训练、武器试验等使命任务。加装医疗模块后,可作为医疗船使用。因其独特功能作用,2002年依托邓世昌舰,成立了我国第一个海上卫生动员中心,承担培养海军高素质新型军事人才和海上国防动员、应急应战的双重使命。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人6】
|
|
||||||
|
|
||||||
刚才完成登舰的,就是大连舰艇学院的大四学员。他们即将驾驶83戚继光舰,完成自己大四学年的最后一次实习远航。在此次实习之后,他们就会奔赴自己的岗位,守护祖国的万里海疆。那么在此次的实习当中,他们又会锻炼到自己的哪些岗位呢?戚继光舰又该如何守护他们,驶向远方呢?接下来,让我们一起去看一看。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
这一次的航行实习,对于所有学员来说,不仅是一次技能检验,更是一场向战而行的青春出征。学员们将在戚继光舰上轮岗实操航海指挥、机电管理、通信导航、损管应急等核心岗位,同步开展远海综合演练、跨区对抗训练与联合搜救任务,在真实海况中锤炼临机决断与协同作战能力。舰艏劈开碧波,航迹延伸向深蓝。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家9】
|
|
||||||
|
|
||||||
在上次也就是大二的时候,一次远洋实习也是在83舰上。那时候,我们在海图桌上,练习这个抛锚。然后我选的抛锚的地点,对于我们航海专业来说,是一个比较好的抛锚点。当教员走到我身旁,问我为什么选择这个地点时,我把我所学的专业知识,给他介绍了一遍。但是他却阴沉着脸对我说,你只考虑了航海的因素,但是你是一名水面舰艇的军官,你不考虑战术要素吗?你不考虑防空,你不考虑反潜的要素吗?我想这是在课堂中的学习,所不能给我带来的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
再一次登上戚继光舰,王浩然已从青涩学员成长为一名能独立完成海图作业的实习骨干。而这一次,他即将接受来自戚继光舰上教员的最后的考核。在此次航行实习中,他首先要进行的就是他最熟悉的海图作业考核。在教员的指导下,为全舰完成出航前最后的准备工作。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家10】
|
|
||||||
|
|
||||||
今天我指导学员训练的,是一般条件下航行的航海保证工作。这个科目是航海军官的基础能力训练,更是未来履职的基本功。航线规划与定位,直接决定着舰艇的航行安全,是准航海军官从理论走向实操的关键一步。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
而在另一边,指挥舱室内,学员杨政和同班组的同学们一起来到驾驶室,在教员的指导下进行着这次远洋实习的驾驶流程训练。每个战位对于他们来说都不是第一次尝试,但是,当他和同学们走出教室,来到真实的戚继光舰驾驶室,才会发现真实的海况,瞬息万变的电子海图,突发的模拟敌情,每一个细节都远超课本的想象边界。
|
|
||||||
|
|
||||||
|
|
||||||
## 【学员4】
|
|
||||||
|
|
||||||
其实我更喜欢在舰上的实习,因为,在舰上的实习本来就比较珍贵,而我们在院校,理论知识是随时可以学的,而真正到实操,只有到船上才能够实操,所以我们都,比较期待在线上学习,它能够将我们,所学的理论知识,更多地去让我们,通过自己的手去体现出来,然后自己也会感觉,收获到更多。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说12】
|
|
||||||
|
|
||||||
但是,想要将一艘9000多吨的巨舰稳稳驾驭于风浪之间,绝不是仅靠海图上的路线和战士手中的舵轮就可以完成的。对于刚刚登上戚继光舰的年轻学员而言,熟知和了解不同岗位的职责与技能是迈向合格舰员的第一步。朱哲是一名航海专业的学员,但是来到舰上的第一课却是被安排在机电部门。这种跨专业的轮岗也是每一名实习舰员必需的经历。
|
|
||||||
|
|
||||||
|
|
||||||
## 【学员5】
|
|
||||||
|
|
||||||
83舰的学习,我们可以了解到,我们这个岗位的业务,包括如何去做,这都是,相当于是一条必经之路,然后珍惜这个时间,也在这个过程中,是有试错机会的,然后我们可以通过这些机会,避免我们以后,在实际岗位上出现问题。无论是在哪个岗位,对于实习舰员们来说,这都是他们,最后一次登上戚继光舰的,远洋实习之旅。此后,他们将奔赴各自战位,或守卫海疆,或驰骋深蓝。我自身的话,还是比较渴望去,主战舰艇上,特别是像055型驱逐舰这种,大型的主战舰艇。我想说,戚继光舰是一个,非常大的舞台。来到这里,你将熟悉学会,几乎整个水面舰艇各个战位,你所要学到的知识,你所要实践的技能。希望大家,都来海军大连舰艇学院。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说13】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人7】
|
|
||||||
|
|
||||||
当中国舰长,在此前的场景当中,我们经常看到战舰列阵,军旗猎猎,感叹大国海军的威武雄壮。而此刻,我们伫立在军港,即将送别远航的莘莘学子,以及护送他们的训练舰。虽然此刻,他们并没有取得驰骋海疆的赫赫战功,但是他们却满怀守护海疆的赤诚初心。他们暂别宁静的港湾,即将在风浪里淬炼意志,砥砺前行。他们,就是人民海军的新鲜血液,撑起强国梦想的明日栋梁。好了观众朋友们,国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 舰炮的暴力美学
|
|
||||||
编导: 穆佩弦
|
|
||||||
播出日期: 2026-04-28
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep015
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《舰炮的暴力美学》
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
在战舰的钢铁身躯上,舰炮,是最震撼的视觉符号。它曾是改变海战格局的利器。从17世纪的滑膛炮,到二战时期的巨型主炮,舰炮的每一次射击,都在书写崭新的海战规则。如今,舰炮,不再只是简单的火力工具,它们已经成为海上战术的多面手。今天,让我们一起走近舰炮的世界,感受那一发发炮弹背后的暴力美学。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》。我是主持人蓝皓。海战中,火力曾经意味着一切。在过去,更大的口径,更重的炮弹,往往直接决定海上战局的胜负。舰炮的每一次齐射,不仅是海军力量的宣示,更是海战战术的核心所在。然而,在如今的战舰上,却出现了一个看似反常的现象。现代军舰的主炮口径,却在不断地缩小。从曾经的巨炮,到如今的自动化舰炮系统,舰炮的口径在变小,作战能力却在持续提升。这门看似平平无奇的舰炮,都经历了哪些坎坷的发展经历呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【二维动画解说1】
|
|
||||||
|
|
||||||
1805年10月21日,特拉法加海域。清晨的海面异常平静,微风缓缓掠过水面。英法西三国舰队都知晓,一场恶战已不可避免。西班牙旗舰圣三位一体号,拥有当时最先进的四层火炮甲板,载有超过100门舰炮,是当时世界上火力最强的战舰之一。而在另一侧海域,英国舰队旗舰胜利号舰身两侧,排列着数十门32磅前装滑膛炮。这些舰炮,由铸铁整体浇铸而成,每门重达数吨。一发炮弹重达十几公斤,依靠黑火药爆炸推出炮口,在近距离内,足以击穿厚重的木质船体。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
滑膛炮首次被安装在战舰上,是海战史上的一次重大转折。16世纪末至17世纪初,海上冲突日益频繁。传统的海战手段——撞击、弓弩、火绳枪——已难以决定战局。舰队作战急需一种能够远程摧毁敌舰的火力武器。滑膛炮适时地被搬上了战舰。它能够发射重型铁弹,射程远、穿透力强,可以击穿敌舰木质船体和甲板。最初,这些炮是由陆地攻城炮改造而来,通过加固甲板和设计专门的炮架,使其能够承受发射时的巨大后坐力。舰船不再只是漂浮的堡垒,而成为真正的移动火力平台。舰炮的数量、排列和齐射能力,逐步成为舰载设计和战术部署的核心。火力密度直接决定了战斗的胜负。滑膛炮的上舰,海战从近身搏杀走向远程火力决胜的时代。它不仅改变了单艘战舰的作战能力,更深刻影响了整个舰队的战术格局。
|
|
||||||
|
|
||||||
|
|
||||||
## 【二维动画解说2】
|
|
||||||
|
|
||||||
随着信号旗升起,英国舰队开始行动。不同于传统的平行对射,英国海军中将纳尔逊选择了一种更为激进的战术。英国舰队分为两路,从垂直方向切入西法舰队的横列队形。当纵队逼近时,英舰突然转向,与敌舰形成交叉角度。舰炮齐射,从侧舷猛烈倾泻。这一瞬间,舰炮的优势被彻底放大。西法舰队由于队形被切割,无法形成有效齐射;而英军则可以在短距离内集中数十门火炮,对单一目标进行打击。而西法联军,尽管拥有更多舰炮,但在混乱中难以形成统一火力。数小时激战后,英军共俘获或摧毁西法舰船22艘。舰炮的轰鸣已经停息,它所决定的战局,已经无法改变。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
18世纪的海战,是火药与木材的直接对抗。在当时主力战舰的炮舱内,一门门32磅长炮整齐排列,沿着两侧舷墙排开,宛如一列沉默而威严的铁巨人。所谓32磅,指的是每枚炮弹的重量。除了32磅炮,英国海军的战舰上还配备有24磅、18磅,甚至12磅的火炮,它们分别被装在不同的位置,形成从主力到辅火的多层火力体系。在激烈的战斗中,一分钟能发射一到两次已是极限。炮手们在烟雾、火光和轰鸣声中紧张操作,每一次装填都是对体力与耐心的考验。那么我们看到,在风帆战舰的鼎盛时期,这个战舰的个头都非常大,它是有多层结构的。那么往往会在战舰的一侧,也就是侧舷,它有很多可以开闭的这样的一些炮口。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
当时在战舰上的这种滑膛炮,和陆地上使用的滑膛炮,基本上没有什么区别。它也是一种非常简易的,射击距离比较近的,这样的一个火力发射系统。它开火的这个准备时间,是比较长的。完成了一次开火之后,需要相关的操炮的人员,针对炮膛进行清理,然后再次地装填火药、炸药,后续还要装填弹丸,最后把它压实,装上引信,点燃之后才能够完成再次的开炮和击发。所以整个的过程非常地长。但是我们注意到这些滑膛炮,它首先是没有膛线的,那么当弹丸离开火炮之后,它稳定飞行的这样的一个时间,并不是特别长。所以很大情况下,是靠这种弹药的密集程度,也就是说,它打的是一个概率。通过火炮数量的增加,去提升它的这样的一个命中率。所以在风帆战舰时代,那么火炮的数量,战舰的吨位就比较重要。随着工业革命的推进,海战的面貌发生了根本性的变化。风帆战舰与滑膛炮的时代逐渐走向终结。铁板与钢甲开始覆盖舰体,使得单纯依靠重量和炮弹密度压制敌舰的战术显得力不从心了。为了突破厚重的装甲,舰炮也迎来了革新,线膛炮应运而生,膛线让弹丸旋转飞行。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
大幅提高稳定性和命中率,射程和穿透力,都得到了前所未有的提升。紧随其后的,是铁甲舰与速射火炮的结合,战舰的设计理念,也在随之改变。单一火力压制,让位于综合作战能力,远程精确打击,开始成为可能。19世纪末,一场新的革命,再次席卷海军,无畏舰的出现,彻底颠覆了传统战列舰观念。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
19世纪末至20世纪初,世界海军正处在由传统向现代转型的关键阶段。蒸汽动力逐步成熟,钢铁装甲广泛应用,远程火控技术不断进步,海上作战方式随之悄然改变。在这样的时代背景下,无畏舰的建成服役,标志着战列舰发展进入新的历史阶段。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
无畏号战列舰,标准排水量约18000吨,全长约160米。动力系统采用蒸汽轮机驱动,最大航速可达21节,在当时属于领先水平。它采用全钢结构建造,厚重装甲集中防护关键舱室与炮塔,使战舰在火力打击面前具备更强的生存能力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
无畏舰对于海战格局的影响,那就是,首先要有强大的工业体系。因为无畏战舰,和这个风帆战舰相比,它的生产成本,还是比较高的。它最大程度地克服了,自然因素的影响,本身的动力系统比较强劲,可以在海上高速地航行。另外一个,它并不是拼火炮的数量,开始使用大口径的舰炮,而且有这种,旋转式的炮塔设计。舰炮的这个俯仰角,是可以进行调节的。其实这里面,海战的这样的一个模式,可以说进一步地升级了。那么针对对方的,火力杀伤的精准程度,进一步地提升。而且,即使是遭到了对方的,大口径火炮的轰击,那么无畏舰,也可以利用自身的舰体结构,包括一部分的装甲防护,更好地去保护,自身的动力系统,甚至是这个弹药舱。所以在无畏战舰,在海上进行决斗的,这个过程当中,各种因素叠加起来,才能够有效地取得胜利,才能够立于不败之地。比如说,航行速度快不快,火炮的口径大不大,操舰的这个海军官兵,他的技术是否纯熟。那么这些因素叠加到一起,才能够成为,在无畏舰时代的,海上的制胜本领。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
无畏号战列舰,共装备10门12英寸主炮,分布在5座双联装炮塔之中。其中,舰首与舰尾各一座炮塔,舰体中部两侧设有侧翼炮塔,另一座位于中轴线上方,形成多方向火力覆盖。所谓12英寸,指的是炮弹直径约305毫米。单枚炮弹重量约380至390公斤,采用分装式装填方式,将炮弹与推进药分别装入炮膛。主炮炮管长度约为45倍口径,全长超过13米。在发射时,炮弹初速可达到约每秒800米,大约射程约15至20公里。在射击节奏上,每门主炮的射速约为每分钟一至两发。一轮齐射时,10门主炮可在极短时间内同时发射,形成大范围火力覆盖。在结构上,炮塔具备水平旋转与俯仰能力,水平旋转角度接近300度,其早期配置的最大仰角约为13至15度,这使得主炮不仅可以进行正面打击,也能覆盖侧舷大范围海域。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
进入20世纪上半叶,工业能力的持续提升,与海军战略需求的变化,推动世界主要海军,围绕火力、装甲与航速,展开全面竞争。造舰技术不断进步,钢铁冶金水平稳步提高,动力系统由往复式蒸汽机,迈向效率更高的蒸汽轮机,战舰性能实现系统性跃升。在这一进程中,舰炮口径持续扩大:以无畏舰为代表的早期型号,普遍装备12英寸主炮;随后,以伊丽莎白女王级为代表的战列舰,将主炮提升至15英寸,显著增强远程打击能力。与火力提升相伴随的,是炮塔布局的不断优化。主炮由早期相对分散的配置,逐步转向中轴线集中布置,以提高齐射效率与火力投送密度。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
随着各国海军战列舰的军备竞赛迅速升级,在大量的技术积累之下,战列舰的发展逐步走向成熟。到了第二次世界大战时期,以依阿华为代表的新一代战列舰,将这一发展推向了。它不仅拥有更大口径的主炮,与更远的射程,同时具备更高的航速,与更完善的火控系统,使舰炮真正成为远程、精确、可控的打击力量。将舰炮的威力,与效率推向了一个全新的阶段。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
1945年9月2日,东京湾海面晨雾尚未散尽。停泊在海上的密苏里号战列舰,成为全世界目光汇聚的中心。就在这艘钢铁巨舰的甲板上,日本正式签署投降书,第二次世界大战宣告结束。很少有人注意到,这一历史性时刻的见证,不只是胜负的分晓,更是一种武器形态的巅峰呈现。作为依阿华级战列舰的代表,密苏里号所搭载的大口径舰炮,曾在战争中以压倒性的火力,支配海面与海岸。从远程齐射的震撼轰鸣,到对岸火力的持续压制,舰炮,曾是人类将力量推向极致的象征。而当历史在这片甲板上落笔,一个属于大口径舰炮的时代,也正悄然走向尾声。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说3】
|
|
||||||
|
|
||||||
在依阿华级战列舰上,最核心的火力,来自其主炮系统,16英寸50口径,Mark7舰炮。16英寸,指的是炮弹直径约406毫米。50口径,则表示炮管长度,为口径的50倍,在炮弹发射过程中,获得更高的初速,和稳定的弹道。每一门主炮,全长超过20米,重量在百吨以上。战舰共配备3座主炮塔,每座3门炮,共计9门主炮,形成强大的集中火力。在发射时,炮弹与推进药分装,单枚穿甲弹重量,可达约1200公斤。依靠高能推进药,炮弹初速,可超过每秒800米。在最佳条件下,射程可达40公里以上。与传统舰炮不同,这套主炮系统,配备了完善的火控系统。雷达测距,弹道计算机,以及炮塔联动系统,使其能够在远距离条件下,对移动目标,实施较高精度的打击。在持续作战能力方面,主炮射速,约为每分钟两发左右。虽然速度不高,但凭借巨大的单发威力,与集中齐射能力,依然具备较强的,毁伤效果。海上的这种火炮对决,它已经到了巅峰时刻。为什么?因为战列舰,它的吨位是非常大的,而且造价很昂贵。只有这个区域性的,或者说世界级的这种海军强国,才能够把战列舰给造出来。一般的小型的工业国家,这个战列舰它根本造不起。因为它涉及到,动力系统,包括船舶的设计,以及,甚至包括在这个钢铁行业,也是需要达到一个,你才能够把战列舰给造出来。战列舰它的火炮,还是要求身管比较长,炮口比较大。有的时候我们看,战列舰的这个炮弹,它的这个弹丸,和一个人差不多那么高。而且,它是全装甲防护。什么叫作全装甲防护?动力系统的相关区域,弹药舱,火炮的炮塔,甚至包括,指挥的这样的一些舰桥,以及其他的这样的一个区域,它都是要有不同程度的,装甲进行防护的。其中我们会注意到,你像三联装主炮的炮塔,或者说四联装主炮的炮塔,它的装甲防御能力是最强的,甚至可以针对对方的,大口径舰炮,进行这种硬扛。所以战列舰最大的优势,就是航行速度是比较快的。另外一个吨位比较大,它上面所使用的,这种主炮的口径,可能会达到数百毫米。而且,这个弹药的威力非常大。如果说对方的战列舰,或者是对方的,大型的这个巡洋舰,那么一轮齐射,只要挨上两枚,这种战列舰所发射的炮弹,它可能就彻底地,失去了作战功能。而且,战列舰,它火炮的射击距离就更远。另外一个,辅助的光学设备就更多了,可以针对对方的这种,海上的大型目标,进行这种猛烈的火力覆盖,而且打得比较准。在很长一段时间里,人们几乎坚信一个,简单而直接的逻辑,口径决定一切。舰炮越大,射程越远,威力越强。巨大的炮塔,数百公斤重的炮弹,跨越数十公里的齐射火力,让海战进入了一个,以规模定义胜负的时代。钢铁与火药的叠加,被推向极致。然而,就在人们以为,舰炮口径仍将持续增长,战列舰,将主宰海洋的时候,一种全新的武器,悄然改变了这一切。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
导弹的出现,使打击不再依赖弹道抛射,而是转向自主飞行与精确制导。目标可以在更远距离被锁定,不再依赖密集齐射,而是依靠一次精准打击完成。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
从“越大越强”,到“越准越有效”。曾经以舰炮口径、齐射密度与毁伤规模为核心的作战理念,在新的技术条件下,逐渐被重新审视。打击,不再单纯依赖弹药数量与口径的优势,而更强调对目标的发现能力、锁定能力与一次性命中效率。在这一进程中,以舰炮为代表的传统海上火力体系,并未简单地退出历史舞台,而是逐步调整其功能定位,从决定性打击手段,转向持续支援与近距离压制等更为具体的任务领域。舰炮所承载的暴力美学,也由此在时代更迭中,逐步成为追求效率、节奏与技术协同的机械之美。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
二战结束后,战列舰逐渐退出海军主力舞台。以往依靠大口径、单舰绝对火力的作战模式,不再适应新的海上威胁环境。与此同时,舰炮的发展方向发生了深刻转变。冷战时期,海军更强调自动化、精确化与多功能。舰炮口径趋于中小型,但射速大幅提升,装填系统实现全自动化。火控依赖雷达与计算机,能够在远距离条件下精确命中移动目标。更重要的是,舰炮不再只是对海作战的工具。它承担起多种任务:防空、反小艇、岸防支援,成为轻型多用途武器系统的重要组成部分。其中最突出的便是MK45舰炮系统。从冷战巡洋舰,到现代宙斯盾驱逐舰,MK45舰炮几乎贯穿了现代水面舰艇的主力构型。它不再追求极限口径,而是在持续、稳定与高效率之间,定义了舰炮在导弹时代的现实定位。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说4】
|
|
||||||
|
|
||||||
MK45舰炮整套系统,可以分为甲板以上的作战单元,和甲板以下的供弹系统。它的炮塔内部由炮管、后膛机构和滑板系统组成。当火炮准备射击时,炮门开启,弹药由下方送入;炮门闭锁后,电动激发装置点火,完成发射。发射瞬间,炮管在摇架带动下,完成俯仰运动,炮塔整体旋转,实现方向瞄准。射击后,后膛机构自动开门、退壳,同时通过吹气系统,将膛内残留气体排出,为下一次射击做准备。弹药储存在弹药库中,通过扬弹机系统,逐级向上输送。弹丸与药筒,分别进入弹鼓,经引信装定后,被送入炮塔内部。在炮塔内,将弹药对准炮管轴线,输弹器将其推入膛内,完成装填。整个过程从供弹、装填到发射,实现了高度机械化。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
未来的舰炮发展,它大概会走两条路。第一条路就是,现在的这种,大口径的舰炮,和中小口径的舰炮,它可以继续用。这个和相关国家海军的体量,和这样的一个海上的任务需求,是相关的。另外就是,军事强国,或者说我们讲海军强国,那么这样的一些,海上的作战体系,它在进行平台更新的时候,舰炮体系也会进行,进一步的创新。主要是有两种方式。第一种方式,就是把传统的化学能的火炮,替换为电磁轨道炮。这个电磁轨道炮,可以说会掀起一场,舰炮的技术革命。电磁轨道炮最大的优势,它所发射的这样的一个弹药,可以说是一个实心的铁疙瘩。它是不需要由这个发射药,为它产生推力的,而是利用这种电磁的,这样的一个原理,能够让一个高密度的金属弹丸,能够快速地飞行。它最大的优势就是打得远。由于弹道飞行轨迹,非常地稳定,所以它的命中率是非常高的。第二种就是,会出现的高技术的舰炮,就是激光炮。那么激光炮最大的优势,就是它是不需要,任何的弹药和弹丸的,就可以持续地,针对对方的目标进行杀伤。那么舰炮它的角色,和它在海战当中的地位,又会出现变化。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
从滑膛炮,到巨型战列舰主炮,再到冷战时期的自动化舰炮,以及今天的智能化集成舰炮系统,舰炮的发展,是海战技术与理念不断进化的缩影。它见证了海战从近身搏杀,到远程精确打击的跨越。无论是远程打击、反导防空,还是两栖支援、反小艇作战,舰炮都不再只是单一的火力工具,而是海上作战体系中不可或缺的一部分。舰炮的演进之路,告诉我们一个清晰的答案,技术与理念的结合,才是海上制胜的核心力量。好了,观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 海上先锋:无人舰艇的实战与未来
|
|
||||||
编导: 左鑫
|
|
||||||
播出日期: 2026-05-05
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep016
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《海上先锋:无人舰艇的实战与未来》
|
|
||||||
|
|
||||||
|
|
||||||
## 【宣传词】
|
|
||||||
|
|
||||||
|
|
||||||
## 【旁白1】
|
|
||||||
|
|
||||||
它们是无人值守的海上尖兵,自主远航、全域巡弋;它们是灵活多能的海战新锐,模块化搭载、全域适配;它们是零伤亡的“智”胜先锋,深海潜行、决胜无形。无人舰艇,以智能破局、以无人制胜,正重塑现代海战场。本期《军事科技》聚焦海上先锋,解析无人舰艇的实战与未来。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。谈及海上作战舰艇,大家首先想到的,往往是舰列森严,武备密布的有人战舰。从巨型航母到远洋驱逐舰,各类水面舰艇的运转,都高度依赖舰员的操控。而如今,无需人员登舰,可长期自主远航的无人舰艇,正快速成为现代海战的新锐力量。近期,美国康拉德造船厂正式官宣,启动自由级中型水面无人艇建造项目。该舰艇作为美海军无人作战体系的核心装备,计划于2027年完成首艘建造,2028年正式交付海军开展测试。消息一经发布,迅速引爆了全球军事领域的热议。这款自由级无人舰艇,究竟具备哪些硬核的性能呢?在现代局部冲突当中,无人舰艇又展现出怎样的实战价值与发展潜力呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
美国一家专注于开发远洋自主无人水面舰艇的科技公司,对外发布了一条视频。碧海波涛之上,由他们和康拉德造船厂共同设计建造的全新构型的自由级舰艇全速航行,与传统战舰形成鲜明反差。为了适配无人化作战需求,该舰彻底取消人员居住舱室、通风管道、温控系统等配套设施,剥离所有以人为核心的设计冗余。省下的舱体空间与载重吨位,全部用于搭载任务载荷与作战模块。作为美国海军模块化攻击水面舰艇项目MASC的标杆,极致的空间利用率,使这款中小型战舰麻雀虽小,五脏俱全。与此同时,为了实现能像香肠一样流水线大量生产的目标,该舰的整体设计与量产模式,全面围绕规模化、流水线制造打造。依托集成机器人装配系统、自动化板材生产线与智能焊接工艺,造船厂可同步推进多艘舰艇并行建造,年产量稳定维持在10艘至20艘,量产效率远超同吨位传统军舰。值得注意的是,这型无人舰艇最惹眼的不是自主系统,而是它采用了二手船体。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访1】
|
|
||||||
|
|
||||||
美国海军对外公布的,自由级的这种无人舰艇,它采用的是这种斧形的舰船。斧形舰船最大的优势,就是结构比较简单。另外一个,它是采用劈浪的设计理念。比如说海上风高浪急,海情海况比较复杂,那么它可以让整个的,无人化的作战平台,在海上高速航行的时候,拥有更强的稳定性。而且它是采用一个二手的,荷兰达门公司设计的这种,商业化的巡逻艇,作为一个改装的平台,并不是完全从零起步,自主研发的。这种无人化的这个平台,这种斧形的巡逻艇,技术非常地成熟,此前已经有了大量的应用,可靠性比较强。也不需要再进行这种,技术的验证,和这种技术的研发,能够在最短的时间内,把这种无人的平台给造出来,然后用它进行,长时间的海上测试,既节约时间,也可以节约人力物力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
据官方信息,自由级无人舰艇全长约58米,差不多两节火车车厢连起来的长度,宽约9米,满载排水量770吨,总载荷约150吨,属于中型无人舰艇,比普通快艇大得多,又比传统护卫舰小巧灵活。最高航速25节,续航里程超过一万海里,自持力可达3个月,中途不用补给,就能长期远洋部署。搭载4级自主航行系统和海洋人工智能,能自主规划航线,躲避障碍物,全程只需少数人员在后方远程监控,几乎不用人工干预。更值得一提的是,自由级舰体采用模块化设计,就像海上乐高,可灵活换装武器、侦察传感器、通信中继设备、后勤物资舱等。同时,它专门预留了标准集装箱武器模块接口,能搭载4个12米标准导弹模块,完美适配当下越来越火的集装箱武器系统。这种武器系统堪称海上隐身火力库,外观和普通海运集装箱几乎一模一样,既能藏反舰导弹、巡航导弹,也能装反潜鱼雷、侦察设备,平时完全看不出武器痕迹,战时能快速解锁开火。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访2】
|
|
||||||
|
|
||||||
自由级,艇体的后部,它应该是一个多功能的任务区。多功能的任务区,它是采用即插即用的,这样的一个设计理念。因为美国海军对于大型无人舰艇,或者说具备远航能力的无人艇,它的这个任务要求是非常多的。既然任务需求比较多,但是它又不能打造很多的,这种无人化的海上作战平台,那么就要采用这种通用的,模块化的设计理念。根据美国海军的任务要求,去选择相应的这种任务模块,或者是火力模块,更好地配合有人战舰执行任务。那么这种方式节约成本,而且,一条无人舰艇,它可以扮演多重角色。利用这种大型的无人舰艇,搭载集装箱,它的外形具有一定的欺骗性。因为这种集装箱,它和普通的,物流当中的这个集装箱,或者是民用的集装箱,尺寸和外形基本上是一样的。那么很难对它的这种,军用无人舰艇身份进行识别。事实上,自由级无人舰艇的研发列装,并非单一装备升级,而是美国海军海上战略布局的,关键落子。2025年,美政府为恢复海上主导地位,将自主无人舰艇研发,列为国家战略优先方向,依托本土造船工业,与成熟商业供应链,全面提速无人舰艇,批量生产与规模化列装。而根据美国国会研究报告显示,2026年,MASC模块化无人舰艇项目,将全面进入原型验证阶段。美国海军同步明确发展规划,加速打造有人战舰,与无人舰艇协同编组的,新型混合舰队。美军国防部预算数据显示,在近70亿美元的,海上无人系统年度经费中,37亿美元将在2027年前,集中投入水面无人装备,研发与列装。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
俄乌冲突中,低成本无人装备的规模化实战应用,更是为美军提供了全新作战思路。可消耗、高机动的无人舰艇,能够深入高危海域,承担前沿侦察、战术袭扰、精准打击等高危任务,有效规避有人舰艇战损风险,保障舰员生命安全。不过,亮眼的性能参数与清晰的列装规划之下,面临的是更大的考验。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访3】
|
|
||||||
|
|
||||||
美国海军对于自由级无人舰艇,它的任务要求是比较高的,是要让自由级的无人舰艇,未来能在大洋上进行长时间的部署,比如说横跨太平洋,或者说在大洋的纵深区域,能够进行自主航行。我认为首先要解决的难题,就是它在航行过程当中的可靠性,毕竟它的排水量不是特别大,一旦在航行的过程当中,出现了设备损坏的这样的一个状况,那么无人舰艇是无法进行自主修复的。其次在使用的这个过程当中,它的信号可能也会受到干扰,因为这种大型的无人舰艇,必须要有卫星的通信手段,那么这样的话,它才能够得到基地所发出的指令。在航行的过程当中,一旦软件系统和通信的能力,尤其是卫星的通信能力,出现了这个问题,它基本上就由这种高技术的无人舰艇,变成了随波逐流的靶船。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
有意思的是,这款新战舰使用了“自由”这个名号,而美国海军此前的自由级濒海战斗舰,却是出了名的问题战舰。这就难免让人疑惑,美国重启“自由”名号打造无人舰艇,是想打个翻身仗,还是会重蹈覆辙呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
刚才我们了解了,自由级无人舰艇的纸面性能,看起来确实很亮眼。一款武器装备能不能成功,不光要看设计,更要看研发制造的底气。我们不得不提到,两个绕不开的问题:一是美国造船工业,早已不复当年之勇,发展缓慢,问题突出;二是顶着“自由”名号的,濒海战斗舰,实战表现一塌糊涂。这两个短板,会不会让这款新型的,自由级无人舰艇的未来,充满变数呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
上世纪中后期,美国造船业堪称全球霸主,巅峰时期拥有上百家船厂,创造了上百万个就业岗位,航母、驱逐舰、核潜艇,“下饺子”一样服役。可如今,受制造业空心化、劳动力流失、成本高涨影响,美国造船业急剧衰落。数据显示,美国全球民用造船份额,不足0.1%,能建造大型军舰的活跃船厂,仅剩几家。更糟的是,美国海军近年主力舰艇建造,全面陷入停滞,堪称造不快、造不好、造不起。福特级首舰福特号,服役多年,电磁弹射、阻拦系统故障频发,核心性能始终不达标,迟迟难形成实战战力。二号舰肯尼迪号,虽已完成海试,但工期一拖再拖、多次延期,交付时间比原计划晚了数年,至今仍未正式入列。原本寄予厚望的星座级护卫舰,因成本失控、进度崩盘直接下马,巨额研发投入付诸东流。令人关注的,特朗普级战列舰项目,也只是停留在舆论层面,遥遥无期、毫无实质进展。而比造船工业更让人揪心的,是“自由”这个名号的前车之鉴。很多人都知道,如今的自由级无人舰艇,和早年的自由级濒海战斗舰,算是同名不同命的两代战舰。而这位“前辈”,堪称美国海军,近年最失败的装备之一,自由级濒海战斗舰。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访5】
|
|
||||||
|
|
||||||
在美国海军的水面舰艇当中,它还是一个鸡肋装备。我们看它的出镜率并不高,包括在美国海军针对伊朗,采取作战行动的过程当中,那么像濒海战斗舰,都是躲到很远的区域,无法直接参与作战行动。正常而言,美国海军对于自由级,它的定位是,在近海沿岸区域进行这种作战,要利用自身的隐身性能和高航速,针对对方的这种小型目标,比如说武装快艇、导弹艇、鱼雷艇进行猎杀。在和伊朗进行较量的过程当中,美国海军是需要自由级濒海战斗舰的,但是由于它的这样的一个战场,生存能力比较弱,如果贸然开赴前线,它只能是被动挨打,不仅没有办法执行有效的作战任务,自身的安全都没有办法得到保障。所以,耗费重金打造的濒海战斗舰,尤其是自由级,现在在美国海军当中,它的实战能力非常弱。这说明此前的指导思路,或者说任务构想,都是出现了重大的偏差。因为性能太差,性价比太低,美国海军早已决定,提前退役部分自由级濒海战斗舰。有的战舰,甚至服役不到10年就退役,远远低于正常30年的服役年限,沦为全球笑柄。有人主力舰接连掉链子,美军只能退而求其次,寄望于建造难度更低、成本更小的无人舰艇。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
想用自由级这类装备,填补战力缺口。但风险依旧明显,美军此前多款无人舰艇项目,要么测试翻车,要么预算超标,大多半途而废。叠加主力舰烂尾前车之鉴,与造船工业短板,自由级想摆脱烂尾命运,形成实战战力,难度极大。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
无人舰艇是未来海战的发展方向,除了美国的“自由”级,其实各国也都在加紧研发无人舰艇。第十五届中国航空航天博览会上,耀眼夺目的,我国自主研发的,500吨级大型,隐身无人舰艇“虎鲸”,它采用了高性能三体船型设计,兼具隐身性,与强大作战能力,是我国无人舰艇领域的亮眼代表。当然,全球无人舰艇的领域亮点,不止于此,我们再来说一款,全球知名的无人舰艇,看一看它的性能如何,以及未来无人舰艇,又会走向何方呢。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
放眼现代局部军事冲突,俄乌冲突持续日久。在长期消耗作战的背景下,双方均大力研发列装成本低、高实用性的无人作战装备。而在黑海海域,无人舰艇上演了一场场以小博大的海上对决。2024年2月1日,乌克兰军事情报部门称,乌军在克里米亚半岛的多努兹拉夫海湾击沉了一艘俄军毒蜘蛛III型导弹艇伊万诺维茨号。在无人艇回传的红外影像中,可以清晰地看到该艇的轮廓,以及艇体中部的P-270白蛉反舰导弹发射筒。乌军无人艇准确地冲入发射筒下方一个已经被前一艘无人艇炸开的缺口,该艇发生巨大爆炸。乌克兰国防部情报总局局长表示,伊万诺维茨号是被六艘乌克兰制造的马古拉无人艇击沉的。2024年2月13日夜间,俄军蟾蜍级登陆舰凯撒·库尼科夫号遭多艘乌军无人艇围攻。俄军舰员利用探照灯搜寻无人艇,使用轻重机枪向无人艇猛烈射击,但该舰仍被多艘自杀式无人艇命中,最终沉没。2024年3月5日,乌军无人艇在刻赤海峡附近击沉了一艘俄军22160型巡逻舰谢尔盖·科托夫号。从现场流出的视频看,可以清晰观察到22160型巡逻舰采用隐身化设计的上层建筑。数艘乌军无人艇从该舰舰尾、两舷和舰艏多个方向发起攻击,俄军战舰发生数次大爆炸,最终沉没。2024年年底,乌克兰的一艘马古拉V5无人艇使用其搭载的R-73空空导弹击落了一架俄军米-8直升机。除了水面目标,乌军无人艇还变身微型航母,潜入克里米亚海岸附近,放飞无人机,袭击俄军的防空系统和雷达站等设施。而这些行动中,乌军出动的就是马古拉无人艇,由乌克兰STE公司研发。目前马古拉无人艇有V5和V7两个型号,前者相对简单,主要执行自杀作战,后者装备比较完善,可执行更复杂的任务。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访6】
|
|
||||||
|
|
||||||
因为乌克兰所使用的这种无人艇,它的外形和结构,和民用的快艇非常地相似。个头很小,上面是采用封闭式的结构。如果再使用一些复合材料,再采用一些这种暗色调的涂装,在海上进行航行的时候,一般对海搜索雷达很难识别出来。为什么呢?因为个头太小了,艇体又很扁。那么海上出现波浪起伏的时候,对海搜索雷达会出现大量的杂波。所以这种小型无人艇,它的雷达反射信号,就隐藏在了海上的这些杂波当中,很难对它进行过滤后的精准识别。刚才我们提到的是探测难度较大,也就是看不清。其实还面临打不着的难题。因为水面战舰上的一些近防速射炮,包括所架设的高射机枪,可能针对这种海上的小型高速目标,想进行精准的火力命中,难度较大。而且马古拉无人艇,它是有星链设备,在冲向目标的过程当中,可能会进行海上高速的蛇形机动,可以躲避对方的近防火力。此外,由于它的艇体结构是比较简单,一旦小口径的机关炮,包括重机枪,针对它的艇体,就是非战斗部的这样的一个区域进行打击,可能直接就把这个艇体给打透了,不一定会对它造成致命性的损伤和影响。所以被打几个窟窿,可能还会继续航行,继续冲向目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
最初,马古拉V5的原型设计较为简单,只有一个星链通信信道,一个简单民用的快艇或摩托艇引擎,和一个控制系统,再加上高爆弹药作为战斗部。现行量产化的版本中,V5无人艇还配备有自动驾驶系统,包括夜视在内的视频子系统,以及带有陀螺仪、海洋雷达和激光测距仪的双视光电系统。V5的船体采用碳纤维和环氧树脂制成,长5.5米,宽1.5米,重量不足一吨,露出水面的部分高度仅为50厘米,表面覆盖有隐身涂层。采用混合动力发动机,最大载荷320千克,其中战斗部重量为200千克,撞击引信位于舰艏。马古拉V5采用油电混合动力,巡航速度22节,冲刺速度可达42节,最远航程450海里。其通信方式也改为卫星与无线网络联合通信,它搭载两具星链天线和一具平板卫星天线,另有一部还有两张SIM卡的无线蜂窝网工业路由器,可由技术人员远程操控完成袭击。简便的结构铸就了它约27万美元的造价。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访7】
|
|
||||||
|
|
||||||
乌克兰使用无人艇,针对俄军黑海舰队的水面战舰,和沿海的军事设施进行攻击。现在已经是实现了狼群战术,而且已经开始打组合拳。那么在无人艇的这样的一个体系当中,有一些诱饵无人艇,专门是诱导俄军水面战舰上的火力,进行这样的一个打击。其他的一些自杀式无人艇,就是配备有战斗部的无人艇,它命中目标的几率就会提升。通过这种不同型号,不同功能的无人艇进行搭配,进行组合,就是要提升这种海上自杀式攻击,它的这样的一个命中率。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
而马古拉无人艇最新型号V7,在V5型号的基础上完成了结构设计与作战功能的全方位升级。不再局限于自杀式攻击任务,还可兼顾防空拦截、后勤补给、巡逻警戒、反潜作战等多项作战任务,多任务适配能力大幅提升。马古拉V7全长7.16米,最大载荷650千克,搭载270马力柴油发动机,最高航速可达39节,巡航速度23节,作战半径540海里。若依托辅助发电机供电,其持续执勤时间最长可达7天。得益于体形升级与模块化复合结构设计,V7具备极强的任务拓展性,可通过快速更换任务模块,切换为自杀攻击艇、防空护卫艇、机枪火力支援艇等不同作战形态。火力配置方面,该型无人艇可搭载两枚经过改装的AIM-9M响尾蛇空空导弹,专门用于空中目标拦截。导弹依托发射导轨搭载,发射前导轨可向上倾斜调整角度,保障拦截打击效率。除搭载响尾蛇导弹的版本外,乌克兰国防部情报总局还首次公开了配备机枪炮塔的衍生改进型号。该变体在艇体前段加装武器模块化炮塔,可选装12.7毫米重机枪或40毫米自动榴弹发射器,主要执行近程反无人机、近距离压制及登陆作战火力支援等任务。
|
|
||||||
|
|
||||||
|
|
||||||
## 【旁白2】
|
|
||||||
|
|
||||||
2025年5月,乌克兰国防部情报总局发布视频,展示了乌军马古拉V7无人艇,发射AIM-9响尾蛇导弹,击中一架俄军苏-30SM战斗机的过程,这也是无人艇首次击落固定翼战斗机。而作为对手,俄军也不甘示弱,同样研发装备了自杀式无人艇。2025年8月28日凌晨,俄军无人艇在多瑙河河口,精准摧毁乌军3550吨的辛菲罗波尔号侦察舰。乌克兰海军发言人证实,该舰被无人艇精准击中船体要害,爆炸瞬间导致舱体破损并迅速进水,最终船体失去平衡而翻覆沉没。
|
|
||||||
|
|
||||||
|
|
||||||
## 【采访9】
|
|
||||||
|
|
||||||
俄军的无人艇数量,可能没有乌军那么多,但是也可以发挥奇效。有这种小型自杀式的无人艇,那就是和对方昂贵的自杀式无人艇同归于尽。那么还有一些性能比较先进的,航行速度比较快,上面配备有遥控武器站。遥控武器站主要是集成重机枪,包括自动榴弹发射器。它的功能就是巡逻和火力拦截。但是俄军所面临的最大问题,就是在黑海的区域内找不到目标。因为乌克兰已经没有这种大型的水面战舰,甚至很多的这样的一些军用的船只,在俄乌冲突,在乌克兰危机爆发之初,都已经遭到了俄军的打击。所以俄军使用无人艇,最大的这样的一个功能,就是使用这种低成本、更小型化的无人艇,针对乌军的自杀式无人艇进行拦截。所以它本质上来讲,是一个防御性的武器装备。无人舰艇的实战应用,早已颠覆了大众对传统海战的固有认知。凭借低成本、零人员伤亡、适配集群作战的突出优势,无人舰艇已广泛应用于近海防御、侦察监视、精准打击等关键领域,发挥着不可替代的重要作用,逐步成长为现代海战场上不容忽视的新兴作战力量。从近海突袭的小型无人艇,到远洋部署的中型无人舰艇,无人装备正在为现代海战注入新的活力,成为海上竞争的核心领域之一。它以智能为内核,无人为特色,降低伤亡的同时。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说13】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
推动作战理念迭代升级,与有人舰艇协同,构建更完善的海上作战体系。未来的大海,将是有人与无人装备协同驰骋的舞台。掌握核心科技,才能在海战场上占据主动权。好了,观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 逆袭的鸭翼
|
|
||||||
编导: 张彤枫
|
|
||||||
播出日期: 2026-05-12
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep017
|
|
||||||
---
|
|
||||||
|
|
||||||
# 导视:
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
美军披露下一代战机F-47的设计方案后,人们惊讶地发现,这款被美军寄予厚望的第六代战机,竟然采用了他们曾经不屑一顾的鸭翼设计。回想以往,美军曾公开宣称,鸭翼最理想的位置,就是在敌机的机翼上,话语中充满对鸭翼的轻视。如今,却主动采纳这一结构,这一反转背后,究竟隐藏着怎样的战略思考,竟能让美军摒弃成见,作出妥协?本期《军事科技》,带您深入解析这片曾备受争议的小翼面。各位观众你们好,欢迎收看《军事科技》。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
我是主持人蓝皓。2025年3月21日,美国总统特朗普宣布,授予波音公司生产美国空军“下一代空中优势”战斗机,并将战机命名为F-47。虽然这款战机离飞上天空还有很远的距离,但是我们可以发现,图片上的F-47竟然采用了鸭翼布局。这种布局方式是以往美国空军从未使用过的。因为在此之前,曾有设计人员宣称,鸭翼最理想的位置就是在敌机的飞机上。这句话充满了讽刺意味。如果我们回看美国空军的主力机型,确实可以发现,无论是美国的四代机,还是F-22、F-35这些五代机,采用的都是常规布局。好像美军确实没有用过鸭翼这种独特的气动布局方式。那么,最新的六代机F-47为什么突然选择拥抱鸭翼呢?鸭翼布局和常规布局到底孰优孰劣呢?今天,我们就从这句经典的调侃出发,看一看鸭翼的秘密。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
上世纪70年代,喷气式战斗机迎来了从二代机向三代机跨越的关键时期。在这个时代,以米格-21和F-4战斗机为首的常规布局的二代机,在越南战场打得有来有回,但其高速性能与机动性已逐渐难以满足超视距空战与复杂电磁环境下的作战需求。可变后掠翼技术虽然在很多方面可以提升战机的性能,但成本高昂、结构复杂,难以兼顾多任务能力。所以,在这个时代,各国都在寻找一种更高效、更灵活的气动解决方案。1971年,美国开启了轻型战斗机竞标项目,多家厂商开始推进自己的方案。通用动力公司提出的YF-16方案脱颖而出,形成了F-16这一经典机型。F-16战机是经典的常规气动布局的代表,但其实在它设计之初,产生过一次关于鸭翼布局和常规布局的激烈争论。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
如果想要搞清楚鸭翼布局的优势,我们要回到飞机结构本身。不同结构的飞机,首先会导致飞机重心与气动中心的相对位置发生改变。常规布局的飞机,所有机翼主要集中在机身的中后部,这时,重心通常位于机身的后部,而负责控制俯仰姿态的水平尾翼则位于机尾。根据杠杆原理,由于两者之间的力臂较短,就需要更大的翼面,产生更强的俯仰力矩,来维持平衡与操控。而鸭翼布局则是鸭翼在前,主翼在后,使飞机重心依旧保持在偏后部的同时,加长了力臂长度,从而以更小的鸭翼面积实现同等甚至更强的俯仰效率。其次,前置的鸭翼比主翼更早地接触气流,能在主翼前产生一个低压涡流,当涡流经过主翼时,进一步地使主翼上表面的压力变低,这样便显著提升了主翼的升力系数。同时由于鸭翼既可以控制力矩,也可以产生一部分升力,采用鸭翼布局的飞机可以把主翼设计得更小。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
既然鸭翼布局优势明显,为什么F-16最终选择了常规布局呢?如果我们还原一下当时设计师的对话,就会发现一个有趣的现象。鸭翼能提供更强的升力,你的飞控设计难度大。可以为飞机提供更强的机动性,你的飞控设计难度大。鸭翼布局可以让飞机设计得更紧凑,你的飞控设计难度大。你还会不会说点别的了,你的飞控设计难度大。不要吵了,鸭翼这种愚蠢的东西,最理想的位置就是在敌机的飞机上。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
鸭翼确实可以赋予战斗机更强的机动性。因为鸭翼它一般是在战斗机的前部靠上这个位置,离飞行员的座舱比较近。配备有鸭翼的战斗机,它会产生涡流,能够增强战斗机的升力。一旦加装了鸭翼,战斗机它可能会处于静不稳定的状态。也就是说在飞行的过程当中,战斗机可能过于灵活。这个时候,想要对它进行精准的操控,就必须要有配套的这种设备。为什么说美国空军它很少采用这种鸭翼的这样的一个设计理念?比较典型的,F-16和F-15它都没有鸭翼。因为在美国空军看来,提升战斗机它飞行性能最好的办法是让它发动机的推力进一步地强化。所以说,美国他的航空工业体系,当时的技术的水平比较高,那么可以选择大推力的发动机。这样的话,战斗机也可以飞得比较灵活,超音速的性能也比较好。所以我们看,美国的战斗机一般情况下是不采取鸭翼的。反而是其他的一些欧洲国家,在鸭翼布局方面,进行了大胆的技术尝试和探索,也取得了很多的成果。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
无论鸭翼有多么大的优势,都无法超越时代的科技锁。所以,美军在F-16的设计上,还是选择了更加稳妥的常规布局。不过,优秀的东西,不应该就这样被埋没,总会有人尝试着迈出第一步。这个敢于第一个吃螃蟹的,并不是美苏这两个超级大国之一,而是欧洲的一个国家。大家看,世界上第一款采用鸭翼布局并正式服役的战机,从瑞典飞来了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
1982年的某一天,一架飞翔在两万米高空之上的SR-71黑鸟侦察机,执行完侦察任务,途经波罗的海上空,准备掉头返航。这款侦察机,可以说是当时世界上最强的侦察机,可以在两万米以上的高空,保持马赫数3以上的巡航速度进行飞行。这架看起来已经天下无敌的黑鸟,在波罗的海上空完成掉头正准备返航时,下方一架造型奇特的战机,突然出现在它的视野当中。这架飞机,不但可以追上黑鸟的速度,还可以在接近两万米的高空,时不时地抬起机头,用雷达锁定黑鸟。虽然此时准备返航的黑鸟,速度和高度并没有达到极限,但是,这架战机所体现出的性能,让机组人员大吃一惊。刚刚设想的发生在万米高空之上的故事,并不是完全虚构的,而是源自于真实事件。故事中,能完成对黑鸟侦察机进行高空拦截的,正是瑞典萨博公司研制的萨博-37雷式战斗机。而这款飞机,也是世界上第一款服役的,并采用了鸭翼布局的喷气式战斗机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
萨博-37,是瑞典独立自主研制的,单发两侧进气道,而且是采用鸭翼布局的,这种战斗机。当时瑞典对于萨博-37的定位,非常地明确,主要还是要强化,它针对高空高速目标的,跟踪和锁定能力。因为,当时瑞典虽然是中立国,但是整体上更加,亲近美西方国家。所以即使黑鸟高空高速侦察机,在瑞典的空域进行飞行,瑞典的飞行员,主要是对它进行跟踪和监视,不太可能采取这种,进攻性的动作。而且,在飞行的过程当中,甚至不排除黑鸟的机组成员,会主动的降低飞行的高度,是以一种安全的状态,或者是他自认为安全的状态,去进行飞行。雷式战斗机,它的这种高速的飞行能力,飞行的高度也比较高,再加上雷达系统比较先进,那么配备有,改进型的空空导弹。所以它针对这种高空高速目标,它的这种锁定能力,确实也是比较强。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
萨博-37雷式战斗机,全长超过16米,主翼翼展超过10米,是个名副其实的大家伙。作为全球首款服役的鸭翼布局战机,在其主翼前方,有着一对翼展超过5米的鸭翼。这样的气动布局,使它的最大飞行速度可以超过马赫数2,升限突破18000米,并且最大起飞重量接近20吨。由于采用了鸭翼布局,萨博-37在短距起降能力上尤为突出,仅需约五百米跑道即可完成起降。这种卓越的短距起降性能,在当时是独一档的存在。此时的萨博-37战斗机,采用的是固定式鸭翼,主要起到增加升力的作用。其机动性,只能靠鸭翼后部额外增加的后缘襟翼来控制。这种方法,只能说是一种初步探索,尚未触及鸭翼在机动性提升上的核心潜力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
萨博-37走出了现代战机对鸭翼探索的第一步。不过此时的航空技术,不足以支撑对于鸭翼的动态控制。其实我们可以将鸭翼,看作尾翼前置化的升力增强装置。尤其是在鸭翼尾部,加装后缘襟翼这种方式,很明显,还带有传统气动设计的惯性思维。这种无法进行偏转运动的鸭翼,通过简单的结构设计,却可以在机动性方面,获得极高的提升。可以说,是一种简单有效的工程智慧。在萨博-37之后,法国的幻影系列战机,和以色列的幼狮战机,也都在三角翼设计的基础上,尝试了固定鸭翼布局。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
那么像萨博-37,包括幼狮,都考虑到,是国土面积不太大的国家,相关的空军力量,对它进行装备和使用。这就会带来一个问题,如果是在战争的状态下,空军基地遭到了破坏,常规的跑道无法起降战斗机,这个时候,无论是幼狮还是雷式战机,必须要依托于公路去进行起飞。战斗机它的升力的效应,就非常地重要。加装了鸭翼之后,它可以利用有限的跑道,或者是粗糙的跑道,进行短距起飞。它针对突发情况的,临时的应变能力,就会变得更强。另外一个,无论是瑞典,还是其他的一些国家,包括以色列,它想拿到推力更大的,这种最先进的发动机,基本上是不可能的。我们看一下雷式战斗机,其实,它所配备的,是从美国引进的一种,商用的涡扇发动机,然后瑞典的沃尔沃公司,对它进行了技术升级和强化,让它达到了军用的标准。一方面可靠性比较好,另外一个成本比较低。既然发动机的推力不够,在空中,很难依靠自身的动力系统,做出这种大过载的机动动作,那么加上鸭翼之后,可能飞行品质和机动性,是有改善的,无论是萨博-37战斗机,还是以色列的幼狮战斗机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
鸭翼布局的早期探索,都印证了一个事实:鸭翼布局在提升飞机机动性与短距起降能力方面,具有先天优势。虽然碍于时代的技术条件,此时的鸭翼大多是不可动的固定式鸭翼,但是,还是在众多气动布局方式中,抢出了一席之地。随着计算机控制技术与材料科学的突破,困扰鸭翼布局的最大障碍——飞行控制系统,从机械控制开始转向电子控制,飞行稳定性与响应速度得以质的飞跃。这一技术突破,让鸭翼布局迎来了新的发展。依旧是瑞典这个北欧国家,开始了全可动鸭翼布局的全新探索。在以往的经验之上,世界上首款真正意义上实现全可动鸭翼飞控一体化设计的战机——JAS-39“鹰狮”,横空出世。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
时间来到上世界90年代末,美国的波音和通用,凭借强大的航空发动机,深耕传统布局飞机设计;苏联的米高扬和苏霍伊,利用翼身融合技术,不断突破战机机动的极限;法国达索,也在三角翼布局上独占鳌头。在众多航空设计公司,都在各自道路上不断深化时,瑞典萨博公司,也等到了属于鸭翼的全新时代。随着科学技术的发展,飞机的航空控制系统,从机械操作,正式进入了电子操作时代。性能强悍的机载电脑,成为了各种复杂气动布局的控制基础。这种跨时代的突破,也成为了萨博公司,弥补萨博-37上,不完美的鸭翼设计的,最重要的机会。让这对固定的小翅膀,成为了可以精准灵活旋转的,全动式鸭翼。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说3】
|
|
||||||
|
|
||||||
JAS-39鹰狮的全动鸭翼,区别于固定式鸭翼,不仅实现了独立的偏转控制,更通过飞控系统与主翼,实时协同,当鹰狮飞在天上时,两片鸭翼,可以通过同向偏转,实现战机的俯仰控制,如果需要进行高机动转弯时,两片鸭翼,则可以实现不同方向偏转,配合主翼与方向舵,形成更强的转向力矩,大幅缩短转弯半径,这种精准的协同控制,使JAS-39,在超音速巡航状态下,仍能保持优异的敏捷性,与稳定性,由于使用了全可动式鸭翼,鹰狮的体积,相较于同时期的F-16,在机长和翼展,均减少接近一米,整机的空重,减少了近两吨,虽然体积变小了,但是机动性并没有减少,无论是最大马赫数2的飞行速度,还是超过15000米的飞行高度,鹰狮均展现出同级战机中,罕见的综合性能平衡,尤其是在爬升速率,和转弯半径上,更是远超常规布局的F-16战机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
萨博公司再次凭借对鸭翼布局的深刻理解与极致优化,将鹰狮打造为全球轻型战机中气动效率与作战效率的标杆。在全动式鸭翼的支持下,进一步将起降距离缩减到接近三百米的极限距离,非常适合北欧广袤而分散的公路跑道与简易机场。全动式鸭翼带来的更极限的机动能力,也让鹰狮在近距格斗中如猎鹰扑食般迅捷凌厉。在红旗军演中,多次以轻型战机身份击败同级别的F-16和以空战能力出名的F-15,让世界再次看到鸭翼布局的非凡潜力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
在鸭翼的设计方面,萨博公司确实有很长时间的技术积累和技术储备。所以正是吃透了鸭翼的这样的一些优势,或者说,对它的一些不可控的因素,进行了长时间的验证,非常地有经验,自己心中有数,心中有谱。才可以在不同的战斗机,或者说在战斗机迭代的过程当中,一直是采用鸭翼的设计理念。欧洲国家的武器,和美国的机载武器,它都可以进行兼容。所以,也是推向了国际防务市场。但是它本身还是有性能和作战能力方面的限制。因为毕竟是一种单发的战斗机,整体的体量比较轻。在最远的航程方面,在这种机载弹药的配置方面,它和双发重型的战斗机,还是有很大的差异性。但是毕竟它是一个单发平台,如果说航程和载弹量,受到了很大程度的限制,在远程奔袭的过程当中,可能就会出现燃油不够用,或者说挂装能力有限的这样的一些限制。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
在鹰狮的基础之上,全可动鸭翼布局,成为了现代先进战斗机,必须考虑的一种设计方向。鹰狮的成功,也让世界各国纷纷跟进研发,各种基于全可动鸭翼布局的机型,相继问世。这其中就包括,欧洲的阵风战斗机,台风战斗机,以及俄罗斯的苏-33舰载机。不过,这些战机虽然都采用了,全可动鸭翼,设计理念,与技术路径各不相同。那么,它们之间有着哪些不同呢?这两片小小的鸭翼,能让大家玩出怎样的新花样呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
无论是在公开航展,还是武器装备贸易中,采用鸭翼布局的战机,永远是最受瞩目的机型。2016年,印度和法国签订的,关于阵风战斗机的合同,单机价格高达2.4亿美元,甚至超过了同期F-35A的采购价。台风战斗机,多次在红旗军演中,模拟击败美国引以为傲的F-22,名噪一时。而同样是鸭翼布局的,歼-10CE型战机,也在2024年的军事演习中,以9比0的战绩,完胜台风战机。歼-20战机,则凭借其独特的,升力体鸭式气动布局,成为当代隐身战机中,独树一帜的存在。那么,同样是鸭翼布局,为何在不同机型上,呈现出截然不同的效果呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说4】
|
|
||||||
|
|
||||||
如果想区分不同机型的鸭翼设计方式有什么不同,主要可以根据鸭翼所处的位置进行判断。阵风战斗机采用的是近距耦合鸭翼,特点是鸭翼的高度要高于主翼,这样才能为主翼提供更高效的气流引导,增强主翼的升力,这种效果被称为“耦合”。而近距的意思则是,鸭翼的位置离主翼非常近,甚至贴近主翼前缘,以最大化气流干扰与升力增益。而与之相对的,则是台风战斗机的远距耦合鸭翼。台风战斗机鸭翼的位置距离主翼较远,已经接近机头的位置,虽然产生的涡流也会对主翼的升力有所提升,但是效果确实微乎其微。但由于鸭翼布置位置更远离飞机的重心,所以其在偏转时,产生的力矩会更大,也就可以让飞机获得更灵敏的俯仰响应,尤其在高速机动中展现极强的瞬时转向能力。而苏-33战机的鸭翼则是与主翼位于同一水平线,所以其产生的涡流几乎不会对主翼升力形成有效增益。但和常规布局相比多出的鸭翼增加了整架飞机的机翼面积,所以会显著提升低速状态下的升力系数,非常适合在航母上进行更低速度的精准起降。由于这种设计将鸭翼、主翼、尾翼放置在一个平面上,所以也被称为三翼面布局。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
虽然同为鸭翼布局,但设计逻辑却各不相同。这就导致,虽然鸭翼家族的成员越来越多,但每种设计上微小的不同之处,都会对性能带来不同的影响。当然,在鸭翼家族逐渐壮大的过程中,各种试验机型,也为鸭翼布局路线,提供了宝贵的数据支撑与工程验证。让我们回到节目最开始的那句话,为什么美国设计师会认为,鸭翼最理想的位置,就是在敌机的机翼上?
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家6】
|
|
||||||
|
|
||||||
这是一种狂妄自大的说法。美国空军认为,它所配备的战斗机,无论是在动力系统方面,还是在这种气动布局方面,都可以实现最优化。客观来讲,在战斗机的技术方面,美国确实曾经长时间地领先,动力系统比较靠谱,推力比较大。另外,美国空军最早进入到了隐身化的时代,打造出了F-22猛禽隐身战斗机。不仅隐身性能比较突出,另外一个,也可以进行超音速的巡航。那么通过这样的一种综合化的设计,美国空军认为不需要鸭翼,只要采用这种更好的发动机,采用更为优化的外形设计,就可以让战斗机飞得更快。对于其他国家的空军而言,我们不仅要考虑到战斗机的性能,也要考虑到设计和发动机方面的这样的一个成本的因素。如果说像美国空军那样,在各个方面都要追求极致,可能战斗机的单价就过高了。那么这个时候,你想要进行批量化的装备,可能在成本方面,有一些国家是承担不起的。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
当然嘴硬的美军,其实还是偷偷搞了不少鸭翼技术研究的。1984年,美国空军和NASA联合开展了X-29A前掠翼验证机项目。这款验证机几乎将当时所有前沿气动技术集于一身,前掠翼加上耦合鸭翼的组合机翼,让这架验证机在亚音速与大迎角状态下展现出惊人的敏捷性。麦道公司也在1988年,针对F-15战斗机开展了“敏捷鹰”鸭翼升级计划。通过加装鸭翼,一款全新的F-15 MTD走向了跑道。这架验证机在F-15的传统气动布局上,上反20度的全动鸭翼。这种修改让原本笨重的F-15可以以最低约每小时70公里的速度完成起飞,也可以利用全动鸭翼的机动性,在接近马赫数2的速度下完成大迎角机动。此外,在麦道和波音两家公司合并后,再次和NASA合作,推进X-36无人验证机项目。虽然这架验证机只是一个小尺寸的验证模型,但其完全取消了垂尾与平尾,仅靠鸭翼与喷口矢量实现全向控制,同时被认为是美军六代机的先驱。美国在技术验证机上加装了鸭翼,就是要进行一定程度的对比,看一看有鸭翼的战斗机,以及没有鸭翼的战斗机,它在这种更多的这种测试条件下,整体的飞行品质如何,或者说机动性如何。所以它目前还是停留在测试平台上。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家7】
|
|
||||||
|
|
||||||
F-47应该是一个另类,当时美国总统特朗普,急于对外召开发布会,那么波音公司,我认为应该是赶工期,临时设计了,或者说凑合地,拿出了一个F-47的,这样的一个想象图,在上面加装了鸭翼,不一定是F-47最终的形态。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
鸭翼布局,能在众多的气动布局当中脱颖而出,成为了当今世界上主流战机的气动设计中的一个重要选项,也是经历了漫长的技术沉淀与反复验证的。美军的下一代战机,一改往日对鸭翼布局的不屑一顾,反而将其纳入到六代机的设计方案之中,也更加证明了鸭翼布局的优秀。当然,未来鸭翼布局还将有哪些潜力被释放,在即将到来的全向隐身时代,还能不能占据一席之地,还需要时间来检验。好了观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间再见。
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 机枪双雄_加特林与马克沁的战争传奇
|
|
||||||
编导: 付天雨
|
|
||||||
播出日期: 2026-05-19
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep018
|
|
||||||
---
|
|
||||||
|
|
||||||
# 机枪双雄:加特林与马克沁的战争传奇
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
当冷兵器落幕,机械化杀戮登场,两位跨界设计者,改写了战争史。医生加特林为止战造枪,工程师马克沁凭灵感,创自动机枪。他们以守护为初心,却让武器沦为战场绞肉机;以创新铸经典,却终生背负杀戮的愧疚。本期《军事科技》,就让我们一起走进,他们不为人知的人生故事,触摸两位设计师,藏在钢铁枪械背后的,柔软内心,聆听他们跨越百年的,灵魂独白。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。在现代枪械百年发展史上,有两位巨匠,注定名留青史。一位是弃医研械,心怀悲悯,想用武器减少战场伤亡的医生,理查德·乔登·加特林。一位是历经坎坷,饱受排挤,从实战劳损中,悟出自动火器奥秘的工程师,海勒姆·史蒂文斯·马克沁。二人同处一个时代,终生却未曾相见,用一生的坚守与纠结,写下了两段,充满宿命感的传奇。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
1818年的美国,北卡罗莱纳州农场主家庭里,一个叫理查德·乔登·加特林的男孩降生了。他的父亲是当地小有名气的机械匠人,从小带着他摆弄棉苗机、播种机,把机械的种子埋进了他的心底。少年时的一场天花疫情,夺走了无数乡邻的生命,也让加特林下定决心弃工从医,他要拿起手术刀守护生命。1850年,他顺利拿到俄亥俄医学院博士学位,成为一名受人尊敬的内科医生,农机发明似乎成了遥远的童年回忆。1861年,南北战争的炮火撕碎了美国的宁静。加特林被征召为战地医生,医院成了他最煎熬的战场。每天,成百上千的伤兵被抬进来,有的胸口中弹,有的四肢断裂,更多士兵不是死于敌人的枪口,而是死于落后枪械导致的低效厮杀。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场1】
|
|
||||||
|
|
||||||
战地医院成了加特林最煎熬的牢笼,满目皆是断肢残躯,血水浸透的绷带,遍地散落,伤兵的哀嚎日夜不绝。他全力救治伤员,却无力挽救一条条鲜活的生命。在深夜里,他在日记中写下:如果一把枪,能撑起一个连的火力,就能让更少士兵踏上战场,让更少家庭,承受生离死别。这个荒诞却悲悯的念头,让他放下手术刀,重新拾起机械图纸,正式迈入到,枪械研发的道路之上。看到这里,相信大家和我一样,心中充满了疑问,那么他当时,究竟是怎么想的呢?今天我们的节目,将借助AI数字复原技术,穿越历史,依据自传,生前采访与公开演讲,还原真实枪械大师,解读截然不同的设计理念。加特林先生您好,今天很荣幸与您进行对话。主持人好,21世纪的各位观众朋友,你们好,随着人类科技的发展,我非常高兴能够在这里,和大家进行一场,跨越时空的对话。好的加特林先生,相信无论是我本人,还是屏幕前的观众朋友们,都有很多问题想请教您,放弃了深耕多年的医疗事业,放下了守护生命的手术刀,转而投身枪械的研发呢?我原本是一名医生,治病救人就是我的使命,但是,随着这战场上一批一批伤员,运到我的面前,我知道,医术再好,也只能治好身上的伤,所以,我又想出一个新的办法,那就是,重新拾起年轻时,热爱的机械图纸,来造出一把枪。我想象这把枪,只需要一个人使用,就可以抵得上一个连的火力,这样,就不用成千上万名士兵,奔赴战场,白白地送命。上战场的人少了,更多的士兵,就可以安安稳稳地待在后方,不用和自己的家人生离死别。我知道,一个医生转头去造枪,这听起来就非常地离谱。我每天看着病床上,备受煎熬的士兵,和窗外漫天的硝烟,我别无他法,用机械的力量,去抵消战争的残忍,用超强的火力,早一点结束战乱,这是我唯一能想象到的了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
几乎在同一时间,1840年,美国缅因州的一个贫寒农家,也迎来了海勒姆·史蒂文斯·马克沁的诞生。家里穷得揭不开锅,他只上了几年小学,14岁就被送到马车作坊当学徒。可这个穷小子,天生就是机械天才,少年时就造出小木船,马拉除靶机,马拉除耙机,甚至能修好作坊里所有坏了的机器。成年后,他一头扎进机械发明当中,短短十几年,拿下50项专利,被誉为爱迪生第二。可这份过人的发明才华,却让他在当时的电器行业步履维艰。1880年,马克沁投身电灯研发,与早已声名显赫、产业初具规模的爱迪生形成了直接竞争。彼时,电器行业的格局已然成形,资源与发展空间都十分有限,马克沁的发展之路屡屡受阻,难以施展抱负。恰逢此时,英国向他递来了诚挚邀约,每年提供两万美金的支持,邀请他前往欧洲,开展新的事业。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场2】
|
|
||||||
|
|
||||||
初到伦敦的日子,马克沁被深深的迷茫裹挟,半生深耕的电器领域,再无立足之地,一身过人的发明才华,仿佛无处安放。那么马克沁先生,您在受到英国的邀约,来到欧洲之后,是什么决定让你转向武器机械领域,进而研发机枪的呢?当时的欧洲,正在殖民扩张中越陷越深,战事不断,各国家都想寻找更高效可靠的武器,来应对战场。在一次和好友的闲谈当中,他就说我可以前往欧洲的机械领域(发展),或许那里可以施展我的抱负。作为一名痴迷机械的工程师,我见到太多老式枪械的缺陷了,开枪时的后坐力,经常让士兵受伤,手动装填子弹,费时又费力,火力的不足,又让他们身处险境。我就想用自己最擅长的机械创造能力,来攻克这些难题。这既是我对机械制造技术的一次极致追求,也是我对自己半生人生的一次转机。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
1882年的维也纳,马克沁试射老式枪械。开火瞬间,后坐力狠狠撞在他的肩膀上。当即,一个大胆构想骤然浮现:火药燃烧产生的后坐力,难道不能转化为动力,实现枪械的全自动射击吗?为潜心圆梦,他变卖全部专利积蓄,隐居伦敦郊外小屋,日夜埋头试验。熬过无数个不眠之夜,攻克重重技术难关,1884年,划时代的世界首挺全自动马克沁机枪就此诞生。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
不管是加特林,还是马克沁的初衷本意,其实都是基于自身所长,回应时代困境,只为用专业的力量,解决眼前的难题。可是,当这两位跨界设计师,带着自己精心打磨的枪械,走上推广之路时,却遭遇了意想不到的重重阻碍。他们那些超前的设计,不仅没有得到市场和行业的认可,反而引来了铺天盖地的质疑,更有人直言嘲讽他们异想天开,斥责这样的设计荒唐可笑,不切实际。
|
|
||||||
|
|
||||||
|
|
||||||
## 【MG1】
|
|
||||||
|
|
||||||
1862年1月,加特林仅用半年时间,就完成了从医生,到枪械设计师的身份转型,成功研制出,多管旋转机枪原型样机。他带着样机前往华盛顿,参与军方武器测试。手摇曲柄转动间,子弹连续呼啸而出,密集弹孔布满靶纸,可在场军方将领却满脸不屑。有陆军军械局少将直言,这只是玩具,算不上真正的武器。在战场上,胜利靠的是士兵的勇气,而非这种,投机取巧的机器。军方的保守质疑,并非毫无依据。初代加特林机枪,短板十分明显:整枪过重,搭配铸铁枪架总重超100千克,需要4匹战马,才能牵引转运;老式纸弹壳,极易在抽壳时撕裂,每试射50发就频繁卡壳;同时,黑火药燃烧时,也会产生大量浓烟,短短一分钟,便遮蔽阵地视野,严重影响瞄准。更关键的是,军方没有配套训练规范,士兵不懂把控摇柄转速,摇速过快,易造成枪管过热,过慢又会大幅削弱火力。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场3】
|
|
||||||
|
|
||||||
一款具备颠覆性的划时代武器,在诞生之初,往往会遭遇世人的不解、质疑,甚至是漠视。而您设计的机枪也是如此。那么您在当时面对这样的问题的时候,到底是怎么想的呢?没错,当时确实有很多人不理解我,也有的人质疑这款武器的实用性,更有的人是直接漠视它的价值。但是,我从来可没有心灰意冷。我始终相信,我的发明能给战争带来颠覆性的改变。那面对这些质疑和不解,您到底是怎么做的呢?听说您为了推广这个发明,做了很多的努力?为了让大家能够看到这款武器的价值,我开启了四处游说的经历,多次实地演示,就是想让军方还有其他人能够直观地了解它的优势。我也承认军方提出的各种问题,这些都是样机实实在在存在的缺陷。所以,在四处奔波的间隙,我就针对这些意见,给样机进行了一系列的修改。我将原来易损坏、受潮的纸弹壳换成了黄铜弹壳,这样就解决了卡壳哑火的问题。其次,我发明了射速调节装置,可以根据战场的需要,切换每分钟100发或者每分钟200发两挡速度。另外,我还重新设计了折叠轻型枪架,让整机的重量减轻到54千克,拆装方便,占地也非常小,两匹战马就可以随军转运,大大提升了机枪的机动性。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
就在加特林机枪声名鹊起之际,一场技术革命正在悄然发生。1883年,英国发明家海勒姆·马克沁发明了自动机枪,其核心创新是火药燃气驱动:利用子弹发射时产生的燃气,推动枪管后坐,进而带动机枪完成抽壳、装弹、击发的循环,无需手摇驱动。
|
|
||||||
|
|
||||||
|
|
||||||
## 【MG2】
|
|
||||||
|
|
||||||
可即便马克沁机枪,拥有划时代的自动射击能力,它的推广之路,依旧布满荆棘、举步维艰。马克沁奔走欧洲列国巡回演示,渴望获得军方认可与采购,却屡屡遭遇保守势力的冷眼与猜忌。俄国沙皇亲临现场,观看实弹演示,全程面露不屑与怀疑,始终认定这款枪械的射速,纯属夸大其词、性能造假,根本不愿相信一件单人便可操作的武器,能拥有碾压整支步兵编队的火力。清政府使臣出访英国,也专程观摩马克沁机枪实弹试射。现场所有人亲眼见证,随着机枪一阵轰鸣,密集弹雨呼啸而出,半米粗的大树,瞬间就被拦腰打断,场面震撼人心。旁人皆惊叹于武器的强悍威力,唯独清政府使臣神色凝重、眉头紧锁,他没有询问售价,也没有商议采购事宜,只是黯然叹息:射速太快,大清国力孱弱,根本供养不起这般海量的弹药消耗。一句感慨,既道出了晚清王朝的财政窘迫,也戳中了当时各国军方共同的顾虑。超高射速的背后,是难以承受的弹药损耗与军费开支。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
一位是心怀悲悯、弃医造枪的战地医者,一位是半生辗转、以技破局的发明工程师。截然不同的人生际遇,天差地别的设计夙愿,从根本上就注定了,这两款彻底颠覆战争形态、改写轻武器发展史的经典机枪,从外在的机械结构、操作方式,到内在的设计灵魂、战场使命,都有着截然不同的基因与内核。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
加特林的设计,从头到尾,都深刻镌刻着“减少伤亡”四个字。他始终坚信,若能打造一款高效速射武器,用密集火力形成战场压制,便能以少数兵力抵御数倍敌军,无需再让士兵以血肉之躯填补战力差距,这便是他执着追求“以一敌百”的核心目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
为实现以一敌百的目标,加特林采用多枪管旋转射击理念,枪身中央设主轴,6至10根枪管,等间距环绕排列,形成圆形枪组,6根满足基础射速,10根可提升火力密度,同时,动力传输采用手摇曲柄模式,射手转动枪身一侧曲柄,通过内部齿轮组,带动中央主轴高速旋转,进而联动所有枪管同步转动,供弹系统,巧用重力原理,枪管组上方,设漏斗状或盒状弹仓,子弹预先装入,当枪管旋转至特定角度时,弹仓供弹口,与枪膛精准对齐,子弹靠重力自然下落,完成装填,击发与退壳,通过依次联动实现,每根枪管,配独立射击针与退壳机构,且与主轴旋转轨迹同步,枪管转至正前方时,击发凸轮,触发击针完成发射,射击后继续旋转,退壳顶出空弹壳,再借离心力,使其脱离枪身,完成射击退壳循环。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场4】
|
|
||||||
|
|
||||||
我想请教您一个问题,您在当时设计这款机枪的时候,这些巧妙的设计,是出自怎样的考量呢?我用的是多枪管旋转设计,一共6到10根枪管,围着主轴排成一圈,就是为了,让每根枪管轮流开火,交替散热,不会像单枪管那样,连着打就发烫,甚至炸膛,能够长时间地持续输出火力。用手摇曲柄来驱动,结构简单还特别靠谱,射手自己,就可以控制转动的快慢,用来适配各种战场情况,也用不着复杂的动力装置。仅仅几名士兵,就能编织出密集的火力网,挡住好几倍于自己的,敌人的冲锋。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
与加特林机械设计不同,马克沁的设计,始终贯穿着以技破局,极致创新的理念。在目睹老式机枪,依赖人力装填,射速缓慢,士兵既要承受后坐力之痛,又要在火力间隙直面险境,他渴望打造一款,无需人力干预的全自动机枪。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
为实现这一目标,马克沁跳出多管手摇的传统框架,开创了单管自动射击体系。枪管短后坐自动原理,子弹发射时,产生的火药后坐力,会带动枪管与枪身沿导轨做短距离后坐。后坐过程中,机枪与枪管解锁,机枪继续后坐时,退壳将空弹壳顶出,同时压缩复进簧。复进簧复位时,拉动枪机向前运动,从弹链中抓取新子弹推入枪膛,完成闭锁后,等待下一次击发,全程无需人工干预。供弹系统则首创帆布弹链设计,将子弹逐发固定在帆布袋中,通过枪械自动运动带动弹链供给,彻底解决了速射武器的持续供弹难题,射速可达每分钟600发。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场5】
|
|
||||||
|
|
||||||
您在当时设计这款机枪的时候,您为什么要执意另起炉灶,打造全新的,单管自动枪械呢?我之所以采用单管自动原理,就是因为它既紧凑又轻巧,不论是行军,还是在战场上机动,都十分地便捷。采用全自动循环结构,就不用再打一发,装一发子弹了,士兵只需要扣动一次扳机,就可以持续输出火力,减少了装弹时间。我还发明帆布弹链供弹,弥补传统弹容量不足的短板,可一次装载百发子弹,能够应对各种复杂的战场环境。我设计的初衷,一方面是想挖掘,自动机械技术的潜力,另一方面,就是为了适配战场环境。它只需要一个人就可以操作,同时火力,也远超过其他的传统枪械,这样一来,就能够以少胜多,掌控战局。1899年,英国陆军宣布,全面列装马克沁机枪,加特林机枪逐渐被淘汰。一个为生命造枪,一个为技术造枪,一个靠人力驱动,一个靠火药自驱,一个是防御的悲悯,一个是进攻的革新。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
没有优劣之分,只有利益之别。可当两款武器被推向战场,被用于侵略,无休止的厮杀时,两位设计者都在其晚年,对此表达过惋惜。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
1898年圣胡安山之战,加特林机枪才一战成名,可这份荣誉,却成了加特林的枷锁。晚年的他,常常对着机枪图纸落泪。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场6】
|
|
||||||
|
|
||||||
您的本意,是想以火力减少士兵伤亡,但现实却是,被大量地运用到征伐与殖民当中,渐渐偏离了初衷。您后悔当初的决定吗?其实,我最后悔的事情,就是放下了手术刀,拿起了枪械图纸。我本来是个医生,已经见惯了生离死别。当时发明机枪的初衷,其实就是想让一个士兵,能够抵得上一个连的火力,让更少的人奔赴战场,让各方都畏惧战争,最后让战争停止。我本来想做的是制止战争的武器,而不是用来杀戮的凶器。但是最后,我的发明被运到了非洲,变成殖民统治的工具,变成列强对外扩张、欺负别国的帮凶。那些在枪口下牺牲的无辜生命,是我这辈子都忘不掉的梦魇。其实罪过,从来不是冰冷的钢铁造成的,而是乱用武器的人心。可是我作为它的创造者,却一辈子都无法原谅我自己。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
1916年,第一次世界大战,索姆河战役,欧洲各参战国,仍沿用拿破仑时代的集群冲锋战术。协约国10万士兵,在炮火掩护后冲出战壕,却迎面撞上德军四百多挺马克沁机枪构筑的火力网。德军采用侧翼斜射战术,子弹如利刃般切割密集的进攻列队。仅一天,就造成英法联军6万人伤亡。141天战役结束时,双方伤亡总数超过120万人,大多数牺牲者都倒在了马克沁机枪下。
|
|
||||||
|
|
||||||
|
|
||||||
## 【小剧场7】
|
|
||||||
|
|
||||||
马克沁先生,您同样也见证过战争的残酷,也看到无数的人间悲剧,那么您是否后悔过,打造出这样威力极强的机枪呢?我发明这款机枪,本身就是人类工业史上的一次突破,同时,也是我费尽半生精力的智慧结晶。对于这套自动机械原理,我十分骄傲,同时也十分愧疚,它给世间带来的灾害,马塔贝莱高原流淌的鲜血,索姆河战场上的亡魂,皆是因为我的这项发明而起。我终究,只是一名工程师,只能创造它,而不能掌握它的命运和用途。它会是一保家卫国的,没想到最后却成为了杀人利器。晚年的我经常,独自面对枪械模型去发呆,我真希望它能够,永远封存在实验室里,变成一堆零件,而不是走向战场。也正是因为这份愧疚,我后来再也没有研发武器装备,而是转向去研制游乐设施,并且,亲手建造起一座电动游乐场。两段人生,两种立心,两套设计理念,他们都不是战争的狂热者,只是被时代裹挟的设计者。一位一生痛苦于作品被滥用,在愧疚中离世,一位以工程师的执着,创造技术,却终生背负杀戮的拷问。武器是时代的产物,设计是本心的回应。抛开火力、杀伤、战争的标签,两位大师留给世界的,不只是经典的机枪,更是永恒的思考。技术本无善恶,立心。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
悲悯与底线,才是所有创造最珍贵的内核。好了观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间。
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 玩具总动员:战场伪装与模块化的黑科技设计
|
|
||||||
编导: 穆佩弦
|
|
||||||
播出日期: 2026-06-02
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep019
|
|
||||||
---
|
|
||||||
|
|
||||||
# 《玩具总动员:战场伪装与模块化的黑科技设计》
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
战场上,有些装备的作用如同玩具,只为在战术中迷惑对手。有些装备的外形酷似玩具,它们小巧灵动,造型别致,总是出奇制胜。有些装备的设计理念甚至直接脱胎于玩具,它们如同积木般自由拼接,协同互动,使装备更具灵活性与作战效能。本期节目,我们将为您揭开装备背后的“玩趣”密码,揭秘战场上那些鲜为人知的创造性思维。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》。我是主持人蓝皓。在人类战争史上,不是只有先进的武器,才能够改变战局的。战争的本质,从来不只是火力的对抗。一张地图,一次伪装,一段虚假的无线电信号,甚至是一些,在旁人看起来,近乎玩具般的道具,都可能对战场,起到决定性的影响。三千多年前,古希腊人将士兵,藏进一匹巨大的木马之中,攻破了特洛伊城。那或许,是人类历史上,最著名的一次,战场伪装了。第二次世界大战期间,盟军也曾组建过一支,特殊的部队。他们成员之中,有画家,设计师,录音师,甚至还有广告从业者。他们最重要的武器,既不是坦克,也不是火炮,而是一批能够充气展开的,橡胶玩具。1944年,盟军即将登陆欧洲大地。为了迷惑德军,美军,秘密组建了一支特殊部队,第23司令部特种部队。人们给了他们一个,更加传奇的名字,幽灵部队。而1945年春天,幽灵部队迎来了最危险,也最关键的一次任务。此时的纳粹德国,已经开始崩溃。盟军部队,正快速逼近德军腹地,但在他们面前。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
仍然横亘着最后一道天然屏障——莱茵河。这条贯穿欧洲的重要河流,不仅河面宽阔,水流湍急,两岸地形也极其复杂。对于防守一方来说,它天然就是一道巨大的反坦克壕沟。德军非常清楚,一旦盟军成功渡过莱茵河,德国本土的大门就将被彻底打开。因此,德军在莱茵河东岸部署了大量炮兵阵地、观察哨以及防御部队,严密监视盟军的一举一动。而就在这样的战场环境下,一场以假乱真的“表演”即将在莱茵河畔展开。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
1945年3月,美军计划在德国小镇威尔森附近,制造一场假渡河。幽灵部队,首先在威尔森附近,布置了大量的重装备。但这些装备,大多数都是假的,包括充气的M4谢尔曼坦克,假火炮,假卡车,木质观察塔。从空中看去,这里仿佛驻扎着一个完整装甲师。实际上,很多坦克,甚至只是橡胶和帆布。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
这些充气装备,是幽灵部队最具代表性的伪装手段之一。他们以美军当时广泛装备的M4谢尔曼坦克作为原型制造。整个模型重量只有四十多公斤,数名士兵便可完成搬运,并在几分钟内迅速展开。但当它们被部署到预设阵地之后,从高空俯瞰,却与真实坦克几乎没有明显区别。战争进入尾声后,德国空军制空权不断丧失,前线侦察能力大幅下降。德军越来越依赖高空航拍照片,判断盟军部队的集结方向与兵力规模。高空侦察能够识别目标轮廓,却很难进一步确认其真实性。在德军侦察照片中,这些由橡胶制造成的假坦克,开始被误判为正在集结的装甲部队。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
为了使伪装行动更加真实,幽灵部队拥有一种特殊装备:声响欺骗车。这些卡车上安装了大功率扬声器。美国陆军曾专门录制部队行军中的各种音效,随后通过音响系统播放。这些声音经过专业声学设计。美国工程师会根据地形、风向、夜间湿度调整播放方向。这些声音最远可传播约20公里。由于德军会长期监听盟军部队通信的习惯,幽灵部队专门训练无线电伪装员。他们会模仿真实部队的通信模式。德军监听员因此误以为美军主力真的已经集结。德军最终误判了盟军部署方向。他们将部分兵力和饱和火力错误地集中到威尔森方向。而与此同时,真正的盟军主力却在另一方向秘密渡河。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
那么在现代化的作战模式的这样的一个状态下,这种幽灵部队和假目标的诱骗方式,仍然是有很大的这样的一个功能的。俄乌冲突爆发之后,双方都设置了大量的假目标。比较典型的是,为了最大程度地去诱骗对方的远程精确打击火力,而且现在俄乌双方已经开始大量地使用这种充气假目标。现在它的仿真的程度越来越高。首先,它可以模拟坦克、装甲车辆、防空导弹,甚至是这种大型的导弹车,在相关的区域内进行这样的一个部署。充气假目标还可以通过小型红外热源的这样的一个方式去模拟真正的坦克装甲车辆,包括防空导弹以及雷达,它处于开机工作状态的这样的一个红外的热辐射信号。所以不仅仅是从视觉上,它可以起到欺骗的作用,从雷达信号,从红外信号上,它也可以起到欺骗的作用。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人2】
|
|
||||||
|
|
||||||
从充气坦克,到今天的无人装备,战争中的一个变化,正在变得越来越明显。20世纪的大部分时间里,武器的发展,始终在追求更大,更强。比如说,更大口径的舰炮,更庞大的轰炸机,更高速的战斗机等等。人类不断试图用,工业规模与火力优势,去主导战场。但是,进入21世纪后,一种截然不同的趋势,开始出现了。越来越多,真正改变战场的装备,正在变得,越来越小。它们不再强调,厚重的装甲,也不再依赖庞大的,钢铁结构。相反,它们开始追求,轻量化,低成本,以及快速部署能力。有些装备,甚至可以小到,直接装进士兵的口袋。而有些外形,则看起来更像是,商店里的模型玩具。然而正是这些,看起来不像武器的装备,正在重新改变,现代战争的形态。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
很多人小时候,都见过航模。轻薄的机身,小巧的螺旋桨,只需轻轻一抛,它便能够借助气流,飞向天空。很长一段时间里,这类飞行器,更多只是模型与玩具。但进入21世纪后,战场却开始赋予它,另一种意义。2003年,伊拉克战争期间,一种外形酷似航模的,小型无人机,开始频繁出现在美军前线。它就是,RQ-11大乌鸦无人机。与此前那些,依赖机场和跑道的,大型无人机相比,大乌鸦显得异常轻巧。整机重量,只有几公斤,可由单兵携带。执行任务时,士兵只需握住机身,将其直接投向空中,这架小型无人机,便会迅速展开机翼,飞向目标区域。从外形到操作方式,它都更像是一架,被放大的航模。但正是这种,看似玩具化的设计,却第一次,让空中侦察能力,被真正带到,普通士兵身边。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说3】
|
|
||||||
|
|
||||||
RQ-11大乌鸦无人机,长度约0.91米,翼展约1.4米,重量约1.9公斤,动力为电动推进,最大速度约每小时95公里,续航时间约60至90分钟,作战半径约10公里,操作人数通常在两人左右。在城市巷战中,大乌鸦能够提前察看街角埋伏、屋顶狙击手,以及道路上的简易爆炸装置。它第一次让空中侦察真正下沉到步兵班一级。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
美军使用的RQ-11,大乌鸦无人机,确实看起来,像是一个大号的玩具,其实它的成本比较低,无论是从设计理念,还是从这种系统,以及材料的应用来看,我认为它和航模,是有很多的相通之处的,不排除在这种民用的,航空模型的基础上,发展而来的,研制而来的,因为这种航模飞机,它是大量地使用复合材料,所以机体比较轻,与此同时会使用小型的电机,去作为动力系统,不仅可以飞得快,也可以飞得远,像大乌鸦这种,手抛式的单兵携带的无人机,它同样也是,使用这种复合材料,使得机身的重量(变得)非常轻,单兵在背包当中就可以携带,组装完毕之后,采用手抛的方式,它就可以飞到,相关的任务区域,所以,看起来像是一个大玩具,但是在战场上,使用起来非常地简单,操作和航模没有什么差别,那么就可以更多地,去批量装备部队。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
拥有一架小型遥控直升飞机,大概是很多人小时候的一个梦想。塑料机身、微型旋翼,飞行时还会发出轻微的电机声。很长一段时间里,人们很难把这种玩具,与真正的战争联系在一起。如今,一些外形与它们极其相似的设备,却已经开始出现在战场。黑黄蜂微型无人机,就是其中之一。它的体积只有成年人手掌大小,旋翼展开后,与普通的小型遥控直升机几乎没有太大区别。它可以穿过街巷与建筑缝隙,对目标区域进行近距离侦察;也能够进入复杂环境,完成室内搜索、目标定位等行动。而这也意味着,现代战场对于武器的定义,正在发生变化。越来越多承担侦察与感知任务的装备,开始变得更小、更轻,也越来越像人们日常的消费电子产品,甚至越来越像孩子们的玩具。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说4】
|
|
||||||
|
|
||||||
黑黄蜂无人机,长度约10至16厘米,可以直接放进口袋。重量约18至33克,相当于一只麻雀的重量。续航时间约20至25分钟,最大距离约两公里,飞行速度约每小时18公里。配备高清和热成像技术,采用双旋翼结构。这与很多民用微型遥控直升机极其相似。这种飞行器结构稳定,易操控,可悬停,适合低速飞行,非常适合狭窄空间侦察。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
未来这种,小型的微型的无人机,它的发展方向,我认为变得更加地细分。也就是说,小型无人机和微型无人机,会出现各种各样的形态,以满足不同的任务需求。现代战场上还出现了一些仿生无人机,比如说,把这个无人机设计成这种扑翼的这样的一个理念。它是不依托于螺旋桨来提供动力,而是扇动翅膀进行飞行。那么这种状态,就极具隐蔽性。当战场上出现一只鸟,一只蝙蝠的时候,对方的作战人员,根本就想象不到,它其实是对方的空中侦察兵。但是这种扑翼的无人机,它通过这种低空飞行的方式,把对方的兵力配备情况,火力的配备情况,全都了解之后,马上就可以引导己方的火力,进行精准打击。那么这些扑翼无人机,最大的优势就是,它的外形具有欺骗性,那和普通的鸟类,和普通的蝙蝠没啥差别。但是在飞行的过程当中,又没有什么噪音,它就可以悄悄地接近目标。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
很长一段时间里,人们对于军事机器人的想象,都是像战车一样,带有厚重的装甲,或者拥有履带式底盘,以及能够搭载火力系统的机械结构。但近年来,另一种截然不同的设计思路,开始进入现代战场。四足机器人,就是其中最具代表性的一类。与传统作战平台不同,这类装备最重要的能力,并不是火力,而是复杂地形下的运动性能。它们能够跨越障碍,上下楼梯,在崎岖地面保持平衡,甚至能够在一定程度上自主调整姿态。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人3】
|
|
||||||
|
|
||||||
现代战场的技术路径,正在发生变化。一些原本复杂的功能,如今被压缩进更小的设备之中,并以更加简单的方式进入前线使用。装备的形态,也在发生改变。它们不再是固定功能的单一平台,而开始具备一定的组合与调整能力,可以根据不同任务进行快速切换。而在这变化当中,装备的一些设计思路,也开始让人联想到一个更日常的概念,积木。这种组合的逻辑,也让装备的使用方式,变得更加灵活,也更加贴近实际的需求。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
积木的特点很简单,由标准化的模块组成,通过不同方式拼接,就可以形成不同结构。它的关键,不在单个部件,而在于整体的可重组性与扩展性。这种思路,在现代海军装备发展中,也曾出现过。上世纪70年代,美国海军建造的斯普鲁恩斯级驱逐舰,就是一个典型案例。与传统驱逐舰在设计之初就非常不同,这一级舰艇在总体设计阶段,就预留了较强的系统扩展空间。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说5】
|
|
||||||
|
|
||||||
这是一艘斯普鲁恩斯级驱逐舰,舰长171.7米,舰宽16.8米,标准排水量5770吨,满载排水量7700吨,最大航速33节,续航力每20节6000海里。武器配置上,初期搭载MK45型127毫米舰炮、阿斯洛克反潜导弹、鱼叉反舰导弹、近防武器系统,可起降两架SH-60海鹰反潜直升机,兼顾反潜、反舰、防空与对海打击多重任务。该舰在设计之初,便摒弃了传统舰艇整体化建造模式,将舰体、动力、武器系统划分为标准化独立模块,各模块可并行建造、快速组装。这种设计赋予舰艇极强的升级改造潜力。舰上预留标准化武器安装基座、管线接口与空间冗余,后期可直接换装MK41垂直发射系统、战斧巡航导弹、新型雷达电子设备,无需对舰体主体结构进行大规模切割改造。斯普鲁恩斯级驱逐舰成为现代海军模块化造船理念的开山之作。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
斯普鲁恩斯级驱逐舰,在当时的,这样的一个时代背景下,是美国海军,要用于和苏联的,这种海上作战体系,进行对抗的这样的一种主力舰。采用模块化设计理念,就可以进行这样的一个系统,和舰载武器的选装。而且当时,斯普鲁恩斯级的驱逐舰,也有外销的考虑。如果是外销的话,那么还是要根据使用方的,这样的一个定制化的要求,针对它的舰载系统,进行一定程度的更改。所以模块化设计理念,既为后续的升级改进,留下了更大的空间,同样也为外销,提供了更多的可能性。斯普鲁恩斯级驱逐舰,可以说对于后续的,提康德罗加级巡洋舰的,研发和制造,以及阿利·伯克级驱逐舰的,研发和制造,都是提供了,非常重要的参考作用。后续像这种,大型的这种战舰,无论是为它配备,相控阵雷达还是安装更多的,通用的导弹垂发系统,那么模块化设计理念,都提供了更多的可能性。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说8】
|
|
||||||
|
|
||||||
如果说斯普鲁恩斯级驱逐舰,让平台化,与可扩展的设计思路,在海军装备中,初步成形,那么,在地面装备体系中,这种理念,被进一步具体化,德国的拳师犬轮式装甲车,就是一个典型代表,它的核心设计,并不是某一种,固定用途的车辆,而是一种,底盘与任务模块的,组合结构,车体本身,承担通用机动,与防护能力,而上方的任务舱,可以根据需要,进行快速更换与配置,在不同任务条件下,同一辆底盘,可以在短时间内,切换为运兵平台,医疗救护车辆,指挥通信节点,甚至电子战作战单元,这种变化,使得装备,不再被单一功能所固定,更像一种,可以不断组合的系统,在某种意义上,这种设计逻辑,就像搭积木,基础结构保持稳定,而功能,则像积木的模块一样,通过不同组合与替换,不断变化,传统军队,往往需要大量不同型号的专用车辆,来完成分工,而模块化设计,则尝试用一套底盘体系,去覆盖更多任务场景,这种变化,不仅简化了装备体系,也在一定程度上,降低了后勤保障的复杂度。
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人4】
|
|
||||||
|
|
||||||
进入21世纪后,这种模块化的思路,开始从单一装备,进一步扩展到更大的作战体系层级。美国陆军提出的斯特赖克旅战斗队,正是这一理念的集中体现。它以统一底盘的装甲车辆为基础,配合标准化编制与数字化指挥系统,试图构建一种可快速部署的作战单元。在理想状态下,这支部队能够在约96小时内完成全球范围内的战略投送,并迅速形成完整的战斗力。从单一装备,到整支作战编组,模块化开始进入了体系层面。而在这变化当中,战争的组织方式,也在悄然发生转向。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说6】
|
|
||||||
|
|
||||||
这是一辆M1126,斯特赖克步兵战车。它的车长约6.93米,车宽约2.72米,车高约2.64米。战斗全重,约16至19吨。采用350马力柴油机。最大时速,约每小时97公里。最大行程,约530公里。能够载员9名步兵。斯特赖克,采用8轮轮式结构,维护简单,油耗更低,噪声更小,适合快速部署。它的底盘,可以搭配不同的结构,变为步兵车,指挥车,医疗车,反坦克车。车身的大量零件,以及动力系统,实际上完全通用。使维修人员,零件仓库,训练体系,都能被大幅简化。它们通过C-130运输机运载,可以向全球任意战场,快速投送。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说9】
|
|
||||||
|
|
||||||
斯特赖克步兵旅的核心,并不只是某一种武器装备,而是一整套围绕“快速反应”构建起来的作战体系。它的基础作战单元,通常以步兵营为核心展开,再配备多个装备“斯特赖克”轮式装甲车的机动营协同行动。表面上看,拥有相似的外形与统一的底盘平台,但实际上,它们却承担着截然不同的任务。有的负责搭载步兵班组快速突入;有的安装反装甲武器,承担火力支援;有的则装备侦察设备,负责搜索目标;还有一些车辆,本身就是移动的指挥节点与通信中枢。同一种底盘,被拆分成不同功能模块,再像积木一样重新组合。美国陆军希望借此打造的,并不是一支传统意义上的装甲部队,而是一支能够快速适应不同战场环境的“模块化军队”。而这种理念,也深刻改变了它的作战方式。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家5】
|
|
||||||
|
|
||||||
冷战结束之后,美国陆军进行了转型。斯特赖克轮式装甲旅,主要是强化这种机动作战,和信息化的作战。采用了这种模块化的,兵力的这样的一个,配置方式。这种模块化的方式,可以让斯特赖克轮式装甲旅,根据任务的需求,根据环境的需求,进行兵力结构的拆分和重组。可以随时组成,比较灵活的这样一个小分队。那么在此前,伊拉克战争的过程当中,美军是使用了,斯特赖克轮式旅,作为这样的一个地面体系的,重要组成部分。比如说,在进入到城市这样的,一种比较复杂的环境当中,斯特赖克轮式旅,它的信息的互通互联能力,是比较强的。可以进行这种,信息的最大程度的共享,感知环境方面有优势。另外一个,在城市的环境当中,轮式底盘的通过性比较好,可以快速地机动到任务区。也可以通过这种模块化的,这样的一个组合方式,那么针对任务,去选择更为灵活的,或者说更有针对性的,这样的一个作战平台。现在直瞄火力,也就是说进行火力支援的,主要是配备有大型遥控炮塔,配备有机关炮的,这种轮式的步战车。既可以搭载步兵,也可以进行一定的火力支援。除此之外,斯特赖克轮式旅,它还有这种火力模块。斯特赖克轮式旅,还是有先天的不足。一旦它面对实力比较强的对手,比如说对方装备,非常先进的主战坦克,和履带式的步战车,那么对方它在,装甲防护能力方面,在火力方面,会有压倒性的优势。在这样的-种作战的状态下,斯特赖克轮式旅,是难以取胜的。甚至可能会出现,全军覆没的情况。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说10】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【演播室主持人5】
|
|
||||||
|
|
||||||
当现代战争,越来越强调更轻、更快、更灵活,一种新的逻辑也正在出现:决定胜负的,未必只是更厚重的装甲,或者更庞大的平台,而是谁能更快组合力量,更快获取信息,更快完成反应。于是,人们看到无人机,像成群的飞行玩具般,掠过天空。模块化部队,像积木一样快速重组。机器狗、微型机器人、仿生装备,陆续走入真实战场。曾经属于娱乐与想象力的创造,正在一步步跨越虚拟与现实之间的边界。或许也意味着,下一款主导未来战场的装备,它的原型设计灵感,就来自于商店某个安静角落里的一款玩具。好了观众朋友们,感谢您持续关注国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说11】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
---
|
|
||||||
标题: 武器装备的颜值洼地
|
|
||||||
编导: 张彤枫
|
|
||||||
播出日期: 2026-06-09
|
|
||||||
类型: 节目文稿
|
|
||||||
期号: ep020
|
|
||||||
---
|
|
||||||
|
|
||||||
# 武器装备的颜值洼地
|
|
||||||
|
|
||||||
|
|
||||||
## 【导视1】
|
|
||||||
|
|
||||||
很多人评价武器装备的好坏,首先想到的,就是重量、威力,这些能直接量化的数据。但其实,还有一个容易被忽视的因素,那就是颜值。在多型武器装备的竞标和使用中,有些因为长得丑,直接被淘汰;有些因为长得丑,被快速更新换代;还有些因为长得丑,直接影响了作战性能。我们就一起回顾一下,那些因为颜值,而备受争议的武器装备。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人1】
|
|
||||||
|
|
||||||
各位观众你们好,欢迎收看《军事科技》,我是主持人蓝皓。自人类诞生开始,无论是停留在画布上的色彩,还是镌刻于青铜器上的纹样,对美的本能追求,已深植文明的基因当中,武器亦不例外。千年过后,当我们把目光,投向由钢铁与烈火铸就的,现代武器装备时,这种对美的本能向往,并未消退。飞机的流线型机身,舰艇的修长轮廓,坦克的棱角分明,无不体现了工业美学,与功能主义的精妙平衡。但是,并不是每一款装备,都极具美感,有些设计,甚至让工程师自己都摇头叹息。那么,这些“颜值担当”,与“颜值黑洞”之间,究竟隔着怎样的设计逻辑呢?是空气动力学,妥协了视觉感受,还是战场需求,压倒了美学考量?本期节目,我们就来看一看那些,走向美与丑,两个极端的武器装备。
|
|
||||||
|
|
||||||
街头采访—X-32与其他战机
|
|
||||||
|
|
||||||
|
|
||||||
## 【街采】
|
|
||||||
|
|
||||||
我觉得左边这架飞机更符合我的审美。我感觉左边这个更偏向于科幻一些,右边就显得更加笨重和老实。
|
|
||||||
|
|
||||||
我觉得左边的好看,因为右边的这个飞机前端下面有一个凹进去的东西,正面看会比较突兀。
|
|
||||||
|
|
||||||
我觉得左边这(架)看起来线条非常流畅,感觉会减少空气阻力。然后右边这(架)看起来是一种几何体,感觉非常地抗击打。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人2】
|
|
||||||
|
|
||||||
在刚才的街头采访中,大家对于这款战机的颜值普遍呈现出负面评价。从外观的角度来看,可能并没有得到大家的认可。这架战机名叫X-32,您可能对它并不熟悉,但是它有一个更广为人知的竞争对手,那就是F-35战机。如果将两架战机放在一起做一个对比,X-32那圆润臃肿的机身与F-35凌厉干练的线条便形成了鲜明的反差。最终从代号上也可以看出,F-35拿下了竞标合同,而X-32则止步于原型机阶段。那么这对颜值差异巨大的孪生兄弟,在当年究竟经历了怎样的博弈呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说1】
|
|
||||||
|
|
||||||
在当年,美军新型隐身战斗机的竞标中,就有一对冤家,因为要考虑到各军种的通用性,所以要求,新型隐身战机,需要具备海军陆战队,所需要的垂直起降能力,这场竞赛的赢家,现在我们都很熟悉了,那就是F-35,而遗憾退场,埋进历史尘埃的,则是波音家的,X-32技术验证机,单看外表,F-35闪电II,与当时其他战机相比,身材确实壮实了不少,所以大家都爱叫它“肥电”,这个外号虽然对它的身材,有些小吐槽的意思,但跟它的竞争对手,X-32—对比,简直算得上相貌端正了,再来看看X-32,第一眼看到它,你大概会忍不住想问,这为啥看起来,像一只鼓着气的青蛙呢,还是只张着大嘴的青蛙,这种既视感,主要来自它那,超级夸张的机头进气道,这种震撼的视觉效果,可不是偶然,而是波音公司,为了满足垂直起降时,巨大的进气需求,所作的性能妥协。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说1】
|
|
||||||
|
|
||||||
X-32采用的垂直起降方案,来自于鹞式战斗机的成熟设计。当飞机进入垂直起降模式时,机身周围的四个辅助喷口同时开启,将发动机的推力由水平方向转向垂直方向,相当于直接将发动机的推力拆解为四个独立的垂直推力支点,从而实现悬停与着陆。这种推力矢量分配方式,虽保障了垂直起降能力,但却导致机身结构异常臃肿。因为发动机担负了飞机的全部推力输出,就需要发动机有更大的推力,而更大的推力又要求飞机有更大的进气口保证进气效率。无论是X-32,还是鹞式战斗机,都有着与机身比例严重失调的硕大进气口,使其整体轮廓显得笨重而怪异。而F-35B,则采用升力风扇加发动机矢量喷口的复合推进方案。升力风扇独立承担约50%的垂直升力,主发动机仅需提供剩余推力,并兼顾水平飞行。虽然这种方案结构更复杂,同样也会占用大量机体空间,但升力风扇的引入,显著降低了主发动机的推力需求,从而允许进气道与机身线条更为协调地融合,使整机气动布局更趋流线,视觉上也更具现代战机所独有的流线型美感。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家1】
|
|
||||||
|
|
||||||
当时的X-32,也有很多人看好。为什么?它的设计有一些颠覆性的一些理念,是采用了特殊设计的下颚进气道。那么整个的外形,看起来有一些怪异,但是有人认为,它的这种雷达的隐身性能,可能会更胜一筹。不过,当时美国军方对于隐身战斗机的定位,是三军通用。其中美国海军陆战队,要求新一代的隐身战斗机,具备短距起飞的能力,要具备垂直降落的能力。而X-32它想要实现短距的起飞和垂直的降落,所采用的系统,包括控制系统以及动力系统,是参考了AV-8B海鹞。所以结构可能会比较复杂,也会带来性能方面的一些限制。不仅仅是X-32它的颜值,可能没有那么漂亮。在短距起飞和垂直降落能力方面,可能和当时的X-35(F-35的试验型号)相比,还是有一定的差异性。所以我们看,有的时候设计层面的颜值,对于它的综合的性能和实战能力,其实还是有关联性,甚至是有影响的。如果说X-32的外形,是由于先天设计取舍导致的臃肿,是一种无奈的选择。那么,有些装备的颜值,就是在后天设计上,直接放弃了美学考量。有很多的武器装备,都是依托于既有平台,进行改装升级,在原有结构上堆砌新的部件。其中最典型的就是预警机。那么预警机中,又有哪款令观众不忍直视的代表呢?我是比较喜欢左边我们这种国产的这个300的预警机的。其实它和运-20很像,我很喜欢这种敦敦厚厚的,四发的这个强大的推力。旁边这个,我觉得它肯定也有自己的理念在里面。但是,我会觉得没有左边那么利索一些。左边的好看,右边的不太好看。因为右边的它这个飞机头,前面有一个圆,然后就有点太突兀了。其实我也更喜欢左边那个。因为右边那个,感觉从颜色上,就比左边那个略显笨拙。左边它那个灰调其实是我个人最喜欢的一个灰色。右边那个感觉像是那种客舱的感觉,在机场里已经司空见惯了的。在刚才的街头采访中,大家对于预警机的外形,有着不同的见解。其中对于有着大鼻子造型的费尔康预警机,吐槽声尤为集中。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说2】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人3】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
街头采访—费尔康预警机与其他预警机
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人4】
|
|
||||||
|
|
||||||
我们来看,它那硕大的机头雷达罩,像是一颗长在机首的巨型青春痘,粗暴地撕裂了原有机身的流畅曲线。和现在众多头顶圆盘的预警机相比,视觉冲击力十足,但是却略显笨重。作为现在预警机颜值的反面教材,从费尔康预警机的巨大鼻头,到现在预警机的头顶圆盘,预警机的颜值,又经历了哪些变化呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说3】
|
|
||||||
|
|
||||||
预警机,作为现代空战体系中的“千里眼”与“顺风耳”,已经成为了空战体系中的绝对C位。而它最关键的雷达系统怎么摆放,直接决定了它的颜值走向。比如,美军早期使用的EC-121预警机,就在机腹上安装了一个巨大的下视雷达,看起来像长了啤酒肚,又在机背上加装了用于探测高空目标的上视雷达,看起来又有些驼背。这一切都是为了满足当时技术条件下的探测需求,而作出的无奈妥协。随着雷达技术的成熟,另一种我们最为熟悉的背负式雷达设计,开始出现在预警机上。圆盘状雷达天线被置于机身顶部,实现360度无死角探测,既提升了探测效率,又大幅优化了气动外形。但随着相控阵雷达的出现,雷达不再依赖机械旋转,固定式阵列可嵌入机身、机翼甚至垂尾,这就让预警机的外形设计迎来新一轮解放。以色列的费尔康预警机就是第一个吃螃蟹的。不过,这种新潮设计也让费尔康长得有点特别,在造型和性能之间做了些有趣的取舍。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说2】
|
|
||||||
|
|
||||||
费尔康预警机,相较于目前主流的,圆盘造型的预警机,最大的缺点在于,缺乏360度,全向雷达覆盖能力,机头巨大的球形雷达罩,虽保障了前半球探测精度,但覆盖角度仅为120度左右,探测距离,可以达到约370公里,机身两侧的两块侧视雷达,也因安装位置受限,60度左右的探测范围,和300公里左右的探测距离,虽在尾翼处,加装了第三块雷达面板,但功率受限,导致后向探测能力,依然薄弱,反观背负式雷达,无论是圆盘造型,还是平衡木造型,虽然也不能完全实现,360度无死角覆盖,但通过旋转基座,或调整飞机飞行角度,可动态补全探测盲区,整体覆盖,连续性与稳定性,远超固定式布局,同时,机头的巨大球形雷达罩,相较于原本的机体设计,增加了至少10%的飞行阻力,与结构重量,也使得机身重心前移,影响飞行稳定性与机动性,而背负式雷达,将探测设备置于机身顶部,既减轻了气动干扰,又使重心分布更趋合理。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家2】
|
|
||||||
|
|
||||||
以色列的费尔康预警机,早期的型号,它是把相控阵雷达和机身的结构贴合到一起,出现了大鼻子的这样的一个结构。之所以采用这样的一个模式,是出于两方面的考虑。早期的相控阵雷达,可能个头比较大。另外一个,它在冷却的这个技术方面,不是特别成熟。采用这种贴合式的一个设计理念,可能会有效地解决散热时的问题。因为它这个雷达是分布式部署的,不同的雷达模块,它的散热系统个头没有那么大,在技术层面是比较好解决的。所以费尔康,它既有大鼻子的型号,也有带着大盘子的型号。我们现在看到的大型的固定翼的预警机,其中很多都是采用背负式的设计。因为背负式对于机身,它是不需要改动的,舱内的空间比较大,容易部署更多的指挥控制的系统,操作台也可以部署很多,甚至还会留出足够多的人员休息区。而采用背负的这样一个方式,雷达的尺寸也可以做得比较大。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说4】
|
|
||||||
|
|
||||||
(本段无对应字幕,待编导核)
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人5】
|
|
||||||
|
|
||||||
从费尔康的“鼻突”,到现代预警机普遍采用的圆盘顶,本质是雷达技术迭代与气动优化的双重结果。但是,给大家带来最直观的感受,却是预警机外形美感的悄然提升。这种既符合功能需求,又兼顾视觉协调的进化逻辑,并不是只出现在预警机身上。海上的军舰,也正在经历着类似的蜕变。
|
|
||||||
|
|
||||||
街头采访—絮弗伦级驱逐舰与其他驱逐舰
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人6】
|
|
||||||
|
|
||||||
我觉得右边这艘,没有左边这艘看起来帅。因为左边这艘是狭长的,感觉甲板也非常的平直,飞机起飞的话会很帅。右边这个就是灰灰的,跟拍摄可能也有关系,各种感觉也比较杂乱。左边更科技现代一点,右边感觉没有左边帅气。还是左边的好看,右边的不好看。左边的这(艘),它的体积会大一点,右边的体积小。然后它上面有一个球,挺大的,我觉得有点太突兀了。第三张图片是两艘军舰,左边这艘军舰的话,有点像一体成型的样子,线条非常的流畅。而右边这艘军舰的话,它中间有一个大球,看起来会笨重许多。所以我总体的观感上来说,我觉得左边这(艘),可能在现实生活中,速度也会更快一些。左边这个也更符合我的审美。刚才大家看到的,同样是头顶一个圆球的驱逐舰,正是法国设计制造的,絮弗伦级驱逐舰。其舰岛顶端那个硕大的雷达罩,成了它的标志性特点。从美观的角度来看,这样一个巨大的圆球,安放在修长的舰体之上,确实显得有些突兀。仿佛为了追求探测性能,而牺牲了整体的协调性。那么,这个突出的大圆球,担负了怎样的作用呢?现代军舰,又是如何避开这种,怪异的设计呢?
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说5】
|
|
||||||
|
|
||||||
对于航行在海上的舰艇来说,雷达的重要性,是和武器系统同等关键的。早期的水面舰艇,并没有配备导弹,雷达主要就是用来,在海面上扫一扫,找找目标,顺便给火炮瞄准“报个点”。经常能在老式驱逐舰,那高高的桅杆上,看到个长得像大喇叭的雷达天线。等导弹时代一来,画风就变了,舰载雷达,必须要能超远程预警,跟踪目标,并且死死地锁定其中几个。这种高难度要求,直接催生出了大型雷达,雷达的功能,也开始“分家”了。这个时期,长得像“苍蝇拍”的,平板雷达天线,和神似“晾衣架”的雷达天线,成了主流。它们被一层一层地,布置在舰岛的不同高度,各司其职。虽然这些雷达,看起来有点糙,不怎么精致,但使用功能上却不打折扣,经济又实惠。不过,这种乱堆一气的方式,让浪漫的法国人,有点坐不住了,他们觉得这些横七竖八的雷达天线,简直破坏了战舰的整体美感。于是,他们琢磨着,能不能把这些天线,都塞进一个更好看的盒子里呢?最终,叙弗伦级驱逐舰的舰岛上,出现了那颗无比醒目的大白球。但是,话又说回来,这种头顶个球的效果,怎么看都有些一言难尽。
|
|
||||||
|
|
||||||
|
|
||||||
## 【三维动画解说3】
|
|
||||||
|
|
||||||
叙弗伦级驱逐舰,巨大的球形雷达罩,其实是时代技术限制的产物。尚未突破相控阵雷达小型化,与多面阵协同技术。如果想提升探测性能,只能依靠增大雷达孔径,和功率来提升探测距离和精度。这就使得雷达体积,被迫变大。为了保证360度全向探测,就需要让巨大的雷达,旋转起来。在这种情况下,为了保证雷达的使用寿命,和降低航行时的风阻,球形雷达罩,便成为最稳妥的工程选择。当然,在同时期其他舰艇,也有着不同的方案。苏联时期的舰艇,为了解决舰载雷达的问题,选择将雷达阵列,放置在更高的位置,同时采取双面阵雷达,并且将不同功能的雷达,分置在舰船的不同位置,以分散布局换取探测冗余。当时的苏联舰艇,看起来都是十分高大的,并且在舰艇上,密密麻麻布满了各式雷达天线,与电子设备。而现代战舰,则普遍采用,分布式相控阵雷达系统,四面固定式阵列嵌入舰桥四周,既保证了360度全向覆盖,又大幅降低了雷达反射截面积,与结构重量。既没有任何体形夸张的装置,也使得舰艇外观,更加简洁流畅,更符合隐身设计。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家3】
|
|
||||||
|
|
||||||
与模块化建造理念,独立的雷达,主要是装备护卫舰,或者是轻型战舰,因为它安装比较简单,不会占据比较大的舰体的空间,而且成本比较可控。像一些护卫舰和轻型护卫舰,会普遍装备。那么大型的,四面阵的相控阵雷达,主要是装备主力战舰,像一些驱逐舰,甚至包括在航母上也可以配备。它的功能,针对整个编队进行预警,它的照射距离比较远,可以在数百公里,甚至是上千公里的范围内,去探测和识别,空中目标和海上目标。而且多目标的处理能力很强,可以引导先进的防空火力,和反导火力,针对空中的来袭目标进行打击。这种雷达个头大,而且比较昂贵。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说6】
|
|
||||||
|
|
||||||
徐弗伦级驱逐舰行驶在茫茫大海上,虽然有点像海洋馆里的白鲸,顶着皮球游弋,但不可否认的是,这种将雷达天线封装进球形罩的设计,既保证了探测性能的稳定性,又兼顾了舰艇整体的结构强度与抗风浪能力。即使来到现在,还有很多舰艇沿用类似设计,只不过这个球的大小不再有那么夸张。很多头顶个球的舰艇,现在看起来反而有些帅气。在颜值上来说,也是各有千秋。而头顶球的代表,徐弗伦级驱逐舰,被认为不好看,只能归咎于这个球太大了。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人7】
|
|
||||||
|
|
||||||
当然,除了我们刚才看到的这几款装备外,其实还有很多的武器装备,在设计之时,就完美避开了“颜值巅峰”,成功走进了“颜值洼地”。那么,武器装备的颜值,究竟能丑出怎样的高度呢?还有哪些深陷颜值洼地的装备,令人啼笑皆非呢?让我们一起盘点一下。
|
|
||||||
|
|
||||||
|
|
||||||
## 【解说7】
|
|
||||||
|
|
||||||
苏联时代,为了打造庞大的钢铁洪流,研制了不少奇形怪状的坦克。279工程坦克,设计出了飞碟一样的车身,配上了4条履带,加上圆鼓鼓的炮塔,看起来,透露着一份憨厚和可爱,但绝谈不上漂亮。长滩号巡洋舰,作为世界上首艘核动力巡洋舰,在作战能力上,是十分强悍的。但是,同样是为了容纳庞大的雷达系统,在修长的舰体上,硬生生堆起巨大的,方方正正的舰岛。虽然在当时看起来颇具威慑力,但以现在的审美来看,更像是一个巨型积木块,被随意堆砌在甲板上。A300-600ST运输机,外号“大白鲸”。它是将一架空客A300客机的客舱,进行了PLUS MAX升级,更大,更高,让它的载重能力,提升了30%,并且可以装下尺寸更大的货物。不过,从颜值上来说,主打一个丑萌丑萌的。日本EC-1电子战飞机的颜值,和费尔康预警机如出一辙,一个是大鼻子,一个是大嘴唇。姑且不讨论其性能的优劣,就说这个丰满的嘴唇,让人看了很难不笑出声来。最后的王炸,则是波音公司设计的BV-235验证机。很难想象,当年和它同台竞技的,是现在的阿帕奇武装直升机。从颜值上来看,只能说阿帕奇,赢得毫无技术含量。
|
|
||||||
|
|
||||||
|
|
||||||
## 【专家4】
|
|
||||||
|
|
||||||
我认为设计武器装备的时候,对于颜值的这样的一个要求,还是应该在整体设计的考察范围内的。为什么这么说?因为武器装备,无论是战斗机、轰炸机,包括大型的战舰,那么它都是一个工业化的这样的一个产品。如果说在设计的层面,没有进行优化,或者说外观看起来很怪异,它在使用的过程中,肯定会有一些功能层面的限制。所以有的时候,颜值确实代表一定程度的作战能力。而且现在我们也注意到,武器装备它的外形和它的颜值,可能也会影响到它的外销和海外防务市场上的订单。这种漂亮的武器装备,看起来很顺眼的武器装备,在进行表演的过程当中,在进行展示的过程当中,也容易吸引人,可能也适合拿到更多的订单。所以我认为颜值比较重要,但是并不是说所有的武器装备都是把颜值作为一个首要考虑的标准。
|
|
||||||
|
|
||||||
|
|
||||||
## 【主持人8】
|
|
||||||
|
|
||||||
虽然说颜值,并不是衡量一款武器装备强弱的核心标准,但是,令人大跌眼镜的外观设计,往往成为公众认知的第一道门槛。X-32验证机,那夸张的下颌式进气道,与臃肿的机身轮廓,预警机和军舰上那颗硕大突兀的圆球,无一不是功能优先逻辑下的无奈选择。虽然外观看起来不太聪明,但它们为各自赛道的后续装备设计,确立了一定的技术范式与审美基准。所以说,人不可貌相,装备亦是如此。感谢您持续关注,国防军事频道《军事科技》,我们下周同一时间,再见。
|
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
|
|
||||||
| 子模块 | API 服务 | Key 类型 | 开通日 | 激活状态 | 到期日 | 责任人 | 备注 |
|
| 子模块 | API 服务 | Key 类型 | 开通日 | 激活状态 | 到期日 | 责任人 | 备注 |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| doco | 讯飞开放平台 - 录音文件转写(标准版) | APP_ID + SECRET_KEY | 2026-06-12 | 待激活(需走 0 元购买) | 2027-06-12 | 制片人 | demo 凭证已过期,需新申请 |
|
| doco | 讯飞开放平台 - 录音文件转写(标准版) | APP_ID + SECRET_KEY | 2026-06-12 | 已激活 | 2027-06-12 | 制片人 | C2 ASR 转写,22 期已跑完 |
|
||||||
| doco | DeepSeek Vision | API_KEY | 2026-06-12 | 已激活 | — | 制片人 | doco OCR 用 |
|
| doco | Ollama 本地 DeepSeek-OCR | 本地部署 | 2026-06-12 | 已激活 | — | 制片人 | P2 本地 OCR,16 路并发,4090D 24GB |
|
||||||
| doco | Anthropic Claude API | API_KEY | 2026-06-12 | 已激活 | — | 制片人 | AI 融合层(P3) |
|
| doco | 小米 MiMo 2.5 Pro | API_KEY | 2026-06-22 | 已激活 | — | 制片人 | AI 融合层(C1/C3/C4),OpenAI 兼容端,替代原 DeepSeek v4-pro |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,4 +41,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*最后更新: 2026-06-12*
|
*最后更新: 2026-07-03*
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 双机同步开发指南
|
||||||
|
|
||||||
|
> 单位 4090D(主力开发机) ↔ 家里电脑,通过 Gitea 云端仓库同步。
|
||||||
|
> 仓库地址:`http://101.42.29.217:3000/simonkoson/tps-dashboard.git`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 到家后第一次(克隆 + 环境搭建)
|
||||||
|
|
||||||
|
如果家里电脑还没有这个项目,先克隆:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone http://101.42.29.217:3000/simonkoson/tps-dashboard.git
|
||||||
|
cd tps-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
然后搭建环境:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 创建 Python 虚拟环境
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
|
||||||
|
# 2. 安装后端依赖
|
||||||
|
pip install fastapi uvicorn sqlmodel python-dotenv psycopg2-binary bcrypt==4.0.1 itsdangerous==2.2.0 python-multipart==0.0.9 openai python-docx
|
||||||
|
|
||||||
|
# 3. 安装前端依赖
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
配置环境变量(**两个 .env 文件,都不在 git 里,需要手动创建**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# backend/.env — 从单位机器上复制内容,包含:
|
||||||
|
# DATABASE_URL=postgresql://...
|
||||||
|
# SECRET_KEY=...
|
||||||
|
# MINIMAX_EMBED_API_KEY=...
|
||||||
|
# MINIMAX_GROUP_ID=...
|
||||||
|
# DEEPSEEK_API_KEY=...
|
||||||
|
|
||||||
|
# ai-labeling/.env — 包含:
|
||||||
|
# MIMO_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:数据库连接指向腾讯云 PostgreSQL,两台机器连的是同一个数据库,不需要本地装 PostgreSQL。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 每次开工(不管在哪台机器)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\tps-dashboard # 或者家里对应的路径
|
||||||
|
git pull origin main # 拉取对方机器推送的最新代码
|
||||||
|
```
|
||||||
|
|
||||||
|
如果有新的前端依赖(别人装了新 npm 包),还需要:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
如果有新的 Python 依赖,激活虚拟环境后 pip install。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 启动开发服务器
|
||||||
|
|
||||||
|
开两个 PowerShell 窗口:
|
||||||
|
|
||||||
|
**窗口 1 — 后端**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\tps-dashboard
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
cd backend
|
||||||
|
python -m uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**窗口 2 — 前端**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\tps-dashboard\frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `http://localhost:5173`(或 5174)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 收工推送(每次下班前必做)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\tps-dashboard
|
||||||
|
git add -A
|
||||||
|
git status # 检查一眼,确认没有不该提交的文件(.env 等)
|
||||||
|
git commit -m "你的提交信息"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### pull 时报冲突
|
||||||
|
说明两台机器改了同一个文件。先看冲突内容:
|
||||||
|
```powershell
|
||||||
|
git diff
|
||||||
|
```
|
||||||
|
手动解决冲突后:
|
||||||
|
```powershell
|
||||||
|
git add .
|
||||||
|
git commit -m "resolve merge conflict"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 忘记 push 就换了机器
|
||||||
|
在另一台机器上 `git pull` 会发现没有新内容。回到原来的机器先 push,再回来 pull。
|
||||||
|
|
||||||
|
### .env 文件丢失
|
||||||
|
.env 不进 git,换机器需要手动复制。建议用安全的方式(如加密 U 盘或密码管理器)在两台机器间同步 .env 内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给 Claude Code 的指令
|
||||||
|
|
||||||
|
如果你在家里用 Claude Code 开新 session,让它先执行:
|
||||||
|
|
||||||
|
```
|
||||||
|
请先阅读 CLAUDE.md 和 docs/dual_machine_sync.md,了解项目背景和双机开发环境。
|
||||||
|
然后阅读 docs/git_workflow.md 第 7 章了解 git 工作流。
|
||||||
|
我现在在家里的电脑上,代码已经 pull 到最新。请帮我启动开发环境并确认一切正常。
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 项目协作主控文件 (episode-intake/CLAUDE.md)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
期次一条龙录入子项目主控文件。
|
||||||
|
通用协作原则、主项目技术栈、角色定位、Git 红线等
|
||||||
|
已在上一级 tps-dashboard/CLAUDE.md 中覆盖,不重复。
|
||||||
|
维护原则:增量更新,不整篇重写。
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔖 状态栏(每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
|
- **最后更新**:Claude Fable(顾问)| 2026-07-07
|
||||||
|
- **当前状态一句话**:**已立项,PRD v1.0 完成(含制片人三项拍板),未开发。** 等制片人排期后按 PRD §8 四刀实施。
|
||||||
|
- **下一个动手的人从这里开始**:读本文件 + `PRD_期次一条龙录入_v1.md` 全文。开发时严格按 PRD §8 分四刀,第一刀 Cline 指令已备好(见下方「⏩ 交接备注」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 子项目概览
|
||||||
|
|
||||||
|
- **项目名**:期次一条龙录入(episode-intake)— 收视分析看板的"持续运行入口"
|
||||||
|
- **目标**:把每期节目的五件事(收视录入 → 文稿入库 → AI 打标+摘要卡 → 制片人审核 → 进看板)收进责编录入页面,做成每期一张任务清单,让看板从"一次性导入的静态报告"变成"每周自动生长的持续诊断"。
|
||||||
|
- **落点页面**:责编录入(`frontend/src/pages/EditorDesk/EditorDesk.jsx`)
|
||||||
|
- **完整方案**:见同目录 `PRD_期次一条龙录入_v1.md`(页面设计/API 设计/schema 变更/验收标准/分刀计划齐全)。
|
||||||
|
|
||||||
|
### 与主干的关系(特殊说明)
|
||||||
|
|
||||||
|
本子项目**实施时全部落在主干**(EditorDesk 前端 + backend API/服务 + 005 迁移),不是独立部署的应用。外迁的是**方案讨论和 PRD 迭代**;动代码时走主干纪律:Cline Plan + Opus 审 + 制片人批准 Act,005 迁移前必须 pg_dump 备份。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 关键决策(制片人已拍板,2026-07-07)
|
||||||
|
|
||||||
|
1. **文稿口径 = doco 融合A稿**。doco 以播出后唱词字幕 OCR 为最标准依据;CCA 是播出前生产工具,审片/播出后还会改,不算最终文稿。**doco 由此转入常态运行**:每期播出后跑一遍 doco 产融合A稿,作为文稿环节的固定上游。
|
||||||
|
2. **doco 22 期旧稿导入知识库时回联期次**(期次号/播出日期在文件名上,可解析匹配),挂 PRD 第二刀。
|
||||||
|
3. **责编可触发「开始 AI 处理」**(系统日常维护以责编为主)。
|
||||||
|
|
||||||
|
### 设计要点(PRD 核心结论,防跨 session 丢失)
|
||||||
|
|
||||||
|
- **任务清单模式,不是强制向导**:五环节各自独立完成,期次表格加 4 个状态点(收视/文稿/AI处理/已审核),全绿 = 进看板全部模块。
|
||||||
|
- **状态不单独存列,全部从现有字段推导**(audience_share / transcript_item_id / program_format+content_digest / ai_label_confidence)。
|
||||||
|
- **审核权仅制片人**:AI 打标落库为 draft;看板象限图等标签类模块**只用 reviewed 期次**——设计哲学红线(编导绝不见 AI 草稿值)的代码落地。
|
||||||
|
- **只加一列 schema**:005 迁移 `episodes.transcript_item_id`(软引用知识库条目,ON DELETE SET NULL,禁级联删)。存量 25 期 `ai_label_confidence` 回填 'reviewed'(源自制片人审定的 GT v0.6.0)。
|
||||||
|
- **全面复用**:Prompt 1-4 留在 `ai-labeling/prompts/` 后端只读;打标调用逻辑移植自 `ai-labeling/scripts/`;文稿入库复用 Phase 3 知识库链路;后台任务用 BackgroundTasks+内存表(不引 celery)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 待办(按 PRD §8 四刀)
|
||||||
|
|
||||||
|
- [ ] **第一刀(纯前端)**:流水线状态列 + 抽屉骨架 + 区块一(复用现有表单)
|
||||||
|
- [ ] **第二刀(后端)**:005 迁移(先 pg_dump)+ 文稿上传入库关联 + doco 22 期批量导入回联
|
||||||
|
- [ ] **第三刀**:AI 处理服务 + 后台任务 + 进度轮询
|
||||||
|
- [ ] **第四刀**:审核表单 + 权限 + 看板 reviewed 过滤
|
||||||
|
- 每刀独立验收(PRD §7 七条验收标准,制片人真实点页面逐条验)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 已完成(只追加,最新在上)
|
||||||
|
|
||||||
|
- [2026-07-07 | Claude Fable] 立项:PRD v1.0 完成(含制片人三项拍板写入 §9)、子项目文件夹建立、主项目寄存条与 CLAUDE.md 登记。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ⏩ 交接备注
|
||||||
|
|
||||||
|
- **开发未启动**,等制片人排期。启动时从 PRD §8 第一刀开始。
|
||||||
|
- **第一刀 Cline 指令**已在 2026-07-07 主项目对话中封装好(Plan 模式:状态列+抽屉骨架+区块一+Response schema 补字段,不动 Model/不迁移)。若找不到原文,按 PRD §8 第一刀范围重新封装即可。
|
||||||
|
- **依赖提醒**:第三刀需 `backend/.env` 加 `MIMO_API_KEY`;后端确认 python-docx 依赖。
|
||||||
|
- **关联子项目**:doco(融合A稿上游,常态运行)、ai-labeling(Prompt 1-4 资产所在地,继续在那边迭代)。
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# PRD:期次一条龙录入(收视分析可持续化)v1.0
|
||||||
|
|
||||||
|
> 立项:2026-07-07 | 需求方:制片人 | 起草:Claude(顾问)
|
||||||
|
> 目标页面:责编录入(`frontend/src/pages/EditorDesk/EditorDesk.jsx`)
|
||||||
|
> 实施方式:Cline Plan + Opus 审 + 制片人批准 Act(含一次 schema 迁移,走既定纪律:迁移前 pg_dump 备份)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
看板升级的终极目标是"每做完一期节目,系统自动消化文稿和收视成绩,持续输出诊断"。但现在 25 期数据是一次性脚本导入的,往后每周新一期没有入口——收视数据、文稿入库、AI 打标、制片人审核、摘要卡生成五件事散在各处。
|
||||||
|
|
||||||
|
**本功能把这五件事收进责编录入页面,做成一条龙:责编录数据传文稿 → 系统自动 AI 处理 → 制片人审核标签 → 该期自动进入收视分析看板全部模块。**
|
||||||
|
|
||||||
|
不做成强制向导(收视数据播出次日才有、文稿可能更早就有),做成**每期一张任务清单**:五个环节各自独立完成、随时可补,页面上一眼看出"这期还差什么"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 每期五个环节(流水线定义)
|
||||||
|
|
||||||
|
| # | 环节 | 谁操作 | 做什么 | 完成判定(从现有字段推导,不加状态列) |
|
||||||
|
|---|------|--------|--------|--------------------------------------|
|
||||||
|
| ① | 基础信息+收视 | 责编 | 现有新增/编辑期次表单,收视份额可后补 | `audience_share` 非空 |
|
||||||
|
| ② | 文稿上传 | 责编 | 上传该期 **doco 融合A稿**(docx/md)→ 自动入知识库+算向量+关联期次 | `transcript_item_id` 非空(新列,见 §5) |
|
||||||
|
| ③ | AI 处理 | 责编或制片人 | 一键触发:打标(Prompt 1/2/3 → 6 个标签字段)+ 摘要卡(Prompt 4 → content_digest),后台串行跑 | `program_format` 非空 且 `content_digest` 非空 |
|
||||||
|
| ④ | 制片人审核 | **仅制片人** | 看 AI 草稿标签,逐项可改(下拉枚举),点「审核通过」 | `ai_label_confidence == 'reviewed'` |
|
||||||
|
| ⑤ | 完成 | — | 无操作,①-④全勾即完成,该期在看板全模块生效 | 前四项全满足 |
|
||||||
|
|
||||||
|
**环节依赖**:③ 依赖 ②(没文稿没法打标);④ 依赖 ③;①与②③无顺序要求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 页面设计(责编录入页改造)
|
||||||
|
|
||||||
|
### 3.1 「节目期次」Tab 升级为流水线视图
|
||||||
|
|
||||||
|
现有表格增加一列「流水线」,每行显示 4 个状态点(收视 / 文稿 / AI处理 / 已审核),完成=实心绿点,未完成=空心灰点。鼠标悬停显示环节名。
|
||||||
|
|
||||||
|
表格行新增「处理」按钮 → 打开该期的**流水线抽屉(Drawer)**。
|
||||||
|
|
||||||
|
### 3.2 流水线抽屉(核心新界面)
|
||||||
|
|
||||||
|
抽屉从右侧滑出(宽约 560px),从上到下四个区块,对应环节 ①-④:
|
||||||
|
|
||||||
|
**区块一:基础信息+收视**
|
||||||
|
- 只读展示期次号/节目名/播出日期/编导/收视份额,带「编辑」按钮打开现有编辑弹窗。收视份额为空时黄色提示"收视数据待补录"。
|
||||||
|
|
||||||
|
**区块二:文稿**
|
||||||
|
- 未上传:拖拽上传框(接受 .docx / .md,单文件,≤10MB)。上传后后端自动:解析文本 → 写入知识库(source_type=manuscript,author=该期编导快照)→ 调 MiniMax 算 1536 维向量 → 回写 `episodes.transcript_item_id`。
|
||||||
|
- 已上传:显示文件名+入库时间+「重新上传」按钮(重传=删旧知识库条目+走一遍新流程,需二次确认弹窗)。
|
||||||
|
|
||||||
|
**区块三:AI 处理**
|
||||||
|
- 前提未满足(无文稿):按钮置灰,提示"请先上传文稿"。
|
||||||
|
- 就绪:「开始 AI 处理」按钮。点击后后台任务串行跑 4 个 Prompt(分类/叙事/钩子/摘要卡),前端每 3 秒轮询进度,显示"打标中 2/4…"。全程预计 1-3 分钟,期间可关抽屉,不阻塞。
|
||||||
|
- 完成:展示 6 个标签结果 + 摘要卡生成时间;标签区显著标注 **「AI 草稿·待制片人审核」**(黄色 Tag)。
|
||||||
|
- 失败:显示错误信息 + 「重试」按钮(按 Prompt 粒度断点续跑,已成功的不重跑)。
|
||||||
|
|
||||||
|
**区块四:制片人审核**
|
||||||
|
- 仅制片人可见可操作(责编看到的是"待制片人审核"或"已审核"状态文字)。
|
||||||
|
- 6 个标签渲染为可编辑表单:program_format(单选下拉 6 值)、equipment_domain(多选 9 值)、scene_tags(多选 4 值)、tech_tags(多选 3 值)、narrative_structure(单选 2 值)、opening_hook(单选 3 值)。枚举值以 `ai-labeling/CLAUDE.md` §1.4 为准,前后端各存一份常量并对齐。
|
||||||
|
- 「审核通过」按钮:保存表单值 + 置 `ai_label_confidence='reviewed'`。
|
||||||
|
- 已审核后再次打开可继续修改并重新保存(保持 reviewed)。
|
||||||
|
- 已审核的期次若重跑 AI 处理,需二次确认:"该期标签已审核,重跑将覆盖为草稿状态,确定?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 后端设计
|
||||||
|
|
||||||
|
### 4.1 新增 API(`backend/app/api/pipeline.py`,前缀 `/api/pipeline`)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/episodes/{id}/status` | 三角色可读 | 返回该期四环节完成状态(从字段推导) |
|
||||||
|
| POST | `/episodes/{id}/transcript` | 责编+制片人 | 上传文稿:解析→知识库入库→embedding→回写关联 |
|
||||||
|
| DELETE | `/episodes/{id}/transcript` | 责编+制片人 | 删除关联文稿(重传前置步骤) |
|
||||||
|
| POST | `/episodes/{id}/ai-process` | 责编+制片人 | 启动后台 AI 处理任务,返回 task_id |
|
||||||
|
| GET | `/episodes/{id}/ai-process/status` | 三角色可读 | 轮询任务进度(pending/running 2/4/done/failed+错误信息) |
|
||||||
|
| POST | `/episodes/{id}/labels/review` | **仅制片人** | 保存审核后标签 + 置 reviewed |
|
||||||
|
|
||||||
|
### 4.2 AI 处理服务(`backend/app/services/labeling_service.py`,新建)
|
||||||
|
|
||||||
|
- 把 `ai-labeling/scripts/run_labeling.py` 和 `gen_content_digest.py` 的调用逻辑移植为后端服务函数(脚本本身保留不动,作为批量工具)。
|
||||||
|
- Prompt 文件继续从 `ai-labeling/prompts/` 读取(与 analytics.py 读 prompt5 的做法一致)——**Prompt 是活资产,继续在子项目里迭代,后端只读不复制**。
|
||||||
|
- 模型:打标+摘要卡用 **mimo-v2.5-pro**(生产模型,与子项目选型一致);`MIMO_API_KEY` 加入 `backend/.env`。
|
||||||
|
- 沿用子项目已验证的技术约束:剥 `<think>` 前缀正则、不用 `response_format=json_object`、打标关 thinking。
|
||||||
|
- 后台任务用 FastAPI `BackgroundTasks` + 内存任务表(与诊断报告内存缓存同款做法,1.0 不引入 celery/redis)。
|
||||||
|
- 打标结果落库时 `ai_label_confidence='draft'`。
|
||||||
|
|
||||||
|
### 4.3 文稿入库复用
|
||||||
|
|
||||||
|
- 复用 Phase 3 知识库上传/embedding 链路(`knowledge.py` 的入库+向量逻辑抽成可复用函数供 pipeline 调用)。
|
||||||
|
- docx→文本解析参考 `ai-labeling/scripts/import_transcripts.py`(python-docx,后端需确认依赖已装)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Schema 变更(005 迁移,需 Opus 审 + 制片人批准 + 迁移前 pg_dump)
|
||||||
|
|
||||||
|
```
|
||||||
|
005_add_transcript_link.sql
|
||||||
|
episodes 表 + 1 列:
|
||||||
|
transcript_item_id INTEGER NULL REFERENCES 知识库条目表(id) ON DELETE SET NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
- 可空软引用:文稿被从知识库删除时自动断开关联(SET NULL),期次不受影响——与"软引用+快照"既有哲学一致,**禁止级联删期次**。
|
||||||
|
- 同步更新 `backend/app/models/episode.py` 和 `schemas/episode.py`。
|
||||||
|
- **不加流水线状态列**——四环节状态全部从现有字段推导(见 §2 表),避免状态与事实不同步。
|
||||||
|
- 迁移同时做一次**存量数据回填**:现有 25 期中已导入 AI 标签的,`ai_label_confidence` 统一置 `'reviewed'`(这批标签源自制片人逐期审定的 ground-truth v0.6.0,视为已审)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与看板的联动规则(设计哲学红线落地)
|
||||||
|
|
||||||
|
- **看板象限图/题材对比等依赖 AI 标签的模块,只使用 `ai_label_confidence='reviewed'` 的期次**。草稿标签绝不进入编导可见的任何图表(红线:"未审核内容对编导显示待审核,绝不展示 AI 草稿值")。
|
||||||
|
- 走势图、指标卡等不依赖标签的模块照常显示全部期次(不受审核状态影响)。
|
||||||
|
- L4 诊断报告的数据组装同样只取 reviewed 期次的标签 + 摘要卡。
|
||||||
|
- v1 简化处理:未审核期次直接不出现在标签类图表中(不做按角色区分渲染),审核通过后自然出现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收标准(制片人真实点页面逐条验,不信自检)
|
||||||
|
|
||||||
|
1. 责编新增一期 + 填收视 → 流水线列显示 1/4 完成。
|
||||||
|
2. 上传一份真实融合A稿 docx → 知识库管理页能看到该文稿、语义可检索(向量已算)、流水线 2/4。
|
||||||
|
3. 点「开始 AI 处理」→ 3 分钟内 6 个标签+摘要卡就位,标签带"AI 草稿"黄标,流水线 3/4。
|
||||||
|
4. 用责编账号看抽屉 → 审核区不可操作;用制片人账号 → 可改标签、点审核通过 → 流水线 4/4。
|
||||||
|
5. 审核通过后刷新收视分析看板 → 该期出现在双引擎象限图和题材对比中;审核前不出现。
|
||||||
|
6. AI 处理中途断网/API 失败 → 状态显示失败+可重试,已成功的 Prompt 不重跑。
|
||||||
|
7. 重跑已审核期次 → 有二次确认,确认后标签回到草稿态、看板中该期从标签类图表消失。
|
||||||
|
|
||||||
|
## 8. 分期实施建议(给 Cline 排刀)
|
||||||
|
|
||||||
|
- **第一刀(纯前端+已有API)**:流水线状态列 + 抽屉骨架 + 区块一(复用现有表单)。
|
||||||
|
- **第二刀(后端)**:005 迁移 + 文稿上传入库关联(区块二)。**doco 22 期成品批量导入知识库+回联期次(§9.2)也挂这一刀**——迁移落地后立即有真实数据可验。
|
||||||
|
- **第三刀(后端+前端)**:AI 处理服务 + 后台任务 + 进度轮询(区块三)。
|
||||||
|
- **第四刀**:审核表单 + 权限 + 看板 reviewed 过滤(区块四 + §6)。
|
||||||
|
- 每刀独立可验收,任何一刀卡住不影响已上线部分。
|
||||||
|
|
||||||
|
## 9. 已拍板决定(制片人 2026-07-07)
|
||||||
|
|
||||||
|
1. **文稿口径 = doco 融合A稿**。理由(制片人):doco 以播出后唱词字幕 OCR 为最标准依据;CCA 是播出前生产唱词的工具,审片和播出后编导/责编还可能改,不是最终文稿。**由此确认 doco 转入常态运行:每期播出后跑一遍 doco 全流程产出融合A稿,作为环节②的固定上游。**
|
||||||
|
2. **旧期次文稿要回联期次**。doco 22 期批量导入知识库时,导入脚本一并回写 `transcript_item_id`。期次号与播出日期已在融合A稿文件名中注明(格式 `第XX期_YYYYMMDD_节目名_编导_融合A稿.docx`),可直接解析匹配。
|
||||||
|
3. **责编可触发「开始 AI 处理」**。系统日常维护以责编为主,权限按"责编+制片人"实施(即 §4.1 现设计,不改)。
|
||||||
@@ -10,6 +10,8 @@ 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 DiagnosisReport from './pages/Analytics/DiagnosisReport'
|
||||||
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 +33,8 @@ 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="analytics/report" element={<DiagnosisReport />} />
|
||||||
<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,242 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { generateDiagnosisReport } from '../../services/analyticsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 诊断报告摘要块 — 收视分析页内嵌
|
||||||
|
* - 根据 avgShare 判定三档 tier
|
||||||
|
* - 调用 DeepSeek 生成报告
|
||||||
|
* - 从 markdown 提取核心发现和行动建议
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 从 markdown 中提取指定章节内容(正则模糊匹配) */
|
||||||
|
function extractSection(markdown, keyword, endKeywords) {
|
||||||
|
if (!markdown) return ''
|
||||||
|
// 用正则匹配:任意数量的 # 开头,后面包含关键词的行
|
||||||
|
const startRegex = new RegExp(`^#{1,6}\\s*.*${keyword}.*$`, 'm')
|
||||||
|
const match = markdown.match(startRegex)
|
||||||
|
if (!match) return ''
|
||||||
|
const afterStart = match.index + match[0].length
|
||||||
|
// 找最近的结束标记(下一个同级或更高级标题)
|
||||||
|
let endIdx = markdown.length
|
||||||
|
if (endKeywords && endKeywords.length > 0) {
|
||||||
|
for (const kw of endKeywords) {
|
||||||
|
const endRegex = new RegExp(`^#{1,6}\\s*.*${kw}.*$`, 'm')
|
||||||
|
const endMatch = markdown.substring(afterStart).match(endRegex)
|
||||||
|
if (endMatch && (afterStart + endMatch.index) < endIdx) {
|
||||||
|
endIdx = afterStart + endMatch.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有 endKeywords 时,找下一个同级标题作为结束
|
||||||
|
const nextHeading = markdown.substring(afterStart).match(/^#{1,6}\s+/m)
|
||||||
|
if (nextHeading) {
|
||||||
|
endIdx = afterStart + nextHeading.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markdown.substring(afterStart, endIdx).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 markdown 文本按行拆分,保留 **bold** 为 <strong> */
|
||||||
|
function parseMarkdownLines(text) {
|
||||||
|
if (!text) return []
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^#+\s*/, '').trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将行内 **text** 转为 React 元素 */
|
||||||
|
function renderInlineMarkdown(text) {
|
||||||
|
const parts = text.split(/(\*\*[^*]+\*\*)/)
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
const boldMatch = part.match(/^\*\*(.+)\*\*$/)
|
||||||
|
if (boldMatch) {
|
||||||
|
return <strong key={i}>{boldMatch[1]}</strong>
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEFT_STYLE = {
|
||||||
|
color: '#c0584f',
|
||||||
|
bg: 'rgba(192,88,79,0.06)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_CONFIG = {
|
||||||
|
danger: {
|
||||||
|
leftTitle: '问题聚焦',
|
||||||
|
rightTitle: '病因与预警',
|
||||||
|
rightColor: '#7aa874',
|
||||||
|
rightBg: 'rgba(122,168,116,0.06)',
|
||||||
|
},
|
||||||
|
on_target: {
|
||||||
|
leftTitle: '核心发现',
|
||||||
|
rightTitle: '病因与提振建议',
|
||||||
|
rightColor: '#5b8db8',
|
||||||
|
rightBg: 'rgba(91,141,184,0.06)',
|
||||||
|
},
|
||||||
|
excellent: {
|
||||||
|
leftTitle: '高光复盘',
|
||||||
|
rightTitle: '经验总结',
|
||||||
|
rightColor: '#c0584f',
|
||||||
|
rightBg: 'rgba(192,88,79,0.06)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosisSummary({ episodes, yearlyTarget, selectedYear }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [report, setReport] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// 计算平均份额和 tier
|
||||||
|
const { avgShare, tier, epStart, epEnd } = useMemo(() => {
|
||||||
|
if (!episodes || episodes.length === 0 || !yearlyTarget) {
|
||||||
|
return { avgShare: 0, tier: null, epStart: 0, epEnd: 0 }
|
||||||
|
}
|
||||||
|
const withShare = episodes.filter((ep) => ep.audience_share != null)
|
||||||
|
if (withShare.length === 0) {
|
||||||
|
return { avgShare: 0, tier: null, epStart: 0, epEnd: 0 }
|
||||||
|
}
|
||||||
|
const sum = withShare.reduce((acc, ep) => acc + Number(ep.audience_share), 0)
|
||||||
|
const avg = sum / withShare.length
|
||||||
|
const base = Number(yearlyTarget.base_target)
|
||||||
|
const stretch = Number(yearlyTarget.stretch_target)
|
||||||
|
|
||||||
|
let t = 'danger'
|
||||||
|
if (avg > stretch) t = 'excellent'
|
||||||
|
else if (avg >= base) t = 'on_target'
|
||||||
|
|
||||||
|
const nums = withShare.map((ep) => ep.episode_number)
|
||||||
|
return {
|
||||||
|
avgShare: avg,
|
||||||
|
tier: t,
|
||||||
|
epStart: Math.min(...nums),
|
||||||
|
epEnd: Math.max(...nums),
|
||||||
|
}
|
||||||
|
}, [episodes, yearlyTarget])
|
||||||
|
|
||||||
|
const config = tier ? TIER_CONFIG[tier] : null
|
||||||
|
|
||||||
|
// 调用 API
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tier || !episodes || episodes.length === 0) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setReport(null)
|
||||||
|
|
||||||
|
generateDiagnosisReport({
|
||||||
|
year: selectedYear,
|
||||||
|
ep_start: epStart,
|
||||||
|
ep_end: epEnd,
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error)
|
||||||
|
} else {
|
||||||
|
setReport(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setError(err?.response?.data?.detail || '报告生成失败')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [selectedYear, epStart, epEnd, tier, episodes])
|
||||||
|
|
||||||
|
// 空态
|
||||||
|
if (!episodes || episodes.length === 0 || !tier) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取核心发现和行动建议
|
||||||
|
const markdown = report?.report_markdown || ''
|
||||||
|
// 核心发现:关键词匹配"核心发现",到"深度分析"或"三、"
|
||||||
|
let coreFindings = extractSection(markdown, '核心发现', ['深度分析', '三、'])
|
||||||
|
// 行动建议:关键词匹配"行动建议",到末尾
|
||||||
|
let actions = extractSection(markdown, '行动建议', [])
|
||||||
|
|
||||||
|
const coreLines = parseMarkdownLines(coreFindings)
|
||||||
|
const actionLines = parseMarkdownLines(actions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="analytics-chart-card">
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div className="diagnosis-summary-header">
|
||||||
|
<h2 className="analytics-chart-title" style={{ marginBottom: 0 }}>AI 诊断报告</h2>
|
||||||
|
{report && !loading && (
|
||||||
|
<span
|
||||||
|
className="diagnosis-summary-link"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/analytics/report?year=${selectedYear}&start=${epStart}&end=${epEnd}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看完整报告 →
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="diagnosis-loading">
|
||||||
|
<Spin size="small" />
|
||||||
|
<span style={{ marginLeft: 8 }}>正在生成 AI 诊断报告…</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="diagnosis-loading" style={{ color: '#c0584f' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : report ? (
|
||||||
|
<div className="diagnosis-summary-content">
|
||||||
|
{/* 左块(固定粉红色系) */}
|
||||||
|
<div
|
||||||
|
className="diagnosis-summary-left"
|
||||||
|
style={{ borderColor: LEFT_STYLE.color, background: LEFT_STYLE.bg }}
|
||||||
|
>
|
||||||
|
<div className="diagnosis-block-title" style={{ color: LEFT_STYLE.color }}>{config.leftTitle}</div>
|
||||||
|
{coreLines.length > 0 ? (
|
||||||
|
coreLines.map((line, i) => (
|
||||||
|
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>报告已生成,请查看完整报告</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右块(跟随 tier 变色) */}
|
||||||
|
<div
|
||||||
|
className="diagnosis-summary-right"
|
||||||
|
style={{ borderColor: config.rightColor, background: config.rightBg }}
|
||||||
|
>
|
||||||
|
<div className="diagnosis-block-title" style={{ color: config.rightColor }}>{config.rightTitle}</div>
|
||||||
|
{actionLines.length > 0 ? (
|
||||||
|
actionLines.map((line, i) => (
|
||||||
|
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>报告已生成,请查看完整报告</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiagnosisSummary
|
||||||
@@ -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,21 @@
|
|||||||
.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;
|
||||||
|
width: calc(100vw - 220px);
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
@@ -29,6 +37,8 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: var(--color-bg-cream);
|
background: var(--color-bg-cream);
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
max-width: 1400px;
|
width: 100%;
|
||||||
|
max-width: 1190px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,380 @@
|
|||||||
|
/* ===== 收视分析页面 — 对齐 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── AI 诊断报告摘要块 ── */
|
||||||
|
.diagnosis-summary-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left,
|
||||||
|
.diagnosis-summary-right {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #444;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left::-webkit-scrollbar,
|
||||||
|
.diagnosis-summary-right::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left::-webkit-scrollbar-thumb,
|
||||||
|
.diagnosis-summary-right::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0,0,0,0.12);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left::-webkit-scrollbar-track,
|
||||||
|
.diagnosis-summary-right::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left {
|
||||||
|
border-left: 3px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-right {
|
||||||
|
border-left: 3px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-block-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b8db8;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 响应式 ===== */
|
||||||
|
@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,623 @@
|
|||||||
|
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 DiagnosisSummary from '../../components/Analytics/DiagnosisSummary'
|
||||||
|
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 诊断报告 ── */}
|
||||||
|
<DiagnosisSummary
|
||||||
|
episodes={filteredEpisodes}
|
||||||
|
yearlyTarget={yearlyTarget}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 双引擎象限图 ── */}
|
||||||
|
<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
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/* ── AI 诊断报告详情页 ── */
|
||||||
|
.diagnosis-report-page {
|
||||||
|
max-width: 900px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 返回链接 ── */
|
||||||
|
.diagnosis-report-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5b8db8;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-back:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 头部信息 ── */
|
||||||
|
.diagnosis-report-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-header h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4a6741;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-header .diagnosis-report-range {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-header .diagnosis-report-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 指标条 ── */
|
||||||
|
.diagnosis-report-kpi-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-item .kpi-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-item .kpi-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 报告正文卡片 ── */
|
||||||
|
.diagnosis-report-body {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 36px 40px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
line-height: 1.9;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body h4 {
|
||||||
|
color: #4a6741;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 28px 0 12px 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #e8e4d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body h4:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body ul,
|
||||||
|
.diagnosis-report-body ol {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body th,
|
||||||
|
.diagnosis-report-body td {
|
||||||
|
border: 1px solid #e0ddd4;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body th {
|
||||||
|
background: #f5f3eb;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 重新生成按钮 ── */
|
||||||
|
.diagnosis-report-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-actions button {
|
||||||
|
background: #6b8e6b;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-actions button:hover {
|
||||||
|
background: #5a7d5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-actions button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 免责声明 ── */
|
||||||
|
.diagnosis-report-disclaimer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ── */
|
||||||
|
.diagnosis-report-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-loading p {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 错误 ── */
|
||||||
|
.diagnosis-report-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #c0584f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 响应式 ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.diagnosis-report-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import { generateDiagnosisReport } from '../../services/analyticsService'
|
||||||
|
import './DiagnosisReport.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 诊断报告详情页
|
||||||
|
* - 从 URL query 读取 year、start、end
|
||||||
|
* - 调用 generateDiagnosisReport(命中缓存则秒出)
|
||||||
|
* - 用 react-markdown 渲染完整报告
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TIER_COLOR = {
|
||||||
|
danger: '#7aa874',
|
||||||
|
on_target: '#5b8db8',
|
||||||
|
excellent: '#c0584f',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosisReport() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const year = Number(searchParams.get('year'))
|
||||||
|
const start = Number(searchParams.get('start'))
|
||||||
|
const end = Number(searchParams.get('end'))
|
||||||
|
|
||||||
|
const [report, setReport] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [regenerating, setRegenerating] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const fetchReport = (force = false) => {
|
||||||
|
if (!year || !start || !end) {
|
||||||
|
setError('缺少必要参数(year / start / end)')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
setRegenerating(true)
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
generateDiagnosisReport({ year, ep_start: start, ep_end: end, force })
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error)
|
||||||
|
} else {
|
||||||
|
setReport(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err?.response?.data?.detail || '报告生成失败')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
setRegenerating(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport(false)
|
||||||
|
}, [year, start, end])
|
||||||
|
|
||||||
|
// 摸高完成率
|
||||||
|
const stretchPct =
|
||||||
|
report?.avg_share && report?.highest
|
||||||
|
? null // 没有 stretch_target 直接信息,从 tier 推断
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 从 report 中计算摸高完成率(如果有 avg_share 和 episode_count)
|
||||||
|
// 实际上后端没返回 stretch_target,所以这里用 pass_count / episode_count 做达标率展示
|
||||||
|
const passRate =
|
||||||
|
report?.episode_count > 0
|
||||||
|
? `${report.pass_count}/${report.episode_count}`
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
const tierColor = report?.tier ? TIER_COLOR[report.tier] : '#999'
|
||||||
|
|
||||||
|
// 从 report_markdown 中提取第一句作为范围描述的补充
|
||||||
|
const startDate = report?.report_markdown
|
||||||
|
? '' // 已经在 header 中展示了期号范围
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="diagnosis-report-page">
|
||||||
|
<div className="diagnosis-report-loading">
|
||||||
|
<Spin size="large" />
|
||||||
|
<p>正在生成诊断报告,预计 15-30 秒…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="diagnosis-report-page">
|
||||||
|
<span
|
||||||
|
className="diagnosis-report-back"
|
||||||
|
onClick={() => navigate('/analytics')}
|
||||||
|
>
|
||||||
|
← 返回收视分析
|
||||||
|
</span>
|
||||||
|
<div className="diagnosis-report-error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diagnosis-report-page">
|
||||||
|
{/* 返回链接 */}
|
||||||
|
<span
|
||||||
|
className="diagnosis-report-back"
|
||||||
|
onClick={() => navigate('/analytics')}
|
||||||
|
>
|
||||||
|
← 返回收视分析
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="diagnosis-report-header">
|
||||||
|
<h1>收视诊断报告</h1>
|
||||||
|
<p className="diagnosis-report-range">
|
||||||
|
第{start}期 — 第{end}期 | {year}年 | 共{report?.episode_count}期
|
||||||
|
</p>
|
||||||
|
{report?.generated_at && (
|
||||||
|
<p className="diagnosis-report-date">
|
||||||
|
生成时间:{new Date(report.generated_at).toLocaleString('zh-CN')}
|
||||||
|
{report?.model && ` | 模型:${report.model}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 指标条 */}
|
||||||
|
<div className="diagnosis-report-kpi-bar">
|
||||||
|
<div className="diagnosis-report-kpi-item">
|
||||||
|
<div className="kpi-value" style={{ color: tierColor }}>
|
||||||
|
{report?.avg_share?.toFixed(4) || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="kpi-label">平均份额</div>
|
||||||
|
</div>
|
||||||
|
<div className="diagnosis-report-kpi-item">
|
||||||
|
<div className="kpi-value">{passRate}</div>
|
||||||
|
<div className="kpi-label">达标期数</div>
|
||||||
|
</div>
|
||||||
|
<div className="diagnosis-report-kpi-item">
|
||||||
|
<div className="kpi-value" style={{ color: '#c0584f' }}>
|
||||||
|
{report?.highest
|
||||||
|
? `${report.highest.share.toFixed(3)}(第${report.highest.ep}期)`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className="kpi-label">最高份额</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 报告正文 */}
|
||||||
|
<div className="diagnosis-report-body">
|
||||||
|
<Markdown>{report?.report_markdown || ''}</Markdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 重新生成 */}
|
||||||
|
<div className="diagnosis-report-actions">
|
||||||
|
<button onClick={() => fetchReport(true)} disabled={regenerating}>
|
||||||
|
{regenerating ? '正在重新生成…' : '重新生成'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 免责声明 */}
|
||||||
|
{report?.disclaimer && (
|
||||||
|
<p className="diagnosis-report-disclaimer">
|
||||||
|
⚠️ {report.disclaimer}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiagnosisReport
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
.dashboard {
|
.dashboard {
|
||||||
max-width: 1200px;
|
padding: 12px 0;
|
||||||
padding: 12px;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Banner ===== */
|
/* ===== Banner ===== */
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -115,6 +86,7 @@ function Dashboard() {
|
|||||||
const displayEpisodes = episodes
|
const displayEpisodes = episodes
|
||||||
.filter(e => e.audience_share != null)
|
.filter(e => e.audience_share != null)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
|
.reverse()
|
||||||
|
|
||||||
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
|
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import useAuthStore from '../../stores/authStore'
|
|||||||
import knowledgeService from '../../services/knowledgeService'
|
import knowledgeService from '../../services/knowledgeService'
|
||||||
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
||||||
|
|
||||||
|
// 渲染 snippet 中的 **keyword** 加粗标记(不使用 dangerouslySetInnerHTML)
|
||||||
|
function renderSnippet(text) {
|
||||||
|
if (!text) return null
|
||||||
|
const parts = text.split(/\*\*(.+?)\*\*/g)
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
// 奇数索引是被 ** 包裹的关键词
|
||||||
|
if (i % 2 === 1) {
|
||||||
|
return <strong key={i} style={{ color: '#1677ff' }}>{part}</strong>
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { Dragger } = Upload
|
const { Dragger } = Upload
|
||||||
|
|
||||||
// source_type 枚举值(固定五类,不写死但供 Select 用)
|
// source_type 枚举值(固定五类,不写死但供 Select 用)
|
||||||
@@ -38,6 +51,20 @@ export default function KnowledgeBase() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [searchResults, setSearchResults] = useState([])
|
const [searchResults, setSearchResults] = useState([])
|
||||||
const [searchLoading, setSearchLoading] = useState(false)
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
const [expandedIds, setExpandedIds] = useState(new Set()) // 展开全文的卡片 id 集合
|
||||||
|
|
||||||
|
// 切换卡片展开/收起
|
||||||
|
const toggleExpand = (id) => {
|
||||||
|
setExpandedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -287,8 +314,8 @@ export default function KnowledgeBase() {
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>
|
<span style={{ fontSize: 12, color: '#888' }}>
|
||||||
{searchResults.length > 0
|
{searchResults.length > 0
|
||||||
? `找到 ${searchResults.length} 条相关结果`
|
? `找到 ${searchResults.length} 条相关结果(相关度 > 30%)`
|
||||||
: '无相关结果'}
|
: '未找到相关内容,试试换个说法?'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,63 +326,110 @@ export default function KnowledgeBase() {
|
|||||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
|
||||||
搜索结果
|
搜索结果
|
||||||
</div>
|
</div>
|
||||||
{searchResults.map(item => (
|
{searchResults.map(item => {
|
||||||
<Card
|
const isHigh = item.similarity >= 0.7
|
||||||
key={item.id}
|
const isLow = item.similarity < 0.5
|
||||||
size="small"
|
const isExpanded = expandedIds.has(item.id)
|
||||||
style={{ borderRadius: 10, marginBottom: 8 }}
|
return (
|
||||||
bodyStyle={{ padding: '12px 16px' }}
|
<Card
|
||||||
>
|
key={item.id}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
size="small"
|
||||||
<div style={{ flex: 1 }}>
|
style={{
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: '#3b4a3b', marginBottom: 4 }}>
|
borderRadius: 10,
|
||||||
{item.title}
|
marginBottom: 8,
|
||||||
</div>
|
opacity: isLow ? 0.65 : 1,
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
background: isLow ? '#fafafa' : undefined,
|
||||||
<Tag color="default" style={{ borderRadius: 6, fontSize: 12 }}>
|
borderLeft: isHigh ? '3px solid #1677ff' : undefined,
|
||||||
{SOURCE_TYPE_LABEL[item.source_type] || item.source_type}
|
}}
|
||||||
</Tag>
|
bodyStyle={{ padding: '12px 16px' }}
|
||||||
{item.author && (
|
>
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>作者:{item.author}</span>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: '#3b4a3b', marginBottom: 4 }}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Tag color="default" style={{ borderRadius: 6, fontSize: 12 }}>
|
||||||
|
{SOURCE_TYPE_LABEL[item.source_type] || item.source_type}
|
||||||
|
</Tag>
|
||||||
|
{item.author && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>作者:{item.author}</span>
|
||||||
|
)}
|
||||||
|
{item.source_detail && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>出处:{item.source_detail}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#555',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderSnippet(item.snippet)}
|
||||||
|
</div>
|
||||||
|
{/* 展开全文区域 */}
|
||||||
|
{isExpanded && item.content_md && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#444',
|
||||||
|
background: '#fafafa',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '10px 12px',
|
||||||
|
marginTop: 8,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
maxHeight: 400,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.content_md}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.source_detail && (
|
{/* 展开/收起按钮 */}
|
||||||
<span style={{ fontSize: 12, color: '#888' }}>出处:{item.source_detail}</span>
|
{item.content_md && (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<span
|
||||||
|
onClick={() => toggleExpand(item.id)}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#1677ff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? '收起全文 ▲' : '展开全文 ▼'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#555',
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: 6,
|
|
||||||
padding: '6px 10px',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 3,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.snippet}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
marginLeft: 16,
|
marginLeft: 16,
|
||||||
minWidth: 56,
|
minWidth: 56,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
background: item.similarity >= 0.7 ? '#e6f4ff' : '#f5f5f5',
|
background: isHigh ? '#e6f4ff' : '#f5f5f5',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: item.similarity >= 0.7 ? '#1677ff' : '#666' }}>
|
<div style={{ fontSize: 18, fontWeight: 700, color: isHigh ? '#1677ff' : '#666' }}>
|
||||||
{Math.max(0, Math.round(item.similarity * 100))}%
|
{Math.max(0, Math.round(item.similarity * 100))}%
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
|
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 AI 诊断报告
|
||||||
|
* @param {Object} params - { year, ep_start, ep_end, force? }
|
||||||
|
* @returns {Promise<Object>} - { tier, avg_share, episode_count, pass_count, highest, lowest, report_markdown, generated_at, model, disclaimer }
|
||||||
|
*/
|
||||||
|
export async function generateDiagnosisReport(params) {
|
||||||
|
const response = await http.post('/analytics/diagnosis-report', params, {
|
||||||
|
timeout: 120000, // DeepSeek 可能需要 30-60 秒
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -56,9 +56,9 @@ const knowledgeService = {
|
|||||||
/**
|
/**
|
||||||
* 语义检索:输入一段文字,返回最相关的知识库条目
|
* 语义检索:输入一段文字,返回最相关的知识库条目
|
||||||
* @param {string} queryText - 查询文字
|
* @param {string} queryText - 查询文字
|
||||||
* @param {number} topK - 返回条数,默认 5
|
* @param {number} topK - 返回条数,默认 10
|
||||||
*/
|
*/
|
||||||
async searchItems(queryText, topK = 5) {
|
async searchItems(queryText, topK = 10) {
|
||||||
const resp = await http.post('/knowledge/search', {
|
const resp = await http.post('/knowledge/search', {
|
||||||
query: queryText,
|
query: queryText,
|
||||||
top_k: topK,
|
top_k: topK,
|
||||||
|
|||||||
@@ -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,79 @@
|
|||||||
|
# 寄存条:CCA 唱词助手子项目已外迁
|
||||||
|
|
||||||
|
> 留在主 project,让未来的 Claude 一眼知道:CCA 唱词助手模块已拉出去单独立项了。
|
||||||
|
> 外迁时间:2026-07-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外迁了什么
|
||||||
|
|
||||||
|
**CCA 唱词助手**(ChangCi Assist)整体作为子项目。
|
||||||
|
|
||||||
|
聚焦:编导 A 稿专有名词提取 → 讯飞 ASR 转写 → AI 校对比对 → 编导审稿台 → 拍词折行 → 大洋格式 SRT 输出。
|
||||||
|
|
||||||
|
目的:编导剪完节目后,半自动生成唱词字幕 SRT 文件,交责编上大洋系统。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主 project 不再讨论的话题
|
||||||
|
|
||||||
|
聊到下面这些词,**先问一句"这是 CCA 子项目的事还是主 project 的事?"**——是子项目的事,提醒制片人切到 CCA 子项目 chat。
|
||||||
|
|
||||||
|
关键词:cca / 唱词助手 / changci / SRT 字幕 / 拍词规则 / 编导审稿台 / 唱词校对 / ASR 转字幕 / 大洋字幕格式 / 折行规则 / 审稿对比
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主 project 仍在管什么
|
||||||
|
|
||||||
|
- Phase 0-3 已落地的主干代码(episodes、editors、收视诊断、知识库)
|
||||||
|
- backend 全部 schema、API、迁移脚本
|
||||||
|
- 前端全部 React 代码实施
|
||||||
|
- 主干 Bug、性能优化、新需求(CCA 以外的)
|
||||||
|
- CCA 成熟后并入 TPS 的对接方案(届时再定)
|
||||||
|
- 看板分析升级 / Doco 文稿整理等其他子项目
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 Doco 子项目的区别
|
||||||
|
|
||||||
|
| | Doco | CCA |
|
||||||
|
|---|---|---|
|
||||||
|
| **时间点** | 播出后 | 剪辑后、播出前 |
|
||||||
|
| **目的** | 整理终版文稿(存档/知识库) | 生成唱词字幕 SRT(上大洋播出) |
|
||||||
|
| **输入** | A稿 + 视频(含字幕+人声) | A稿 + 纯人声音频 |
|
||||||
|
| **输出** | 融合 A 稿 docx | SRT 字幕文件 |
|
||||||
|
| **ASR 角色** | 辅助参照 | 核心底稿(时间戳+内容基准) |
|
||||||
|
| **共同点** | 都用讯飞录音文件转写标准版 + 热词偏置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 接收子项目交付物的接口
|
||||||
|
|
||||||
|
CCA 最终要并入 TPS 主项目作为子功能。接口约定:
|
||||||
|
|
||||||
|
- **独立 Web 应用**:先在 lanhao 配音 2.0 部署测试
|
||||||
|
- **并入路径**:成熟后以子功能形式嵌入 TPS 前端,走主项目路由
|
||||||
|
- **凭证管理**:子项目自己 .env,主项目 `api_credentials_inventory.md` 登记元信息
|
||||||
|
|
||||||
|
**纪律**:
|
||||||
|
- 并入前,子项目不改主项目 backend 代码 / schema
|
||||||
|
- 并入方案由主项目侧审批
|
||||||
|
- 实施完成后回访子项目登记状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开局口径
|
||||||
|
|
||||||
|
进主 project 新 chat:
|
||||||
|
> "续接 TPS 主项目。读 `寄存条CCA唱词助手子项目已外迁.md` 和其他寄存条。当前要做 [具体任务]。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前外迁的子项目清单(随更新)
|
||||||
|
|
||||||
|
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 原型阶段收工(2026-07-03) |
|
||||||
|
| Doco 文稿整理 | 2026-06-12 | `寄存条Doco文稿整理子项目已外迁.md` | 开发收尾(2026-07-03) |
|
||||||
|
| CCA 唱词助手 | 2026-07-04 | 本文件 | 已部署腾讯云,内测中(2026-07-05) |
|
||||||
|
| 期次一条龙录入 | 2026-07-07 | `寄存条期次一条龙录入子项目已外迁.md` | 立项,PRD 就绪,待开发 |
|
||||||
@@ -102,5 +102,6 @@ Doco 子项目交付物的接口约定,见**子项目主 Brief §六**(交付什
|
|||||||
|
|
||||||
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|
||||||
| ---------------- | ---------- | -------------------------- | ------------------------- |
|
| ---------------- | ---------- | -------------------------- | ------------------------- |
|
||||||
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 设计阶段 |
|
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 原型阶段收工(2026-07-03) |
|
||||||
| Doco 文稿整理 | 2026-06-12 | 本文件 | 等子项目出 PRD v3 启动 P1 |
|
| Doco 文稿整理 | 2026-06-12 | 本文件 | **已交付(2026-07-07)**:22 期融合A稿已批量导入主项目知识库(含 embedding),转常态运行 |
|
||||||
|
| CCA 唱词助手 | 2026-07-04 | `寄存条CCA唱词助手子项目已外迁.md` | **立项,待开发** |
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 寄存条:期次一条龙录入子项目已外迁
|
||||||
|
|
||||||
|
> 留在主 project,让未来的 Claude 一眼知道:期次一条龙录入(收视分析持续运行入口)已单独立项。
|
||||||
|
> 外迁时间:2026-07-07
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外迁了什么
|
||||||
|
|
||||||
|
**期次一条龙录入**(episode-intake)作为子项目,目录 `episode-intake/`。
|
||||||
|
|
||||||
|
聚焦:责编录入页升级为每期流水线任务清单——收视录入 → doco 融合A稿入库(知识库+向量+回联期次)→ AI 打标+摘要卡(一键后台跑)→ 制片人审核标签 → 该期自动进收视分析看板全部模块。
|
||||||
|
|
||||||
|
目的:让看板升级的收视分析从"一次性导入"变成"每周持续生长",补上 L1 数据沉淀层的入口。
|
||||||
|
|
||||||
|
**状态:已立项,PRD v1.0 完成(含制片人三项拍板),未开发。** 方案全文见 `episode-intake/PRD_期次一条龙录入_v1.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主 project 不再讨论的话题
|
||||||
|
|
||||||
|
聊到下面这些词,**先问一句"这是期次一条龙子项目的事还是主 project 的事?"**——是子项目的事,提醒制片人切到对应 chat。
|
||||||
|
|
||||||
|
关键词:一条龙 / 期次录入流水线 / 流水线状态点 / 期次任务清单 / 文稿回联期次 / transcript_item_id / 005 迁移 / AI 处理按钮 / 标签审核台 / draft-reviewed 置信度流转 / 责编录入抽屉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主 project 仍在管什么
|
||||||
|
|
||||||
|
- Phase 0-3 已落地的主干代码、全部 backend schema/API/迁移、全部前端 React 实施
|
||||||
|
- 责编录入页**现有功能**的 bug/维护(期次增删改、年度目标、Excel 批量导入)
|
||||||
|
- 收视分析看板 L1-L4 已上线功能的维护
|
||||||
|
- 其他子项目(看板升级 / doco / CCA)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特殊说明:本子项目实施时落在主干
|
||||||
|
|
||||||
|
与 doco/CCA 不同,本子项目**没有独立部署物**——外迁的是方案讨论和 PRD 迭代,实施时直接改主干(EditorDesk 前端 + backend + 005 迁移)。因此实施阶段纪律:
|
||||||
|
|
||||||
|
- 走主干规矩:Cline Plan + Opus 审 + 制片人批准 Act
|
||||||
|
- **005 迁移前必须 pg_dump 备份**
|
||||||
|
- 分四刀实施(见 PRD §8),每刀独立验收,制片人真实点页面
|
||||||
|
|
||||||
|
## 已拍板决定(2026-07-07,别翻案)
|
||||||
|
|
||||||
|
1. **文稿口径 = doco 融合A稿**(CCA 是播出前工具,之后还会改,不是最终文稿)。doco 转入常态运行,每期播出后跑一遍。
|
||||||
|
2. **doco 22 期批量导入知识库时回联期次**(文件名含期次号/播出日期,解析匹配),挂第二刀。
|
||||||
|
3. **责编可触发 AI 处理**(日常维护以责编为主)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开局口径
|
||||||
|
|
||||||
|
进主 project 新 chat:
|
||||||
|
> "续接 TPS 主项目。读 `寄存条期次一条龙录入子项目已外迁.md` 和其他寄存条。当前要做 [具体任务]。"
|
||||||
|
|
||||||
|
启动本子项目开发:
|
||||||
|
> "续接期次一条龙录入子项目。读 `episode-intake/CLAUDE.md` 和 PRD。从第 N 刀开始。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前外迁的子项目清单(随更新)
|
||||||
|
|
||||||
|
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| TPS 看板分析升级 | 2026-06-12 | `寄存条看板升级已外迁.md` | 原型收工,L1-L4 已在主干实现(2026-07-06) |
|
||||||
|
| Doco 文稿整理 | 2026-06-12 | `寄存条Doco文稿整理子项目已外迁.md` | 开发收尾(2026-07-03),转常态运行(每期播出后跑) |
|
||||||
|
| CCA 唱词助手 | 2026-07-04 | `寄存条CCA唱词助手子项目已外迁.md` | 已部署腾讯云,内测中(2026-07-05) |
|
||||||
|
| 期次一条龙录入 | 2026-07-07 | 本文件 | **立项,PRD 就绪,待开发** |
|
||||||
Generated
+1290
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,78 @@
|
|||||||
|
"""
|
||||||
|
一次性脚本:将 AI 生成的内容摘要卡写入 episodes 表的 content_digest 字段
|
||||||
|
数据来源:ai-labeling/experiments/content_digests/_all_digests.json
|
||||||
|
前置:先手动执行 backend/sql/004_add_content_digest.sql 添加字段
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# Windows 终端中文输出
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
# 加 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 # FK 解析需要
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
# ── 1. 读 digest 数据 ──
|
||||||
|
project_root = os.path.join(os.path.dirname(__file__), '..')
|
||||||
|
digest_file = os.path.join(project_root, 'ai-labeling', 'experiments', 'content_digests', '_all_digests.json')
|
||||||
|
|
||||||
|
with open(digest_file, 'r', encoding='utf-8') as f:
|
||||||
|
all_digests = json.load(f)
|
||||||
|
|
||||||
|
print(f"读入 {len(all_digests)} 条摘要记录")
|
||||||
|
|
||||||
|
# ── 2. 逐条匹配并写入 ──
|
||||||
|
session = next(get_session())
|
||||||
|
success = 0
|
||||||
|
skipped = 0
|
||||||
|
mismatch = 0
|
||||||
|
|
||||||
|
for d in all_digests:
|
||||||
|
ep_num = d['ep']
|
||||||
|
digest_date = d['date'] # "2026-04-21" 格式
|
||||||
|
|
||||||
|
# 查 episode
|
||||||
|
ep = session.exec(select(Episode).where(Episode.episode_number == ep_num)).first()
|
||||||
|
if not ep:
|
||||||
|
print(f"⚠️ ep{ep_num:02d} 在数据库中不存在,跳过")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 校验日期
|
||||||
|
if str(ep.air_date) != digest_date:
|
||||||
|
print(f"⚠️ ep{ep_num:02d} 日期不匹配: DB={ep.air_date}, digest={digest_date},跳过")
|
||||||
|
mismatch += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 写入(只存 digest 字段,不存外壳的 ep/date/title/editor/filename/generated_at)
|
||||||
|
ep.content_digest = d['digest']
|
||||||
|
session.add(ep)
|
||||||
|
success += 1
|
||||||
|
print(f"✅ ep{ep_num:02d} {ep.program_name} <- digest 已写入")
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# ── 3. 打印统计 ──
|
||||||
|
print()
|
||||||
|
print(f"=== 统计 ===")
|
||||||
|
print(f"成功写入: {success}")
|
||||||
|
print(f"跳过(不存在): {skipped}")
|
||||||
|
print(f"跳过(日期不匹配): {mismatch}")
|
||||||
|
print(f"总记录: {len(all_digests)}")
|
||||||
|
|
||||||
|
# ── 4. 验证:所有 episodes 的 content_digest 状态 ──
|
||||||
|
print()
|
||||||
|
print(f"=== 验证: episodes content_digest 状态 ===")
|
||||||
|
all_eps = session.exec(select(Episode).order_by(Episode.episode_number)).all()
|
||||||
|
for e in all_eps:
|
||||||
|
has_digest = "✅" if e.content_digest is not None else "❌"
|
||||||
|
print(f"ep{e.episode_number:02d} | {e.program_name} | {e.air_date} | digest: {has_digest}")
|
||||||
@@ -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