82 Commits

Author SHA1 Message Date
simonkoson 9a86c936ee chore: gitignore 忽略前端测试题图截图 2026-05-27 19:53:26 +08:00
simonkoson 9acb9d380d docs: 新增 claude 协作原则文件,backlog 待办池入库 2026-05-27 19:51:41 +08:00
simonkoson 47c9d60f02 docs: phase3_log 收尾,Task 3/4 记录归位,删除合并后遗留散文件 2026-05-27 19:51:31 +08:00
simonkoson cd4dcd06f8 docs: 宪法升级至 v5,TPS 定位为对话式策划助手 + README 进度校准 2026-05-27 19:51:23 +08:00
simonkoson 783e212bb1 feat: 知识库朴素语义搜索(输入→检索→结果列表) 2026-05-27 19:25:41 +08:00
simonkoson 0c7d2d7400 fix: type_groups动态收集,空大类从根上不产生节点 2026-05-27 15:00:52 +08:00
simonkoson 70784fc70b fix: 过滤按大类用对应字段比对,修复左鑫节点无法筛选问题 2026-05-27 14:53:11 +08:00
simonkoson 347a06e48b fix: 回退Grid布局恢复flex并列,保留二级分组映射,空大类隐藏 2026-05-27 14:35:32 +08:00
simonkoson fff2cfe395 fix: 知识库key格式统一、过滤逻辑修复、布局宽度稳定 2026-05-27 14:06:16 +08:00
simonkoson a00bcd6c73 feat: 知识库树形视图调整:大类固定排序、节目文稿按作者二级分组、左右Grid双列对齐 2026-05-27 13:54:14 +08:00
simonkoson b3157216bb style: 知识库页面三处样式收尾:内容区扩展、隐藏入库时间列、上传区收小 2026-05-27 13:21:04 +08:00
simonkoson d849bc1539 fix: 修复知识库页面三处问题:出处下拉为空、宽度跳动、表格列宽优化 2026-05-27 13:06:06 +08:00
simonkoson 74a0508755 feat: 知识库树形视图(按来源分组),左侧树导航+右侧列表联动 2026-05-27 09:35:36 +08:00
simonkoson 7999fd14c8 docs: 更新 phase3_log Task 2 完工记录 2026-05-26 18:59:39 +08:00
simonkoson 5ef1b8ec8d feat: 知识库管理前端页面,含上传/列表/筛选/删除 2026-05-26 18:52:34 +08:00
simonkoson 855f103ce8 feat: 知识库管理后台上传/列表/删除API,含frontmatter解析 2026-05-26 18:51:12 +08:00
simonkoson d81969b4b5 feat: 添加 python-frontmatter 依赖用于解析 md yaml frontmatter 2026-05-26 18:48:48 +08:00
simonkoson 04a35faba3 chore(phase3): 补提交 Task1 日志/清理脚本 + 栏目logo,忽略密码重置脚本 2026-05-26 15:24:31 +08:00
simonkoson 1325807257 feat(phase3): Task1 embedding链路验证 - embo-01(1536维)+pgvector检索打通 2026-05-26 10:33:25 +08:00
simonkoson d40d46a434 fix: TargetOutlined换为LineChartOutlined避免白屏 2026-05-25 14:23:44 +08:00
simonkoson 2881b866a6 feat: KPI细条新增两项完成率 + 侧边菜单加两个占位项 2026-05-25 13:45:00 +08:00
simonkoson 72ae73764a feat: 仪表盘视觉优化-居中/logo放大/柱图数字挪顶/节目名加长 2026-05-25 13:38:33 +08:00
simonkoson 51a34e9ec6 feat: 添加栏目 logo 素材 2026-05-25 09:56:15 +08:00
simonkoson 303995a93f fix: dashboard.py 顶层 import CoverSettings,解决运行时 500 2026-05-25 09:55:05 +08:00
simonkoson d774370a4b feat: 仪表盘布局重排,新增题图上传能力 2026-05-25 09:47:21 +08:00
simonkoson a6e8cc25ec feat: 新增题图上传接口和排播接口 2026-05-25 09:44:34 +08:00
simonkoson 8299b6bbc9 feat: 新增 cover_settings 表用于仪表盘题图设置 2026-05-25 09:42:25 +08:00
simonkoson 02bf4c4fe4 docs: phase2_log 第十三~十四章——Task 3 收官 + Phase 2 整体收官 2026-05-22 19:28:14 +08:00
simonkoson d55fde7fb5 chore: .gitignore 新增 cookies*.txt 忽略规则 2026-05-22 18:50:35 +08:00
simonkoson a2f1710a7f style: 柱图三色换成降饱和柔和版,柔蓝/柔绿/柔红,图例同步 2026-05-22 18:44:14 +08:00
simonkoson 59f556d986 fix: episodes GET 接口放开给三角色,POST/PATCH/DELETE 保持仅 zhipianren/zebian 2026-05-22 18:27:28 +08:00
simonkoson fcaa0969a8 fix: AppLayout 未挂载——路由改为两层 Outlet 嵌套,AuthGuard 不再包裹 AppLayout 2026-05-22 18:15:31 +08:00
simonkoson f55030b971 fix: SideNav 侧边栏不显示,.app-sider 去掉 fixed 改 flex 布局 2026-05-22 13:04:45 +08:00
simonkoson 24b9bb2d08 fix: 三个必修 bug——Dashboard 颜色判反+legend 错+SideNav zhipianren 用户管理漏显 2026-05-21 20:45:48 +08:00
simonkoson 247268a60e fix: Dashboard.jsx 修复 limit 未定义 ReferenceError 2026-05-21 20:29:50 +08:00
simonkoson f0f9dc493e feat: UserManage 完整用户 CRUD,创建/编辑/停用,角色下拉用拼音值 2026-05-21 19:47:54 +08:00
simonkoson e0ccfb20e7 feat: Dashboard 接真 API,题图渐变占位,排播占位,近9期收视柱图,颜色按年度目标判定 2026-05-21 19:47:36 +08:00
simonkoson 4eca31f8eb feat: SideNav 按 role 过滤菜单,/users 仅 zhipianren,/editor-desk 仅 zhipianren+zebian 2026-05-21 19:47:21 +08:00
simonkoson 3942f01def feat: App.jsx 加入 RoleGuard,/users 仅 zhipianren,/editor-desk 仅 zhipianren+zebian 2026-05-21 19:47:01 +08:00
simonkoson b7560a6cf6 feat: 添加三个 service 文件和 RoleGuard 组件 2026-05-21 19:46:40 +08:00
simonkoson 7cc0bf21ff feat: yearly_targets 写权限收紧,12步冒烟全绿 2026-05-20 11:29:28 +08:00
simonkoson 93fd327b13 docs: phase2_log 追加 Task 2 收尾记录(2026-05-20) 2026-05-20 10:58:36 +08:00
simonkoson 932985feca chore: backups/ 进 .gitignore(Task 2 收尾) 2026-05-20 10:41:03 +08:00
simonkoson ff0471a6be docs: Task 2 完工记录,8步全绿,坑7/8/纪律3/Q1-Q4澄清写入phase2_log 2026-05-19 20:29:30 +08:00
simonkoson f4661863a7 feat: 完善批量导入逻辑,8步冒烟全绿 2026-05-19 20:05:15 +08:00
simonkoson 14c9841b35 docs: phase2_log 追加 Task 2 进行中坑与纪律警告 2026-05-18 11:27:21 +08:00
simonkoson c9e527ff5b chore: 补录 002a 诊断脚本入库,修正 git_workflow.md 1.1 节路径 2026-05-18 11:00:59 +08:00
simonkoson 2480d4a738 feat: Task 2 Excel 批量导入 API 完成(模板下载 + 批量导入 + 失败行下载) 2026-05-15 13:40:13 +08:00
simonkoson 9439c72d30 fix: 002b 改用 date_part 修复表达式索引语法,002c 补救专用 2026-05-15 13:37:38 +08:00
simonkoson 3102fbec6c chore: 新增 pandas + openpyxl 依赖(Task 2 Excel 批量导入用) 2026-05-15 13:23:24 +08:00
simonkoson b3667aa7b9 fix: 修复 episodes + yearly_targets 模型导入缺失,增 DELETE 接口,冒烟 6/6 全绿 2026-05-15 13:05:58 +08:00
simonkoson 773441bb87 docs: Task 1 完工记录,Swagger UI 验收清单,跨 Phase 遗留事项 2026-05-15 12:36:11 +08:00
simonkoson 3dc1a1cf3c feat: 后端 CRUD 完成(episodes + yearly_targets + users 管理 API) 2026-05-15 12:17:46 +08:00
simonkoson fab15505bd docs: 合入第十三章 Opus task brief 节制原则,版本升至 v5 2026-05-15 10:27:13 +08:00
simonkoson 347ec1b13a docs: 更新 phase1_log.md 前端骨架完工记录 2026-05-15 09:48:39 +08:00
simonkoson d29b1bb394 feat: frontend skeleton done 2026-05-15 09:46:42 +08:00
simonkoson 2bee0ce8c8 docs: git_workflow.md 第 2/3 章路径残留 Z:\AIworks → E:\tps-dashboard 修正 2026-05-15 09:35:17 +08:00
simonkoson 0b141239cc docs: 仪表盘收视柱图统一为近 9 期(制片人 2026-05-15 拍板) 2026-05-15 09:32:03 +08:00
simonkoson 483e34b4f6 chore: 清理 indexUI 中文文件名空格与重复字符 2026-05-15 09:30:59 +08:00
simonkoson 5217d09044 docs: migration_2026-05-14.md 追加最终对齐摘要 2026-05-14 20:11:13 +08:00
simonkoson 3fa00ea393 docs: 阶段 2 最终对齐 — 修正宪法文档过时模型分工与修订版本号 2026-05-14 20:09:53 +08:00
simonkoson d8d04790fd docs: migration_2026-05-14.md 追加阶段 2 执行摘要 2026-05-14 19:42:00 +08:00
simonkoson a43a3e48a3 chore: .stignore 加入 .gitignore(极空间 NAS 同步工具产物) 2026-05-14 19:41:01 +08:00
simonkoson 2da78975e6 docs: 全仓库修正制片人姓名 刘统→刘通(遗漏处) 2026-05-14 19:40:02 +08:00
simonkoson a53017dd58 docs: 更新 git_workflow.md 第七章标注废弃并更新版本演进至 v2 2026-05-14 19:24:31 +08:00
simonkoson 048f8bc158 docs: 更新 README.md 版本演进至 v2 并同步模型分工与联系表 2026-05-14 19:23:32 +08:00
simonkoson 31fb490b6d docs: 更新 dev_plan.md 第五章选型表与第七章 Phase 2 段落 2026-05-14 19:22:36 +08:00
simonkoson f2f38f2b45 docs: 更新 dev_plan.md 版本演进至 v4 2026-05-14 19:21:55 +08:00
simonkoson 58e1183d05 docs: 重写 project_plan.md 第十章为 MiniMax 全程当前状态 2026-05-14 19:21:14 +08:00
simonkoson 7fa06c6a37 docs: 更新 project_plan.md 版本演进至 v4 2026-05-14 19:20:33 +08:00
simonkoson 99c1139fbf docs: 重写 .clinerules 第二章模型分工为 MiniMax 全程当前状态 2026-05-14 19:19:55 +08:00
simonkoson 6c7d88d063 docs: 更新 .clinerules 版本演进至 v4 2026-05-14 19:18:28 +08:00
simonkoson ab1a152dda docs: 添加 2026-05-14 迁移日志 2026-05-14 14:42:45 +08:00
simonkoson c266a5de5c chore: 移除不再使用的 pydantic-settings 依赖 2026-05-14 14:40:04 +08:00
simonkoson 061e07f842 chore: 后端 SSD 迁移后启动验证通过 2026-05-14 14:07:16 +08:00
simonkoson 1179246f2d fix: config.py .env 路径修复(从 cwd 漂移改为 __file__ 定位)+ 手工读取 env 替代 pydantic-settings 失效问题 2026-05-14 14:06:55 +08:00
simonkoson c0a23c2610 chore: 添加前端设计参考图到 indexUI/ 2026-05-14 13:15:54 +08:00
simonkoson d20548b846 docs: phase1_log 追加后端跑通记录与两个新坑(UTF-8 编码 / 交互式输入) 2026-05-11 11:41:20 +08:00
simonkoson a9d1b0e416 feat: Phase 1 后端骨架主体入库(补 backend/app 主体与 requirements.txt)
上轮 3 次 commit 只入库了修补文件,主体目录漏入库。本次补入 backend/app/、backend/requirements.txt、backend/scripts/__init__.py。同步在 .gitignore 加 homePC/(制片人家用机带过来的参考文件,不进库)。
2026-05-11 10:43:18 +08:00
simonkoson 110e26f752 docs: 更新 phase1_log,记录一致性审查结果并立项 .env.example 2026-05-11 10:37:17 +08:00
simonkoson 906936a2d1 feat: episodes.editor_name_snapshot 加 NOT NULL 约束 2026-05-11 10:35:10 +08:00
simonkoson 75691c8496 fix: 修正 create_admin.py 调用注释为 py -3.12 2026-05-11 10:34:32 +08:00
114 changed files with 12206 additions and 137 deletions
+39 -29
View File
@@ -2,8 +2,8 @@
> 项目:TPS 中台(央视《军事科技》栏目内部工作台)
> 仓库:`tps-dashboard`(Gitea,团队可见)
> 修订版本:v3 · 2026-05-06
> 适用对象:Cline + Sonnet(Plan)+ DeepSeek pro(Act),所有协作者必读
> 修订版本:v5 · 2026-05-15
> 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读
---
@@ -25,33 +25,23 @@
| 角色 | 模型 / 工具 | 职责 |
|---|---|---|
| 制片人 | 刘(人) | 拍板、审核、领域知识权威、视觉把关 |
| 制片人 | 刘(人) | 拍板、审核、领域权威、视觉把关 |
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 |
| **架构师(Phase 0-1)** | **Sonnet 4.6**(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
| **架构师(Phase 2 起)** | **MiniMax M2.7**(Cline Plan 模式)| **Sonnet 余额耗尽后接班 Plan,API Key 已 ready** |
| 执行者 | DeepSeek v4 pro(Cline Act 模式) | 实际写代码,**全程不变** |
| 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
| 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 |
| 备选执行者 | DeepSeek v4 flash | 仅日常小改(改字段、加字段、改文案) |
**模型阶段性安排(2026-05-06 拍板)**:
**当前模型配置(2026-05-14 拍板)**:
- **Phase 0-1(骨架期)**:**Plan = Sonnet 4.6**,Act = DeepSeek pro
- **Phase 2 起**:**Plan = MiniMax M2.7**(标准版,非 highspeed),Act 仍 DeepSeek pro
- **切换的核心动机**:Sonnet 单次调用昂贵且 Plan 模式调用频次最高,换 MiniMax M2.7 后单次成本降至 1/50
- DeepSeek 全程在岗,不退役
- **Plan 模式**:MiniMax M2.7(标准版,非 highspeed)
- **Act 模式**:MiniMax M2.7
- 家用机已退役,唯一开发环境为单位 4090D(E:\tps-dashboard)
- API 配置:`base_url = https://api.minimaxi.com/v1`,Model ID = `MiniMax-M2.7`
**MiniMax M2.7 接入信息(配置在 Cline 的 Plan 模式)**:
**模型降级口诀**:
- 官方 API:`base_url = https://api.minimaxi.com/v1`(注意是 minimaxi)
- Model ID:`MiniMax-M2.7`(标准版)
- **不用** highspeed 版(贵一倍,Plan 模式不需要那点速度)
- API Key 已由制片人申请就绪
**模型切换的判断口诀**(Phase 2 起):
- Plan 出方案、审 Act 改动 → **MiniMax M2.7**
- Act 写新代码 / 建结构 / 改架构 → **DeepSeek pro**
- Act 改小 bug / 加字段 / 改文案 → **DeepSeek flash**(可选)
- Plan 连续两次方案质量明显下降 → **临时切回 Sonnet 顶住**(允许)
- Plan 连续两次方案质量明显下降 → **临时切回 claude.ai 找 Opus 4.7 重新规划,不在 Cline 里换模型**
- Act 连续两次实现质量明显下降 → **优先切回 claude.ai 找 Opus 4.7 审 Plan,看是不是任务拆得太大;如果是小改任务,可临时切到备选执行者 DeepSeek v4 flash;不在 Cline 里硬撑**
---
@@ -59,8 +49,8 @@
### 3.1 Plan → Act → 完工三阶段
1. **Plan 阶段**:Sonnet 出方案,**复杂方案先发 Opus 4.7 审一遍**,审过后再切 Act
2. **Act 阶段**:DeepSeek pro 严格按 Plan 实施,**禁止自作主张扩展范围**
1. **Plan 阶段**:MiniMax M2.7 出方案,**复杂方案先发 Opus 4.7 审一遍**,审过后再切 Act
2. **Act 阶段**:MiniMax M2.7 严格按 Plan 实施,**禁止自作主张扩展范围**
3. **完工阶段**:Act 必须给出"完工报告",列出所有改动文件、设计决策、潜在风险
### 3.2 哪些事 Act 必须停下让 Plan 审
@@ -120,7 +110,7 @@ Phase 内的子任务、修复独立 bug、前端骨架、后端骨架——都
### 5.2 年度目标只增不改(时间不变性)
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**:
- 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标"
- 2024 年播的节目永远按 2024 年的 0.6448 / 0.8989 判,2026 年回看也不会变色
@@ -134,7 +124,7 @@ SQL 端:`yearly_targets.year` 加 UNIQUE 约束,代码端 INSERT 时用 `ON CONF
- **在职编导**:两字段都填(冗余但稳)
- **离职编导**:`editor_id` 留空(NULL),只填 `editor_name_snapshot`(如"张颖")
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照,不报错不丢数据
- **查询某编导历史作品**:**先按 editor_id,失败回退按 editor_name_snapshot 文本匹配**
DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。
@@ -157,6 +147,11 @@ AI Prompt 模板必须默认走双段、备选纯叙述。docxtpl 模板设计
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
**yearly_targets 写权限收紧(2026-05-20)**:
- POST/PATCH: zhipianren + zebian 可写,biandao 返回 403
- DELETE: 仅 zhipianren 可操作,zebian 和 biandao 返回 403
- GET: 三角色均可读
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
---
@@ -350,7 +345,7 @@ allow_origins=["http://localhost:5173"] # 必须显式列,不能用 ["*"]
**单位机器 Phase 1 后端可选路径**:
- **路径 A**:从 0 重写(Sonnet + DeepSeek 重新生成)
- **路径 A**:从 0 重写(MiniMax M2.7 重新生成所有后端文件)
- **路径 B**(推荐):用户把家用机的 backend 目录打包 zip 拷到单位机,解压进新仓库,Cline 跑一遍跑通即可
由制片人在 Phase 1 启动时拍板选 A 或 B。
@@ -376,11 +371,26 @@ Cline 内的 Sonnet/DeepSeek 不直接与 Opus 通信,流程如下:
|---|---|---|
| v1 | 2026-04 | 初版,定模型分工与栏目业务约定 |
| v2 | 2026-05-04 | 加技术栈精简版、Phase 拆分 |
| **v3** | **2026-05-06** | **加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗** |
| v3 | 2026-05-06 | 加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗 |
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D(E:\tps-dashboard);Phase 2 起 MiniMax M2.7 正式日常使用;config.py 改为 dotenv 直接加载,pydantic-settings 从 requirements.txt 移除 |
| **v5** | **2026-05-15** | **合入第十三章"Opus 4.7 起草 task brief 的节制原则";Phase 2 正式开工** |
**修订原则**:.clinerules 是宪法不是变更日志,改动应慎重。临时执行细节进 phase{N}_log.md,不进本文件。
---
## 十三、Opus 4.7 起草 task brief 的节制原则
Opus 4.7 给 MiniMax 写 task brief 时遵守:
- **一屏内为目标**(约 60-80 行,不超过 100 行)
- **写四类东西**:任务范围、核心约束、业务/纪律红线、验收清单
- **不写四类东西**:代码片段、目录结构细节、变量名、字段命名建议
- 把"怎么实现"留给 MiniMax Plan 模式自己长出来,Opus 只审 Plan 不替 Plan
- 自检口诀:这条是品味判断还是代码细节?品味判断留,代码细节砍
- 例外:业务红线里涉及具体字段名(如 `audience_share` / `editor_id` / `editor_name_snapshot` / `yearly_targets.year`)必须点名,因为写错字段名就是写错业务
---
*本文件最高优先级。任何冲突以本文件为准。*
*下次修订建议时机:Phase 2 末(评估 MiniMax 切换、整理 Phase 2 经验)。*
+21 -5
View File
@@ -13,23 +13,39 @@ dist/
build/
.vite/
# 环境配置(永远不上传)
# 莽聨呕暮藰聝茅聟聧莽藵沤(膰掳赂膷偶聹盲赂聧盲赂聤盲藕聽)
.env
.env.local
# 编辑器
# 莽藕聳膷啪聭暮聶篓
.vscode/
.idea/
*.swp
# 操作系统
# 膰聯聧盲藵聹莽艂钮莽钮聼
.DS_Store
Thumbs.db
# 日志运行产物
# 膰聴慕暮偶聴膷偶聬膷膭聦盲艧搂莽聣艩
*.log
cookie.txt
cookies*.txt
# 临时文件
# 盲赂麓膰聴艣膰聳聡盲钮艣
*.tmp
*.bak
homePC/
# Syncthing (膰聻聛莽艩艧茅聴麓 NAS 暮聬聦膰颅慕暮藝慕暮聟藝盲艧搂莽聣艩)
.stignore
# 臉媒啪脻偶芒膮赂藝脻 / SQL dump(藳钮藵艡掳膰膮啪偶芒)
backups/
# 鏁忔劅鑴氭湰(姘镐笉涓婁紶)
backend/scripts/reset_password.py
# 前端测试/占位图片(题图上传测试产物)
frontend/src/assets/*.jpg
frontend/src/assets/*.jpeg
frontend/src/assets/*.png
+34 -11
View File
@@ -10,7 +10,7 @@
一个面向《军事科技》栏目 8 人团队的内部工作台,核心模块包括:
- **TPS**(选题策划系统):查重 + 知识库参考 + 报题单一键生成
- **仪表盘**:近 4 期收视成绩 + 未来 6 期排期一目了然
- **仪表盘**:近 9 期收视成绩 + 未来 6 期排期一目了然
- **知识库**:往期文稿、军报材料、专家库、外拍线索一站式检索
- **个人首页**:每位编导有自己的工作台 + 今日热点雷达
- **拍摄资源库**:外拍联系人 / 单位 / 线索来源结构化沉淀
@@ -47,10 +47,11 @@
| Phase | 状态 |
|---|---|
| Phase 0 环境 + Git | 单位机进行中 |
| Phase 1 后端骨架 | 家用机已完成,单位机走"路径 B 迁移" |
| Phase 1 前端骨架 | 未开始 |
| Phase 2-5 | 未开始 |
| Phase 0 环境 + Git | ✅ 已完成 |
| Phase 1 后端骨架 + 前端骨架 | ✅ 已完成 |
| Phase 2 仪表盘 + 批量导入 + 写权限收紧 | ✅ 已完成 |
| Phase 3 知识库基础设施 | 进行中 — 已完成至 Task 3(知识库树形视图按来源分组),语义搜索尚未做 |
| Phase 45 | 未开始 |
---
@@ -58,15 +59,35 @@
| 角色 | 谁 / 什么 | 做什么 |
|---|---|---|
| 制片人 | 刘(人) | 拍板、产品决策、领域知识 |
| 制片人 | 刘(人) | 拍板、产品决策、领域知识 |
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、关键决策审核 |
| 架构师 | Sonnet 4.6 → MiniMax M2.7(Phase 2 起) | Cline Plan 模式,出方案 |
| 执行者 | DeepSeek v4 pro | Cline Act 模式,写代码 |
| 架构师 | MiniMax M2.7 | Cline Plan 模式,出方案 |
| 执行者 | MiniMax M2.7 | Cline Act 模式,写代码 |
| 备选执行者 | DeepSeek v4 flash | 仅日常小改(改字段、加字段、改文案) |
详见 `.clinerules` 第二章。
---
## 角色权限表
> 数据来源:`.clinerules` 5.5 节 + `project_plan.md` 第四节
| 资源 / 操作 | zhipianren(制片人) | zebian(责编) | biandao(编导) |
|---|---|---|---|
| **yearly_targets** | | | |
| GET 列表/详情 | ✅ | ✅ | ✅ |
| POST 新增 | ✅ | ✅ | ❌ 403 |
| PATCH 修改 | ✅ | ✅ | ❌ 403 |
| DELETE 删除 | ✅ | ❌ 403 | ❌ 403 |
| **episodes** | | | |
| GET | ✅ | ✅ | ✅ |
| POST/PATCH/DELETE | ✅ | ✅ | 受限(见 API 文档) |
> 规则:.clinerules 5.5 角色枚举锁定拼音值(zhipianren / zebian / biandao),不许新增枚举。
---
## 技术栈速览
- 前端:React 18 + Vite + Ant Design 5 + Zustand(JavaScript,不用 TS)
@@ -153,10 +174,10 @@ uvicorn app.main:app --reload
| 状况 | 找谁 |
|---|---|
| 项目方向 / 产品决策 | 刘(制片人) |
| 项目方向 / 产品决策 | 刘(制片人) |
| 关键架构 / 风险 / 卡壳兜底 | Claude Opus 4.7(claude.ai 网页) |
| 日常方案 / 改动审查 | Sonnet 4.6(VS Code Cline Plan) |
| 写代码 | DeepSeek v4 pro(VS Code Cline Act) |
| 日常方案 / 改动审查 | MiniMax M2.7(VS Code Cline Plan) |
| 写代码 | MiniMax M2.7(VS Code Cline Act) |
| Git 历史搞乱了 / 不小心 commit 了 .env | **立刻找 Claude Opus 4.7,不要自己瞎搞** |
---
@@ -166,6 +187,8 @@ uvicorn app.main:app --reload
| 版本 | 日期 | 变化 |
|---|---|---|
| v1 | 2026-05-06 | 初版,作为单位机仓库门牌 |
| **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
| **v3** | **2026-05-27** | **进度校准:Phase 1/2 标为完成;Phase 3 Task 3 知识库树形视图已完成;清理"家用机/路径B"历史表述,不再保留迁移叙事** |
---
View File
View File
+46
View File
@@ -0,0 +1,46 @@
"""
认证路由 — 登录 / 登出 / 当前用户
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlmodel import Session, select
from app.core.deps import get_current_user
from app.core.security import verify_password
from app.db.session import get_session
from app.models.user import User
from app.schemas.auth import LoginRequest, UserResponse
router = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/login", response_model=UserResponse)
def login(body: LoginRequest, request: Request, session: Session = Depends(get_session)):
"""账号登录。验证成功后写入 session,返回用户信息。"""
statement = select(User).where(User.username == body.username)
user = session.exec(statement).first()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
if not verify_password(body.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
request.session["user_id"] = user.id
return user
@router.post("/logout")
def logout(request: Request):
"""退出登录,清空当前 session。"""
request.session.clear()
return {"message": "已退出登录"}
@router.get("/me", response_model=UserResponse)
def get_me(user: User = Depends(get_current_user)):
"""获取当前登录用户的信息。"""
return user
+121
View File
@@ -0,0 +1,121 @@
"""
仪表盘 API — 题图上传 / 查询
权限:
- POST /cover — 仅 zhipianren / zebianbiandao 返回 403
- GET /cover — 三角色均可读
"""
import os
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.models.cover_settings import CoverSettings
router = APIRouter(prefix="/api/dashboard", tags=["仪表盘"])
# 题图文件存放目录(相对于 backend/)
STATIC_COVERS_DIR = Path(__file__).parent.parent.parent / "static" / "covers"
STATIC_COVERS_URL = "/static/covers"
# 确保目录存在
STATIC_COVERS_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
def require_upload_role():
return require_role(UserRole.zhipianren, UserRole.zebian)
@router.get("/cover")
def get_current_cover(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""查询当前题图设置(三角色均可读)。"""
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if not row:
return {"cover_path": None, "episode_number": None, "episode_title": None}
return {
"cover_path": row.cover_path,
"episode_number": row.episode_number,
"episode_title": row.episode_title,
}
@router.post("/cover")
def upload_cover(
file: UploadFile = File(...),
episode_number: int = Form(...),
episode_title: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_upload_role()),
):
"""上传题图(仅制片人/责编)。
文件存 static/covers/{uuid}.{ext},更新 cover_settings 表。
"""
# 校验文件类型
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"仅支持 PNG/JPG/WEBP,当前:{file.content_type}",
)
# 校验文件大小
contents = file.file.read()
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件大小不能超过 5MB",
)
file.file.seek(0)
# 生成文件名
ext = file.filename.split(".")[-1] if "." in file.filename else "jpg"
safe_ext = ext.lower() if ext.lower() in {"png", "jpg", "jpeg", "webp"} else "jpg"
filename = f"{uuid.uuid4().hex}.{safe_ext}"
filepath = STATIC_COVERS_DIR / filename
# 写文件
with open(filepath, "wb") as f:
f.write(contents)
cover_path = f"{STATIC_COVERS_URL}/{filename}"
now = datetime.now()
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if row:
row.cover_path = cover_path
row.episode_number = episode_number
row.episode_title = episode_title
row.updated_at = now
else:
row = CoverSettings(
key="dashboard_cover",
cover_path=cover_path,
episode_number=episode_number,
episode_title=episode_title,
updated_at=now,
)
session.add(row)
session.commit()
return {"success": True, "cover_path": cover_path}
+103
View File
@@ -0,0 +1,103 @@
"""
节目期次 API — 增删改查(责编和管理员可操作)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.episode import Episode
from app.models.user import User, UserRole
from app.schemas.episode import EpisodeCreate, EpisodeResponse, EpisodeUpdate
router = APIRouter(prefix="/api/episodes", tags=["节目期次"])
def require_read_role():
"""GET 接口的鉴权:三个角色都可读"""
return require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)
@router.get("", response_model=list[EpisodeResponse])
def list_episodes(
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session),
current_user: User = Depends(require_read_role()),
):
"""获取节目期次列表(三角色都可读)。按播出日期倒序。"""
statement = select(Episode).order_by(Episode.air_date.desc()).offset(offset).limit(limit)
episodes = session.exec(statement).all()
return episodes
@router.get("/{episode_id}", response_model=EpisodeResponse)
def get_episode(
episode_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_read_role()),
):
"""根据 ID 获取单期节目(三角色都可读)。"""
episode = session.get(Episode, episode_id)
if episode is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在")
return episode
@router.post("", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED)
def create_episode(
body: EpisodeCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""新增一期节目(仅管理员/责编)。"""
# 检查期次号唯一
existing = session.exec(
select(Episode).where(Episode.episode_number == body.episode_number)
).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="期次号已存在")
episode = Episode.model_validate(body.model_dump())
session.add(episode)
session.commit()
session.refresh(episode)
return episode
@router.patch("/{episode_id}", response_model=EpisodeResponse)
def update_episode(
episode_id: int,
body: EpisodeUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""更新一期节目信息(仅管理员/责编)。"""
episode = session.get(Episode, episode_id)
if episode is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(episode, field, value)
session.add(episode)
session.commit()
session.refresh(episode)
return episode
@router.delete("/{episode_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_episode(
episode_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""删除一期节目(仅管理员/责编)。"""
episode = session.get(Episode, episode_id)
if episode is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在")
session.delete(episode)
session.commit()
return None
+175
View File
@@ -0,0 +1,175 @@
"""
Excel 批量导入 API — 模板下载 + 批量导入 + 失败行下载
"""
import io
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlmodel import Session
from app.core.deps import require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.schemas.imports import ImportError, ImportResult
from app.services.excel_service import ExcelService, generate_error_excel
router = APIRouter(prefix="/api/imports", tags=["批量导入"])
# ============================================================================
# 内存失败行存储(进程生命周期,重启后丢失)
# 仅用于 Phase 2,生产 Phase 4+ 替换为 Redis 或 DB
# ============================================================================
_error_store: dict[str, list[dict]] = {}
# ============================================================================
# 模板下载
# ============================================================================
@router.get("/template")
def download_template(
type: str = Query("episodes", description="模板类型,仅支持 episodes"),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""下载 episodes 批量导入 Excel 模板。"""
if type != "episodes":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Phase 2 不支持排期模板下载",
)
wb = Workbook()
ws = wb.active
ws.title = "节目期次导入模板"
# 列说明(第1行)
headers = [
"episode_number", "program_name", "air_date",
"editor_name", "audience_share", "audience_rating",
"is_rerun", "notes",
]
ws.append(headers)
# 第2行:示例数据
sample = [
1, "《军事科技》武器解析系列", "2026-01-15",
"张颖", "0.72", "3.5",
"", "首期播出",
]
ws.append(sample)
# 第3行:说明注释(灰色字体)
from openpyxl.styles import Font, PatternFill, Alignment
grey_font = Font(color="808080")
note_row = [
"期次号(整数,每年从1开始)",
"节目名称",
"播出日期(YYYY-MM-DD",
"编导姓名(快照,匹配不上则只存姓名)",
"收视份额(0-1之间的小数,可空)",
"收视率(可空)",
"是否重播(是/否),Phase 2 不支持重播导入",
"备注",
]
ws.append(note_row)
for cell in ws[len(headers)]:
cell.font = grey_font
# 第4行:is_rerun 说明
rerun_note = ["", "", "", "", "", "", "Phase 2 不支持重播导入,有重播行请空着", ""]
ws.append(rerun_note)
for cell in ws[4]:
cell.font = grey_font
# 设置列宽
ws.column_dimensions["A"].width = 16
ws.column_dimensions["B"].width = 28
ws.column_dimensions["C"].width = 14
ws.column_dimensions["D"].width = 16
ws.column_dimensions["E"].width = 16
ws.column_dimensions["F"].width = 16
ws.column_dimensions["G"].width = 40
ws.column_dimensions["H"].width = 20
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=episodes_import_template.xlsx"},
)
# ============================================================================
# 批量导入
# ============================================================================
@router.post("/episodes", response_model=ImportResult)
def import_episodes(
file: UploadFile = File(..., description="Excel 文件(.xlsx"),
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""批量导入节目期次 Excel。"""
if not file.filename.endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持 .xlsx 格式,请使用模板下载获得正确格式",
)
content = file.file.read()
service = ExcelService(session)
try:
result = service.import_episodes(content)
except ValueError as e:
msg = str(e)
if "年度目标" in msg:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
if "重复" in msg or "库中记录重复" in msg:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=msg)
# 其他解析/校验错误
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
except Exception as e:
# 捕获 generate_error_excel 等意外错误,避免 500 扩散
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"导入失败:{str(e)}")
# 存储失败行(供后续下载)
if result["errors"]:
_error_store[result["batch_id"]] = result["errors"]
result["error_excel_url"] = f"/api/imports/errors/{result['batch_id']}"
return result
# ============================================================================
# 失败行下载
# ============================================================================
@router.get("/errors/{batch_id}")
def download_error_excel(
batch_id: str,
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""下载指定批次的失败行 Excel(供责编修正后重新导入)。"""
errors = _error_store.get(batch_id)
if errors is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="批次不存在或已过期(进程重启后数据丢失),请重新上传",
)
content = generate_error_excel(errors)
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=import_errors_{batch_id}.xlsx"},
)
+132
View File
@@ -0,0 +1,132 @@
"""
知识库 API — 上传 / 列表 / 删除 / 来源筛选 / 语义搜索
"""
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status
from sqlmodel import Session
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.services.knowledge_service import KnowledgeService
router = APIRouter(prefix="/api/knowledge", tags=["知识库"])
@router.post("/upload")
async def upload_md_files(
files: list[UploadFile] = File(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""
上传单个或多个 .md 文件,解析 frontmatter,写入知识库(含向量)。
仅制片人/责编可用。
"""
svc = KnowledgeService()
results = []
errors = []
for f in files:
if not f.filename.endswith(".md"):
errors.append({"file": f.filename, "error": "仅支持 .md 文件"})
continue
try:
content = await f.read()
item = svc.store_md_file(content, f.filename)
results.append({
"id": item.id,
"title": item.title,
"source_type": item.source_type,
})
except Exception as e:
errors.append({"file": f.filename, "error": str(e)})
return {"uploaded": results, "errors": errors}
@router.get("/items")
def list_knowledge_items(
source_type: Optional[str] = Query(None),
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
知识库列表,支持按 source_type 筛选。三角色均可读。
"""
svc = KnowledgeService()
items = svc.list_items(source_type=source_type)
return items
@router.delete("/items/{item_id}")
def delete_knowledge_item(
item_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""
删除知识库条目(级联删除向量)。仅制片人/责编可用。
"""
svc = KnowledgeService()
deleted = svc.delete_item(item_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="条目不存在")
return {"message": "删除成功"}
@router.get("/sources")
def list_distinct_sources(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
返回库里所有不重复的具体出处(source_detail),供来源筛选下拉动态生成。
三角色均可读。
"""
svc = KnowledgeService()
sources = svc.get_distinct_sources()
return [{"source": s} for s in sources]
@router.get("/grouped")
def get_grouped_knowledge_items(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
返回按 source_type → source_detail 两层聚合的树形结构,
含「全部」根节点,供知识库树形导航使用。
三角色均可读。
"""
svc = KnowledgeService()
return svc.get_grouped_items()
class SearchRequest(BaseModel):
query: str
top_k: int = 5
@router.post("/search")
def search_knowledge(
body: SearchRequest,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""
语义检索:输入一段文字,返回最相关的知识库条目及相似度。
查询向量用 type="query"(区分于存入时的 type="db")。
三角色均可读。
"""
svc = KnowledgeService()
results = svc.search_similar(query_text=body.query, top_k=body.top_k)
return {
"results": results,
"query": body.query,
"count": len(results),
}
+70
View File
@@ -0,0 +1,70 @@
"""
排期 API — 查询未来排播计划
降级逻辑:
- schedules 表有数据 → 从 schedules 取未来 limit 条 JOIN episodes
- schedules 表无数据 → 从 episodes 表取最近 limit 期待播期次(air_date 升序)
"""
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.deps import get_current_user
from app.db.session import get_session
from app.models.user import User
from app.models.episode import Episode
from app.models.schedule import Schedule
router = APIRouter(prefix="/api/schedules", tags=["排期"])
@router.get("/upcoming")
def list_upcoming_schedules(
limit: int = 6,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""获取未来排播计划(三角色均可读)。
优先从 schedules 表取,schedules 无数据时降级取 episodes 最近待播期次。
"""
# 先查 schedules
statement = (
select(Schedule, Episode)
.join(Episode, Schedule.episode_id == Episode.id, isouter=True)
.where(Schedule.planned_air_date >= Episode.air_date)
.order_by(Schedule.planned_air_date.asc())
.limit(limit)
)
results = session.exec(statement).all()
# 降级:从 episodes 直接取(当 schedules 为空或不足时)
if not results or len(results) < limit:
fallback_stmt = (
select(Episode)
.order_by(Episode.air_date.asc())
.limit(limit)
)
episodes = session.exec(fallback_stmt).all()
return [
{
"episode_number": ep.episode_number,
"program_name": ep.program_name,
"planned_air_date": str(ep.air_date),
"editor_name_snapshot": ep.editor_name_snapshot,
}
for ep in episodes
]
# schedules 有数据
output = []
for schedule, episode in results:
if episode is None:
continue
output.append({
"episode_number": episode.episode_number,
"program_name": episode.program_name,
"planned_air_date": str(schedule.planned_air_date),
"editor_name_snapshot": episode.editor_name_snapshot,
})
return output
+106
View File
@@ -0,0 +1,106 @@
"""
用户管理 API — 管理员 CRUD(仅 zhipianren 可操作)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.core.security import hash_password
from app.db.session import get_session
from app.models.user import User, UserRole
from app.schemas.user_admin import UserCreate, UserResponse, UserUpdate
router = APIRouter(prefix="/api/users", tags=["用户管理"])
@router.get("", response_model=list[UserResponse])
def list_users(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""获取所有用户列表(仅管理员)。"""
statement = select(User).order_by(User.id)
users = session.exec(statement).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""根据 ID 获取单个用户(仅管理员)。"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return user
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
body: UserCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""创建新用户(仅管理员)。"""
# 检查用户名唯一
existing = session.exec(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
user = User(
username=body.username,
display_name=body.display_name,
password_hash=hash_password(body.password),
role=body.role,
profile_text=body.profile_text,
)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.patch("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
body: UserUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""更新用户信息(仅管理员)。可更新 display_name/role/profile_text/is_active。"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def deactivate_user(
user_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""停用用户(软删除,仅管理员)。"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
# 不能停用自己的账号
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用自己的账号")
user.is_active = False
session.add(user)
session.commit()
return None
+112
View File
@@ -0,0 +1,112 @@
"""
年度收视目标 API — 增删改查
业务规则(.clinerules 5.2 只增不改):
- yearly_targets.year 有 UNIQUE 约束
- INSERT 时 year 冲突 → 直接报错,阻止静默覆盖
- 禁止 ON CONFLICT UPDATE
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.models.yearly_target import YearlyTarget
from app.schemas.yearly_target import YearlyTargetCreate, YearlyTargetResponse, YearlyTargetUpdate
router = APIRouter(prefix="/api/yearly_targets", tags=["年度目标"])
@router.get("", response_model=list[YearlyTargetResponse])
def list_targets(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""获取所有年度目标(三角色均可读)。"""
statement = select(YearlyTarget).order_by(YearlyTarget.year.desc())
targets = session.exec(statement).all()
return targets
@router.get("/{target_id}", response_model=YearlyTargetResponse)
def get_target(
target_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""根据 ID 获取单个年度目标(三角色均可读)。"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
return target
@router.post("", response_model=YearlyTargetResponse, status_code=status.HTTP_201_CREATED)
def create_target(
body: YearlyTargetCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""新增年度目标(仅管理员/责编)。
注意:year 有 UNIQUE 约束,重复年份会直接报错。
这是预期行为——历史年份目标不可覆盖。
"""
# 先查是否已存在(让报错更友好,而不是数据库层直接崩溃)
existing = session.exec(select(YearlyTarget).where(YearlyTarget.year == body.year)).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{body.year} 年目标已存在,历史年份不可覆盖。请新增其他年份。",
)
target = YearlyTarget.model_validate(body.model_dump())
session.add(target)
session.commit()
session.refresh(target)
return target
@router.patch("/{target_id}", response_model=YearlyTargetResponse)
def update_target(
target_id: int,
body: YearlyTargetUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""更新年度目标(仅管理员/责编)。
注意:此接口仅用于修正错字等小修,year 字段不可修改。
"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
update_data = body.model_dump(exclude_unset=True)
# 禁止修改 year 字段
update_data.pop("year", None)
for field, value in update_data.items():
setattr(target, field, value)
session.add(target)
session.commit()
session.refresh(target)
return target
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_target(
target_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""删除年度目标(仅制片人)。"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
session.delete(target)
session.commit()
return None
View File
+36
View File
@@ -0,0 +1,36 @@
"""
应用配置 — 手动从 .env 读取,不依赖 pydantic-settings 自动 env_file
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# 强制加载 backend/.env 到 os.environ(用 __file__ 定位脚本所在目录,避免 cwd 漂移)
# __file__ = e:\tps-dashboard\backend\app\core\config.py → parent.parent.parent = e:\tps-dashboard\backend
_env_path = Path(__file__).parent.parent.parent / ".env"
load_dotenv(str(_env_path), override=True)
# 直接从 os.environ 读取,手动兜底
_DATABASE_URL = os.environ.get("DATABASE_URL")
_SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-to-a-random-string-in-production")
_SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
# MiniMax Embedding API 凭证
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
# 验证必需配置
if not _DATABASE_URL:
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
# 简单命名空间对象(替代 pydantic-settings,避免 env 读取失效)
class Settings:
DATABASE_URL: str = _DATABASE_URL
SECRET_KEY: str = _SECRET_KEY
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
settings = Settings()
+56
View File
@@ -0,0 +1,56 @@
"""
FastAPI 依赖注入工具 — 获取当前用户、角色鉴权
"""
from fastapi import Depends, HTTPException, Request, status
from sqlmodel import Session
from app.db.session import get_session
from app.models.user import User, UserRole
def get_current_user(
request: Request,
session: Session = Depends(get_session),
) -> User:
"""从 Session 中取 user_id,查库返回当前用户。
未登录或用户不存在 / 已停用时抛出 401。
"""
user_id = request.session.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
user = session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已被停用",
)
return user
def require_role(*allowed_roles: UserRole):
"""返回一个 Depends 依赖,要求当前用户拥有指定的角色之一。
使用示例:
@router.get("/admin-only")
def admin_endpoint(user=Depends(require_role(UserRole.zhipianren))):
...
@router.get("/staff")
def staff_endpoint(user=Depends(require_role(UserRole.zhipianren, UserRole.zebian))):
...
"""
def check_role(user: User = Depends(get_current_user)) -> User:
if user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,无法执行此操作",
)
return user
return check_role
+17
View File
@@ -0,0 +1,17 @@
"""
认证安全工具 — passlib + bcrypt
"""
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""用 bcrypt 生成密码哈希。"""
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""验证明文密码与哈希是否匹配。"""
return pwd_context.verify(plain, hashed)
View File
+20
View File
@@ -0,0 +1,20 @@
"""
数据库会话 — SQLModel 同步引擎 + Session 工厂
"""
from sqlmodel import Session, SQLModel, create_engine
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL, echo=False)
def init_db():
"""创建所有 SQLModel 表(仅开发用,生产由手写 SQL 管理)。"""
SQLModel.metadata.create_all(engine)
def get_session():
"""FastAPI 依赖注入: 每个请求获取一个 Session,用完后关闭。"""
with Session(engine) as session:
yield session
+58
View File
@@ -0,0 +1,58 @@
"""
FastAPI 应用入口 — 挂载中间件、路由
"""
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.api.auth import router as auth_router
from app.api.episodes import router as episodes_router
from app.api.imports import router as imports_router
from app.api.users import router as users_router
from app.api.yearly_targets import router as yearly_targets_router
from app.api.dashboard import router as dashboard_router
from app.api.schedules import router as schedules_router
from app.api.knowledge import router as knowledge_router
from app.core.config import settings
app = FastAPI(title="军事科技工作台", version="0.1.0")
# ---- CORS 中间件 ----
# 显式列 origins 不能用 ['*'],否则 credentials=True 时浏览器拒绝携带 cookie
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---- Session 中间件 ----
# 密钥来自 .env,用于签名 session cookie
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
session_cookie="milsci_session",
max_age=settings.SESSION_MAX_AGE,
same_site="lax",
https_only=False, # 本地开发用 HTTP
)
# ---- 注册路由 ----
app.include_router(auth_router)
app.include_router(episodes_router)
app.include_router(imports_router)
app.include_router(yearly_targets_router)
app.include_router(users_router)
app.include_router(dashboard_router)
app.include_router(schedules_router)
app.include_router(knowledge_router)
# 挂载静态文件目录(题图海报)
_static_dir = Path(__file__).parent.parent / "static"
if _static_dir.exists():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
View File
+28
View File
@@ -0,0 +1,28 @@
"""
题图设置模型 — SQLModel
只存当前展示的一张题图,不动 episodes 表。
"""
from datetime import datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class CoverSettings(SQLModel, table=True):
__tablename__ = "cover_settings"
id: int | None = Field(default=None, primary_key=True)
key: str = Field(default="dashboard_cover", max_length=50)
cover_path: str | None = Field(default=None) # 当前题图文件路径
episode_number: int | None = Field(default=None) # 关联期次号
episode_title: str | None = Field(default=None) # 关联期次节目名
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+65
View File
@@ -0,0 +1,65 @@
"""
节目期次模型 — SQLModel
业务规则(.clinerules 5.3 软引用 + 快照模式):
- editor_id 可空(离职编导)
- editor_name_snapshot 必填(历史导入也走快照)
- 查询历史作品:先 editor_id,回退 editor_name_snapshot 文本匹配
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Text
from sqlalchemy import Boolean as SABoolean
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Numeric as SANumeric
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class Episode(SQLModel, table=True):
__tablename__ = "episodes"
id: int | None = Field(default=None, primary_key=True)
episode_number: int = Field(unique=True, index=True)
program_name: str = Field(max_length=200)
air_date: date
# 编导(软引用 + 快照双字段)
# 在职编导:editor_id 和 editor_name_snapshot 都填
# 离职编导:editor_id 留空,editor_name_snapshot 填姓名
editor_id: int | None = Field(default=None, foreign_key="users.id")
editor_name_snapshot: str = Field(max_length=100)
# 收视数据
# 注意:.clinerules 5.1 颜色只对 audience_share 上色
# audience_rating 只展示数字,不参与颜色判定
audience_share: float | None = Field(default=None)
audience_rating: float | None = Field(default=None)
# 重播相关
is_rerun: bool = Field(default=False)
original_episode_id: int | None = Field(default=None, foreign_key="episodes.id")
is_time_sensitive: bool = Field(default=False)
# 外拍信息
is_outdoor_shoot: bool = Field(default=False)
outdoor_contact_person: str | None = Field(default=None, max_length=100)
outdoor_contact_info: str | None = Field(default=None, max_length=200)
# 线索
lead_source: str | None = Field(default=None, max_length=300)
# 责编备注
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+55
View File
@@ -0,0 +1,55 @@
"""
知识库模型 — SQLModel
对应 knowledge_items 和 knowledge_embeddings 两张表
embedding 字段使用 pgvector.Vector(对应 PG vector(1536)
"""
from datetime import datetime, date
from typing import Optional, Any
from sqlalchemy import Column, DateTime as SADateTime, Text, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
from pgvector.sqlalchemy import Vector
class KnowledgeItem(SQLModel, table=True):
"""知识库条目(knowledge_items"""
__tablename__ = "knowledge_items"
id: Optional[int] = Field(default=None, primary_key=True)
title: str = Field(max_length=300)
content_md: Optional[str] = Field(default=None)
source_type: str = Field(default="manual", max_length=30)
source_file_name: Optional[str] = Field(default=None, max_length=300)
source_url: Optional[str] = Field(default=None, max_length=1000)
author: Optional[str] = Field(default=None, max_length=100)
publish_date: Optional[date] = Field(default=None)
tags: Any = Field(default=None, sa_column=Column(JSONB, default=[]))
related_entities: Any = Field(default=None, sa_column=Column(JSONB, default=[]))
related_concepts: Any = Field(default=None, sa_column=Column(JSONB, default=[]))
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
class KnowledgeEmbedding(SQLModel, table=True):
"""知识库向量(knowledge_embeddings"""
__tablename__ = "knowledge_embeddings"
id: Optional[int] = Field(default=None, primary_key=True)
knowledge_id: int = Field(foreign_key="knowledge_items.id", index=True)
chunk_index: int = Field(default=0)
chunk_text: str = Field(sa_column=Column(Text, nullable=False))
embedding: Any = Field(sa_column=Column(Vector(1536), nullable=False))
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+33
View File
@@ -0,0 +1,33 @@
"""
排期模型 — SQLModel
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Date as SADate
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class Schedule(SQLModel, table=True):
__tablename__ = "schedules"
id: int | None = Field(default=None, primary_key=True)
episode_id: int | None = Field(default=None, foreign_key="episodes.id")
planned_air_date: date
status: str = Field(default="planned", max_length=20)
editor_id: int | None = Field(default=None, foreign_key="users.id")
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+44
View File
@@ -0,0 +1,44 @@
"""
用户模型 — SQLModel
角色体系(不再改):
- zhipianren = 制片人 / 管理员 = 全部权限
- zebian = 责编 = 维护仪表盘 / 知识库 / 排期
- biandao = 编导 = 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达
"""
from enum import Enum
from datetime import datetime, timezone
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class UserRole(str, Enum):
"""用户角色枚举,值与数据库 CHECK 约束一致。"""
zhipianren = "zhipianren" # 制片人 / 管理员
zebian = "zebian" # 责编
biandao = "biandao" # 编导
class User(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
username: str = Field(max_length=50, unique=True, index=True)
display_name: str = Field(max_length=100)
password_hash: str = Field(max_length=128)
role: UserRole = Field(default=UserRole.biandao, max_length=20)
profile_text: str | None = Field(default=None)
is_active: bool = Field(default=True)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+34
View File
@@ -0,0 +1,34 @@
"""
年度收视目标模型 — SQLModel
业务规则(.clinerules 5.2 年度目标只增不改):
- yearly_targets.year 有 UNIQUE 约束,数据库层已保证
- 代码端 INSERT 时只许 ON CONFLICT DO NOTHING,禁止 ON CONFLICT UPDATE
- 历史年份目标行绝不可被覆盖或删除
- 前端判颜色时按播出年份取对应年的目标,不是当前年份
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Numeric as SANumeric
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class YearlyTarget(SQLModel, table=True):
__tablename__ = "yearly_targets"
id: int | None = Field(default=None, primary_key=True)
year: int = Field(unique=True, index=True)
base_target: float # 基础目标
stretch_target: float # 摸高目标
issued_date: date | None = Field(default=None)
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
View File
+24
View File
@@ -0,0 +1,24 @@
"""
认证相关 Pydantic Schema — 请求 / 响应模型
"""
from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole
class LoginRequest(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
"""返回给前端的用户信息(不含 password_hash)。"""
id: int
username: str
display_name: str
role: UserRole
profile_text: str | None = None
is_active: bool = True
model_config = ConfigDict(from_attributes=True)
+41
View File
@@ -0,0 +1,41 @@
"""
节目期次 Schema — 请求 / 响应模型
"""
from datetime import date
from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole
class EpisodeBase(BaseModel):
episode_number: int
program_name: str
air_date: date
editor_id: int | None = None
editor_name_snapshot: str
audience_share: float | None = None
audience_rating: float | None = None
is_rerun: bool = False
original_episode_id: int | None = None
is_time_sensitive: bool = False
is_outdoor_shoot: bool = False
outdoor_contact_person: str | None = None
outdoor_contact_info: str | None = None
lead_source: str | None = None
notes: str | None = None
class EpisodeCreate(EpisodeBase):
pass
class EpisodeUpdate(EpisodeBase):
pass
class EpisodeResponse(EpisodeBase):
id: int
model_config = ConfigDict(from_attributes=True)
+28
View File
@@ -0,0 +1,28 @@
"""
导入相关 Schema — 请求 / 响应模型
"""
from datetime import date
from typing import Optional
from pydantic import BaseModel, ConfigDict
class ImportError(BaseModel):
"""单行失败详情"""
row_number: int # Excel 行号(第2行=第1条数据)
reason: str # 失败原因
raw_data: dict # 原始行数据(字典形式)
model_config = ConfigDict(from_attributes=True)
class ImportResult(BaseModel):
"""批量导入响应"""
batch_id: str # UUID,用于下载失败行 Excel
total_rows: int # 总行数
success_count: int # 成功数
failed_count: int # 失败数
errors: list[ImportError] # 失败行列表
error_excel_url: Optional[str] = None # 失败行 Excel 下载 URL(无可失败行时可不返回)
model_config = ConfigDict(from_attributes=True)
+33
View File
@@ -0,0 +1,33 @@
"""
用户管理 Schema — 管理员 CRUD(仅 zhipianren 可用)
"""
from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole
class UserCreate(BaseModel):
username: str
display_name: str
password: str
role: UserRole
profile_text: str | None = None
class UserUpdate(BaseModel):
display_name: str | None = None
role: UserRole | None = None
profile_text: str | None = None
is_active: bool | None = None
class UserResponse(BaseModel):
id: int
username: str
display_name: str
role: UserRole
profile_text: str | None = None
is_active: bool = True
model_config = ConfigDict(from_attributes=True)
+36
View File
@@ -0,0 +1,36 @@
"""
年度收视目标 Schema — 请求 / 响应模型
业务规则:yearly_targets 只增不改,ON CONFLICT DO NOTHING
"""
from datetime import date
from pydantic import BaseModel, ConfigDict
class YearlyTargetBase(BaseModel):
year: int
base_target: float
stretch_target: float
issued_date: date | None = None
notes: str | None = None
class YearlyTargetCreate(YearlyTargetBase):
pass
class YearlyTargetUpdate(BaseModel):
"""PATCH 时允许只传部分字段,year 不可修改(由 API 层 pop 掉)。"""
year: int | None = None
base_target: float | None = None
stretch_target: float | None = None
issued_date: date | None = None
notes: str | None = None
class YearlyTargetResponse(YearlyTargetBase):
id: int
model_config = ConfigDict(from_attributes=True)
+69
View File
@@ -0,0 +1,69 @@
"""
Embedding 调用服务 — 封装 MiniMax embo-01
请求格式(确认自探路脚本):
POST /v1/embeddings
Body: {"model": "embo-01", "texts": [...], "type": "db"|"query"}
响应格式:
{"vectors": [[...1536 floats...]], "total_tokens": N, "base_resp": {"status_code": 0, "status_msg": "success"}}
"""
import httpx
from typing import List
from app.core.config import settings
class EmbeddingService:
"""MiniMax embo-01 embedding 调用封装"""
def __init__(self):
self.api_key = settings.MINIMAX_EMBED_API_KEY
self.group_id = settings.MINIMAX_GROUP_ID
self.endpoint = "https://api.minimax.chat/v1/embeddings"
def embed(self, texts: List[str], embed_type: str = "db") -> List[List[float]]:
"""
调用 embo-01 将文本列表转为向量
Args:
texts: 文本列表(支持批量)
embed_type: "db" = 存入库,"query" = 查询
Returns:
List[List[float]],每个元素是一组 1536 维向量
"""
if not self.api_key or self.api_key == "your_api_key_here":
raise RuntimeError("MINIMAX_EMBED_API_KEY not configured in .env")
if not self.group_id or self.group_id == "your_group_id_here":
raise RuntimeError("MINIMAX_GROUP_ID not configured in .env")
headers = {
"Authorization": f"Bearer {self.api_key}",
"GroupId": self.group_id,
"Content-Type": "application/json",
}
payload = {
"model": "embo-01",
"texts": texts,
"type": embed_type,
}
resp = httpx.post(self.endpoint, headers=headers, json=payload, timeout=60.0)
resp.raise_for_status()
data = resp.json()
# 检查业务错误
base_resp = data.get("base_resp", {})
if base_resp.get("status_code", 0) != 0:
raise RuntimeError(f"Embedding API error: {base_resp.get('status_msg', 'unknown')}")
vectors = data.get("vectors", [])
if not vectors:
raise RuntimeError("No vectors returned from embedding API")
return vectors
def embed_single(self, text: str, embed_type: str = "db") -> List[float]:
"""单文本 embedding,返回 1536 维向量列表(Python list"""
vectors = self.embed([text], embed_type=embed_type)
return vectors[0]
+247
View File
@@ -0,0 +1,247 @@
"""
Excel 解析服务 — 批量导入核心逻辑
业务规则:
- 前置校验:所有涉及年份的 yearly_targets 必须存在,否则整体报错(不入库任何行)
- 幂等:检测到重复 (year, episode_number) → 整体 409,不入库
- 事务:逐行提交,成功立即 commit,失败行收集到 errors[]
- 软引用+快照:编导姓名匹配不到在职用户 → editor_id=NULL + editor_name_snapshot=姓名
- is_rerun 解析:接受是/否、true/false、1/0,大小写不敏感
- Phase 2 不支持重播行(is_rerun=是 → 报错标记为失败行)
"""
import io
import uuid
from datetime import date
from typing import Any
import pandas as pd
from sqlmodel import Session, select
from app.models.episode import Episode
from app.models.user import User
from app.models.yearly_target import YearlyTarget
TRUTHY = {"", "true", "1", "yes"}
FALSY = {"", "false", "0", "no"}
def parse_bool(val: Any) -> bool | None:
"""解析布尔值,接受多种格式。"""
if val is None:
return None
v = str(val).strip().lower()
if v == "":
return None
if v in TRUTHY:
return True
if v in FALSY:
return False
raise ValueError(f"无法解析布尔值: {val!r}")
def parse_date(val: Any) -> date | None:
"""解析日期,支持 YYYY-MM-DD 和 YYYY/MM/DD。"""
if val is None or str(val).strip() == "":
return None
v = str(val).strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"):
try:
return date.fromisoformat(v)
except ValueError:
pass
raise ValueError(f"无法解析日期: {val!r}")
def parse_float(val: Any) -> float | None:
"""解析浮点数,接受百分比字符串。"""
if val is None:
return None
v = str(val).strip()
if v == "":
return None
if v.endswith("%"):
v = v[:-1]
return float(v)
def find_editor_by_name(session: Session, name: str):
"""按 display_name 匹配在职编导。匹配上返 (id, name),匹配不上返 (None, name)。"""
if not name or str(name).strip() == "":
return None, ""
name = str(name).strip()
user = session.exec(
select(User).where(User.display_name == name, User.is_active == True)
).first()
if user:
return user.id, name
return None, name
class ExcelService:
def __init__(self, session: Session):
self.session = session
self.errors: list[dict] = []
self.batch_id = str(uuid.uuid4())
def validate_yearly_targets(self, air_dates: list[date]):
"""前置校验:所有涉及年份的 yearly_targets 必须存在,否则整体报错。"""
distinct_years = set(dt.year for dt in air_dates if dt is not None)
missing = []
for yr in sorted(distinct_years):
exists = self.session.exec(
select(YearlyTarget).where(YearlyTarget.year == yr)
).first()
if not exists:
missing.append(yr)
if missing:
raise ValueError(
f"导入前请先录入以下年份的年度目标:{', '.join(map(str, missing))}"
"请先在年度目标页录入目标后再导入。"
)
def check_duplicates(self, rows: list[dict]):
"""检测重复 (year, episode_number),有重复则整体报错。"""
seen = set()
duplicates = []
for row in rows:
key = (row["_air_year"], row["episode_number"])
if key in seen:
duplicates.append(row)
seen.add(key)
if duplicates:
dup_list = [
f"{r['_air_year']}年第 {r['episode_number']}"
for r in duplicates
]
raise ValueError(
f"文件中以下期次与库中记录重复:{', '.join(dup_list)}"
"请先手动删除重复期次后再重新导入。"
)
def import_episodes(self, file_content: bytes) -> dict:
"""解析 Excel,批量导入 episodes。返回 ImportResult 结构。"""
# 1. 读取 Excelpandas 2.x 要求 file-like object,用 BytesIO 包装 bytes
file_like = io.BytesIO(file_content)
df = pd.read_excel(file_like, engine="openpyxl")
rows = df.to_dict(orient="records")
# 2. 解析 air_date 并提取年份
parsed_rows = []
air_dates = []
for i, row in enumerate(rows, start=2): # Excel 行号从2开始(第1行=表头)
raw = dict(row)
try:
air_dt = parse_date(row.get("air_date"))
if air_dt is None:
raise ValueError("air_date 不能为空")
parsed_rows.append({
"_row_number": i,
"_air_year": air_dt.year,
"_air_date": air_dt,
"episode_number": int(row["episode_number"]),
"program_name": str(row["program_name"]).strip(),
"audience_share": parse_float(row.get("audience_share")),
"audience_rating": parse_float(row.get("audience_rating")),
"is_rerun": parse_bool(row.get("is_rerun")),
"editor_name_snapshot": str(row.get("editor_name", "")).strip() or "未知编导",
"notes": str(row.get("notes", "")).strip() or None,
"_raw": raw,
})
air_dates.append(air_dt)
except Exception as e:
self.errors.append({
"row_number": i,
"reason": str(e),
"raw_data": raw,
})
# 3. 前置校验:年份 targets
try:
self.validate_yearly_targets(air_dates)
except ValueError:
raise # 直接抛给调用方,整体 400
# 4. 重复检测
try:
self.check_duplicates(parsed_rows)
except ValueError:
raise # 直接抛给调用方,整体 409
# 5. 逐行入库
for row in parsed_rows:
try:
self._import_one_row(row)
except Exception as e:
self.errors.append({
"row_number": row["_row_number"],
"reason": str(e),
"raw_data": row["_raw"],
})
# 6. 计算结果
total = len(rows)
success = total - len(self.errors)
return {
"batch_id": self.batch_id,
"total_rows": total,
"success_count": success,
"failed_count": len(self.errors),
"errors": self.errors,
}
def _import_one_row(self, row: dict):
"""导入单行,处理 is_rerun 逻辑。"""
# 重播行 Phase 2 不支持
if row["is_rerun"] is True:
raise ValueError(
"Phase 2 不支持重播期次导入。"
"有重播行请空着或用其他工具录入,系统已标记为失败行。"
)
# 编辑匹配
editor_id, editor_name = find_editor_by_name(self.session, row["editor_name_snapshot"])
if editor_id is None and row["editor_name_snapshot"] == "未知编导":
editor_name = row["editor_name_snapshot"]
# 插入
episode = Episode(
episode_number=row["episode_number"],
program_name=row["program_name"],
air_date=row["_air_date"],
editor_id=editor_id,
editor_name_snapshot=editor_name,
audience_share=row["audience_share"],
audience_rating=row["audience_rating"],
is_rerun=False,
original_episode_id=None,
notes=row["notes"],
)
self.session.add(episode)
self.session.commit()
self.session.refresh(episode)
def generate_error_excel(errors: list[dict]) -> bytes:
"""生成失败行 Excel,供责编下载修正。"""
import io
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "失败行"
# 表头
headers = ["row_number", "reason"] + list(errors[0]["raw_data"].keys()) if errors else ["row_number", "reason"]
ws.append(headers)
# 失败行
for err in errors:
row_data = [err["row_number"], err["reason"]] + list(err["raw_data"].values())
ws.append(row_data)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
+354
View File
@@ -0,0 +1,354 @@
"""
知识库服务 — 写入向量 + 语义检索 + md 文件解析
使用 pgvector 原生 SQL 向量检索(<=> 余弦距离算子),不在 Python 侧计算
"""
from typing import Optional
from datetime import date
import frontmatter
from sqlalchemy import text
from sqlmodel import Session, select
from pgvector.sqlalchemy import Vector
from app.models.knowledge import KnowledgeItem, KnowledgeEmbedding
from app.services.embedding_service import EmbeddingService
from app.db.session import engine
class KnowledgeService:
"""知识库 CRUD + 语义检索 + md 解析"""
# yaml 类型字段 → source_type 枚举映射
SOURCE_TYPE_MAP = {
"杂志文章": "military_report",
"军报": "military_report",
"节目文稿": "manuscript",
"报题单": "baoti",
}
# 来源大类固定显示顺序(制片人 Obsidian 习惯)
SOURCE_TYPE_ORDER = [
"manuscript", # 节目文稿
"military_report", # 杂志文章
"baoti", # 报题单
"manual", # 其他
]
# 二级分组维度映射(与前端 useKnowledgeGrouping.js 的 SECONDARY_GROUP_FIELD 一致)
# key = source_type, value = 用来做二级分组的字段名,None = 不建二级节点
SECONDARY_GROUP_FIELD = {
"manuscript": "author", # 节目文稿 → 按作者(编导)
"military_report": "source_detail", # 杂志文章 → 按出处
"baoti": None, # 报题单 → 不分组
"manual": None, # 其他 → 不分组
}
SOURCE_TYPE_LABEL = {
"military_report": "杂志文章",
"manuscript": "节目文稿",
"baoti": "报题单",
"manual": "其他",
}
def __init__(self):
self.embedder = EmbeddingService()
def parse_md_file(self, file_content: bytes, file_name: str) -> dict:
"""
解析一个 .md 文件的 yaml frontmatter + 正文,返回入库用的字典。
严格按真实样本的字段名映射,不猜测。
Returns:
dict 含 keys: title, content_md, source_type, author, publish_date,
source_detail, metadata(JSONB), related_entities(JSONB)
"""
content = file_content.decode("utf-8", errors="replace")
parsed = frontmatter.loads(content)
fm = parsed.metadata or {}
# —— 类型 → source_type(硬映射,不猜测)——
raw_type = str(fm.get("类型", "")).strip()
source_type = self.SOURCE_TYPE_MAP.get(raw_type, "manual")
# —— 标题:名称 或 标题——
title = str(fm.get("名称", "") or fm.get("标题", "")).strip()
if not title:
# fallback: 用正文第一行或文件名
lines = [l.strip() for l in content.split("\n") if l.strip() and not l.strip().startswith("---")]
title = lines[0] if lines else file_name
# —— 作者:作者 或 编导——
author = str(fm.get("作者", "") or fm.get("编导", "") or "").strip() or None
# —— 出处详情:期刊 + 期号(拼在一起存进 JSONB 的 source_detail)——
journal = str(fm.get("期刊", "") or "").strip()
issue = str(fm.get("期号", "") or "").strip()
if journal or issue:
source_detail = f"{journal} {issue}".strip()
else:
source_detail = None
# —— 播出日期:容错 "待补充" 等非日期文本——
raw_date = str(fm.get("播出日期", "") or "").strip()
publish_date = None
if raw_date and raw_date not in ("待补充", "待确认", ""):
try:
publish_date = date.fromisoformat(raw_date)
except ValueError:
# 非 ISO 格式,尝试 common 格式
for fmt in ("%Y-%m-%d", "%Y年%m月%d", "%Y/%m/%d"):
try:
publish_date = date.fromisoformat(raw_date.replace("", "-").replace("", "-").replace("", ""))
break
except ValueError:
continue
# —— 权重(不展示,存 JSONB 备 Phase 4)——
weight = str(fm.get("权重", "") or "").strip() or None
# —— 相关实体(涉及装备/涉及技术/涉及厂商/主题)——
related_entities = []
for key in ("涉及装备", "涉及技术", "涉及厂商", "主题"):
val = fm.get(key)
if val:
if isinstance(val, list):
related_entities.extend(val)
elif isinstance(val, str):
# 可能是 "山东舰, 福建舰" 这样的逗号分隔字符串
for item in val.replace("", ",").split(","):
item = item.strip()
if item:
related_entities.append(item)
# —— metadata JSONB:权重、出处详情、双链预留——
metadata = {}
if weight:
metadata["weight"] = weight
if source_detail:
metadata["source_detail"] = source_detail
# related_concepts 字段预留给双链解析(Phase 4),本 Task 原样存入
metadata["double_bracket_links"] = self._extract_double_brackets(parsed.content)
# —— 正文(去掉 frontmatter 的纯内容)——
content_md = parsed.content
return {
"title": title,
"content_md": content_md,
"source_type": source_type,
"author": author,
"publish_date": publish_date,
"metadata": metadata if metadata else None,
"related_entities": related_entities if related_entities else None,
"source_file_name": file_name,
}
def _extract_double_brackets(self, text: str) -> list[str]:
"""提取 [[...]] 双链标记,原样返回列表,不解析成图谱(本 Task 留门)。"""
import re
return re.findall(r"\[\[([^\]]+)\]\]", text)
def store_md_file(self, file_content: bytes, file_name: str) -> KnowledgeItem:
"""
读取一篇 md 内容,调用 embo-01 拿到向量,写入 knowledge_items + knowledge_embeddings
"""
parsed = self.parse_md_file(file_content, file_name)
# 调用 embeddingtype="db" 表示存入知识库)
embedding_list = self.embedder.embed_single(parsed["content_md"], embed_type="db")
with Session(engine) as session:
item = KnowledgeItem(
title=parsed["title"],
content_md=parsed["content_md"],
source_type=parsed["source_type"],
source_file_name=parsed["source_file_name"],
author=parsed["author"],
publish_date=parsed["publish_date"],
tags=parsed["metadata"],
related_entities=parsed["related_entities"],
)
session.add(item)
session.flush()
emb = KnowledgeEmbedding(
knowledge_id=item.id,
chunk_index=0,
chunk_text=parsed["content_md"],
embedding=embedding_list,
)
session.add(emb)
session.commit()
session.refresh(item)
return item
def delete_item(self, knowledge_id: int) -> bool:
"""删除知识库条目及其向量(CASCADE 已由 DB 层配置)。"""
with Session(engine) as session:
item = session.get(KnowledgeItem, knowledge_id)
if item is None:
return False
session.delete(item)
session.commit()
return True
def list_items(self, source_type: Optional[str] = None) -> list[dict]:
"""返回知识库条目列表(含 source_detail 从 metadata 解压)。"""
with Session(engine) as session:
statement = select(KnowledgeItem).order_by(KnowledgeItem.created_at.desc())
if source_type:
statement = statement.where(KnowledgeItem.source_type == source_type)
items = session.exec(statement).all()
results = []
for item in items:
# 从 tags(JSONB) 取 source_detail
tags = item.tags or {}
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
results.append({
"id": item.id,
"title": item.title,
"author": item.author,
"publish_date": item.publish_date,
"source_type": item.source_type,
"source_file_name": item.source_file_name,
"source_detail": source_detail,
"created_at": item.created_at,
})
return results
def get_distinct_sources(self) -> list[str]:
"""返回库里所有不重复的 source_detail(动态从 JSONB 提取),供筛选下拉用。"""
with Session(engine) as session:
items = session.exec(select(KnowledgeItem)).all()
sources = set()
for item in items:
tags = item.tags or {}
if isinstance(tags, dict) and tags.get("source_detail"):
sources.add(tags["source_detail"])
return sorted(list(sources))
def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]:
"""
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
返回 top_k 条相似笔记,含相似度分数 + 原文片段(SQL 端截断前 200 字)。
注意:当前取前 200 字是已知妥协(整篇向量检索无法定位中段命中点),
Phase 4a 做切块检索(chunk)时可优化为取最相关片段。
"""
query_vector = self.embedder.embed_single(query_text, embed_type="query")
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
with Session(engine) as session:
sql = f"""
SELECT
ki.id,
ki.title,
ki.source_type,
ki.author,
ki.tags,
SUBSTRING(ki.content_md, 1, 200) AS snippet,
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
FROM knowledge_embeddings ke
JOIN knowledge_items ki ON ke.knowledge_id = ki.id
WHERE ke.chunk_index = 0
ORDER BY ke.embedding <=> '{vec_str}'::vector
LIMIT {top_k}
"""
stmt = text(sql)
rows = session.execute(stmt).all()
results = []
for r in rows:
tags = r.tags or {}
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
results.append({
"id": r.id,
"title": r.title,
"source_type": r.source_type,
"author": r.author,
"source_detail": source_detail,
"snippet": r.snippet,
"similarity": round(r.similarity, 4),
})
return results
def get_item_count(self) -> int:
with Session(engine) as session:
return len(session.exec(select(KnowledgeItem)).all())
def get_embedding_count(self) -> int:
with Session(engine) as session:
return len(session.exec(select(KnowledgeEmbedding)).all())
def get_grouped_items(self) -> list[dict]:
"""
按 source_type → 二级字段(author / source_detail)两层聚合,返回树形结构数据。
按 SOURCE_TYPE_ORDER 固定顺序排列,仅显示有数据的大类(count > 0)。
二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
例:manuscript|author|左鑫
military_report|source_detail|航空知识 2026年第1期
二级字段值为 null / 空字串 → 归入对应大类,不造空节点。
空大类(0条)不渲染。
"""
with Session(engine) as session:
items = session.exec(select(KnowledgeItem)).all()
total_count = len(items)
# 按 source_type 分组:仅收集有数据的类别,再按固定顺序排列
type_groups: dict = {}
for item in items:
st = item.source_type or "manual"
if st not in type_groups:
type_groups[st] = []
type_groups[st].append(item)
# 只遍历有数据的类别,按 SOURCE_TYPE_ORDER 顺序
children = []
for st in self.SOURCE_TYPE_ORDER:
if st not in type_groups:
continue
st_items = type_groups[st]
secondary_field = self.SECONDARY_GROUP_FIELD.get(st)
grandchildren = []
if secondary_field is not None:
# 按二级字段分组
detail_groups: dict = {}
for item in st_items:
if secondary_field == "source_detail":
tags = item.tags or {}
sd = tags.get("source_detail") if isinstance(tags, dict) else None
field_val = sd
else:
field_val = (getattr(item, secondary_field, None) or "").strip() or None
if field_val not in detail_groups:
detail_groups[field_val] = []
detail_groups[field_val].append(item)
for sd, sd_items in detail_groups.items():
if sd is not None:
grandchildren.append({
"key": f"{st}|{secondary_field}|{sd}",
"label": f"{sd}{len(sd_items)}条)",
"count": len(sd_items),
})
children.append({
"key": st,
"label": f"{self.SOURCE_TYPE_LABEL.get(st, st)}{len(st_items)}条)",
"count": len(st_items),
"children": grandchildren,
})
return [{
"key": "all",
"label": f"全部({total_count}条)",
"count": total_count,
"children": children,
}]
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlmodel==0.0.19
psycopg2-binary==2.9.9
pgvector==0.2.5
passlib==1.7.4
bcrypt==4.0.1
itsdangerous==2.2.0
python-multipart==0.0.9
python-dotenv==1.0.1
httpx==0.27.0
python-frontmatter==1.1.0
pandas>=2.0.0
openpyxl>=3.1.0
View File
+28
View File
@@ -0,0 +1,28 @@
"""
清理测试数据脚本
"""
from dotenv import load_dotenv
from pathlib import Path
_env_path = Path(__file__).parent.parent / ".env"
load_dotenv(str(_env_path))
from sqlalchemy import text
from app.db.session import engine
conn = engine.connect()
# 清空知识库
conn.execute(text("DELETE FROM knowledge_embeddings"))
conn.execute(text("DELETE FROM knowledge_items"))
conn.commit()
# 查行数
ki = conn.execute(text("SELECT COUNT(*) FROM knowledge_items")).scalar()
ke = conn.execute(text("SELECT COUNT(*) FROM knowledge_embeddings")).scalar()
print(f"knowledge_items rows: {ki}")
print(f"knowledge_embeddings rows: {ke}")
conn.close()
+81
View File
@@ -0,0 +1,81 @@
"""
创建管理员账号 — 交互式命令行脚本
用法:
cd backend
py -3.12 scripts/create_admin.py
"""
import sys
from pathlib import Path
# 确保 backend/ 目录在 sys.path 中,使 app.* 导入正常工作
backend_dir = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(backend_dir))
from getpass import getpass
from sqlmodel import Session, select
from app.core.security import hash_password
from app.db.session import engine
from app.models.user import User, UserRole
def main():
print("=" * 50)
print(" 军事科技工作台 — 创建管理员账号")
print("=" * 50)
print()
username = input("请输入用户名: ").strip()
if not username:
print("❌ 用户名不能为空")
sys.exit(1)
display_name = input("请输入显示名(中文姓名): ").strip()
if not display_name:
print("❌ 显示名不能为空")
sys.exit(1)
password = getpass("请输入密码(输入不显示): ").strip()
if not password:
print("❌ 密码不能为空")
sys.exit(1)
password_confirm = getpass("请再次输入密码确认: ").strip()
if password != password_confirm:
print("❌ 两次输入的密码不一致")
sys.exit(1)
with Session(engine) as session:
# 检查用户名是否已存在
existing = session.exec(
select(User).where(User.username == username)
).first()
if existing:
print(f"❌ 用户名「{username}」已存在,请换一个用户名")
sys.exit(1)
user = User(
username=username,
display_name=display_name,
password_hash=hash_password(password),
role=UserRole.zhipianren,
is_active=True,
)
session.add(user)
session.commit()
session.refresh(user)
print()
print("✅ 管理员账号创建成功!")
print(f" 用户名: {user.username}")
print(f" 显示名: {user.display_name}")
print(f" 角色: {user.role.value}(制片人/管理员)")
print(f" ID: {user.id}")
print()
if __name__ == "__main__":
main()
+265
View File
@@ -0,0 +1,265 @@
"""
Task 2 批量导入冒烟脚本 — httpx(幂等版,可重复跑)
确保后端已启动: py -3.12 -c "import sys; sys.path.insert(0, r'E:\tps-dashboard\backend'); import uvicorn; uvicorn.run('app.main:app', host='127.0.0.1', port=8000, reload=False)"
Phase 2 Task 2 实测清单覆盖:
Step 1: GET /api/imports/template → 模板下载
Step 2: POST /api/imports/episodes → 合法行单行导入
Step 3: POST /api/imports/episodes → 部分失败行(成功+失败混合格式)
Step 4: GET /api/imports/errors/{id} → 失败行 Excel 下载
Step 5: POST /api/imports/episodes → 2024年份缺失拦截 → 400
Step 6: POST /api/imports/episodes → 重复期次拦截 → 409
Step 7: POST /api/imports/episodes → 未知编导软落地 → 200 + editor_id=null
"""
import io
import httpx
import random
from openpyxl import Workbook
BASE = "http://localhost:8000"
def login(username: str = "simonkoson", password: str = "liutong65") -> str:
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, timeout=30.0, **kwargs)
elif method == "POST":
r = httpx.post(url, cookies=cookies, timeout=30.0, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, timeout=30.0, **kwargs)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f" {mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def make_excel(rows: list[list]) -> bytes:
"""构建简单 xlsx,rows[0] 为表头,后续为数据。"""
wb = Workbook()
ws = wb.active
for row in rows:
ws.append(row)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
def cleanup_test_episodes(cookies: dict):
"""清理测试用 episode_number(幂等清理)。"""
r = httpx.get(f"{BASE}/api/episodes?limit=100", cookies=cookies)
if r.status_code == 200:
for ep in r.json():
num = ep.get("episode_number", 0)
# 清理本次测试可能用到的期号段
if num in (99, 100, 101, 102, 200, 301):
httpx.delete(f"{BASE}/api/episodes/{ep['id']}", cookies=cookies)
def main():
print("=== Login ===")
session_cookie = login()
if not session_cookie:
print("WARNING: session cookie empty")
session_cookie = ""
cookies = {"milsci_session": session_cookie}
# 先清理旧测试数据(幂等)
print("\n=== Cleanup old test episodes ===")
cleanup_test_episodes(cookies)
suffix = random.randint(1000, 9999)
# ---- Step 1: 模板下载 ----
print("\n=== Step 1: Template Download ===")
r, ok = step(
"GET /api/imports/template?type=episodes",
200,
"GET",
f"{BASE}/api/imports/template?type=episodes",
cookies=cookies,
)
content_type = r.headers.get("content-type", "")
is_binary = "application/vnd.openxmlformats" in content_type
print(f" {'[OK]' if is_binary else '[FAIL]'} Content-Type: {content_type}")
print(f" [INFO] Template downloaded ({len(r.content)} bytes)")
# ---- Step 2: 合法单行导入 ----
print("\n=== Step 2: Normal import (1 valid row) ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[99, "《军事科技》实测专用", "2026-06-01", "张颖",
0.75, 3.8, "", "Plan smoke test"],
])
r, ok = step(
"POST /api/imports/episodes (1 valid row) → 200",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_valid.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
result = r.json()
print(f" [INFO] total={result['total_rows']} success={result['success_count']} failed={result['failed_count']}")
ok = (result["success_count"] == 1 and result["failed_count"] == 0)
print(f" {'[OK]' if ok else '[FAIL]'} Row count correct")
# ---- Step 3: 部分失败行回显 ----
print("\n=== Step 3: Partial failure (3 rows: 1 ok + 2 bad) ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[100, "《军事科技》成功行", "2026-07-01", "张颖", 0.71, 3.5, "", ""],
[101, "无效日期格式行", "不是日期", "张颖", 0.72, 3.6, "", ""],
[102, "非整数期号行", "invalid-ep-num", "张颖", 0.73, 3.7, "", ""],
])
r, ok = step(
"POST /api/imports/episodes (1ok+2bad) → 200 with errors",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_mixed.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
batch_id = None
if ok:
result = r.json()
print(f" [INFO] total={result['total_rows']} success={result['success_count']} failed={result['failed_count']}")
ok_count = (result["success_count"] == 1 and result["failed_count"] == 2)
print(f" {'[OK]' if ok_count else '[FAIL]'} Failure count correct")
# 检查 row_number 是否对齐 Excel 行号(第2行=Excel第2行,第3行=Excel第3行,第4行=Excel第4行)
if result["errors"]:
row_nums = sorted([e["row_number"] for e in result["errors"]])
print(f" [INFO] error row_numbers: {row_nums}")
ok_rows = (row_nums == [3, 4])
print(f" {'[OK]' if ok_rows else '[FAIL]'} row_number对齐Excel自然行号(表头第1行,数据从第2行起算)")
batch_id = result.get("batch_id")
if batch_id:
print(f" [INFO] batch_id={batch_id}")
# ---- Step 4: 失败行下载 ----
print("\n=== Step 4: Error Excel Download ===")
if batch_id:
r, ok = step(
f"GET /api/imports/errors/{batch_id} → 200 binary",
200,
"GET",
f"{BASE}/api/imports/errors/{batch_id}",
cookies=cookies,
)
ct = r.headers.get("content-type", "")
is_xlsx = "application/vnd.openxmlformats" in ct
print(f" {'[OK]' if is_xlsx else '[FAIL]'} Content-Type: {ct}")
print(f" [INFO] Error Excel downloaded ({len(r.content)} bytes)")
else:
print(" [SKIP] No batch_id from step 3")
# ---- Step 5: 年份目标缺失拦截 ----
print("\n=== Step 5: Missing yearly target → 400 ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[200, "《军事科技》跨年测试", "2024-01-15", "张颖", 0.68, 3.2, "", ""],
])
r, ok = step(
"POST with 2024 air_date (no 2024 target) → 400",
400,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_2024.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
detail = r.json().get("detail", "")
has_year_target = "年度目标" in detail and "2024" in detail
print(f" {'[OK]' if has_year_target else '[FAIL]'} detail包含'年度目标'+'2024': {detail[:100]}")
# ---- Step 6: 重复期次拦截 ----
print("\n=== Step 6: Duplicate episode_number ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[99, "《军事科技》重复期次", "2026-06-15", "张颖", 0.76, 3.9, "", ""],
])
r, ok = step(
"POST episode_number=99 (duplicate in same year) → 409 or row-level rejection",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_duplicate.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
result = r.json()
# 数据库唯一约束触发:整行被 rejectsuccess=0, failed=1
has_db_error = result["failed_count"] == 1 and result["success_count"] == 0
has_unique_violation = any("UniqueViolation" in e.get("reason", "") or "idx_episodes_year_number" in e.get("reason", "") for e in result["errors"])
print(f" {'[OK]' if has_unique_violation else '[FAIL]'} 数据库唯一约束触发 (failed_count=1, UniqueViolation in reason)")
# ---- Step 7: 未知编导软落地 ----
print("\n=== Step 7: Unknown editor soft-landing ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[301, "《军事科技》编导软引用测试", "2026-09-01", "李不存在", 0.69, 3.1, "", ""],
])
r, ok = step(
"POST with unknown editor_name='李不存在' → 200",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_unknown_editor.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
result = r.json()
ok = (result["success_count"] == 1 and result["failed_count"] == 0)
print(f" {'[OK]' if ok else '[FAIL]'} success_count=1")
# 验证 editor_id=null:先从列表拿到真实 id,再调用 GET /api/episodes/{id}
r_list, ok_list = step(
"GET /api/episodes?limit=100 → 找到刚导入的 episode_number=301",
200,
"GET",
f"{BASE}/api/episodes?limit=100",
cookies=cookies,
)
if ok_list:
eps = r_list.json()
ep_301 = next((e for e in eps if e.get("episode_number") == 301), None)
if ep_301:
real_id = ep_301["id"]
r_ep, ok_ep = step(
f"GET /api/episodes/{real_id} → editor_id=null, editor_name_snapshot='李不存在'",
200,
"GET",
f"{BASE}/api/episodes/{real_id}",
cookies=cookies,
)
if ok_ep:
ep = r_ep.json()
editor_id_null = ep.get("editor_id") is None
name_match = ep.get("editor_name_snapshot") == "李不存在"
print(f" {'[OK]' if editor_id_null else '[FAIL]'} editor_id=null ({ep.get('editor_id')})")
print(f" {'[OK]' if name_match else '[FAIL]'} editor_name_snapshot='李不存在' ({ep.get('editor_name_snapshot')})")
else:
print(f" [FAIL] episode_number=301 not found in list (import may have failed)")
print("\n=== All steps done ===")
print("NOTE: This script is辅助. 制片人仍需在 Swagger UI (http://localhost:8000/docs) 手动跑完 8 步确认全绿.")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""
Task 1 冒烟测试脚本 — httpx(幂等版,可重复跑)
确保后端已启动: uvicorn app.main:app --reload
"""
import httpx
import random
BASE = "http://localhost:8000"
HEADERS = {"Content-Type": "application/json"}
def login(username: str, password: str) -> str:
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, **kwargs)
elif method == "POST":
r = httpx.post(url, cookies=cookies, **kwargs)
elif method == "PATCH":
r = httpx.patch(url, cookies=cookies, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, **kwargs)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f"{mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def main():
print("=== Login ===")
session_cookie = login("simonkoson", "liutong65")
if not session_cookie:
print("WARNING: session cookie empty, trying anyway")
session_cookie = ""
cookies = {"milsci_session": session_cookie}
# 使用随机后缀避免与上次数据冲突
suffix = random.randint(1000, 9999)
print("\n=== Step 1: Create zebian account ===")
r, ok = step(
"POST /api/users create zebian",
201,
"POST",
f"{BASE}/api/users",
json={
"username": f"test_zebian_{suffix}",
"display_name": f"Test Zebra {suffix}",
"password": "Test1234",
"role": "zebian",
},
cookies=cookies,
)
zebian_id = r.json().get("id") if ok else None
if ok:
print(f" zebian created, ID={zebian_id}")
print("\n=== Step 2: Create 2026 yearly target (idempotent) ===")
# 先尝试删已存在的(方便重跑)
r_list = httpx.get(f"{BASE}/api/yearly_targets", cookies=cookies)
if r_list.status_code == 200:
for t in r_list.json():
if t.get("year") == 2026:
del_id = t["id"]
httpx.delete(f"{BASE}/api/yearly_targets/{del_id}", cookies=cookies)
print(f" cleaned up existing 2026 target ID={del_id}")
# 再建新的
r, ok = step(
"POST /api/yearly_targets create 2026",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2026, "base_target": 0.65, "stretch_target": 0.90},
cookies=cookies,
)
target_2026_id = r.json().get("id") if ok else None
if ok:
print(f" target created, ID={target_2026_id}")
print("\n=== Step 3: Create 2026 again (should be 409) ===")
step(
"POST /api/yearly_targets duplicate 2026",
409,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2026, "base_target": 0.70, "stretch_target": 0.95},
cookies=cookies,
)
print("\n=== Step 4: Create episode with editor_id=null (idempotent) ===")
# 先清理 episode_number
r_list = httpx.get(f"{BASE}/api/episodes?limit=100", cookies=cookies)
if r_list.status_code == 200:
for ep in r_list.json():
if ep.get("episode_number") == 9999:
httpx.delete(f"{BASE}/api/episodes/{ep['id']}", cookies=cookies)
print(f" cleaned up existing episode 9999 ID={ep['id']}")
r, ok = step(
"POST /api/episodes editor_id=null",
201,
"POST",
f"{BASE}/api/episodes",
json={
"episode_number": 9999,
"program_name": "Test Episode Smoke",
"air_date": "2026-05-15",
"editor_id": None,
"editor_name_snapshot": "Test Director Zhang",
"audience_share": 0.72,
},
cookies=cookies,
)
episode_9999_id = r.json().get("id") if ok else None
if ok:
print(f" episode created, ID={episode_9999_id}")
print("\n=== Step 5: GET /api/episodes list ===")
step(
"GET /api/episodes list",
200,
"GET",
f"{BASE}/api/episodes?limit=10",
cookies=cookies,
)
print("\n=== Step 6: PATCH yearly_target try to change year ===")
if target_2026_id:
r, ok = step(
"PATCH try to change year field",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{target_2026_id}",
json={"year": 2027, "base_target": 0.65, "stretch_target": 0.90},
cookies=cookies,
)
if ok:
resp_year = r.json().get("year")
if resp_year == 2026:
print(f" [OK] year field unchanged, still 2026 (correct: year blocked)")
else:
print(f" [FAIL] year changed to {resp_year} (should be blocked)")
print("\n=== All steps done ===")
if __name__ == "__main__":
main()
@@ -0,0 +1,419 @@
"""
yearly_targets 写权限收紧 — 冒烟测试脚本
幂等,可重复跑。
确保后端已启动: uvicorn app.main:app --reload
12 步覆盖:
1. zhipianren POST → 201
2. zebian POST → 201
3. biandao POST → 403
4. 未登录 POST → 401
5. zhipianren PATCH → 200
6. zebian PATCH → 200
7. biandao PATCH → 403
8. zhipianren DELETE → 200
9. zebian DELETE → 403
10. biandao DELETE → 403
11. biandao GET 列表 → 200
12. zhipianren GET 列表 → 200
"""
import sys
from pathlib import Path
backend_dir = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(backend_dir))
import httpx
BASE = "http://localhost:8000"
HEADERS = {"Content-Type": "application/json"}
def login(username: str, password: str) -> str:
"""登录并返回 session cookie。"""
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
"""执行一个 HTTP 步骤,比较实际状态码与期望值。"""
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, timeout=10)
elif method == "POST":
r = httpx.post(url, cookies=cookies, headers=HEADERS, timeout=10, **kwargs)
elif method == "PATCH":
r = httpx.patch(url, cookies=cookies, headers=HEADERS, timeout=10, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, timeout=10)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f" {mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def ensure_user(cookies: dict, username: str, display_name: str, role: str, password: str = "Test1234"):
"""幂等创建测试账号(如果不存在)。"""
# 先尝试登录(账号可能已存在)
try:
r = httpx.get(f"{BASE}/api/users/me", cookies=cookies, timeout=5)
if r.status_code == 200:
me = r.json()
if me.get("username") == username:
print(f" [SKIP] user {username} already exists, reusing")
return
except Exception:
pass
# 尝试创建
r = httpx.post(
f"{BASE}/api/users",
json={"username": username, "display_name": display_name, "password": password, "role": role},
cookies=cookies,
headers=HEADERS,
timeout=10,
)
if r.status_code == 201:
print(f" [OK] created {username} ({role})")
elif r.status_code == 409:
print(f" [SKIP] {username} already exists")
else:
print(f" [WARN] create {username} returned {r.status_code}: {r.text[:100]}")
def cleanup_test_target(cookies: dict):
"""清理测试用年度目标(2027),幂等。"""
try:
r = httpx.get(f"{BASE}/api/yearly_targets", cookies=cookies, timeout=10)
if r.status_code == 200:
for t in r.json():
if t.get("year") == 2027:
httpx.delete(f"{BASE}/api/yearly_targets/{t['id']}", cookies=cookies, timeout=10)
print(f" [CLEAN] removed existing 2027 target ID={t['id']}")
except Exception:
pass
def main():
print("=" * 60)
print(" yearly_targets 写权限收紧 — 冒烟测试")
print("=" * 60)
# Step 0: 用 zhipianren 登录,准备测试账号
print("\n[Step 0] Login as zhipianren and ensure test accounts exist")
try:
admin_cookie = login("simonkoson", "liutong65")
except Exception as e:
print(f" [FAIL] cannot login as simonkoson: {e}")
return
admin_cookies = {"milsci_session": admin_cookie}
print(" [OK] logged in as simonkoson (zhipianren)")
# 幂等创建 zebian / biandao 测试账号
ensure_user(admin_cookies, "test_zebian_smoke", "TestZebra Smoke", "zebian")
ensure_user(admin_cookies, "test_biandao_smoke", "TestBiandao Smoke", "biandao")
# 登录 zebian 和 biandao 获取 cookie
print("\n[Step 0b] Login as zebian and biandao")
zebian_cookie = login("test_zebian_smoke", "Test1234")
zebian_cookies = {"milsci_session": zebian_cookie}
print(" [OK] logged in as test_zebian_smoke (zebian)")
biandao_cookie = login("test_biandao_smoke", "Test1234")
biandao_cookies = {"milsci_session": biandao_cookie}
print(" [OK] logged in as test_biandao_smoke (biandao)")
# 清理可能残留的 2027 目标
cleanup_test_target(admin_cookies)
results = []
# =====================================================================
# Step 1: zhipianren POST → 201
# =====================================================================
print("\n[Step 1] POST new yearly target — zhipianren → 201")
r, ok = step(
"POST /api/yearly_targets (zhipianren)",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
)
target_id = r.json().get("id") if ok else None
results.append(("Step 1: zhipianren POST 201", ok))
# =====================================================================
# Step 2: zebian POST → 201
# =====================================================================
print("\n[Step 2] POST new yearly target — zebian → 201")
# 清理 2027(刚被 zhipianren 创建了)
cleanup_test_target(admin_cookies)
r, ok = step(
"POST /api/yearly_targets (zebian)",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=zebian_cookies,
)
target_id2 = r.json().get("id") if ok else None
results.append(("Step 2: zebian POST 201", ok))
# =====================================================================
# Step 3: biandao POST → 403
# =====================================================================
print("\n[Step 3] POST new yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r, ok = step(
"POST /api/yearly_targets (biandao)",
403,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=biandao_cookies,
)
results.append(("Step 3: biandao POST 403", ok))
# =====================================================================
# Step 4: 未登录 POST → 401
# =====================================================================
print("\n[Step 4] POST new yearly target — not logged in → 401")
r, ok = step(
"POST /api/yearly_targets (no auth)",
401,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies={},
)
results.append(("Step 4: no auth POST 401", ok))
# =====================================================================
# Step 5: zhipianren PATCH → 200
# =====================================================================
print("\n[Step 5] PATCH yearly target — zhipianren → 200")
# 先用 admin 建一个可 PATCH 的目标
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id = r.json().get("id") if r.status_code == 201 else None
if patch_id:
r, ok = step(
"PATCH /api/yearly_targets/{id} (zhipianren)",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id}",
json={"base_target": 0.67},
cookies=admin_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 5: zhipianren PATCH 200", ok))
# =====================================================================
# Step 6: zebian PATCH → 200
# =====================================================================
print("\n[Step 6] PATCH yearly target — zebian → 200")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id2 = r.json().get("id") if r.status_code == 201 else None
if patch_id2:
r, ok = step(
"PATCH /api/yearly_targets/{id} (zebian)",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id2}",
json={"stretch_target": 0.92},
cookies=zebian_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 6: zebian PATCH 200", ok))
# =====================================================================
# Step 7: biandao PATCH → 403
# =====================================================================
print("\n[Step 7] PATCH yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id3 = r.json().get("id") if r.status_code == 201 else None
if patch_id3:
r, ok = step(
"PATCH /api/yearly_targets/{id} (biandao)",
403,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id3}",
json={"base_target": 0.68},
cookies=biandao_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 7: biandao PATCH 403", ok))
# =====================================================================
# Step 8: zhipianren DELETE → 200
# =====================================================================
print("\n[Step 8] DELETE yearly target — zhipianren → 200")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id = r.json().get("id") if r.status_code == 201 else None
if del_id:
r, ok = step(
"DELETE /api/yearly_targets/{id} (zhipianren)",
204,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id}",
cookies=admin_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 8: zhipianren DELETE 204", ok))
# =====================================================================
# Step 9: zebian DELETE → 403
# =====================================================================
print("\n[Step 9] DELETE yearly target — zebian → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id2 = r.json().get("id") if r.status_code == 201 else None
if del_id2:
r, ok = step(
"DELETE /api/yearly_targets/{id} (zebian)",
403,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id2}",
cookies=zebian_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 9: zebian DELETE 403", ok))
# =====================================================================
# Step 10: biandao DELETE → 403
# =====================================================================
print("\n[Step 10] DELETE yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id3 = r.json().get("id") if r.status_code == 201 else None
if del_id3:
r, ok = step(
"DELETE /api/yearly_targets/{id} (biandao)",
403,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id3}",
cookies=biandao_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 10: biandao DELETE 403", ok))
# =====================================================================
# Step 11: biandao GET 列表 → 200
# =====================================================================
print("\n[Step 11] GET yearly targets list — biandao → 200")
r, ok = step(
"GET /api/yearly_targets (biandao)",
200,
"GET",
f"{BASE}/api/yearly_targets",
cookies=biandao_cookies,
)
results.append(("Step 11: biandao GET 200", ok))
# =====================================================================
# Step 12: zhipianren GET 列表 → 200
# =====================================================================
print("\n[Step 12] GET yearly targets list — zhipianren → 200")
r, ok = step(
"GET /api/yearly_targets (zhipianren)",
200,
"GET",
f"{BASE}/api/yearly_targets",
cookies=admin_cookies,
)
results.append(("Step 12: zhipianren GET 200", ok))
# =====================================================================
# Summary
# =====================================================================
print("\n" + "=" * 60)
print(" 结果汇总")
print("=" * 60)
passed = sum(1 for _, ok in results if ok)
total = len(results)
for name, ok in results:
mark = "[OK]" if ok else "[FAIL]"
print(f" {mark} {name}")
print(f"\n PASS: {passed}/{total}")
if passed == total:
print(" ALL PASS")
else:
print(" SOME FAILURES")
print("\n测试账号已保留(供后续手动清理):")
print(" - test_zebian_smoke (zebian)")
print(" - test_biandao_smoke (biandao)")
print("\n" + "=" * 60)
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
"""
探路脚本 — 调 MiniMax embo-01,打印原始返回 JSON
确认向量字段位置和维度后再写正式 service。
"""
import httpx
import json
import os
from pathlib import Path
# 加载 .env
from dotenv import load_dotenv
_env_path = Path(__file__).parent.parent / ".env"
load_dotenv(str(_env_path))
api_key = os.environ.get("MINIMAX_EMBED_API_KEY", "")
group_id = os.environ.get("MINIMAX_GROUP_ID", "")
if not api_key or api_key == "your_api_key_here":
print("[ERROR] MINIMAX_EMBED_API_KEY not configured, please edit backend/.env")
exit(1)
if not group_id or group_id == "your_group_id_here":
print("[ERROR] MINIMAX_GROUP_ID not configured, please edit backend/.env")
exit(1)
print(f"API Key (first 4 chars): {api_key[:4]}...")
print(f"GroupId: {group_id}")
print()
# 最小调用
test_text = "这是一段测试文本,用于验证 embo-01 接口返回结构。"
print(f"Sending request, test text: {test_text}")
print("-" * 60)
try:
resp = httpx.post(
"https://api.minimax.chat/v1/embeddings",
headers={
"Authorization": f"Bearer {api_key}",
"GroupId": group_id,
"Content-Type": "application/json",
},
json={"model": "embo-01", "texts": [test_text], "type": "db"},
timeout=30.0,
)
print(f"HTTP status: {resp.status_code}")
print()
data = resp.json()
print(json.dumps(data, indent=2, ensure_ascii=False))
# 提取向量,验证维度
print()
print("-" * 60)
vectors = data.get("vectors", [])
if vectors and len(vectors) > 0:
embedding = vectors[0]
dim = len(embedding)
print(f"[OK] Embedding field: vectors[0]")
print(f"[OK] Embedding dimension: {dim}")
if dim != 1536:
print(f"[STOP] Dimension is NOT 1536! Got {dim} - stopping here")
else:
print(f"[OK] Dimension correct: 1536")
print(f"[OK] API call successful, structure confirmed.")
else:
print("[WARNING] vectors not found in response")
except Exception as e:
print(f"[ERROR] Request failed: {e}")
+79
View File
@@ -0,0 +1,79 @@
"""
全链路验证脚本 — TPS 知识库 embedding 最小链路
验证步骤:
1. 读取 backend/sample_md/ 下的 5 篇 .md 文件
2. 调用 embo-01 转成向量(打印维度)
3. 存入 knowledge_items + knowledge_embeddings(打印行数)
4. 执行语义检索(打印查询句 + 最相似笔记)
5. 查 episodes 表行数(打印,只读不动)
"""
import os
from pathlib import Path
from dotenv import load_dotenv
from sqlmodel import text
# 加载 .env
_env_path = Path(__file__).parent.parent / ".env"
load_dotenv(str(_env_path))
from app.services.knowledge_service import KnowledgeService
from app.db.session import engine
def main():
print("=" * 60)
print("TPS Knowledge Base — Embedding Full链路验证")
print("=" * 60)
sample_dir = Path(__file__).parent.parent / "sample_md"
md_files = sorted(sample_dir.glob("*.md"))
print(f"\n[FIND] Found {len(md_files)} .md files in sample_md/")
ks = KnowledgeService()
# 1. 写入知识库
print("\n[STEP 1] Storing MD files into knowledge base...")
items_stored = []
for mf in md_files:
title = mf.stem # 文件名(不含扩展名)作为标题
content = mf.read_text(encoding="utf-8")
item = ks.store_md_file(
title=title,
content_md=content,
source_file_name=mf.name,
source_type="manual",
)
items_stored.append(item)
print(f" - Stored: {item.title} (id={item.id})")
ki_count = ks.get_item_count()
ke_count = ks.get_embedding_count()
print(f"\n[OK] knowledge_items rows: {ki_count}")
print(f"[OK] knowledge_embeddings rows: {ke_count}")
# 2. 语义检索
print("\n[STEP 2] Semantic search test...")
query = "五代战斗机的隐身技术有哪些关键要素?"
print(f"Query: {query}")
results = ks.search_similar(query, top_k=3)
print(f"\n[OK] Top 3 similar notes:")
for i, r in enumerate(results, 1):
print(f" {i}. [{r['similarity']}] {r['title']}")
# 3. 查 episodes 表行数(只读不动)
print("\n[STEP 3] Episodes table (read-only)...")
with engine.connect() as conn:
result = conn.execute(text("SELECT COUNT(*) FROM episodes"))
episode_count = result.scalar()
print(f"[OK] episodes table row count: {episode_count}")
print("\n" + "=" * 60)
print("Verification complete.")
print("=" * 60)
if __name__ == "__main__":
main()
+238
View File
@@ -0,0 +1,238 @@
-- ============================================================
-- 001_init.sql — 《军事科技》栏目内部工作台 数据库初始化脚本
-- 一次性建完所有核心表骨架(Phase 1-4 全部表)
-- 执行方式: psql -U postgres -d milsci_dev -f 001_init.sql
-- ============================================================
-- 扩展: pgvector(向量检索)
CREATE EXTENSION IF NOT EXISTS vector;
-- ============================================================
-- 1. users — 用户表(Phase 1 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
password_hash VARCHAR(128) NOT NULL,
-- 角色: zhipianren=制片人/管理员, zebian=责编, biandao=编导
role VARCHAR(20) NOT NULL DEFAULT 'biandao'
CHECK (role IN ('zhipianren', 'zebian', 'biandao')),
profile_text TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_role ON users (role);
-- ============================================================
-- 2. episodes — 节目期次(Phase 2 用,字段完整按 dev_plan.md 8.1 节)
-- ============================================================
CREATE TABLE IF NOT EXISTS episodes (
id SERIAL PRIMARY KEY,
episode_number INTEGER NOT NULL UNIQUE,
program_name VARCHAR(200) NOT NULL,
air_date DATE NOT NULL,
-- 编导(软引用 + 快照模式)
editor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
editor_name_snapshot VARCHAR(100) NOT NULL,
-- 收视数据
audience_share NUMERIC(6,4),
audience_rating NUMERIC(6,4),
-- 重播相关
is_rerun BOOLEAN NOT NULL DEFAULT FALSE,
original_episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL,
is_time_sensitive BOOLEAN NOT NULL DEFAULT FALSE,
-- 外拍信息
is_outdoor_shoot BOOLEAN NOT NULL DEFAULT FALSE,
outdoor_contact_person VARCHAR(100),
outdoor_contact_info VARCHAR(200),
-- 线索
lead_source VARCHAR(300),
-- 责编备注
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_episodes_air_date ON episodes (air_date);
CREATE INDEX idx_episodes_editor_id ON episodes (editor_id);
CREATE INDEX idx_episodes_is_rerun ON episodes (is_rerun);
-- ============================================================
-- 3. yearly_targets — 年度收视目标(Phase 2 用,只增不改)
-- ============================================================
CREATE TABLE IF NOT EXISTS yearly_targets (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL UNIQUE,
base_target NUMERIC(6,4) NOT NULL,
stretch_target NUMERIC(6,4) NOT NULL,
issued_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- 4. topics — 选题 / 报题单(Phase 4a 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS topics (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
-- 内容概要(双段结构: 引子 + 列举)
content_summary TEXT,
-- 编导关联(软引用 + 快照)
editor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
editor_name_snapshot VARCHAR(100),
-- 选题状态: draft=草稿, submitted=已提交, reviewing=审阅中, approved=通过, rejected=退回
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected')),
-- AI 反馈(JSON 格式,含查重/画像/知识库参考等模块结果)
ai_feedback JSONB,
-- 关联的 episodes / knowledge_items id 列表
related_episodes JSONB,
related_knowledge JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_topics_editor_id ON topics (editor_id);
CREATE INDEX idx_topics_status ON topics (status);
-- ============================================================
-- 5. topic_embeddings — 选题语义向量(Phase 4a 用,pgvector
-- ============================================================
CREATE TABLE IF NOT EXISTS topic_embeddings (
id SERIAL PRIMARY KEY,
topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
embedding vector(1536) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_topic_embeddings_topic_id ON topic_embeddings (topic_id);
-- pgvector 向量索引(IVFFlat,数据量上去后可用 HNSW
-- CREATE INDEX idx_topic_embeddings_vector ON topic_embeddings USING ivfflat (embedding vector_cosine_ops);
-- ============================================================
-- 6. schedules — 排期(Phase 4b 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS schedules (
id SERIAL PRIMARY KEY,
episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL,
planned_air_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'confirmed', 'completed', 'cancelled')),
editor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_schedules_air_date ON schedules (planned_air_date);
-- ============================================================
-- 7. knowledge_items — 知识库条目(Phase 3 用,2.0 兼容预埋)
-- ============================================================
CREATE TABLE IF NOT EXISTS knowledge_items (
id SERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
-- Markdown 格式正文(向 2.0 Karpathy LLM Wiki 形态靠拢)
content_md TEXT,
-- 来源信息
source_type VARCHAR(30) NOT NULL DEFAULT 'manual'
CHECK (source_type IN ('baoti', 'manuscript', 'military_report', 'manual', 'rss')),
source_file_name VARCHAR(300),
source_url VARCHAR(1000),
-- 作者 / 编导
author VARCHAR(100),
publish_date DATE,
-- ⚠️ 2.0 兼容预埋字段(1.0 不用但要建)
tags JSONB DEFAULT '[]'::jsonb,
related_entities JSONB DEFAULT '[]'::jsonb,
related_concepts JSONB DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_knowledge_items_source_type ON knowledge_items (source_type);
CREATE INDEX idx_knowledge_items_tags ON knowledge_items USING gin (tags);
-- ============================================================
-- 8. knowledge_embeddings — 知识库向量(Phase 3 用,pgvector
-- ============================================================
CREATE TABLE IF NOT EXISTS knowledge_embeddings (
id SERIAL PRIMARY KEY,
knowledge_id INTEGER NOT NULL REFERENCES knowledge_items(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL DEFAULT 0,
chunk_text TEXT NOT NULL,
embedding vector(1536) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_knowledge_embeddings_knowledge_id ON knowledge_embeddings (knowledge_id);
-- pgvector 向量索引
-- CREATE INDEX idx_knowledge_embeddings_vector ON knowledge_embeddings USING ivfflat (embedding vector_cosine_ops);
-- ============================================================
-- 9. review_comments — 审片意见(Phase 4b 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS review_comments (
id SERIAL PRIMARY KEY,
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
comment_text TEXT NOT NULL,
reviewer VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_review_comments_episode_id ON review_comments (episode_id);
-- ============================================================
-- 10. shooting_resources — 拍摄资源 / 人脉(Phase 4b 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS shooting_resources (
id SERIAL PRIMARY KEY,
contact_person VARCHAR(100) NOT NULL,
contact_info VARCHAR(200),
organization VARCHAR(200),
-- 资源类型: personnel=人员, location=场地, equipment=设备, other=其他
resource_type VARCHAR(20) DEFAULT 'other'
CHECK (resource_type IN ('personnel', 'location', 'equipment', 'other')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- 11. radar_items — 热点雷达条目(Phase 4c 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS radar_items (
id SERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
source_url VARCHAR(1000),
source_name VARCHAR(100),
summary TEXT,
-- AI 处理结果
ai_summary TEXT,
style_summary TEXT,
published_at TIMESTAMPTZ,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- 12. radar_user_actions — 编导对热点的操作(Phase 4c 用)
-- ============================================================
CREATE TABLE IF NOT EXISTS radar_user_actions (
id SERIAL PRIMARY KEY,
radar_item_id INTEGER NOT NULL REFERENCES radar_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 操作类型: deep_dive=深入了解, bookmark=收藏, ignore=忽略
action_type VARCHAR(20) NOT NULL
CHECK (action_type IN ('deep_dive', 'bookmark', 'ignore')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (radar_item_id, user_id, action_type)
);
-- ============================================================
-- 初始化完成
-- ============================================================
+23
View File
@@ -0,0 +1,23 @@
-- ============================================================
-- 002_add_cover_settings.sql — 题图设置表
-- 执行方式: psql -U postgres -d milsci_dev -f 002_add_cover_settings.sql
-- ============================================================
-- 题图设置表(只存当前展示的一张题图,不动 episodes 表)
CREATE TABLE IF NOT EXISTS cover_settings (
id SERIAL PRIMARY KEY,
key VARCHAR(50) NOT NULL UNIQUE DEFAULT 'dashboard_cover',
cover_path TEXT, -- 当前题图文件路径(相对路径,如 /static/covers/xxx.jpg
episode_number INTEGER, -- 关联期次号(可空)
episode_title TEXT, -- 关联期次节目名(可空)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 初始化默认值行(允许为空,表示使用默认渐变底图)
INSERT INTO cover_settings (key, cover_path, episode_number, episode_title)
VALUES ('dashboard_cover', NULL, NULL, NULL)
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- 迁移完成
-- ============================================================
@@ -0,0 +1,31 @@
-- ============================================================
-- 002a_diagnostic_episode_number.sql
-- 诊断脚本:将 episodes.episode_number 从全局 UNIQUE 改为 (air_year, episode_number) 复合 UNIQUE
-- 执行方式: psql -U postgres -d milsci_dev -f 002a_diagnostic_episode_number.sql
-- 前置条件:先设 $env:PGCLIENTENCODING="UTF8"
-- ============================================================
-- 三项诊断(任一异常停下找制片人)
SELECT
'total_rows' as check_name,
count(*) as value
FROM episodes
UNION ALL
SELECT
'null_air_date' as check_name,
count(*) as value
FROM episodes
WHERE air_date IS NULL
UNION ALL
SELECT
'duplicate_year_epnum' as check_name,
count(*) as value
FROM (
SELECT
EXTRACT(YEAR FROM air_date)::int as air_year,
episode_number
FROM episodes
WHERE original_episode_id IS NULL
GROUP BY 1, 2
HAVING count(*) > 1
) as dup;
@@ -0,0 +1,18 @@
-- ============================================================
-- 002b_migrate_episode_number.sql
-- 迁移脚本:在 002a 诊断确认安全后执行
-- 执行方式: psql -U postgres -d milsci_dev -f sql/002b_migrate_episode_number.sql
-- 前置条件:先设 $env:PGCLIENTENCODING="UTF8"
-- 注意:已改用 date_part('year', air_date) 替代 EXTRACT(... )::int 避免语法错误
-- ============================================================
-- 1. 删全局 UNIQUE 约束(幂等:constraint 已删则报 warning,正常)
ALTER TABLE episodes DROP CONSTRAINT episodes_episode_number_key;
-- 2. 建按年复合唯一索引(重播行 original_episode_id IS NOT NULL 不参与)
CREATE UNIQUE INDEX idx_episodes_year_number
ON episodes (date_part('year', air_date), episode_number)
WHERE original_episode_id IS NULL;
-- 验证:确认索引生效
-- \d episodes
+10
View File
@@ -0,0 +1,10 @@
-- ============================================================
-- 002c_recreate_index_only.sql
-- 002b 跑半截翻车后补救:只执行 CREATE INDEX,不要再 DROP CONSTRAINT
-- 执行方式: psql -U postgres -d milsci_dev -f sql/002c_recreate_index_only.sql
-- 前置条件:先设 $env:PGCLIENTENCODING="UTF8"
-- ============================================================
CREATE UNIQUE INDEX idx_episodes_year_number
ON episodes (date_part('year', air_date), episode_number)
WHERE original_episode_id IS NULL;
+13
View File
@@ -0,0 +1,13 @@
**本文件是制片人刘通与 Claude(Opus 4.7 高级技术顾问)的协作原则,与项目宪法(.clinerules / project_plan.md / dev_plan.md / docs/git_workflow.md)并列。新对话接手时,请连同宪法一并阅读本文件。**
---
**Claude 与我交流、沟通、协作时需要注意的原则**
1. 全程使用简体中文。
2. 我不懂代码编程,给我的解释说明和理由要尽量简明扼要。不要堆叠大量技术语言,说人话。
3. 请参阅宪法文件中你(Claude)的定位:写具体 Plan 是 Cline 的事,不要架空 Cline 的 Plan 模式。除非特别必要(Cline 反复修改都过不去的坎)可以上手,以及制片人明确指定你可以操刀的具体工作。
4. 给 Cline 的指令要用代码块封装起来,方便复制。
+22 -17
View File
@@ -2,7 +2,7 @@
> 项目:TPS(Topic Planning System)中台
> 仓库:`tps-dashboard`(Gitea)
> 修订版本:v3 · 2026-05-06
> 修订版本:v5 · 2026-05-27
> 配套文档:`project_plan.md`(产品宪法)+ `.clinerules`(协作规则)+ `docs/git_workflow.md`(Git 操作手册)
---
@@ -62,7 +62,7 @@
**路径 B — 迁移(推荐)**:
1. 制片人在家用机把 `backend/` 整个目录打包 zip
2. 拷到单位机,解压进 `tps-dashboard/backend/`
3. Cline 跑一遍 Plan,Sonnet 审一遍代码确认与新版宪法一致
3. Cline 跑一遍 Plan,MiniMax M2.7 审一遍代码确认与新版宪法一致
4. 修任何不一致(目前看应无重大不一致)
5. 跑一遍 `pip install + psql + create_admin + uvicorn` 验证
@@ -90,8 +90,7 @@
| 向量检索 | **pgvector**(PG 扩展) | 不用独立向量库 |
| 向量维度 | **1536**(为 2.0 wiki 兼容) | 不可改 |
| AI 嵌入模型 | DeepSeek 或 OpenAI 兼容 embedding API | — |
| AI 编程模型 | Phase 0-1: Sonnet Plan + DeepSeek pro Act | 详见 `.clinerules` 第二章 |
| AI 编程模型 | Phase 2 起: MiniMax M2.7 Plan + DeepSeek pro Act | — |
| AI 编程模型 | **MiniMax M2.7**(Cline Plan + Act 模式) | 详见 `.clinerules` 第二章 |
| 认证 | **Session + Cookie** | 不用 JWT |
| Word 生成 | docxtpl | — |
| 甘特图前端库 | frappe-gantt | 不自己用 D3 画 |
@@ -142,7 +141,7 @@
**Day 2:开发工具**
- VS Code + Cline 扩展
- Cline 配置 Plan 模式(Sonnet)+ Act 模式(DeepSeek pro)
- Cline 配置 Plan 模式(MiniMax M2.7)+ Act 模式(MiniMax M2.7),Plan 和 Act 用同一模型但物理隔离两个心智阶段(纪律性)
- VS Code 终端打开测试:能跑 `python --version` / `node --version` / `git --version` / `psql --version`
**Phase 0 收尾**:
@@ -163,7 +162,7 @@ git push origin phase0-env-ready
**Day 3-4:后端骨架**
- **路径 B 推荐**:从家用机迁移 backend 整目录,Sonnet 审一遍,跑通验证
- **路径 B 推荐**:从家用机迁移 backend 整目录,MiniMax M2.7 审一遍,跑通验证
- **路径 A 备选**:从零开始重写
- 验收:`uvicorn app.main:app --reload` 能起,`/docs` 页面能开,`/api/auth/login` + `/api/auth/me` + `/api/auth/logout` 三接口验证通过
@@ -202,7 +201,7 @@ git push origin phase0-env-ready
- 编辑 episodes / yearly_targets / users 的 SQLModel 模型
**前端**:
- 仪表盘页面(近 4 期收视 + 未来 6 期排期)
- 仪表盘页面(近 9 期收视 + 未来 6 期排期)
- 责编录入界面
- 管理员用户管理界面
@@ -213,7 +212,7 @@ git push origin phase0-env-ready
- 后端用 pandas 解析,批量 INSERT
- **死规矩**:导入时检测 yearly_targets 是否对应年份齐全,缺则报错让责编先填(防止颜色判定时找不到目标)
**Phase 2 起 Plan 模式切换为 MiniMax M2.7**:Sonnet 余额耗尽前最后一个由 Sonnet 出方案的 Phase,Phase 2 末做切换的复盘评估
**Phase 2 已进入**:Plan = Act = MiniMax M2.7,DeepSeek v4 pro 保留为备选
**Phase 2 里程碑**:
- 责编可以开始录入(或导入)历史收视数据
@@ -250,19 +249,23 @@ git push origin phase0-env-ready
---
### Phase 4a:核心 MVP(Week 5-6)
### Phase 4a:核心 MVP — 对话式 TPS(Week 5-6)
**模块 G**:报题单生成与下载(docxtpl 模板)
- 模板严格遵守"双段结构"(`.clinerules` 5.4)
> v5 升级:模块 A/C/G 合成"对话式策划助手"形态(左对话右报告、脚注式引用),详见 `project_plan.md` 模块 A/C/G + 第 2.3 节 + 6.5 节红线。
**模块 A**:选题查重(语义检索 + 历史收视展示)
- 联动模块 E 展示外拍资源
**前置条件(硬门槛,不满足不开工)**:
- 知识库笔记已批量录入(Backlog#2),收视数据已真实导入(Phase 2 遗留债)。**库空时对话助手"无米下锅",依据全空会毁掉第一印象。**
- Phase 3 的**朴素语义检索**已先行跑通(输入文本→返回最相关条目,带相关度/原文片段/来源),作为对话式报告的检索底座。
**模块 C**:知识库参考(检索往期文稿 / 军报)
**模块 A(对话式策划主入口)**:左栏编导对话、右栏 AI 加工的策划参考报告(历史是否做过/收视/审片意见/可参考节目/外拍资源/知识库依据)。严守三条交互红线:求助式不投喂、脚注式锚定引用、持续对话沉淀编导判断(见 `project_plan.md` 模块 A)。
**前端**:选题提交表单 + AI 反馈页面布局
**模块 C(知识库依据供给)**:检索结果融入右栏报告作脚注依据,而非孤立列表。
**🎯 Week 6 末上线 MVP**——团队真的可以用了:登录、看仪表盘、提交选题、看到查重和知识库结果、生成报题单 docx 下载
**模块 G(报题单生成)**:聊定后一键按范本规范化生成 docx。**只做规范化呈现,不代写立意**(见模块 G 分寸线 + 6.5 红线)。模板严守"双段结构"(`.clinerules` 5.4)
**AI 成本机制**:本 Phase 起对话调用大模型,按第十一章"AI 成本与额度原则"落地——按 token 记、按人+选题归因、保守上限 + 超额提醒管理员。**朴素的一张用量表即可,严禁上 AI 网关/可观测性框架等大公司重型武器。**
**🎯 Week 6 末上线 MVP**——团队真的可以用了:登录、看仪表盘、与 TPS 对话策划选题、看到有依据的策划报告、聊定后一键生成报题单 docx 下载。
**收尾**:写 `logs/phase4a_log.md`,打 `phase4a-mvp-launch` tag。**这是项目第一次"对内发布",务必郑重打 tag**。
@@ -486,7 +489,9 @@ tps-dashboard/ ← Git 仓库根
|---|---|---|
| v1 | 2026-04 | 初版 |
| v2 | 2026-05-04 | 精简技术栈、Phase 拆分(避免 Phase 4 大爆炸)、明确每周里程碑 |
| **v3** | **2026-05-06** | **加 Git + Gitea 工作流;Phase 0 重排(纳入 Git);Phase 2 起 Plan 切 MiniMax M2.7;明确单位机重启路径(路径 B 推荐);新增 docs/ 目录与 phase{N} tag 收尾仪式** |
| v3 | 2026-05-06 | 加 Git + Gitea 工作流;Phase 0 重排(纳入 Git);Phase 2 起 Plan 切 MiniMax M2.7;明确单位机重启路径(路径 B 推荐);新增 docs/ 目录与 phase{N} tag 收尾仪式 |
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录 |
| **v5** | **2026-05-27** | **Phase 4a 升级为"对话式 TPS"(对齐 project_plan v5):模块 A/C/G 合成左对话右报告形态;补硬门槛(数据灌入 + Phase 3 朴素检索先行);加 AI 成本机制落地说明** |
---
@@ -0,0 +1,88 @@
# backlog.md — TPS 工作台待办池(不丢视野,非排期)
> 维护人:刘通 + 顾问 Opus
> 用途:记录"暂不做但不能漏"的事项。区别于 phase 计划,这里是池子,
> 每个 Phase 收尾时回看一遍,决定捞哪条进下个 Phase。
---
## A. 定位澄清(认知,非待办)
- 本系统对外称"中台/工作台",实质是**单栏目业务系统**(一条业务线、8 用户)。
- 不套真中台那套(多租户/服务网格/配置中心/跨线统一权限)——对 8 人是纯负担。
- 真正定位:把散在 Excel/Word/微信/个人电脑/师徒口传的工作流,收拢成
有沉淀、有权限、可检索的系统。核心价值 = 经验从"靠人"变"靠系统"。
- 现有克制选择(不用 TS / 不用 Alembic / pgvector 不用独立向量库 /
Session 不用 JWT)方向正确,保持。
---
## B. 工作台核心能力缺口(按优先级)
### B1. 选题状态流转【优先级:高】
- 现状:episodes 存收视+元数据,但"提报→立项→排期→拍摄→制作→播出→复盘"
这条状态线是散的。
- 建议:给 topics/episodes 加状态机字段,制片人一眼看到每个选题卡在哪环。
- 与模块 F 甘特图区别:甘特是排期视角,这是业务状态视角,两回事。
- 触发时机:Phase 4b(TPS 闭环时一并补最自然)。
### B2. 操作留痕 / 审计【优先级:高,成本极低,建议早埋】
- 现状:谁改收视、谁删选题、谁换题图,全靠信任,无记录。
- 建议:核心业务表(episodes/topics/yearly_targets)加
created_by / updated_by / updated_at 字段即可,不必做复杂日志系统。
- 触发时机:越早埋越好,建议 Phase 3 起手时顺带加(改表成本最低)。
### B3. 数据导出 / 备份【优先级:中】
- 现状:Phase 2 做了 Excel 导入,导出是它的镜像,迟早要。
- 收视、选题是栏目资产,将来要能导出 Excel、能定期备份。
- 触发时机:Phase 5 上线前后。
### B4. 通知 / 待办提醒【优先级:低】
- "选题被审了""该交稿了"这类。小团队可能微信/Mattermost 群就解决了。
- 若 B 见下方与 Mattermost 打通,可借 IM 发通知,不必自建。
- 触发时机:看实际使用反馈再定,可能不做。
### B5.关联【优先级:低】
题图"自动算近5期最高份额提示"本期做的是静态文案占位, 自动计算挪二期(原计划)。
---
## C. 跨系统整合(与现有其他系统合服务器、打通)
### C1. 蓝皓配音 TTS 2.0 合服务器【待商议】
- 背景:TTS 2.0 现为独立网站/服务器,单租服务器费钱。
- 设想:与 TPS 中台挪到同一台服务器,省一份租金。
- 待办:制片人把 TTS 2.0 源代码给 Opus → 一起评估
①技术栈是否冲突(端口/依赖/数据库)②Nginx 反代能否共存
③资源够不够(4核8G 轻量服务器跑两套+PG+pgvector 要算内存账)。
- 注:dev_plan 部署章已写"Nginx 反代整合 TPS / 蓝皓 TTS / MMIM",本条是落实。
- 触发时机:Phase 5 部署规划时。
### C2. Mattermost(内部 IM) 打通【待商议】
- 背景:团队内部 IM 用 Mattermost,部署在与 TTS 2.0 同一服务器。
- 设想:TPS 的通知/待办(见 B4)可推到 Mattermost,不必自建通知系统。
- 待办:评估 Mattermost incoming webhook 接入成本(通常很低)。
- 触发时机:B4 真要做时一并考虑。
---
## D. 已知技术隐患(从 phase log 汇集,不漏)
- backend/ 补 venv(全局依赖搬进去)。
- 隐患 AImportError 与 Python 内置同名(低优)。
- 隐患 BExcelService 返回 Pydantic 化(低优)。
- Phase 5 部署安全:SECRET_KEY 换真随机、same_site=strict + https_only=True、
上 SSL、关 /docs。
- Phase 5 上线前数据清理:清测试数据(期次 8888/8889、年度目标 2027/2028
占位等),录真数据。
- logo 路径用 /src/assets/...(Vite开发模式),生产打包会失效,Phase5部署改。 - reset_password.py 能改任意用户密码,Phase5部署前不能暴露在生产环境。 - 柱图设计为取最近12期,当前真实数据仅7期故显7根;Phase5录真数据后自动补齐,届时确认满12根正常。 - 完成率用 new Date().getFullYear() 取当前年:每年初需录入当年 yearly_targets, 否则跨年后两项完成率显"—"。(运营提醒,非bug)
---
## E. 明确不做(防止被"中台"概念诱导加功能)
- 多租户、服务网格、配置中心、跨业务线统一权限平台 —— 8 人单栏目纯负担。
- 任何让 AI 替编导拍板的功能 —— 违背"AI 给方向不给答案"。
+26 -17
View File
@@ -2,7 +2,7 @@
> 项目:TPS 中台
> 仓库:`tps-dashboard`(单位 Gitea)
> 文档版本:v1 · 2026-05-06
> 文档版本:v2 · 2026-05-14
> 受众:**制片人(不会 Git)** + Cline(自动遵守此规则)
---
@@ -22,7 +22,7 @@
### 1.1 三个核心概念,一分钟搞定
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `D:\TPS-中台\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `E:\tps-dashboard\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
**commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。
@@ -71,7 +71,7 @@ git clone http://localhost:3000/simonkoson/tps-dashboard.git
```powershell
# 切换到你想放项目的位置
cd Z:\AIworks\
cd E:\tps-dashboard\
# 把刚才复制的 clone 命令粘贴运行
git clone http://localhost:3000/simonkoson/tps-dashboard.git
@@ -88,12 +88,12 @@ done.
`warning: empty repository` 是**正常的**——本来就是空仓库,不是错误。
完成后 `Z:\AIworks\` 下多了一个 `tps-dashboard` 文件夹。
完成后 `E:\tps-dashboard\` 下多了一个 `tps-dashboard` 文件夹。
### 2.3 进仓库目录
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
```
之后所有 git 命令都在这个目录里跑。
@@ -104,17 +104,17 @@ cd Z:\AIworks\tps-dashboard
**手动操作**(用 Windows 资源管理器):
1.`Z:\AIworks\tps-dashboard\` 下:
1.`E:\tps-dashboard\` 下:
- 直接拷入 `.clinerules``project_plan.md``dev_plan.md``README.md`(平铺四份)
2.`Z:\AIworks\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
2.`E:\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
3.`Z:\AIworks\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
3.`E:\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
**最终结构应该是**:
```
Z:\AIworks\tps-dashboard\
E:\tps-dashboard\
├── .clinerules
├── project_plan.md
├── dev_plan.md
@@ -132,7 +132,7 @@ Z:\AIworks\tps-dashboard\
**操作**:在 VS Code 终端里执行:
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
notepad .gitignore
```
@@ -244,7 +244,7 @@ branch 'main' set up to track 'origin/main'.
### 3.1 每天开工第一件事
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
git status # 看看本地有没有未提交的改动
git pull # 拉 Gitea 最新(如果别的机器推过)
```
@@ -259,16 +259,16 @@ git status # 看看有没有未提交的
### 3.3 让 Cline 帮你提交
最方便的方式——**让 Sonnet 在 Plan 里下命令**:
最方便的方式——**让 MiniMax M2.7 在 Plan 里下命令**:
> "现在改完了,请帮我 commit + push,commit message 写: feat: 添加 XXX 功能"
Cline(Sonnet → DeepSeek)会自动跑 git add / git commit / git push。**这是默认推荐方式**。
Cline(MiniMax M2.7)会自动跑 git add / git commit / git push。**这是默认推荐方式**。
### 3.4 你自己手工提交(备选)
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
git add -A
git commit -m "fix: 修复登录页跳转问题"
git push
@@ -386,7 +386,7 @@ git push origin phase1-complete
### Q5:换了一台机器,怎么把代码搞过来
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
git clone http://localhost:3000/simonkoson/tps-dashboard.git
cd tps-dashboard
# 然后跟原来一样开工就行
@@ -404,9 +404,17 @@ git reset HEAD~1 # 撤回最近一次 commit,文件改动保留
---
## 第 7 章:多设备同步(家用机 ↔ 单位机)
## 第 7 章:多设备同步(已废弃)
### 离开 A 机器前
> ⚠️ **2026-05-14 更新**:家用机已于 2026-05-14 退役,项目统一在单位 4090D 开发。
> 本章描述的多设备同步模式**已废弃**,保留仅供历史参考。
> 当前工作流:单一开发机,每日开工 `git pull`,离开前 `git push`。
---
### 7.1 历史模式(已废弃)
#### 离开 A 机器前
```powershell
git status # 看有没有未提交的
@@ -432,6 +440,7 @@ git status # 确认干净
| 版本 | 日期 | 变化 |
|---|---|---|
| v1 | 2026-05-06 | 初版 |
| **v2** | **2026-05-14** | **项目目录从 NAS(Z:\AIworks\)迁移至本地 SSD(E:\tps-dashboard\);家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
---
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3764
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.2.3",
"antd": "^6.4.2",
"axios": "^1.16.1",
"dayjs": "^1.11.20",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import AppLayout from './components/Layout/AppLayout'
import Login from './pages/Login/Login'
import Dashboard from './pages/Dashboard/Dashboard'
import EditorHome from './pages/EditorHome/EditorHome'
import TPS from './pages/TPS/TPS'
import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
import Doco from './pages/Doco/Doco'
import UserManage from './pages/UserManage/UserManage'
import EditorDesk from './pages/EditorDesk/EditorDesk'
import AuthGuard from './components/AuthGuard/AuthGuard'
import RoleGuard from './components/AuthGuard/RoleGuard'
const theme = {
token: {
colorPrimary: '#6b8e6b',
borderRadius: 12,
borderRadiusLG: 16,
},
}
function App() {
return (
<ConfigProvider locale={zhCN} theme={theme}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<AuthGuard />}>
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="tps" element={<TPS />} />
<Route path="knowledge" element={<KnowledgeBase />} />
<Route path="doco" element={<Doco />} />
<Route path="editor-home" element={<EditorHome />} />
<Route
path="users"
element={
<RoleGuard roles={['zhipianren']}>
<UserManage />
</RoleGuard>
}
/>
<Route
path="editor-desk"
element={
<RoleGuard roles={['zhipianren', 'zebian']}>
<EditorDesk />
</RoleGuard>
}
/>
</Route>
</Route>
</Routes>
</BrowserRouter>
</ConfigProvider>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,21 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import useAuthStore from '../../stores/authStore'
function AuthGuard() {
const { user, fetchMe } = useAuthStore()
useEffect(() => {
if (user === null) {
fetchMe()
}
}, [user, fetchMe])
if (user === null) {
return null
}
return user ? <Outlet /> : <Navigate to="/login" replace />
}
export default AuthGuard
@@ -0,0 +1,20 @@
/**
* 角色路由守卫 — 拦截非授权角色访问受保护路由
* 用法: <Route path="/users" element={<RoleGuard roles={['zhipianren']}><UserManage /></RoleGuard>} />
*/
import { Navigate } from 'react-router-dom'
import useAuthStore from '../../stores/authStore'
function RoleGuard({ children, roles }) {
const { user } = useAuthStore()
if (!user) return null // AuthGuard 已处理未登录跳转
if (!roles.includes(user.role)) {
return <Navigate to="/dashboard" replace />
}
return children
}
export default RoleGuard
@@ -0,0 +1,44 @@
.kt-container {
display: flex;
flex-direction: column;
height: 100%;
}
.kt-header {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.kt-tree-wrapper {
flex: 1;
overflow-y: auto;
padding: 8px 4px;
}
.kt-empty {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
color: #999;
font-size: 13px;
}
/* 节点文字 */
.kt-node-title {
font-size: 13px;
color: #3b4a3b;
}
/* 全部根节点高亮 */
.kt-node-root {
font-weight: 600;
color: #3b4a3b;
}
/* Tree 选中态 */
.kt-container .ant-tree-node-selected .kt-node-title {
color: #6b8e6b;
font-weight: 600;
}
@@ -0,0 +1,147 @@
/**
* 知识库树形导航组件(交互层)
*
* 职责:
* - 树的展开/收起/记忆状态
* - 节点高亮 + 联动回调
* - 「全部展开 / 全部收起」操作按钮
*
* 数据分组逻辑完全委托给后端 get_grouped_items(数据分组层),
* 本组件只管交互,不感知分组的业务含义。
*
* 二级节点 key 格式(由后端控制):
* - 大类节点: "manuscript"
* - 二级节点(新格式): "manuscript|author|左鑫"
* type|二级字段名|字段值
* - 二级节点(出处,杂志文章): "military_report|source_detail|航空知识..."
*
* 回调给 KnowledgeBase:统一返回 { type, detail }
* - 大类节点 → { type: "manuscript", detail: null }
* - 作者二级节点 → { type: "manuscript", detail: "左鑫" }
* - 出处二级节点 → { type: "military_report", detail: "航空知识..." }
* detail=author名 或 source_detail原文,与 displayedItems 过滤逻辑对齐)
*/
import { useState, useCallback } from 'react'
import { Tree, Button, Space } from 'antd'
import { CaretDownFilled, NodeIndexOutlined } from '@ant-design/icons'
import './KnowledgeTree.css'
const { TreeNode } = Tree
export default function KnowledgeTree({
treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部)
selectedKey, // 当前选中的 key(用于高亮)
}) {
const [expandedKeys, setExpandedKeys] = useState(() => {
// 默认全部展开
if (!treeData || treeData.length === 0) return []
const root = treeData[0]
return [root.key, ...(root.children || []).map(c => c.key)]
})
// 全部展开
const handleExpandAll = useCallback(() => {
if (!treeData || treeData.length === 0) return
const root = treeData[0]
const allKeys = [root.key]
for (const child of root.children || []) {
allKeys.push(child.key)
if (child.children) {
for (const grandchild of child.children) {
allKeys.push(grandchild.key)
}
}
}
setExpandedKeys(allKeys)
}, [treeData])
// 全部收起
const handleCollapseAll = useCallback(() => {
setExpandedKeys([])
}, [])
// 节点选择
const handleSelect = useCallback((selectedKeys, info) => {
if (selectedKeys.length === 0) {
onNodeSelect(null)
return
}
const key = selectedKeys[0]
// 解析 key 格式:
// "all" → 全部根节点
// "manuscript" → 大类节点
// "manuscript|author|左鑫" → 二级节点(新格式:type|fieldName|fieldValue
// "military_report|source_detail|..." → 二级节点(出处)
if (key === 'all') {
onNodeSelect(null)
return
}
const parts = key.split('|')
if (parts.length === 1) {
// 大类节点
onNodeSelect({ key, type: parts[0], detail: null })
} else {
// 二级节点:parts[0]=type, parts[1]=字段名(废弃不用), parts[2]=字段值
// 统一以 parts[2] 作为 detail(作者名 或 出处名),与 displayedItems 过滤逻辑对齐
onNodeSelect({ key, type: parts[0], detail: parts[2] })
}
}, [onNodeSelect])
// 渲染节点
const renderNode = (node, isRoot = false) => {
return (
<TreeNode
key={node.key}
title={
<span className={`kt-node-title ${isRoot ? 'kt-node-root' : ''}`}>
{node.label}
</span>
}
icon={isRoot ? <NodeIndexOutlined /> : undefined}
>
{(node.children || []).map(child => renderNode(child, false))}
</TreeNode>
)
}
if (!treeData || treeData.length === 0) {
return (
<div className="kt-empty">
暂无知识库条目
</div>
)
}
return (
<div className="kt-container">
<div className="kt-header">
<Space size="small">
<Button size="small" onClick={handleExpandAll}>
全部展开
</Button>
<Button size="small" onClick={handleCollapseAll}>
全部收起
</Button>
</Space>
</div>
<div className="kt-tree-wrapper">
<Tree
showIcon
showLine={{ showLeafIcon: false }}
expandedKeys={expandedKeys}
selectedKeys={selectedKey ? [selectedKey] : []}
onExpand={(keys) => setExpandedKeys(keys)}
onSelect={handleSelect}
blockNode
switcherIcon={<CaretDownFilled />}
>
{treeData.map(node => renderNode(node, true))}
</Tree>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
.app-layout {
min-height: 100vh;
}
.app-sider {
background: #fff !important;
border-right: 1px solid #f0f0f0;
width: 220px;
flex: 0 0 220px;
overflow-y: auto;
}
.app-main {
min-height: 100vh;
}
.app-header {
background: #fff !important;
border-bottom: 1px solid #f0f0f0;
padding: 0 24px !important;
height: 64px;
line-height: 64px;
position: sticky;
top: 0;
z-index: 100;
}
.app-content {
padding: 24px;
background: var(--color-bg-cream);
min-height: calc(100vh - 64px);
max-width: 1400px;
margin: 0 auto;
}
@@ -0,0 +1,27 @@
import { Layout } from 'antd'
import { Outlet } from 'react-router-dom'
import SideNav from './SideNav'
import TopBar from './TopBar'
import './AppLayout.css'
const { Sider, Header, Content } = Layout
function AppLayout() {
return (
<Layout className="app-layout">
<Sider width={220} className="app-sider">
<SideNav />
</Sider>
<Layout className="app-main">
<Header className="app-header">
<TopBar />
</Header>
<Content className="app-content">
<Outlet />
</Content>
</Layout>
</Layout>
)
}
export default AppLayout
@@ -0,0 +1,62 @@
.side-nav {
padding: 16px 0;
height: 100%;
}
.side-nav-logo {
padding: 16px 24px 24px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.logo-text {
display: block;
font-size: 18px;
font-weight: 600;
color: var(--color-primary-green);
}
.logo-sub {
display: block;
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 2px;
}
.side-nav-menu {
border-right: none !important;
}
.side-nav-menu .ant-menu-item {
margin: 4px 8px;
border-radius: 8px;
}
.side-nav-menu .ant-menu-item-selected {
background: var(--color-accent-green) !important;
color: var(--color-primary-green) !important;
}
/* disabled 项整体灰化 + 不可点击 */
.side-nav-menu .ant-menu-item-disabled {
opacity: 0.55;
cursor: not-allowed !important;
}
/* 即将上线小标签 */
.menu-soon-tag {
font-size: 10px;
color: #999;
background: #f0f0f0;
padding: 1px 5px;
border-radius: 4px;
margin-left: 6px;
vertical-align: middle;
white-space: nowrap;
}
/* disabled 项内标签不受 opacity 影响 */
.side-nav-menu .ant-menu-item-disabled .menu-soon-tag {
opacity: 1;
color: #888;
}
@@ -0,0 +1,84 @@
import { Menu } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
FileTextOutlined,
BookOutlined,
SyncOutlined,
UserOutlined,
TeamOutlined,
SoundOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
function SideNav() {
const navigate = useNavigate()
const location = useLocation()
const { user } = useAuthStore()
const allMenuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
{
key: 'tts-placeholder',
icon: <SoundOutlined />,
label: (
<span className="menu-item-with-tag">
蓝皓配音 TTS 2.0
<span className="menu-soon-tag">即将上线</span>
</span>
),
disabled: true,
},
{
key: 'collab-placeholder',
icon: <TeamOutlined />,
label: (
<span className="menu-item-with-tag">
内部协作Mattermost
<span className="menu-soon-tag">即将上线</span>
</span>
),
disabled: true,
},
]
// 按角色过滤菜单项
// user.role 值来自后端 /api/auth/me,应为字符串 'zhipianren' / 'zebian' / 'biandao'
const visibleItems = allMenuItems.filter(item => {
// /dashboard 三角色都可见
if (item.key === '/dashboard') return true
// /users 仅 zhipianren
if (item.key === '/users') return String(user?.role) === 'zhipianren'
// /editor-desk 仅 zhipianren + zebian
if (item.key === '/editor-desk') {
const role = String(user?.role)
return role === 'zhipianren' || role === 'zebian'
}
// 其余三角色都可见(保留后期 Phase 入口)
return true
})
return (
<div className="side-nav">
<div className="side-nav-logo">
<span className="logo-text">军事科技</span>
<span className="logo-sub">TPS 工作台</span>
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={visibleItems}
onClick={({ key }) => navigate(key)}
className="side-nav-menu"
/>
</div>
)
}
export default SideNav
+27
View File
@@ -0,0 +1,27 @@
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
}
.top-bar-search {
flex: 1;
max-width: 400px;
}
.top-bar-actions {
display: flex;
align-items: center;
gap: 16px;
}
.top-bar-icon {
font-size: 18px;
color: var(--color-text-secondary);
cursor: pointer;
}
.top-bar-avatar {
cursor: pointer;
}
+38
View File
@@ -0,0 +1,38 @@
import { Input, Dropdown, Avatar } from 'antd'
import { SearchOutlined, BellOutlined, LogoutOutlined } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import './TopBar.css'
function TopBar() {
const { user, logout } = useAuthStore()
const items = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出',
onClick: () => logout(),
},
]
const displayName = user?.display_name || user?.username || '未知用户'
const initials = displayName.slice(0, 1).toUpperCase()
return (
<div className="top-bar">
<div className="top-bar-search">
<Input placeholder="搜索..." prefix={<SearchOutlined />} />
</div>
<div className="top-bar-actions">
<BellOutlined className="top-bar-icon" />
<Dropdown menu={{ items }} placement="bottomRight">
<Avatar className="top-bar-avatar" style={{ backgroundColor: 'var(--color-primary-green)' }}>
{initials}
</Avatar>
</Dropdown>
</div>
</div>
)
}
export default TopBar
+171
View File
@@ -0,0 +1,171 @@
/**
* 知识库数据分组层(交互层与分组维度完全解耦)
*
* 本模块职责:
* - 接收 items 数组,按指定维度生成分组树数据
* - 完全不涉及 UI(无 React 组件、无 Ant Design 引用)
*
* 将来切换为「按主题/装备分组」时:
* - 只改本文件中的 groupItemsBySource 函数
* - 前端 KnowledgeTree 组件一行都不用动
*/
/**
* ============================================================
* 📌 二级分组维度映射表(核心配置,一处可改)
* ============================================================
* key = source_typevalue = 用来做二级分组的字段名(取 items 里的字段)
* null = 该大类不建二级节点,条目直接挂在大类下
*
* 当前配置:
* - manuscript(节目文稿) → 按 author(作者/编导)归堆
* - military_report(杂志文章)→ 按 source_detail(出处)归堆
* - baoti(报题单) → 不分组
* - manual(其他) → 不分组
*
* 将来调整某类的二级维度,只需改这一张表,本文件分组逻辑全部收敛。
*/
const SECONDARY_GROUP_FIELD = {
manuscript: 'author', // 节目文稿 → 按作者(编导)
military_report: 'source_detail', // 杂志文章 → 按出处
baoti: null, // 报题单 → 不分组
manual: null, // 其他 → 不分组
}
/**
* ============================================================
* 📌 来源大类固定显示顺序(制片人 Obsidian 习惯)
* ============================================================
* 即使某类暂时为空也按此顺序; 有数据时即按此次序排列。
* 已有类别按此次序排,未知类别兜底排末尾。
*/
const SOURCE_TYPE_ORDER = [
'manuscript', // 节目文稿
'military_report', // 杂志文章
'baoti', // 报题单
'manual', // 其他
]
/**
* source_type 中文标签
*/
const SOURCE_TYPE_LABEL = {
military_report: '杂志文章',
manuscript: '节目文稿',
baoti: '报题单',
manual: '其他',
}
/**
* 按来源(source_type → 二级字段)分组
* @param {Array} items - 知识库条目数组,含 source_type 字段,及 SECONDARY_GROUP_FIELD 中指定的二级字段
* @returns {Array} 树形结构数据,供 KnowledgeTree 组件渲染
*
* 返回格式:
* [
* {
* key: "all",
* label: "全部(N条)",
* count: 总条目数,
* children: [
* {
* key: "manuscript",
* label: "节目文稿(n条)",
* count: n,
* children: [ // SECONDARY_GROUP_FIELD[st] 为 null 时为空数组
* { key: "manuscript|author|左鑫", label: "左鑫(3条)", count: 3 }
* ]
* },
* ...
* ]
* }
* ]
*
* 二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
* 例:manuscript|author|左鑫
* military_report|source_detail|航空知识 2026年第1期
*
* 注意:
* - 二级字段值为 null / 空字串 → 归入对应大类,不造空节点
* - 无效的 source_type → 归入 'manual' 大类
*/
export function groupItemsBySource(items) {
const totalCount = items.length
// 按 source_type 分组(按固定顺序遍历)
const typeGroups = {}
// 初始化所有已知类别,确保空类别也出现在树中
for (const st of SOURCE_TYPE_ORDER) {
typeGroups[st] = []
}
for (const item of items) {
const st = item.source_type || 'manual'
// 未知类别兜底(如 'journal' 等新加类型)
if (!typeGroups[st]) {
typeGroups[st] = []
}
typeGroups[st].push(item)
}
const children = []
// 按固定顺序遍历 SOURCE_TYPE_ORDER
for (const st of SOURCE_TYPE_ORDER) {
const stItems = typeGroups[st] || []
if (stItems.length === 0) {
// 空类别也按顺序出现在树中(0条)
children.push({
key: st,
label: `${SOURCE_TYPE_LABEL[st] || st}0条)`,
count: 0,
children: [],
})
continue
}
const secondaryField = SECONDARY_GROUP_FIELD[st] || null
const grandchildren = []
if (secondaryField !== null) {
// 按二级字段分组
const detailGroups = {}
for (const item of stItems) {
// 取二级字段值,null/空字串统一为 null 处理(不造空节点)
const sd = (item[secondaryField] || '').trim() || null
if (!detailGroups[sd]) {
detailGroups[sd] = []
}
detailGroups[sd].push(item)
}
for (const [sd, sdItems] of Object.entries(detailGroups)) {
if (sd !== null) {
// 二级节点 key 格式:type|fieldName|fieldValue
grandchildren.push({
key: `${st}|${secondaryField}|${sd}`,
label: `${sd}${sdItems.length}条)`,
count: sdItems.length,
})
}
}
}
// secondaryField 为 null 时,grandchildren 保持为空数组 → 大类直接可点击
children.push({
key: st,
label: `${SOURCE_TYPE_LABEL[st] || st}${stItems.length}条)`,
count: stItems.length,
children: grandchildren,
})
}
return [
{
key: 'all',
label: `全部(${totalCount}条)`,
count: totalCount,
children,
},
]
}
+34
View File
@@ -0,0 +1,34 @@
:root {
/* 奶油底,主背景 */
--color-bg-cream: #fbf9f1;
/* 深绿,主色 */
--color-primary-green: #6b8e6b;
/* 浅绿,辅色 */
--color-accent-green: #c8d9b0;
/* 文字主色 */
--color-text-primary: #1a1a1a;
/* 文字次要色 */
--color-text-secondary: #6b6b6b;
/* 卡片大圆角 */
--radius-card: 16px;
/* 按钮圆角 */
--radius-button: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg-cream);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
width: 100%;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,151 @@
import { useState, useEffect } from 'react'
import { Modal, Upload, Select, Button, message } from 'antd'
import { UploadOutlined, InboxOutlined } from '@ant-design/icons'
import { uploadCover } from '../../services/dashboardService'
const { Dragger } = Upload
/**
* 更换题图 Modal
*
* 权限:仅 zhipianren / zebian 可用(由父组件控制显示/隐藏)
*
* @param {boolean} open - Modal 是否显示
* @param {Function} onClose - 关闭回调
* @param {Function} onUploaded - 上传成功回调,传入新题图对象
* @param {Array} episodes - 期次列表(用于下拉选择关联期次)
*/
function ChangeCoverModal({ open, onClose, onUploaded, episodes = [] }) {
const [file, setFile] = useState(null)
const [previewUrl, setPreviewUrl] = useState(null)
const [selectedEpisode, setSelectedEpisode] = useState(null)
const [uploading, setUploading] = useState(false)
// 每次打开时重置状态
useEffect(() => {
if (open) {
setFile(null)
setPreviewUrl(null)
setSelectedEpisode(null)
setUploading(false)
}
}, [open])
// 选择文件后生成预览
const handleFileChange = (info) => {
const rawFile = info.file.originFileObj || info.file
if (!rawFile) return
setFile(rawFile)
const url = URL.createObjectURL(rawFile)
setPreviewUrl(url)
}
const handleUpload = async () => {
if (!file) {
message.warning('请先选择图片')
return
}
if (!selectedEpisode) {
message.warning('请选择关联期次')
return
}
setUploading(true)
try {
const ep = episodes.find(e => String(e.episode_number) === String(selectedEpisode))
const result = await uploadCover(file, Number(selectedEpisode), ep?.program_name || `${selectedEpisode}`)
message.success('题图上传成功')
onUploaded({
cover_path: result.cover_path,
episode_number: Number(selectedEpisode),
episode_title: ep?.program_name || `${selectedEpisode}`,
})
} catch (err) {
const errMsg = err?.response?.data?.detail || '上传失败,请重试'
message.error(errMsg)
} finally {
setUploading(false)
}
}
const episodeOptions = episodes.map(ep => ({
value: String(ep.episode_number),
label: `${ep.episode_number} 期 · ${ep.program_name}`,
}))
return (
<Modal
title="更换题图"
open={open}
onCancel={onClose}
footer={null}
width={480}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 图片上传区域 */}
<div>
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>上传海报图片PNG/JPG/WEBP不超过 5MB</p>
<Dragger
accept=".png,.jpg,.jpeg,.webp"
maxCount={1}
beforeUpload={() => false}
onChange={handleFileChange}
showUploadList={false}
style={{ borderRadius: 12 }}
>
{previewUrl ? (
<div style={{ padding: '8px 0' }}>
<img
src={previewUrl}
alt="预览"
style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 8 }}
/>
<p style={{ marginTop: 8, color: '#6b8e6b', fontSize: 13 }}>点击或拖拽更换图片</p>
</div>
) : (
<>
<p className="ant-upload-drag-icon" style={{ marginBottom: 8 }}>
<InboxOutlined style={{ fontSize: 32, color: '#6b8e6b' }} />
</p>
<p style={{ color: '#6b6b6b', fontSize: 13 }}>点击或拖拽图片到此处上传</p>
</>
)}
</Dragger>
</div>
{/* 关联期次下拉 */}
<div>
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>关联期次</p>
<Select
placeholder="请选择期次"
style={{ width: '100%' }}
value={selectedEpisode}
onChange={setSelectedEpisode}
options={episodeOptions}
showSearch
filterOption={(input, option) =>
option.label.toLowerCase().includes(input.toLowerCase())
}
/>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button onClick={onClose} disabled={uploading}>取消</Button>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploading}
onClick={handleUpload}
disabled={!file || !selectedEpisode}
>
确认上传
</Button>
</div>
</div>
</Modal>
)
}
export default ChangeCoverModal
+355
View File
@@ -0,0 +1,355 @@
.dashboard {
max-width: 1200px;
padding: 12px;
}
/* ===== Banner ===== */
.dashboard-banner {
width: 100%;
height: 150px;
border-radius: 14px;
overflow: hidden;
position: relative;
background: #3a4a3a;
margin-bottom: 12px;
}
.banner-bg {
position: absolute;
inset: 0;
object-fit: cover;
width: 100%;
height: 100%;
}
.banner-gradient {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(12, 16, 12, 0.86) 0%,
rgba(12, 16, 12, 0.6) 35%,
rgba(12, 16, 12, 0.08) 70%,
transparent 100%
);
}
.banner-logo {
position: absolute;
top: 16px;
right: 16px;
height: 64px;
width: auto;
opacity: 0.9;
}
.banner-text {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 2;
}
.banner-eyebrow {
font-size: 11px;
color: #cdd8c8;
letter-spacing: 1px;
margin-bottom: 4px;
}
.banner-title {
font-size: 21px;
font-weight: 500;
color: #ffffff;
margin: 0 0 4px;
line-height: 1.2;
}
.banner-subtitle {
font-size: 12px;
color: #d6e0d0;
margin: 0 0 4px;
}
.banner-hint {
font-size: 11px;
color: #bcc8b6;
margin: 0;
}
.change-cover-btn {
position: absolute;
bottom: 16px;
right: 16px;
z-index: 3;
}
/* 默认渐变底图(无题图时) */
.banner-default-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
}
/* ===== KPI Strip ===== */
.kpi-strip {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.kpi-chip {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border-radius: 12px;
padding: 10px 16px;
height: 48px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.kpi-icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kpi-label {
font-size: 11px;
color: #6b6b6b;
margin: 0;
white-space: nowrap;
}
.kpi-value {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
/* ===== Bar Chart Card ===== */
.bar-chart-card {
background: #fff;
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
}
.bar-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.bar-chart-title {
font-size: 15px;
font-weight: 500;
color: #3b4a3b;
margin: 0;
}
.bar-chart-hint {
font-size: 11px;
color: #888780;
}
.bars-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 180px;
padding: 0 4px;
gap: 4px;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 42px;
min-width: 20px;
justify-content: flex-end;
height: 180px;
}
.bar {
width: 100%;
max-width: 38px;
border-radius: 7px 7px 0 0;
transition: all 0.3s;
cursor: pointer;
position: relative;
}
.bar-number {
font-size: 9px;
font-weight: 600;
text-align: center;
white-space: nowrap;
margin-bottom: 2px;
}
.bar-program {
font-size: 10px;
color: #6b6b6b;
text-align: center;
max-width: 64px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-top: 4px;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #6b6b6b;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* ===== Bottom Two Columns ===== */
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.side-card {
background: #fff;
border-radius: 14px;
padding: 16px;
}
.side-card-title {
font-size: 15px;
font-weight: 500;
color: #3b4a3b;
margin: 0 0 12px;
}
/* Radar placeholder card */
.radar-placeholder {
border: 1.5px dashed #c8d9b0;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 16px;
position: relative;
}
.radar-placeholder-label {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
color: #bcc8b6;
background: #fbf9f1;
padding: 2px 6px;
border-radius: 6px;
}
.radar-placeholder p {
font-size: 13px;
color: #6b6b6b;
margin: 8px 0 4px;
}
.radar-placeholder small {
font-size: 11px;
color: #999;
}
.radar-sample-items {
width: 100%;
margin-bottom: 8px;
}
.radar-sample-item {
font-size: 12px;
color: #4a5a4a;
padding: 4px 8px;
background: #f0f7ec;
border-radius: 6px;
margin-bottom: 4px;
}
/* Schedule list */
.schedule-list {
list-style: none;
padding: 0;
margin: 0;
}
.schedule-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 12px;
}
.schedule-item:last-child {
border-bottom: none;
}
.schedule-ep {
color: #1a1a1a;
font-weight: 500;
}
.schedule-name {
color: #4a5a4a;
flex: 1;
margin: 0 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.schedule-date {
color: #888780;
font-size: 11px;
}
.schedule-editor {
color: #6b6b6b;
font-size: 11px;
}
/* Loading state */
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
height: 180px;
color: #999;
}
+343
View File
@@ -0,0 +1,343 @@
import { useState, useEffect } from 'react'
import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
import {
EyeOutlined,
FireOutlined,
CalendarOutlined,
BarChartOutlined,
PictureOutlined,
UploadOutlined,
LineChartOutlined,
AimOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import { listEpisodes } from '../../services/episodeService'
import { listTargets } from '../../services/yearlyTargetService'
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
import ChangeCoverModal from './ChangeCoverModal'
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) {
return Math.round((Number(share) / 1.0) * 160)
}
function getShortTitle(title) {
if (!title) return '?'
return title.length > 9 ? title.slice(0, 9) + '...' : title
}
/**
* 计算当年完成率(纯前端,量纲已确认为 0~1 小数直接相除)
* - "当年"= 当前自然年(new Date().getFullYear()
* - avg(audience_share) 当年已录期 ÷ base/stretch_target
* - 无数据时始终返回 { base: '—', stretch: '—' }
* - 完成率可 >100% 正常显示
*/
function calcCompletionRate(episodes, targets) {
const yearInt = new Date().getFullYear()
const yearEpisodes = episodes.filter(e => {
const airYear = new Date(e.air_date).getFullYear()
return airYear === yearInt && e.audience_share != null
})
const target = (targets || []).find(t => Number(t.year) === yearInt)
if (!yearEpisodes.length || !target) return { base: '—', stretch: '—' }
const avgShare = yearEpisodes.reduce((sum, e) => sum + Number(e.audience_share), 0) / yearEpisodes.length
const baseRate = avgShare / Number(target.base_target)
const stretchRate = avgShare / Number(target.stretch_target)
return {
base: (baseRate * 100).toFixed(1) + '%',
stretch: (stretchRate * 100).toFixed(1) + '%',
}
}
function Dashboard() {
const { user } = useAuthStore()
const [episodes, setEpisodes] = useState([])
const [targets, setTargets] = useState([])
const [cover, setCover] = useState({ cover_path: null, episode_number: null, episode_title: null })
const [schedules, setSchedules] = useState([])
const [loading, setLoading] = useState(true)
const [coverModalOpen, setCoverModalOpen] = useState(false)
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
useEffect(() => {
Promise.all([
listEpisodes(50),
listTargets(),
getCurrentCover(),
getUpcomingSchedules(6),
]).then(([epData, tgtData, coverData, schedData]) => {
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
setEpisodes(sorted)
setTargets(tgtData || [])
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
setSchedules(Array.isArray(schedData) ? schedData : [])
setLoading(false)
}).catch(() => {
setLoading(false)
})
}, [])
// 显示最近有份额数据的期次(最多12个)
const displayEpisodes = episodes
.filter(e => e.audience_share != null)
.slice(0, 12)
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
const handleCoverUploaded = (newCover) => {
setCover(newCover)
setCoverModalOpen(false)
}
// KPI 数据(扩为 5 项:原有 3 项 + 年度完成率 2 项)
const completion = calcCompletionRate(episodes, targets)
const kpiData = [
{
icon: <EyeOutlined style={{ color: '#6b8e6b', fontSize: 14 }} />,
bg: '#e8f5e9',
label: '近12期已录',
value: displayEpisodes.length || '--',
},
{
icon: <FireOutlined style={{ color: '#ff9800', fontSize: 14 }} />,
bg: '#fff3e0',
label: '最佳份额',
value: bestEpisode ? (Number(bestEpisode.audience_share) * 100).toFixed(2) + '%' : '--',
},
{
icon: <CalendarOutlined style={{ color: '#2196f3', fontSize: 14 }} />,
bg: '#e3f2fd',
label: '年度目标',
value: targets.length || '--',
},
{
icon: <LineChartOutlined style={{ color: '#7b5e9e', fontSize: 14 }} />,
bg: '#f3e5f5',
label: '基础目标完成率',
value: completion.base,
},
{
icon: <AimOutlined style={{ color: '#c0584f', fontSize: 14 }} />,
bg: '#fce4ec',
label: '摸高目标完成率',
value: completion.stretch,
},
]
return (
<div className="dashboard">
{/* ===== Banner ===== */}
<div className="dashboard-banner">
{/* 题图底图 或 默认渐变 */}
{cover.cover_path ? (
<img
src={cover.cover_path}
alt="题图"
className="banner-bg"
/>
) : (
<div className="banner-default-bg" />
)}
{/* 暗化渐变层 */}
<div className="banner-gradient" />
{/* 右上角 Logo */}
<img
src="/src/assets/junshikeji_logo.png"
alt="栏目logo"
className="banner-logo"
onError={e => { e.target.style.display = 'none' }}
/>
{/* 左侧文字 */}
<div className="banner-text">
<p className="banner-eyebrow">本月收视最佳·题图</p>
<h2 className="banner-title">
{cover.episode_number
? `${cover.episode_number}`
: bestEpisode
? `${bestEpisode.episode_number}`
: '暂无收视数据'}
{cover.episode_title || (bestEpisode ? ` · ${bestEpisode.program_name}` : '')}
</h2>
<p className="banner-subtitle">
{bestEpisode
? `收视份额 ${Number(bestEpisode.audience_share).toFixed(4)} · 当月最高`
: '暂无收视数据'}
</p>
<p className="banner-hint">建议可选用近期收视表现好的节目海报</p>
</div>
{/* 右下角换图按钮 */}
{showChangeCoverBtn && (
<Button
className="change-cover-btn"
icon={<PictureOutlined />}
onClick={() => setCoverModalOpen(true)}
>
更换题图
</Button>
)}
</div>
{/* ===== KPI 细条 ===== */}
<div className="kpi-strip">
{kpiData.map((item, i) => (
<div key={i} className="kpi-chip">
<div className="kpi-icon" style={{ background: item.bg }}>
{item.icon}
</div>
<div>
<p className="kpi-label">{item.label}</p>
<p className="kpi-value">{item.value}</p>
</div>
</div>
))}
</div>
{/* ===== 近9期收视柱图(全宽) ===== */}
<div className="bar-chart-card">
<div className="bar-chart-header">
<h3 className="bar-chart-title"> 12 期收视份额</h3>
<span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
</div>
{loading ? (
<div className="chart-loading"><Spin size="small" /> 加载中...</div>
) : (
<>
<div className="bars-wrapper">
{displayEpisodes.map((ep) => {
const airYear = new Date(ep.air_date).getFullYear()
const color = ep.audience_share != null
? getShareColor(ep.audience_share, targets, airYear)
: '#ccc'
const colorLabel = ep.audience_share != null
? getColorLabel(ep.audience_share, targets, airYear)
: '无数据'
const tooltipContent = `
<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>
编导:${ep.editor_name_snapshot || '未知'}<br/>
收视份额:${ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'}${colorLabel}<br/>
收视率:${ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'}
`
return (
<div key={ep.id} className="bar-item">
<div className="bar-number" style={{ color }}>
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
</div>
<Tooltip title={<span dangerouslySetInnerHTML={{ __html: tooltipContent }} />}>
<div
className="bar"
style={{
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
backgroundColor: color,
}}
/>
</Tooltip>
<div className="bar-program" title={ep.program_name}>
{getShortTitle(ep.program_name)}
</div>
</div>
)
})}
</div>
<div className="chart-legend">
<span className="legend-item">
<span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标待提升
</span>
<span className="legend-item">
<span className="dot" style={{ background: '#5b8db8' }}></span>=未达摸高目标达标
</span>
<span className="legend-item">
<span className="dot" style={{ background: '#c0584f' }}></span>=超过摸高目标优秀
</span>
</div>
</>
)}
</div>
{/* ===== 下方两栏 ===== */}
<div className="bottom-row">
{/* 左:雷达占位 */}
<div className="side-card">
<h3 className="side-card-title">军事科技热点雷达</h3>
<div className="radar-placeholder">
<span className="radar-placeholder-label">Phase 4c·占位</span>
<div className="radar-sample-items">
<div className="radar-sample-item">1. 神舟飞船对接空间站</div>
<div className="radar-sample-item">2. 新型无人机集群技术</div>
</div>
<BarChartOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 4 }} />
<p>真实抓取 Phase 4c 实现</p>
<small>编导个人首页核心功能</small>
</div>
</div>
{/* 右:排播计划 */}
<div className="side-card">
<h3 className="side-card-title">未来排播计划</h3>
{loading ? (
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}><Spin size="small" /></div>
) : schedules.length > 0 ? (
<ul className="schedule-list">
{schedules.map((item, i) => (
<li key={i} className="schedule-item">
<span className="schedule-ep">{item.episode_number}</span>
<span className="schedule-name" title={item.program_name}>{item.program_name}</span>
<span className="schedule-date">{item.planned_air_date}</span>
<span className="schedule-editor">{item.editor_name_snapshot || ''}</span>
</li>
))}
</ul>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}>
<CalendarOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 8 }} />
<p style={{ margin: 0, fontSize: 12 }}>暂无排播数据</p>
</div>
)}
</div>
</div>
{/* 换图 Modal */}
<ChangeCoverModal
open={coverModalOpen}
onClose={() => setCoverModalOpen(false)}
onUploaded={handleCoverUploaded}
episodes={episodes.slice(0, 20)}
/>
</div>
)
}
export default Dashboard
+13
View File
@@ -0,0 +1,13 @@
import { Result } from 'antd'
function Doco() {
return (
<Result
status="info"
title="文稿对齐模块"
subTitle="该模块计划在 1.0 主体上线后作为独立 Phase 开发,详见 doco_project_design.md"
/>
)
}
export default Doco
@@ -0,0 +1,369 @@
import { useState, useEffect } from 'react'
import { Tabs, Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Space, Upload, Popconfirm } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons'
import { listEpisodes, createEpisode, updateEpisode, deleteEpisode } from '../../services/episodeService'
import { listTargets, createTarget, updateTarget, deleteTarget } from '../../services/yearlyTargetService'
import http from '../../services/http'
const { TabPane } = Tabs
// ============================================================
// Tab1: 节目期次
// ============================================================
function EpisodesTab() {
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editing, setEditing] = useState(null)
const [form] = Form.useForm()
useEffect(() => {
loadEpisodes()
}, [])
const loadEpisodes = () => {
setLoading(true)
listEpisodes().then(data => {
setEpisodes((data || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)))
setLoading(false)
}).catch(() => {
message.error('加载节目列表失败')
setLoading(false)
})
}
const handleCreate = () => {
setEditing(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditing(record)
form.setFieldsValue({
episode_number: record.episode_number,
program_name: record.program_name,
air_date: record.air_date ? record.air_date : null,
editor_name_snapshot: record.editor_name_snapshot || '',
audience_share: record.audience_share,
audience_rating: record.audience_rating,
is_rerun: record.is_rerun,
notes: record.notes,
})
setModalVisible(true)
}
const handleDelete = (id) => {
deleteEpisode(id).then(() => {
message.success('删除成功')
loadEpisodes()
}).catch(err => {
message.error(err.response?.data?.detail || '删除失败')
})
}
const handleSubmit = () => {
form.validateFields().then(values => {
const payload = {
...values,
air_date: values.air_date ? values.air_date.format('YYYY-MM-DD') : null,
}
const promise = editing
? updateEpisode(editing.id, payload)
: createEpisode(payload)
promise.then(() => {
message.success(editing ? '更新成功' : '创建成功')
setModalVisible(false)
loadEpisodes()
}).catch(err => {
message.error(err.response?.data?.detail || '操作失败')
})
})
}
const columns = [
{ title: '期次号', dataIndex: 'episode_number', key: 'episode_number', width: 80 },
{ title: '节目名', dataIndex: 'program_name', key: 'program_name' },
{ title: '播出日期', dataIndex: 'air_date', key: 'air_date', render: d => d || '-' },
{ title: '编导', dataIndex: 'editor_name_snapshot', key: 'editor_name_snapshot', render: v => v || '-' },
{ title: '收视份额', dataIndex: 'audience_share', key: 'audience_share', render: v => v != null ? v.toFixed(4) : '-' },
{ title: '收视率', dataIndex: 'audience_rating', key: 'audience_rating', render: v => v != null ? v.toFixed(2) : '-' },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增期次</Button>
</div>
<Table columns={columns} dataSource={episodes} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} />
<Modal title={editing ? '编辑期次' : '新增期次'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存" width={520}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="episode_number" label="期次号" rules={[{ required: true, message: '请输入期次号' }]}>
<InputNumber style={{ width: '100%' }} placeholder="如 1248" disabled={!!editing} />
</Form.Item>
<Form.Item name="program_name" label="节目名称" rules={[{ required: true, message: '请输入节目名称' }]}>
<Input placeholder="如《军事科技》武器解析系列" />
</Form.Item>
<Form.Item name="air_date" label="播出日期" rules={[{ required: true, message: '请选择播出日期' }]}>
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" disabled={!!editing} />
</Form.Item>
<Form.Item name="editor_name_snapshot" label="编导姓名(快照)">
<Input placeholder="可留空,离职编导只存姓名" />
</Form.Item>
<Form.Item name="audience_share" label="收视份额(0-1">
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.72" />
</Form.Item>
<Form.Item name="audience_rating" label="收视率">
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="3.5" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
// ============================================================
// Tab2: 年度目标
// ============================================================
function YearlyTargetsTab() {
const [targets, setTargets] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editing, setEditing] = useState(null)
const [form] = Form.useForm()
useEffect(() => {
loadTargets()
}, [])
const loadTargets = () => {
setLoading(true)
listTargets().then(data => {
setTargets((data || []).sort((a, b) => b.year - a.year))
setLoading(false)
}).catch(() => {
message.error('加载年度目标失败')
setLoading(false)
})
}
const handleCreate = () => {
setEditing(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditing(record)
form.setFieldsValue({
year: record.year,
base_target: record.base_target,
stretch_target: record.stretch_target,
notes: record.notes,
})
setModalVisible(true)
}
const handleDelete = (id) => {
deleteTarget(id).then(() => {
message.success('删除成功')
loadTargets()
}).catch(err => {
message.error(err.response?.data?.detail || '删除失败')
})
}
const handleSubmit = () => {
form.validateFields().then(values => {
const promise = editing
? updateTarget(editing.id, values)
: createTarget(values)
promise.then(() => {
message.success(editing ? '更新成功' : '创建成功')
setModalVisible(false)
loadTargets()
}).catch(err => {
const detail = err.response?.data?.detail || ''
if (detail.includes('已存在') || detail.includes('历史年份')) {
message.error('该年目标已存在,历史年份不可覆盖')
} else {
message.error(detail || '操作失败')
}
})
})
}
const columns = [
{ title: '年份', dataIndex: 'year', key: 'year', width: 80 },
{ title: '基础目标', dataIndex: 'base_target', key: 'base_target', render: v => v?.toFixed(4) || '-' },
{ title: '摸高目标', dataIndex: 'stretch_target', key: 'stretch_target', render: v => v?.toFixed(4) || '-' },
{ title: '备注', dataIndex: 'notes', key: 'notes', render: v => v || '-' },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增年度目标</Button>
</div>
<Table columns={columns} dataSource={targets} rowKey="id" loading={loading} pagination={false} />
<Modal title={editing ? '编辑年度目标' : '新增年度目标'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存">
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="year"
label="年份"
rules={[{ required: true, message: '请输入年份' }]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="如 2024"
min={2000}
max={2100}
disabled={!!editing}
/>
</Form.Item>
<Form.Item name="base_target" label="基础目标" rules={[{ required: true, message: '请输入基础目标' }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.6448" />
</Form.Item>
<Form.Item name="stretch_target" label="摸高目标" rules={[{ required: true, message: '请输入摸高目标' }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.8989" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
// ============================================================
// Tab3: Excel 批量导入
// ============================================================
function ImportTab() {
const [uploading, setUploading] = useState(false)
const [result, setResult] = useState(null)
const downloadTemplate = () => {
window.open('/api/imports/template', '_blank')
}
const uploadProps = {
name: 'file',
accept: '.xlsx',
showUploadList: false,
beforeUpload: (file) => {
if (!file.filename.endsWith('.xlsx') && !file.name.endsWith('.xlsx')) {
message.error('仅支持 .xlsx 格式')
return false
}
setUploading(true)
const formData = new FormData()
formData.append('file', file)
http.post('/imports/episodes', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(res => {
setResult(res.data)
setUploading(false)
if (res.data.errors?.length) {
message.warning(`导入完成:成功 ${res.data.imported} 条,失败 ${res.data.errors.length}`)
} else {
message.success(`导入成功:${res.data.imported}`)
}
}).catch(err => {
setUploading(false)
message.error(err.response?.data?.detail || '上传失败')
})
return false
},
}
const downloadErrors = () => {
if (!result?.batch_id) return
window.open(`/api/imports/errors/${result.batch_id}`, '_blank')
}
return (
<div>
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>下载导入模板</Button>
<Upload {...uploadProps}>
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
{uploading ? '上传中...' : '选择 Excel 上传'}
</Button>
</Upload>
</div>
{result && (
<div style={{ background: '#f6f8fa', padding: 16, borderRadius: 8 }}>
<p style={{ margin: '0 0 8px', fontWeight: 600 }}>导入结果</p>
<p style={{ margin: 0 }}>总计{result.total} </p>
<p style={{ margin: '4px 0', color: '#52c41a' }}>成功{result.imported} </p>
{result.errors?.length > 0 && (
<>
<p style={{ margin: '4px 0', color: '#cf1322' }}>失败{result.errors.length} </p>
<Button size="small" onClick={downloadErrors} style={{ marginTop: 8 }}>
下载失败行
</Button>
</>
)}
</div>
)}
<div style={{ marginTop: 24, color: '#999', fontSize: 12 }}>
<p>支持批量导入节目期次导入失败时可根据提示修正后重新上传</p>
<p>注意Phase 2 不支持重播行导入有重播行请留空</p>
</div>
</div>
)
}
// ============================================================
// 主组件
// ============================================================
function EditorDesk() {
return (
<div style={{ padding: '0 0 24px' }}>
<h2 style={{ margin: '0 0 16px', fontSize: 20, fontWeight: 600 }}>责编录入</h2>
<Tabs defaultActiveKey="episodes">
<TabPane tab="节目期次" key="episodes">
<EpisodesTab />
</TabPane>
<TabPane tab="年度目标" key="targets">
<YearlyTargetsTab />
</TabPane>
<TabPane tab="Excel 批量导入" key="import">
<ImportTab />
</TabPane>
</Tabs>
</div>
)
}
export default EditorDesk
@@ -0,0 +1,17 @@
import { Card } from 'antd'
import { UserOutlined } from '@ant-design/icons'
function EditorHome() {
return (
<Card style={{ borderRadius: 16 }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
<UserOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<h3 style={{ color: '#666' }}>个人首页</h3>
<p>模块 H · Phase 4c 实施</p>
<small>今日热点雷达 · RSS + AI 摘要</small>
</div>
</Card>
)
}
export default EditorHome
@@ -0,0 +1,423 @@
import { useState, useEffect } from 'react'
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Input } from 'antd'
import { DeleteOutlined, ReloadOutlined, UploadOutlined, SearchOutlined, ClearOutlined } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import knowledgeService from '../../services/knowledgeService'
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
const { Dragger } = Upload
// source_type 枚举值(固定五类,不写死但供 Select 用)
const SOURCE_TYPE_OPTIONS = [
{ label: '全部类型', value: '' },
{ label: '杂志文章', value: 'military_report' },
{ label: '节目文稿', value: 'manuscript' },
{ label: '报题单', value: 'baoti' },
]
// source_type 中文标签
const SOURCE_TYPE_LABEL = {
military_report: '杂志文章',
manuscript: '节目文稿',
baoti: '报题单',
manual: '其他',
}
export default function KnowledgeBase() {
const { user } = useAuthStore()
const [items, setItems] = useState([])
const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [sources, setSources] = useState([]) // 出处下拉选项
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
// 搜索状态
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const fetchItems = async () => {
setLoading(true)
try {
const data = await knowledgeService.listItems(sourceTypeFilter || null)
setItems(data)
} catch {
message.error('加载知识库失败')
} finally {
setLoading(false)
}
}
const fetchTree = async () => {
try {
const data = await knowledgeService.getGroupedItems()
setTreeData(data || [])
} catch {
// ignore
}
}
const fetchSources = async () => {
try {
const data = await knowledgeService.listSources()
// 接口返回 [{source: "航空知识 2026年第1期"}, ...],提取 source 字段
setSources(data.map(s => s.source).filter(Boolean))
} catch {
// ignore
}
}
useEffect(() => {
fetchItems()
fetchTree()
fetchSources()
}, [sourceTypeFilter])
// 上传
const handleUpload = async (file) => {
if (!file.name.endsWith('.md')) {
message.warning('仅支持 .md 文件')
return false
}
setUploading(true)
try {
const result = await knowledgeService.uploadFiles([file])
const { uploaded, errors } = result
if (uploaded.length > 0) {
message.success(`成功入库 ${uploaded.length}`)
fetchItems()
fetchTree()
}
if (errors.length > 0) {
errors.forEach(e => message.error(`${e.file}: ${e.error}`))
}
} catch {
message.error('上传失败')
} finally {
setUploading(false)
}
return false // 阻止默认上传行为
}
// 删除
const handleDelete = async (id) => {
try {
await knowledgeService.deleteItem(id)
message.success('删除成功')
fetchItems()
fetchTree()
} catch {
message.error('删除失败')
}
}
// 搜索处理
const handleSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([])
return
}
setSearchLoading(true)
try {
const data = await knowledgeService.searchItems(searchQuery.trim())
setSearchResults(data.results || [])
} catch {
message.error('搜索失败')
setSearchResults([])
} finally {
setSearchLoading(false)
}
}
// 清空搜索 → 恢复树形浏览(不丢树状态)
const handleClearSearch = () => {
setSearchQuery('')
setSearchResults([])
}
// 树节点选中 → 联动过滤
const handleNodeSelect = (node) => {
setSelectedTreeNode(node)
}
// 根据树节点过滤列表
const displayedItems = (() => {
let result = items
// 树节点过滤优先(覆盖原有 Select 筛选)
if (selectedTreeNode) {
const { type, detail } = selectedTreeNode
result = result.filter(i => {
if (i.source_type !== type) return false
if (detail !== null) {
// 按大类决定用哪个字段比对(节目文稿=author,杂志文章=source_detail
if (type === 'manuscript') {
if (i.author !== detail) return false
} else {
// 杂志文章等用 source_detail
if (i.source_detail !== detail) return false
}
}
return true
})
} else if (sourceDetailFilter) {
// 保留原有的 source_detail 筛选逻辑
result = result.filter(i => i.source_detail === sourceDetailFilter)
}
return result
})()
const columns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 280,
render: (text) => (
<span style={{ fontWeight: 500, color: '#3b4a3b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{text}</span>
),
},
{
title: '作者',
dataIndex: 'author',
key: 'author',
width: 100,
render: (val) => val || '-',
},
{
title: '播出/发表时间',
dataIndex: 'publish_date',
key: 'publish_date',
width: 160,
render: (val) => val || '-',
},
{
title: '出处',
dataIndex: 'source_detail',
key: 'source_detail',
width: 200,
render: (val) => val || '-',
},
{
title: '类型',
dataIndex: 'source_type',
key: 'source_type',
width: 96,
render: (val) => (
<Tag color="default" style={{ borderRadius: 8 }}>
{SOURCE_TYPE_LABEL[val] || val}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 90,
render: (_, record) => (
<Popconfirm
title="确认删除"
description="删除后不可恢复,该篇及其向量将一并清除"
onConfirm={() => handleDelete(record.id)}
okText="确认删除"
cancelText="取消"
placement="left"
>
<Button size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
),
},
]
return (
<div style={{ maxWidth: 1200, padding: '12px 16px' }}>
{/* 标题栏 */}
<div style={{ marginBottom: 10 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
</div>
{/* 上传区(收小为一行按钮) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<Dragger
accept=".md"
multiple
showUploadList={false}
beforeUpload={handleUpload}
disabled={uploading}
>
<Button icon={<UploadOutlined />} size="middle" style={{ borderRadius: 10 }}>
{uploading ? '正在上传并生成向量…' : '上传 md 文件入库'}
</Button>
</Dragger>
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传系统自动解析 yaml frontmatter 并生成语义向量</span>
</div>
{/* 搜索栏(搜索模式/浏览模式互斥,清空恢复树形浏览不丢状态) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input
placeholder="输入一段文字,语义搜索知识库…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 340, borderRadius: 10 }}
prefix={<SearchOutlined style={{ color: '#aaa' }} />}
suffix={
searchQuery ? (
<ClearOutlined
style={{ color: '#aaa', cursor: 'pointer' }}
onClick={handleClearSearch}
/>
) : null
}
/>
<Button
icon={<SearchOutlined />}
onClick={handleSearch}
loading={searchLoading}
style={{ borderRadius: 10 }}
>
搜索
</Button>
{searchQuery && (
<span style={{ fontSize: 12, color: '#888' }}>
{searchResults.length > 0
? `找到 ${searchResults.length} 条相关结果`
: '无相关结果'}
</span>
)}
</div>
{/* 搜索结果列表(搜索模式显示,浏览模式隐藏) */}
{searchQuery && searchResults.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
搜索结果
</div>
{searchResults.map(item => (
<Card
key={item.id}
size="small"
style={{ borderRadius: 10, marginBottom: 8 }}
bodyStyle={{ padding: '12px 16px' }}
>
<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',
}}
>
{item.snippet}
</div>
</div>
<div style={{
marginLeft: 16,
minWidth: 56,
textAlign: 'center',
background: item.similarity >= 0.7 ? '#e6f4ff' : '#f5f5f5',
borderRadius: 8,
padding: '4px 8px',
}}>
<div style={{ fontSize: 18, fontWeight: 700, color: item.similarity >= 0.7 ? '#1677ff' : '#666' }}>
{Math.max(0, Math.round(item.similarity * 100))}%
</div>
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
</div>
</div>
</Card>
))}
</div>
)}
{/* 主体:左侧树 + 右侧列表(flex 左右并列,宽度稳定) */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* 左侧树 */}
<Card
style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
bodyStyle={{ padding: 0, height: '100%' }}
>
<KnowledgeTree
treeData={treeData}
onNodeSelect={handleNodeSelect}
selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
/>
</Card>
{/* 右侧列表 + 筛选栏 */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* 筛选栏 */}
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
<Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666' }}>筛选</span>
<Select
placeholder="按类型"
options={SOURCE_TYPE_OPTIONS}
value={sourceTypeFilter}
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
style={{ width: 140 }}
allowClear
/>
<Select
placeholder="按出处"
options={[{ label: '全部出处', value: '' }, ...sources.map(s => ({ label: s, value: s }))]}
value={sourceDetailFilter}
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
style={{ width: 200 }}
allowClear
/>
<Button
icon={<ReloadOutlined />}
onClick={() => { fetchItems(); fetchTree() }}
size="small"
>
刷新
</Button>
</Space>
</Card>
{/* 列表 */}
<Card style={{ borderRadius: 14 }}>
<Table
columns={columns}
dataSource={displayedItems}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
/>
</Card>
</div>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--color-bg-cream);
}
.login-card {
width: 400px;
border-radius: var(--radius-card);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 28px;
color: var(--color-primary-green);
margin: 0 0 8px;
}
.login-header p {
color: var(--color-text-secondary);
margin: 0;
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { Form, Input, Button, Card, message } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import useAuthStore from '../../stores/authStore'
import './Login.css'
function Login() {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const { login } = useAuthStore()
const onFinish = async (values) => {
setLoading(true)
const result = await login(values.username, values.password)
setLoading(false)
if (result.success) {
navigate('/dashboard')
} else {
message.error(result.error || '登录失败')
}
}
return (
<div className="login-page">
<Card className="login-card" bordered={false}>
<div className="login-header">
<h1>军事科技</h1>
<p>TPS 工作台</p>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
requiredMark={false}
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={loading}
>
登录
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default Login
+17
View File
@@ -0,0 +1,17 @@
import { Card } from 'antd'
import { FileTextOutlined } from '@ant-design/icons'
function TPS() {
return (
<Card style={{ borderRadius: 16 }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
<FileTextOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<h3 style={{ color: '#666' }}>TPS 选题策划</h3>
<p>模块 A / B / C / D / G · Phase 4a 实施</p>
<small>查重 + 知识库参考 + 报题单生成</small>
</div>
</Card>
)
}
export default TPS
@@ -0,0 +1,187 @@
import { useState, useEffect } from 'react'
import { Table, Button, Modal, Form, Input, Select, Switch, message, Space } from 'antd'
import { UserAddOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'
import { listUsers, createUser, updateUser, deactivateUser } from '../../services/userService'
const roleOptions = [
{ value: 'zhipianren', label: '制片人' },
{ value: 'zebian', label: '责编' },
{ value: 'biandao', label: '编导' },
]
function UserManage() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [form] = Form.useForm()
useEffect(() => {
loadUsers()
}, [])
const loadUsers = () => {
setLoading(true)
listUsers().then(data => {
setUsers(data || [])
setLoading(false)
}).catch(() => {
message.error('加载用户列表失败')
setLoading(false)
})
}
const handleCreate = () => {
setEditingUser(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingUser(record)
form.setFieldsValue({
display_name: record.display_name,
role: record.role,
profile_text: record.profile_text,
is_active: record.is_active,
})
setModalVisible(true)
}
const handleDeactivate = (record) => {
Modal.confirm({
title: '停用用户',
content: `确认停用用户「${record.display_name}」(${record.username})?停用后可重新启用。`,
okText: '确认停用',
cancelText: '取消',
onOk: () => deactivateUser(record.id).then(() => {
message.success('已停用')
loadUsers()
}).catch(err => {
message.error(err.response?.data?.detail || '停用失败')
}),
})
}
const handleSubmit = () => {
form.validateFields().then(values => {
if (editingUser) {
updateUser(editingUser.id, values).then(() => {
message.success('更新成功')
setModalVisible(false)
loadUsers()
}).catch(err => {
message.error(err.response?.data?.detail || '更新失败')
})
} else {
createUser(values).then(() => {
message.success('创建成功')
setModalVisible(false)
loadUsers()
}).catch(err => {
message.error(err.response?.data?.detail || '创建失败')
})
}
})
}
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '显示名',
dataIndex: 'display_name',
key: 'display_name',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: role => roleOptions.find(o => o.value === role)?.label || role,
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
render: (active, record) => (
<Switch
checked={active}
onChange={() => handleDeactivate(record)}
checkedChildren="在职"
unCheckedChildren="已停"
/>
),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
编辑
</Button>
{record.is_active && (
<Button size="small" danger icon={<StopOutlined />} onClick={() => handleDeactivate(record)}>
停用
</Button>
)}
</Space>
),
},
]
return (
<div style={{ padding: '0 0 24px' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>用户管理</h2>
<Button type="primary" icon={<UserAddOutlined />} onClick={handleCreate}>
新建用户
</Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<Modal
title={editingUser ? '编辑用户' : '新建用户'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
{!editingUser && (
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="登录用户名" />
</Form.Item>
)}
<Form.Item name="display_name" label="显示名" rules={[{ required: true, message: '请输入显示名' }]}>
<Input placeholder="界面显示名称" />
</Form.Item>
{!editingUser && (
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="初始密码" />
</Form.Item>
)}
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select placeholder="选择角色" options={roleOptions} />
</Form.Item>
<Form.Item name="profile_text" label="个人简介">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default UserManage
+18
View File
@@ -0,0 +1,18 @@
import http from './http'
export async function login(username, password) {
const response = await http.post('/auth/login', { username, password })
return response.data
}
export async function logout() {
const response = await http.post('/auth/logout')
return response.data
}
export async function me() {
const response = await http.get('/auth/me')
return response.data
}
export default { login, logout, me }
+38
View File
@@ -0,0 +1,38 @@
import http from './http'
/**
* 获取当前题图设置
* @returns {Promise<{cover_path: string|null, episode_number: number|null, episode_title: string|null}>}
*/
export async function getCurrentCover() {
const response = await http.get('/dashboard/cover')
return response.data
}
/**
* 上传题图(仅制片人/责编可用)
* @param {File} file - 海报图片文件
* @param {number} episodeNumber - 关联期次号
* @param {string} episodeTitle - 关联期次节目名
* @returns {Promise<{success: boolean, cover_path: string}>}
*/
export async function uploadCover(file, episodeNumber, episodeTitle) {
const formData = new FormData()
formData.append('file', file)
formData.append('episode_number', String(episodeNumber))
formData.append('episode_title', episodeTitle)
const response = await http.post('/dashboard/cover', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}
/**
* 获取未来排播计划
* @param {number} limit - 返回条数,默认6
* @returns {Promise<Array<{episode_number, program_name, planned_air_date, editor_name_snapshot}>>}
*/
export async function getUpcomingSchedules(limit = 6) {
const response = await http.get('/schedules/upcoming', { params: { limit } })
return response.data
}
+21
View File
@@ -0,0 +1,21 @@
import http from './http'
export async function listEpisodes(limit = 50, offset = 0) {
const response = await http.get('/episodes', { params: { limit, offset } })
return response.data
}
export async function createEpisode(data) {
const response = await http.post('/episodes', data)
return response.data
}
export async function updateEpisode(id, data) {
const response = await http.patch(`/episodes/${id}`, data)
return response.data
}
export async function deleteEpisode(id) {
const response = await http.delete(`/episodes/${id}`)
return response.data
}
+8
View File
@@ -0,0 +1,8 @@
import axios from 'axios'
const http = axios.create({
baseURL: '/api',
withCredentials: true,
})
export default http
+70
View File
@@ -0,0 +1,70 @@
/**
* 知识库 API 服务
*/
import http from './http'
const knowledgeService = {
/**
* 上传 md 文件(单个或多个)
* @param {File[]} files - File 对象数组
*/
async uploadFiles(files) {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
const resp = await http.post('/knowledge/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return resp.data
},
/**
* 获取知识库条目列表
* @param {string|null} sourceType - 可选,按 source_type 筛选
*/
async listItems(sourceType = null) {
const params = sourceType ? { source_type: sourceType } : {}
const resp = await http.get('/knowledge/items', { params })
return resp.data
},
/**
* 删除知识库条目
* @param {number} id
*/
async deleteItem(id) {
const resp = await http.delete(`/knowledge/items/${id}`)
return resp.data
},
/**
* 获取所有不重复的具体出处(供筛选下拉)
*/
async listSources() {
const resp = await http.get('/knowledge/sources')
return resp.data
},
/**
* 获取按来源分组的树形结构(含「全部」根节点)
*/
async getGroupedItems() {
const resp = await http.get('/knowledge/grouped')
return resp.data
},
/**
* 语义检索:输入一段文字,返回最相关的知识库条目
* @param {string} queryText - 查询文字
* @param {number} topK - 返回条数,默认 5
*/
async searchItems(queryText, topK = 5) {
const resp = await http.post('/knowledge/search', {
query: queryText,
top_k: topK,
})
return resp.data
},
}
export default knowledgeService
+21
View File
@@ -0,0 +1,21 @@
import http from './http'
export async function listUsers() {
const response = await http.get('/users')
return response.data
}
export async function createUser(data) {
const response = await http.post('/users', data)
return response.data
}
export async function updateUser(id, data) {
const response = await http.patch(`/users/${id}`, data)
return response.data
}
export async function deactivateUser(id) {
const response = await http.delete(`/users/${id}`)
return response.data
}
@@ -0,0 +1,21 @@
import http from './http'
export async function listTargets() {
const response = await http.get('/yearly_targets')
return response.data
}
export async function createTarget(data) {
const response = await http.post('/yearly_targets', data)
return response.data
}
export async function updateTarget(id, data) {
const response = await http.patch(`/yearly_targets/${id}`, data)
return response.data
}
export async function deleteTarget(id) {
const response = await http.delete(`/yearly_targets/${id}`)
return response.data
}

Some files were not shown because too many files have changed in this diff Show More