54 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
67 changed files with 6492 additions and 341 deletions
+24 -5
View File
@@ -2,7 +2,7 @@
> 项目:TPS 中台(央视《军事科技》栏目内部工作台) > 项目:TPS 中台(央视《军事科技》栏目内部工作台)
> 仓库:`tps-dashboard`(Gitea,团队可见) > 仓库:`tps-dashboard`(Gitea,团队可见)
> 修订版本:v4 · 2026-05-14 > 修订版本:v5 · 2026-05-15
> 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读 > 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读
--- ---
@@ -25,7 +25,7 @@
| 角色 | 模型 / 工具 | 职责 | | 角色 | 模型 / 工具 | 职责 |
|---|---|---| |---|---|---|
| 制片人 | 刘通(人) | 拍板、审核、领域知识权威、视觉把关 | | 制片人 | 刘通(人) | 拍板、审核、领域权威、视觉把关 |
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 | | 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 |
| 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 | | 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
| 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 | | 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 |
@@ -110,7 +110,7 @@ Phase 内的子任务、修复独立 bug、前端骨架、后端骨架——都
### 5.2 年度目标只增不改(时间不变性) ### 5.2 年度目标只增不改(时间不变性)
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除** `yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**:
- 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标" - 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标"
- 2024 年播的节目永远按 2024 年的 0.6448 / 0.8989 判,2026 年回看也不会变色 - 2024 年播的节目永远按 2024 年的 0.6448 / 0.8989 判,2026 年回看也不会变色
@@ -124,7 +124,7 @@ SQL 端:`yearly_targets.year` 加 UNIQUE 约束,代码端 INSERT 时用 `ON CONF
- **在职编导**:两字段都填(冗余但稳) - **在职编导**:两字段都填(冗余但稳)
- **离职编导**:`editor_id` 留空(NULL),只填 `editor_name_snapshot`(如"张颖") - **离职编导**:`editor_id` 留空(NULL),只填 `editor_name_snapshot`(如"张颖")
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照 - **历史导入**:按姓名匹配在职用户,匹配不上只填快照,不报错不丢数据
- **查询某编导历史作品**:**先按 editor_id,失败回退按 editor_name_snapshot 文本匹配** - **查询某编导历史作品**:**先按 editor_id,失败回退按 editor_name_snapshot 文本匹配**
DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。 DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。
@@ -147,6 +147,11 @@ AI Prompt 模板必须默认走双段、备选纯叙述。docxtpl 模板设计
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 | | `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 | | `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
**yearly_targets 写权限收紧(2026-05-20)**:
- POST/PATCH: zhipianren + zebian 可写,biandao 返回 403
- DELETE: 仅 zhipianren 可操作,zebian 和 biandao 返回 403
- GET: 三角色均可读
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。 **所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
--- ---
@@ -367,11 +372,25 @@ Cline 内的 Sonnet/DeepSeek 不直接与 Opus 通信,流程如下:
| v1 | 2026-04 | 初版,定模型分工与栏目业务约定 | | v1 | 2026-04 | 初版,定模型分工与栏目业务约定 |
| v2 | 2026-05-04 | 加技术栈精简版、Phase 拆分 | | 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 移除** | | 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,不进本文件。 **修订原则**:.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 经验)。* *下次修订建议时机:Phase 2 末(评估 MiniMax 切换、整理 Phase 2 经验)。*
+18 -6
View File
@@ -13,27 +13,39 @@ dist/
build/ build/
.vite/ .vite/
# 环境配置(永远不上传) # 莽聨呕暮藰聝茅聟聧莽藵沤(膰掳赂膷偶聹盲赂聧盲赂聤盲藕聽)
.env .env
.env.local .env.local
# 编辑器 # 莽藕聳膷啪聭暮聶篓
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
# 操作系统 # 膰聯聧盲藵聹莽艂钮莽钮聼
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# 日志运行产物 # 膰聴慕暮偶聴膷偶聬膷膭聦盲艧搂莽聣艩
*.log *.log
cookie.txt cookie.txt
cookies*.txt
# 临时文件 # 盲赂麓膰聴艣膰聳聡盲钮艣
*.tmp *.tmp
*.bak *.bak
homePC/ homePC/
# Syncthing (极空间 NAS 同步工具产物) # Syncthing (膰聻聛莽艩艧茅聴麓 NAS 暮聬聦膰颅慕暮藝慕暮聟藝盲艧搂莽聣艩)
.stignore .stignore
# 臉媒啪脻偶芒膮赂藝脻 / SQL dump(藳钮藵艡掳膰膮啪偶芒)
backups/
# 鏁忔劅鑴氭湰(姘镐笉涓婁紶)
backend/scripts/reset_password.py
# 前端测试/占位图片(题图上传测试产物)
frontend/src/assets/*.jpg
frontend/src/assets/*.jpeg
frontend/src/assets/*.png
+25 -4
View File
@@ -47,10 +47,11 @@
| Phase | 状态 | | Phase | 状态 |
|---|---| |---|---|
| Phase 0 环境 + Git | 单位机进行中 | | Phase 0 环境 + Git | ✅ 已完成 |
| Phase 1 后端骨架 | 家用机已完成,单位机走"路径 B 迁移" | | Phase 1 后端骨架 + 前端骨架 | ✅ 已完成 |
| Phase 1 前端骨架 | 未开始 | | Phase 2 仪表盘 + 批量导入 + 写权限收紧 | ✅ 已完成 |
| Phase 2-5 | 未开始 | | Phase 3 知识库基础设施 | 进行中 — 已完成至 Task 3(知识库树形视图按来源分组),语义搜索尚未做 |
| Phase 45 | 未开始 |
--- ---
@@ -68,6 +69,25 @@
--- ---
## 角色权限表
> 数据来源:`.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) - 前端:React 18 + Vite + Ant Design 5 + Zustand(JavaScript,不用 TS)
@@ -168,6 +188,7 @@ uvicorn app.main:app --reload
|---|---|---| |---|---|---|
| v1 | 2026-05-06 | 初版,作为单位机仓库门牌 | | v1 | 2026-05-06 | 初版,作为单位机仓库门牌 |
| **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** | | **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
| **v3** | **2026-05-27** | **进度校准:Phase 1/2 标为完成;Phase 3 Task 3 知识库树形视图已完成;清理"家用机/路径B"历史表述,不再保留迁移叙事** |
--- ---
+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
+6
View File
@@ -16,6 +16,10 @@ _DATABASE_URL = os.environ.get("DATABASE_URL")
_SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-to-a-random-string-in-production") _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")) _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: if not _DATABASE_URL:
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。") raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
@@ -25,6 +29,8 @@ class Settings:
DATABASE_URL: str = _DATABASE_URL DATABASE_URL: str = _DATABASE_URL
SECRET_KEY: str = _SECRET_KEY SECRET_KEY: str = _SECRET_KEY
SESSION_MAX_AGE: int = _SESSION_MAX_AGE 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() settings = Settings()
+22
View File
@@ -6,7 +6,17 @@ from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware 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.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 from app.core.config import settings
app = FastAPI(title="军事科技工作台", version="0.1.0") app = FastAPI(title="军事科技工作台", version="0.1.0")
@@ -34,3 +44,15 @@ app.add_middleware(
# ---- 注册路由 ---- # ---- 注册路由 ----
app.include_router(auth_router) 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")
+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()),
)
+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()),
)
+2 -3
View File
@@ -2,7 +2,7 @@
认证相关 Pydantic Schema — 请求 / 响应模型 认证相关 Pydantic Schema — 请求 / 响应模型
""" """
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole from app.models.user import UserRole
@@ -21,5 +21,4 @@ class UserResponse(BaseModel):
profile_text: str | None = None profile_text: str | None = None
is_active: bool = True is_active: bool = True
class Config: model_config = ConfigDict(from_attributes=True)
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,
}]
+3
View File
@@ -9,3 +9,6 @@ itsdangerous==2.2.0
python-multipart==0.0.9 python-multipart==0.0.9
python-dotenv==1.0.1 python-dotenv==1.0.1
httpx==0.27.0 httpx==0.27.0
python-frontmatter==1.1.0
pandas>=2.0.0
openpyxl>=3.1.0
+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()
+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()
+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 的指令要用代码块封装起来,方便复制。
+15 -10
View File
@@ -2,7 +2,7 @@
> 项目:TPS(Topic Planning System)中台 > 项目:TPS(Topic Planning System)中台
> 仓库:`tps-dashboard`(Gitea) > 仓库:`tps-dashboard`(Gitea)
> 修订版本:v4 · 2026-05-14 > 修订版本:v5 · 2026-05-27
> 配套文档:`project_plan.md`(产品宪法)+ `.clinerules`(协作规则)+ `docs/git_workflow.md`(Git 操作手册) > 配套文档:`project_plan.md`(产品宪法)+ `.clinerules`(协作规则)+ `docs/git_workflow.md`(Git 操作手册)
--- ---
@@ -249,19 +249,23 @@ git push origin phase0-env-ready
--- ---
### Phase 4a:核心 MVP(Week 5-6) ### Phase 4a:核心 MVP — 对话式 TPS(Week 5-6)
**模块 G**:报题单生成与下载(docxtpl 模板) > v5 升级:模块 A/C/G 合成"对话式策划助手"形态(左对话右报告、脚注式引用),详见 `project_plan.md` 模块 A/C/G + 第 2.3 节 + 6.5 节红线。
- 模板严格遵守"双段结构"(`.clinerules` 5.4)
**模块 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**。 **收尾**:写 `logs/phase4a_log.md`,打 `phase4a-mvp-launch` tag。**这是项目第一次"对内发布",务必郑重打 tag**。
@@ -486,7 +490,8 @@ tps-dashboard/ ← Git 仓库根
| v1 | 2026-04 | 初版 | | v1 | 2026-04 | 初版 |
| v2 | 2026-05-04 | 精简技术栈、Phase 拆分(避免 Phase 4 大爆炸)、明确每周里程碑 | | 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 正式日常使用;更新版本演进记录** | | 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 给方向不给答案"。
+1 -1
View File
@@ -22,7 +22,7 @@
### 1.1 三个核心概念,一分钟搞定 ### 1.1 三个核心概念,一分钟搞定
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `D:\TPS-中台\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。 **Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `E:\tps-dashboard\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
**commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。 **commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。
+21 -9
View File
@@ -9,7 +9,9 @@ import TPS from './pages/TPS/TPS'
import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase' import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
import Doco from './pages/Doco/Doco' import Doco from './pages/Doco/Doco'
import UserManage from './pages/UserManage/UserManage' import UserManage from './pages/UserManage/UserManage'
import EditorDesk from './pages/EditorDesk/EditorDesk'
import AuthGuard from './components/AuthGuard/AuthGuard' import AuthGuard from './components/AuthGuard/AuthGuard'
import RoleGuard from './components/AuthGuard/RoleGuard'
const theme = { const theme = {
token: { token: {
@@ -25,21 +27,31 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route <Route path="/" element={<AuthGuard />}>
path="/" <Route element={<AppLayout />}>
element={
<AuthGuard>
<AppLayout />
</AuthGuard>
}
>
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="tps" element={<TPS />} /> <Route path="tps" element={<TPS />} />
<Route path="knowledge" element={<KnowledgeBase />} /> <Route path="knowledge" element={<KnowledgeBase />} />
<Route path="doco" element={<Doco />} /> <Route path="doco" element={<Doco />} />
<Route path="editor-home" element={<EditorHome />} /> <Route path="editor-home" element={<EditorHome />} />
<Route path="users" element={<UserManage />} /> <Route
path="users"
element={
<RoleGuard roles={['zhipianren']}>
<UserManage />
</RoleGuard>
}
/>
<Route
path="editor-desk"
element={
<RoleGuard roles={['zhipianren', 'zebian']}>
<EditorDesk />
</RoleGuard>
}
/>
</Route>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

@@ -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>
)
}
+4 -5
View File
@@ -5,15 +5,12 @@
.app-sider { .app-sider {
background: #fff !important; background: #fff !important;
border-right: 1px solid #f0f0f0; border-right: 1px solid #f0f0f0;
position: fixed !important; width: 220px;
left: 0; flex: 0 0 220px;
top: 0;
bottom: 0;
overflow-y: auto; overflow-y: auto;
} }
.app-main { .app-main {
margin-left: 220px;
min-height: 100vh; min-height: 100vh;
} }
@@ -32,4 +29,6 @@
padding: 24px; padding: 24px;
background: var(--color-bg-cream); background: var(--color-bg-cream);
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
max-width: 1400px;
margin: 0 auto;
} }
@@ -36,3 +36,27 @@
background: var(--color-accent-green) !important; background: var(--color-accent-green) !important;
color: var(--color-primary-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;
}
+48 -6
View File
@@ -7,20 +7,62 @@ import {
SyncOutlined, SyncOutlined,
UserOutlined, UserOutlined,
TeamOutlined, TeamOutlined,
SoundOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
const menuItems = [ function SideNav() {
const navigate = useNavigate()
const location = useLocation()
const { user } = useAuthStore()
const allMenuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' }, { key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' }, { key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' }, { key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' }, { key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' }, { key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
{ key: '/users', icon: <TeamOutlined />, 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,
},
]
function SideNav() { // 按角色过滤菜单项
const navigate = useNavigate() // user.role 值来自后端 /api/auth/me,应为字符串 'zhipianren' / 'zebian' / 'biandao'
const location = useLocation() 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 ( return (
<div className="side-nav"> <div className="side-nav">
@@ -31,7 +73,7 @@ function SideNav() {
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[location.pathname]} selectedKeys={[location.pathname]}
items={menuItems} items={visibleItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => navigate(key)}
className="side-nav-menu" className="side-nav-menu"
/> />
+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,
},
]
}
@@ -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
+286 -114
View File
@@ -1,122 +1,173 @@
.dashboard { .dashboard {
max-width: 1200px; max-width: 1200px;
padding: 12px;
} }
/* Banner */ /* ===== Banner ===== */
.dashboard-banner { .dashboard-banner {
background: #fff; width: 100%;
border-radius: var(--radius-card); height: 150px;
padding: 24px; border-radius: 14px;
display: flex; overflow: hidden;
align-items: center; position: relative;
justify-content: space-between; background: #3a4a3a;
margin-bottom: 24px; margin-bottom: 12px;
} }
.banner-text h2 { .banner-bg {
margin: 0 0 8px; position: absolute;
font-size: 20px; inset: 0;
color: var(--color-text-primary); object-fit: cover;
} width: 100%;
.banner-text p {
margin: 0;
color: var(--color-text-secondary);
}
.banner-image-placeholder {
width: 200px;
height: 100px;
background: var(--color-accent-green);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-green);
font-size: 12px;
}
/* KPI Cards */
.kpi-row {
margin-bottom: 24px;
}
.kpi-card {
border-radius: var(--radius-card);
display: flex;
align-items: center;
gap: 16px;
}
.kpi-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.kpi-info {
display: flex;
flex-direction: column;
}
.kpi-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-primary);
}
.kpi-label {
font-size: 12px;
color: var(--color-text-secondary);
}
/* Content Cards */
.cards-row {
margin-bottom: 24px;
}
.content-card {
border-radius: var(--radius-card);
height: 100%; height: 100%;
} }
.content-card .ant-card-head-title { .banner-gradient {
font-weight: 600; 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%
);
} }
.placeholder-text { .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; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 32px 16px; flex-shrink: 0;
text-align: center;
color: var(--color-text-secondary);
} }
.placeholder-text p { .kpi-label {
margin: 8px 0 4px; 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; font-weight: 500;
color: #3b4a3b;
margin: 0;
} }
.placeholder-text small { .bar-chart-hint {
color: #999; font-size: 11px;
} color: #888780;
/* Chart Container */
.chart-container {
padding: 8px 0;
} }
.bars-wrapper { .bars-wrapper {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; align-items: flex-end;
height: 200px; height: 180px;
padding: 0 8px; padding: 0 4px;
gap: 4px;
} }
.bar-item { .bar-item {
@@ -124,41 +175,40 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex: 1; flex: 1;
max-width: 40px; max-width: 42px;
min-width: 20px;
justify-content: flex-end;
height: 180px;
} }
.bar { .bar {
width: 24px; width: 100%;
border-radius: 4px 4px 0 0; max-width: 38px;
border-radius: 7px 7px 0 0;
transition: all 0.3s; transition: all 0.3s;
cursor: pointer; cursor: pointer;
position: relative;
} }
.bar-label-top { .bar-number {
font-size: 9px; font-size: 9px;
color: var(--color-text-secondary); font-weight: 600;
text-align: center; text-align: center;
margin-top: 4px; white-space: nowrap;
max-width: 40px; margin-bottom: 2px;
}
.bar-program {
font-size: 10px;
color: #6b6b6b;
text-align: center;
max-width: 64px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
}
.bar-label-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-top: 4px; margin-top: 4px;
} }
.bar-label-bottom span {
font-size: 9px;
color: var(--color-text-secondary);
}
/* Chart Legend */
.chart-legend { .chart-legend {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -173,7 +223,7 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 11px; font-size: 11px;
color: var(--color-text-secondary); color: #6b6b6b;
} }
.dot { .dot {
@@ -181,3 +231,125 @@
height: 8px; height: 8px;
border-radius: 50%; 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;
}
+312 -121
View File
@@ -1,150 +1,341 @@
import { Row, Col, Card, Avatar, Tooltip } from 'antd' import { useState, useEffect } from 'react'
import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
import { import {
BarChartOutlined,
EyeOutlined, EyeOutlined,
CalendarOutlined,
FireOutlined, FireOutlined,
CalendarOutlined,
BarChartOutlined,
PictureOutlined,
UploadOutlined,
LineChartOutlined,
AimOutlined,
} from '@ant-design/icons' } 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' import './Dashboard.css'
// 假数据:近 9 期收视(基础目标 0.6,摸高目标 0.9) /**
const fakeEpisodes = [ * 收视份额颜色判定(极易写反,禁止凭直觉):
{ id: 1, title: '第 1247 期 现代防空反导大对决', editor: 'simonkoson', share: 0.72 }, * audience_share > stretch_target → 🔴 红(优秀)
{ id: 2, title: '第 1246 期 深海暗战', editor: 'simonkoson', share: 0.85 }, * base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
{ id: 3, title: '第 1245 期 隐形战场', editor: 'simonkoson', share: 0.94 }, * audience_share < base_target → 🟢 绿(待提升)
{ id: 4, title: '第 1244 期 无人机革命', editor: 'simonkoson', share: 0.65 }, * 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
{ id: 5, title: '第 1243 期 核潜艇传奇', editor: 'simonkoson', share: 0.58 }, */
{ id: 6, title: '第 1242 期 电子战风暴', editor: 'simonkoson', share: 0.88 }, function getShareColor(share, targets, airYear) {
{ id: 7, title: '第 1241 期 太空博弈', editor: 'simonkoson', share: 0.91 }, const n = Number(share)
{ id: 8, title: '第 1240 期 装甲对决', editor: 'simonkoson', share: 0.67 }, const target = (targets || []).find(t => Number(t.year) === Number(airYear))
{ id: 9, title: '第 1239 期 隐身战机', editor: 'simonkoson', share: 0.55 }, 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' // 绿=未达基础目标(待提升)
}
const BASE_TARGET = 0.6 function getColorLabel(share, targets, airYear) {
const STRETCH_TARGET = 0.9 const n = Number(share)
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
function getBarColor(share) { if (!target) return '未知'
if (share > STRETCH_TARGET) return '#cf1322' // 红=优秀 const stretch = Number(target.stretch_target)
if (share >= BASE_TARGET) return '#6b8e6b' // 蓝=达标 const base = Number(target.base_target)
return '#d4a017' // 绿=待提升 if (n > stretch) return '优秀'
if (n >= base) return '达标'
return '待提升'
} }
function getBarHeight(share) { function getBarHeight(share) {
return Math.round((share / 1.0) * 180) return Math.round((Number(share) / 1.0) * 160)
} }
function getShortTitle(title) { function getShortTitle(title) {
return title.length > 12 ? title.slice(0, 12) + '...' : 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() { 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 ( return (
<div className="dashboard"> <div className="dashboard">
{/* 顶部 Banner */} {/* ===== Banner ===== */}
<div className="dashboard-banner"> <div className="dashboard-banner">
<div className="banner-text"> {/* 题图底图 或 默认渐变 */}
<h2>本月收视最佳</h2> {cover.cover_path ? (
<p> 1245 隐形战场 · 收视份额 0.94</p> <img
</div> src={cover.cover_path}
<div className="banner-image-placeholder"> alt="题图"
<span>题图位 · Phase 2 实装</span> className="banner-bg"
</div>
</div>
{/* KPI Cards */}
<Row gutter={16} className="kpi-row">
<Col span={8}>
<Card className="kpi-card">
<div className="kpi-icon" style={{ background: '#e8f5e9' }}>
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} />
</div>
<div className="kpi-info">
<span className="kpi-value">0.82</span>
<span className="kpi-label">本期平均收视</span>
</div>
</Card>
</Col>
<Col span={8}>
<Card className="kpi-card">
<div className="kpi-icon" style={{ background: '#fff3e0' }}>
<FireOutlined style={{ color: '#ff9800', fontSize: 24 }} />
</div>
<div className="kpi-info">
<span className="kpi-value">98.6%</span>
<span className="kpi-label">完成率</span>
</div>
</Card>
</Col>
<Col span={8}>
<Card className="kpi-card">
<div className="kpi-icon" style={{ background: '#e3f2fd' }}>
<CalendarOutlined style={{ color: '#2196f3', fontSize: 24 }} />
</div>
<div className="kpi-info">
<span className="kpi-value">6</span>
<span className="kpi-label">未来待排期</span>
</div>
</Card>
</Col>
</Row>
{/* 三卡片区域 */}
<Row gutter={16} className="cards-row">
{/* 热点雷达 */}
<Col span={8}>
<Card className="content-card" title="热点雷达">
<div className="placeholder-text">
<BarChartOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
<p>模块 H · Phase 4c 实施</p>
<small>热点雷达将在 Phase 4c 开发作为编导个人首页的核心功能</small>
</div>
</Card>
</Col>
{/* 近 9 期收视柱图 */}
<Col span={8}>
<Card className="content-card" title="近 9 期收视">
<div className="chart-container">
<div className="bars-wrapper">
{fakeEpisodes.map((ep) => (
<div key={ep.id} className="bar-item">
<Tooltip title={`${ep.title} · ${ep.share}`}>
<div
className="bar"
style={{
height: getBarHeight(ep.share),
backgroundColor: getBarColor(ep.share),
}}
/> />
</Tooltip> ) : (
<div className="bar-label-top">{getShortTitle(ep.title)}</div> <div className="banner-default-bg" />
<div className="bar-label-bottom"> )}
<Avatar size={20} style={{ fontSize: 10 }}>{ep.editor[0]}</Avatar> {/* 暗化渐变层 */}
<span>{ep.editor}</span> <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> </div>
))} ))}
</div> </div>
<div className="chart-legend">
<span className="legend-item"><span className="dot" style={{ background: '#cf1322' }}></span>=优秀</span>
<span className="legend-item"><span className="dot" style={{ background: '#6b8e6b' }}></span>=达标</span>
<span className="legend-item"><span className="dot" style={{ background: '#d4a017' }}></span>绿=待提升</span>
</div>
</div>
</Card>
</Col>
{/* 排播计划 */} {/* ===== 近9期收视柱图(全宽) ===== */}
<Col span={8}> <div className="bar-chart-card">
<Card className="content-card" title="排播计划"> <div className="bar-chart-header">
<div className="placeholder-text"> <h3 className="bar-chart-title"> 12 期收视份额</h3>
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} /> <span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
<p>模块 F · Phase 4b 实施</p>
<small>甘特图排期将在 Phase 4b 开发使用 frappe-gantt 实现拖拽排期</small>
</div> </div>
</Card> {loading ? (
</Col> <div className="chart-loading"><Spin size="small" /> 加载中...</div>
</Row> ) : (
<>
<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> </div>
) )
} }
@@ -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
@@ -1,17 +1,423 @@
import { Card } from 'antd' import { useState, useEffect } from 'react'
import { BookOutlined } from '@ant-design/icons' 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'
function KnowledgeBase() { const { Dragger } = Upload
return (
<Card style={{ borderRadius: 16 }}> // source_type 枚举值(固定五类,不写死但供 Select 用)
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}> const SOURCE_TYPE_OPTIONS = [
<BookOutlined style={{ fontSize: 48, marginBottom: 16 }} /> { label: '全部类型', value: '' },
<h3 style={{ color: '#666' }}>知识库</h3> { label: '杂志文章', value: 'military_report' },
<p>模块 C · Phase 3 实施</p> { label: '节目文稿', value: 'manuscript' },
<small>往期报题单 / 文稿 / 军报语义检索</small> { label: '报题单', value: 'baoti' },
</div> ]
</Card>
) // source_type 中文标签
const SOURCE_TYPE_LABEL = {
military_report: '杂志文章',
manuscript: '节目文稿',
baoti: '报题单',
manual: '其他',
} }
export default KnowledgeBase 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>
)
}
+179 -9
View File
@@ -1,16 +1,186 @@
import { Card } from 'antd' import { useState, useEffect } from 'react'
import { TeamOutlined } from '@ant-design/icons' 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() { 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 ( return (
<Card style={{ borderRadius: 16 }}> <div style={{ padding: '0 0 24px' }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}> <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<TeamOutlined style={{ fontSize: 48, marginBottom: 16 }} /> <h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>用户管理</h2>
<h3 style={{ color: '#666' }}>用户管理</h3> <Button type="primary" icon={<UserAddOutlined />} onClick={handleCreate}>
<p>Phase 2 实施</p> 新建用户
<small>管理员创建账号 / 角色分配 zhipianren 可见</small> </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> </div>
</Card>
) )
} }
+2
View File
@@ -14,3 +14,5 @@ export async function me() {
const response = await http.get('/auth/me') const response = await http.get('/auth/me')
return response.data 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
}
+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
}
+4
View File
@@ -10,6 +10,10 @@ export default defineConfig({
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
}, },
'/static': {
target: 'http://localhost:8000',
changeOrigin: true,
},
}, },
}, },
}) })
+538
View File
@@ -0,0 +1,538 @@
# Phase 2 开发日志 — 仪表盘真数据 + 用户管理 + Excel 批量导入
> 《军事科技》栏目内部工作台 · Phase 2
> 撰写日期:2026-05-15
> 状态:**Task 1 后端 CRUD 完成,冒烟实测 6/6 通过;Task 2 进行中**
---
## 一、本 Phase 完成时间
- **开始日期**:2026-05-15
- **Task 1 后端 CRUD**:2026-05-15 完成,冒烟 6/6 全绿
- **Task 2 Excel 批量导入**:进行中
- **Task 3 前端**:待开始
- **预计 Phase 2 整体收官**:待定
---
## 二、本 Phase 做了什么
### Task 1 — 后端 CRUD2026-05-15
1. **models**`episode.py`(软引用+快照)、`yearly_target.py`year UNIQUE
2. **schemas**`episode.py``yearly_target.py``user_admin.py``auth.py` 顺手改 ConfigDict
3. **API**`users.py`(管理员 CRUD)、`episodes.py`(增删改查)、`yearly_targets.py`(增删改查,新增 DELETE
4. **路由注册**`main.py` 注册新路由
---
## 三、Task 1 冒烟实测记录(2026-05-15 Cline 自跑)
使用 `backend/scripts/smoke_test.py`(幂等版可重复跑),结果:
| 步骤 | 接口 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | POST /api/users 新建 zebian | 201 | 201 | ✅ ID=4 |
| 2 | POST /api/yearly_targets 新建 2026 | 201 | 201 | ✅ ID=2 |
| 3 | POST /api/yearly_targets 重复 2026 | 409 | 409 | ✅ 正确拦截 |
| 4 | POST /api/episodes editor_id=null | 201 | 201 | ✅ ID=2 |
| 5 | GET /api/episodes 列表 | 200 | 200 | ✅ |
| 6 | PATCH /api/yearly_targets 尝试改 year | 200year 不变) | 200 | ✅ year 仍为 2026 |
**6/6 全部通过。**
---
## 四、业务红线验证记录
| 红线 | 代码位置 | 验证结果 |
|------|----------|----------|
| 收视颜色只对 audience_share | `episode.py` 注释 | ✅ 未实现颜色逻辑,仅存字段 |
| 年度目标只增不改 year UNIQUE | `yearly_targets.py` create_target + `yearly_target.py` UNIQUE | ✅ 冲突报 409 |
| year 字段禁止 UPDATE 修改 | `yearly_targets.py` update_target pop("year") | ✅ 冒烟 Step 6 验证 |
| editor_id 可空+editor_name_snapshot 必填 | `episode.py` Field 设计 | ✅ 冒烟 Step 4 editor_id=null 成功 |
| 角色枚举全拼音 | `UserRole` Enum + CHECK 约束 | ✅ 三角色全是拼音 |
| ON CONFLICT DO NOTHING(非 UPDATE | `yearly_targets.py` 注释 | ✅ 只增不改 |
| password_hash 明文传输开发配置 | `security.py` + `main.py` https_only=False | ⚠️ Phase 5 部署前必改 |
---
## 五、踩过的坑(Phase 2 内部教训)
### 坑 1:API 文件漏了模型类导入(一类 bug)
**现象**Swagger UI 实测 `POST /api/yearly_targets` 报 500`NameError: name 'YearlyTarget' is not defined`
**原因**`api/yearly_targets.py``api/episodes.py` 用了 `Episode` / `YearlyTarget` 类但顶部漏了 import 语句
**排查**:同步检查 `api/users.py``api/episodes.py``api/yearly_targets.py` 三文件,发现 episodes.py 也漏了
**修复**
- `episodes.py``from app.models.episode import Episode`
- `yearly_targets.py``from app.models.yearly_target import YearlyTarget`
- `users.py` 删掉未使用的 `verify_password` 导入
**教训**:新建 API 文件后,**第一件事检查所有模型类是否已 import**,与 Schema import 同等优先级。Task 1 完工报告前未做最小冒烟测试,导致这类问题漏到实测阶段才发现。以后每个 Task Act 完成后必须自跑冒烟,不依赖制片人发现。
### 坑 2:冒烟脚本 emoji + 中文导致 Windows GBK 崩溃
**现象**`UnicodeEncodeError: 'gbk' codec can't encode character '\u274c'`
**修复**:将 `smoke_test.py` 全部改英文 + ASCII 字符(`✅``[OK]``❌``[FAIL]`
### 坑 3:数据不幂等导致重跑失败
**现象**:第二次跑冒烟,Step 1/2/4 全部 409(数据已存在)
**修复**:改为幂等版脚本,先清理同名数据再创建
### 坑 4pip 默认路由到系统 Python 3.14,绕过项目锁定的 3.12
**现象**`pip install pandas openpyxl` 后报错,或运行时报 `ModuleNotFoundError: No module named 'pandas'`(已确认装过)
**原因**:系统 PATH 中 python 命令指向 Microsoft Store 的 Python 3.14,而项目要求 Python 3.12,导致装到了错误的解释器。`pip show pandas` 发现路径在 AppData/Local/Microsoft/WindowsApps 而非项目 venv。
**修复**
- 确认项目使用 `python -m pip install ...`(明确调项目锁定的 3.12
- 或激活 venv 后 `& .\venv\Scripts\Activate.ps1` 再 pip install
- 装完 `pip show pandas` 核对路径包含 `venv` 或项目目录
**教训****任何 pip install 必须验证装到了哪个 Python 解释器**。先用 `where python` 确认 PATH 优先级,用 `python -c "import sys; print(sys.executable)"` 确认实际调用的解释器,再执行安装。
---
### 坑 5002b SQL 表达式索引使用 EXTRACT(...)::int 语法不被 PostgreSQL 接受
**现象**`CREATE INDEX idx_episodes_broadcast_date ON episodes (EXTRACT(year FROM broadcast_date)::int);` 在 pgAdmin / psql 中执行报错:`ERROR: syntax error at or near "::"`
**原因**PostgreSQL 的 `EXTRACT()` 返回 `double precision` 类型,`::int` 是标准 cast 语法,但某些 PostgreSQL 版本或客户端对表达式索引内 cast 有限制。SQL 在 commit 前未本机干跑直接进库,翻车后才发现。
**修复**:改为 `date_part('year', broadcast_date)` 语法,等效且兼容性更好:
```sql
CREATE INDEX idx_episodes_broadcast_date ON episodes (date_part('year', broadcast_date));
```
**教训****SQL 文件 commit 前必须在本机干跑**。即使是看似简单的 DDL,本机跑一遍能提前发现语法兼容性问题,避免入库后才发现翻车。Task 1 NameError(坑 1)与本坑同源,都是"没自测就 commit"。
---
### 坑 6Gitea OAuth refresh token 过期导致 git push 反复 401
**现象**`git push` 多次返回 401 Unauthorized,Git 凭证管理器反复弹框,输入正确账密后仍然失败。
**原因**Gitea OAuth access token 有效期到期,refresh token 也已失效,导致 Git 凭证无法续期。多次 401 后需重新完成 OAuth 认证流程刷新 token。
**修复**
- 删掉旧 tokenGit Bash 执行 `git credential reject` + 重置 credential helper
- 重新触发 GitHub/Gitea OAuth 流程获取新 access token
- 验证:`git push` 成功后确认 remote URL 使用新的 token
**教训**:**push 不成功不算完工**。本次 Task 2 期间 push 已全部恢复正常,之前的 git push 条目从"认证失败,需制片人手动"已转为✅完成。
---
## 六、纪律警告(Phase 2 Task 2 期间立)
### 纪律 1"push 待制片人手动处理"不算完工
**背景**Task 1 期间 MiniMax 多次以"push 待手动处理"作为完工结尾。本 chat 期间明确规矩。
**教训**:push 是 Git 工作流最后一环,未 push 就不是真正的完工。Git commit 只代表本地变更保存,push 成功才代表远程仓库同步完成。在任务报告中以"push 待手动处理"结尾会误导协作者认为工作已完成。
**落地规矩**:任何任务的完工报告必须包含"git push 成功"状态才算闭环。若 push 遇到认证问题:
- 优先排查 token 状态(坑 6 经验)
- 不得以"待手动处理"为由跳过 push 环节
- push 失败时用红字标注"⚠️ push 待解决",不写入"完工"状态
---
### 纪律 2SQL / 代码 commit 前必须本机干跑
**背景**Task 1 NameError(坑 1+ Task 2 002b SQL 语法错(坑 5)同源,都是 commit 前没自测直接进库。
**教训**:任何变更(SQL 文件、后端代码、前端代码)在 commit 前必须经过本机验证。对于 SQL 文件,本机用 `psql -U postgres -d milsci_dev -f backend/sql/00X_xxx.sql` 干跑;对于 Python 代码,用 `python -m pytest` 或冒烟脚本验证。发现错误当场修,不把问题带进 commit。
**落地规矩**
- SQL 文件:commit 前用 psql 在本机 dev 数据库干跑,确认无语法错误
- Python 代码:至少跑一遍冒烟测试或单元测试,确认基本功能正常
- 禁止用"这只是小改/语法简单/理论上没问题"为由跳过自测
- 养成习惯:**commit 前自测,发现问题当场修,比入库后发现 cost 低一个数量级**
---
## 七、遗留事项
### 7.1 Phase 2 内部待完成
- [x] **Task 1 后端 CRUD**:✅ 冒烟 6/6 全绿
- [x] **git push**:✅ 已恢复正常(坑 6 已解决)
- [ ] **Task 2**:Excel 批量导入(收视数据 + 排期模板)进行中
- [ ] **Task 3**:前端仪表盘 + 录入界面 + 用户管理界面
### 7.2 跨 Phase 遗留
#### Phase 4 或后续 Task 处理
- **PATCH /api/users 接口 password 相关权限**:当前 `UserUpdate` Schema 允许改 `is_active``role``display_name``profile_text`,但:
- `password_hash` **不允许**通过 API 修改(当前设计)
- 能否改自己密码?不能(当前设计)
- 改别人密码是否强制对方下次登录改密?未实现
- 建议 Phase 4+ 新增独立 `/api/users/change-password` 接口,设计时拉 Op
#### Phase 5 部署时处理(与 SECRET_KEY 并列)
- **传输安全**:当前 `password` 在请求体明文传输,仅 HTTPS 环境下可接受。Phase 5 部署到腾讯云后:
-`same_site="strict"` + `https_only=True`
- 确认 HTTPS 开启,强制全站 TLS
- 考虑 API 请求体密码加密或单独 `/api/auth/change-password` 接口
- **SECRET_KEY 占位符**`.env` 当前是 `change-me-to-a-random-string-in-production`**Phase 5 部署前必须改**为真实随机值。生成命令:
```python
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
---
## 八、Git commit 记录
| commit | 内容 |
|--------|------|
| 143601c | docs: 合入第十三章 Opus task brief 节制原则,版本升至 v5 |
| 33bee0c | feat: 后端 CRUD 完成(episodes + yearly_targets + users 管理 API |
| 63c031a | docs: Task 1 完工记录,Swagger UI 验收清单,跨 Phase 遗留事项 |
| 4f7c92d | fix: 修复 episodes.py + yearly_targets.py 模型导入缺失,删 users.py 残留 verify_password,增 DELETE 接口(幂等冒烟后确认 6/6 全绿) |
| f82a71b | docs: phase2_log 追加 Task 2 进行中坑与纪律警告 |
---
---
## 九、Task 2 完工记录(2026-05-19 commit 442d52f
### 9.1 冒烟结果(8 步全绿)
| Step | 描述 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | GET /api/imports/template | 200 xlsx | 200 xlsx (5595 bytes) | ✅ |
| 2 | POST /api/imports/episodes 合法行 | 200 success=1 | 200 success=1 failed=0 | ✅ |
| 3 | POST 部分失败(1ok+2bad | 200 errors=2 | 200 errors=2 row_number=[3,4] | ✅ |
| 4 | GET /api/imports/errors/{batch_id} | 200 xlsx | 200 xlsx (5181 bytes) | ✅ |
| 5 | POST 2024 无年度目标 | 400 | 400 含"年度目标"+"2024" | ✅ |
| 6 | POST 重复期次(库内重复) | 数据库约束触发 | failed_count=1 UniqueViolation | ✅ |
| 7 | POST 未知编导"李不存在"软落地 | 200 GET id 确认 null | 200 id=22 editor_id=null | ✅ |
| 8 | GET /api/episodes/{真实id} 验收 | 200 editor_id=null | 200 ✅ | ✅ |
**smoke 脚本:`backend/scripts/import_smoke.py`(幂等版,可重复跑)**
---
### 9.2 坑 7pandas 2.x 不接受 raw bytesBytesIO 包装解决)
**发现时间**2026-05-19 第一轮冒烟
**现象**`pd.read_excel(file_content)` 传入 bytespandas 2.x 报错或读不出数据。
**原因**pandas 2.x 要求 file-like object,不再直接接受 bytes。
**修复**`excel_service.py` 中用 `io.BytesIO(file_content)` 包装后传给 `pd.read_excel`。
**commit**442d52f
---
### 坑 8openpyxl add_named_style 误用(4ca24dc 原代码遗留)
**发现时间**2026-05-19 冒烟 Step 4 下载失败行 Excel
**现象**`generate_error_excel()` 调用 `wb.add_named_style("error_header")` 触发 `TypeError: Only NamedStyle instances can be added`,导致 GET /api/imports/errors/{batch_id} 500。
**来源**:经 git show 4ca24dc 确认,该 bug **在 4ca24dc 原代码中已存在**,非修坑引入。4ca24dc 自检只发现了 pandas bytes 问题,未发现此第二个 bug。
**修复**:删除 `wb.add_named_style("error_header")` 这一行(该样式未实际使用)。
**教训**commit 前自测要覆盖**所有**接口路径,不能只测主要流程。Step 1-3 通过不等于 Step 4 也通。
---
### 9.3 业务红线澄清:Step 6 重复期次拦截的两套机制
**两套拦截路径,各管各的事,不能混为一谈:**
1. **文件内重复 → check_duplicates() 整体 409**
- 检测 Excel 内部是否有重复的 (year, episode_number)
- 由 `ExcelService.check_duplicates()` 在入库前调用
- 有重复则整体拒绝,整文件不入库,返回 HTTP 409
2. **库内重复 → 数据库 UNIQUE 约束 单行失败,其他行入库**
- 检测 Excel 中某行与数据库已有记录重复
- 由 PostgreSQL `idx_episodes_year_number` 唯一约束触发
- 重复行报错进入 `errors[]`,其他行继续入库,返回 HTTP 200
**Step 6 实测的是第二套**(库内重复):Excel 写入 episode_number=99(与 Step 2 已入库的重复),数据库约束抛出 `UniqueViolation`,整行失败,success=0 failed=1。这两套机制分管文件内重复和库内重复,职责清晰。
---
### 9.4 纪律 3:启动期三件套不能证明接口业务路径通
**背景**:Task 2 开发期间,启动期(imports.py + excel_service.py + schemas/imports.py)写完后只跑了 GET 接口自测,POST/PATCH/DELETE 接口靠理论推导认为没问题就直接 commit。实际冒烟时发现 Step 2/3/4/5/6/7 全是 POST,全部出问题。
**教训**:启动期三件套(models/schemas/API 文件 + 路由注册 + 依赖注入)只能证明**文件层面**没有导入错误、路由能匹配、类型能解析。**不能**证明接口的业务路径(读写数据库、事务提交、错误处理)正常。任何 POST/PUT/PATCH/DELETE 接口 commit 前必须发真请求验证业务路径。
**落地规矩**
- 所有变更(SQL 文件、后端代码、前端代码)在 commit 前必须经过本机验证
- POST/PUT/PATCH/DELETE 接口必须用真请求实测,不能靠代码逻辑推导
- 发现错误当场修,不把问题带进 commit
---
### 9.5 遗留 bugsmoke 脚本 Step 7 写错(已改正)
**发现时间**2026-05-19 冒烟 Step 7 第一版
**现象**GET /api/episodes/300 返回 404"在库中不存在"。
**原因**smoke 脚本**把 episode_number 当 id 传**给 GET /api/episodes/{id}。后端 `GET /api/episodes/{id}` 接收的是数据库自增主键 id,不是业务期号 episode_number。episode_number=300 的记录入库后真实 id 是 14(或列表查询返回的其他值),不是 300。
**改正**:先从 `GET /api/episodes?limit=100` 列表中筛选 episode_number=301 的记录,取其真实 id,再用该真实 id 调用 `GET /api/episodes/{real_id}`。确保 GET /api/episodes/{id} 路径被实际验收。
**教训**:smoke 测试中查到新记录后,应先从查询结果中提取真实 id,再进行后续验证,不能假设 episode_number 等于数据库 id。
---
### 9.6 Task 2 收尾清单(还未做的四件小尾巴)
- [ ] **补录 2024 真值年度目标**Step 5 冒烟用到 2024 年,当前库中无 2024 目标,补录后 Step 5 才能真正通过
- [ ] **Windows python 默认 PATH 改指向 3.12**:坑 4 未完全解决,`py` 命令仍可能路由到系统 Python 3.14,需在系统 PATH 中显式优先 3.12
- [ ] **backups/ 进 .gitignore**:虽然 .gitignore 有 `*.log` 等模式,但 `backups/` 目录显式加入更稳,避免样本数据误传
- [ ] **MiniMax 自检报告里 schema 类名对照 Plan 自查**`ImportError`/`ImportResult` vs `UploadTemplateResponse`/`ImportResultResponse` 等命名差异对着 Plan 确认是否一致
---
## 十、Git commit 记录(2026-05-19 追加)
| commit | 内容 |
|--------|------|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿 |
*本日志由 MiniMax M2.7(Cline Act 模式)于 2026-05-15 持续追加,2026-05-19 追加 Task 2 完工坑与红线澄清,2026-05-20 追加 Task 2 收尾记录。*
---
## 十一、Task 2 收尾完工记录(2026-05-20)
### 11.1 Git 安全卫生:token 从 URL 剥离
**背景**:`.git/config` 里 origin 远程 URL 含明文 token(`cline-tps-dashboard`),
存在被 Cline 误贴日志、截图泄露、备份带出等风险。
**改动**:
- `git remote set-url origin` 改成干净 URL(去掉 `user:token@`)
- token 改由 Windows Git Credential Manager (manager) 加密存储
- 两次 fetch 静默通过,push 走 GCM 透明工作
- 旧 token `cline-tps-dashboard` 继续使用,不吊销
**纪律备注**:以后任何凭证(密码、API key、token)都不许写进 URL、不许写进 .py 文件、
不许写进任何会被 commit 的文件。
### 11.2 小尾巴 1:补录 2024 真值
**操作**:制片人手动 psql 直接 INSERT(绕过 API,因 API 权限收紧待办未做)。
**当前 yearly_targets 真值**:
- 2024: base=0.6448 / stretch=0.8290 (issued_date=2024-01-01, 补录说明在 notes)
- 2025: base=0.6448 / stretch=0.8989
- 2026: base=0.6448 / stretch=0.8989
### 11.3 小尾巴 3:backups/ 进 .gitignore
**commit**:20c0a1d(chore: backups/ 进 .gitignore)
**push**:已成功
### 11.4 小尾巴 4:schema 类名自查通过
类名与 Task 2 Plan 一致,无漂移。`ImportError` / `ImportResult` 命名正确。
发现 2 个隐患,**暂不处理,记为待办**:
- 隐患 A:`ImportError` 与 Python 内置同名,当前未撞,未来若在导入逻辑里 raise 才会冲突
- 隐患 B:`ExcelService.import_episodes()` 返回 dict 而非 Pydantic 对象,
API 层手动构造 `ImportResult`,缺一次 Pydantic 校验层。8 步冒烟已验证业务正确,
仅代码洁癖问题
### 11.5 小尾巴 2:Python PATH 根治坑 4
**改动**:从系统 PATH 删除 `C:\Python314\Scripts\` 和 `C:\Python314\` 两行。
用户 PATH 中 Python 3.12 自动顶上来。
**验证**:
- `where.exe python` 第一行变成 3.12 路径
- `python --version` = Python 3.12.10
- `sys.executable` 指向 3.12
**遗留**:E:\tps-dashboard\backend\ 下没有 venv,Python 依赖装在系统 3.12 全局
site-packages 里。**记入待办**,Phase 4 部署前或 Task 3 启动前评估是否补 venv。
### 11.6 待办清单(Task 3 启动前或后续 Phase 处理)
| 待办 | 触发时机 | 优先级 |
|---|---|---|
| `yearly_targets` 写权限收紧到 `zhipianren` + `zebian`,`biandao` 只读 | Task 3 启动前 | 高(业务红线) |
| backend/ 补 venv,把全局依赖搬进去 | Task 3 启动前评估 | 中 |
| 隐患 A:`ImportError` 同名冲突预防 | 真要 raise 时再说 | 低 |
| 隐患 B:`ExcelService` 返回 Pydantic 化 | 代码洁癖,有空时做 | 低 |
### 11.7 Task 2 完整 commit 链(2026-05-19 → 2026-05-20)
| commit | 内容 |
|---|---|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿(2026-05-19) |
| 6de8111 | docs: Task 2 完工记录,坑7/8/纪律3/Q1-Q4 写入(2026-05-19) |
| 20c0a1d | chore: backups/ 进 .gitignore(Task 2 收尾)(2026-05-20) |
**Task 2 完整收尾,Phase 2 Task 2 状态:✅ 完工。**
---
## 第十二章 yearly_targets 写权限收紧(2026-05-20
**状态:✅ 完工。冒烟 12/12 全绿。**
### 12.1 任务来源
task brief 明确要求收紧 yearly_targets 表的写权限,是业务红线。
### 12.2 改动文件
| 文件 | 改动内容 |
|------|----------|
| `backend/app/api/yearly_targets.py` | 5处 `require_role` 参数修正 |
| `backend/app/schemas/yearly_target.py` | `YearlyTargetUpdate` 改为部分字段可选(支持 PATCH 部分更新) |
| `backend/scripts/smoke_yearly_targets_auth.py` | 新建,12步幂等冒烟脚本 |
| `README.md` | 新增"角色权限表"节 |
| `.clinerules` 5.5 | 新增 yearly_targets 写权限收紧说明 |
| `logs/phase2_log.md` | 本章记录 |
### 12.3 权限矩阵最终状态
| 接口 | zhipianren | zebian | biandao |
|------|-----------|--------|---------|
| GET 列表/详情 | ✅ 200 | ✅ 200 | ✅ 200 |
| POST 新增 | ✅ 201 | ✅ 201 | ❌ 403 |
| PATCH 修改 | ✅ 200 | ✅ 200 | ❌ 403 |
| DELETE 删除 | ✅ 204 | ❌ 403 | ❌ 403 |
### 12.4 GET 权限修改说明
GET 权限修改原本不在 task brief 范围,是 Plan 阶段自查发现并扩大的范围。
Opus 审 Plan 时同意纳入,理由:权限矩阵整体校正一次做完更合理。
此条在 Opus 审方案 v2 中明确记录,phase2_log 本章也写入此说明,不得糊涂带过。
### 12.5 冒烟实测记录
`backend/scripts/smoke_yearly_targets_auth.py`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 | 204 | ✅ |
| 9 | zebian DELETE | 403 | ✅ |
| 10 | biandao DELETE | 403 | ✅ |
| 11 | biandao GET 列表 | 200 | ✅ |
| 12 | zhipianren GET 列表 | 200 | ✅ |
**12/12 全部通过。**
### 12.6 测试账号保留
冒烟脚本末尾显式打印保留账号(供后续手动清理):
- `test_zebian_smoke` (zebian)
- `test_biandao_smoke` (biandao)
---
## 第十三章 Task 3 前端三界面收官(2026-05-22
**状态:✅ 完工。六条尾巴全闭环,Phase 2 整体收官。**
Task 3 = 仪表盘真数据 + 用户管理 + 责编录入页。本次开局承接 2026-05-21 的"部分完成、未收官"状态(HEAD `1ebb8a6`),清完六条尾巴正式收官。
### 13.1 今日 commit 链
| commit | 内容 |
|--------|------|
| `4946ab2` | fix: AppLayout 未挂载——路由改两层 Outlet 嵌套,AuthGuard 不再包裹 AppLayout |
| `ebcb530` | fix: episodes GET 放开三角色,写权限仍仅 zhipianren/zebian |
| `c7296a5` | style: 柱图三色换降饱和柔和版(柔蓝/柔绿/柔红),图例同步 |
| `5839fc6` | chore: .gitignore 新增 cookies*.txt 忽略规则 |
(昨日另有 `bfcaddf` CSS 改动,非侧边栏病根但保留无害。)
### 13.2 六条尾巴收官情况
1. **侧边栏死穴(最硬)**simonkoson 登录后整个侧边栏不渲染。连追五轮才定位真因——**根因不是 CSS(前两轮误判 fixed/flex),是 AuthGuard 被当包裹组件用却内部只渲染 `<Outlet/>`、丢弃了 children `<AppLayout/>`**。改为路由两层 Outlet 嵌套(AuthGuard 独立守卫层 + AppLayout 独立 layout route)。三账号实测侧边栏 + 角色过滤全对(zhipianren 见用户管理、zebian 见责编录入不见用户管理、biandao 两者都不见)。
2. **episodes GET 放开三角色**:biandao 仪表盘从空变有数据;反向验证 biandao 发 POST/PATCH/DELETE 全 403。
3. **柱图配色**:见 13.4 色板固化。
4. **EditorDesk 责编录入**:**实测发现三界面本就完整**(三 Tab + 列表 CRUD + 表单字段齐全,含 audience_share 输入框),昨日"表单不全 / 找不到 audience_share"系侧边栏不通、进不去页面时凭印象误记。四步实测全过,无需改码:
- 新增期次能存(期次 8888 / 份额 0.95 / 编导张颖)
- 编导姓名快照必填、留空不能存(符合软引用红线:editor_id 可空、editor_name_snapshot 必填)
- 年度目标只增不改:2028 能存、2026 重复被拒(year UNIQUE 生效,旧值不被覆盖)
- 三色极端数据实测通过
5. **cookies 进 .gitignore**:5 个测试 cookie 文件忽略,工作区干净。
6. **极端数据三色实测**:造 0.95→柔红、0.5→柔绿、0.71/0.72→柔蓝,三色同框,颜色逻辑 + 配色双验。
### 13.3 侧边栏一役复盘(重点教训)
| 轮次 | Cline 判定的"根因" | 实际 | 结果 |
|------|-------------------|------|------|
| 1 | AuthGuard user===null 返回 null,整页空白 | 与现象矛盾(仪表盘明明渲染了) | 打回 |
| 2 | CSS `.app-sider` fixed 塌成 0 宽 | 猜测,未看真实 CSS | 打回 |
| 3 | 改 fixed 为 flex | 改了但浏览器仍不显示 | 非病根 |
| 4 | 制片人 F12 看真实 DOM | `#root` 下直接是 `.dashboard`,无 AppLayout 层 | 锁定方向 |
| 5 | AuthGuard 不渲染 children 却被当包裹组件用 | 真因,两段代码 + DOM 三者咬合 | 修复成功 |
教训沉淀为下方纪律 4。
### 13.4 柱图三色色板固化(制片人拍板)
**降饱和柔和版**:达标 = 柔蓝 `#5b8db8` / 待提升 = 柔绿 `#7aa874` / 优秀 = 柔红 `#c0584f`
原则:①降饱和度,贴合奶油底 `#fbf9f1` + 深绿主色 `#6b8e6b` 整体柔和基调,不用高饱和纯色;②待提升的绿须与主色深绿拉开(更浅更嫩),且与达标蓝有明显色相区分,杜绝"达标待提升一个色系"。
语义映射仍按 `.clinerules` 5.1:红 = 优秀 / 蓝 = 达标 / 绿 = 待提升。
**教训**:此色板此前仅口头拍板未入档,导致 Cline 重新生成时配错(达标套了主色深绿、待提升用土黄凑数)。**凡视觉拍板必入 log 固化**。
### 13.5 纪律 4:根因未经代码 / DOM 实证,不许动手修
侧边栏一役 Cline 连续三轮在猜测的根因上改代码(loading 态 → CSS fixed → CSS flex),均非病根。最终靠制片人 F12 看真实 DOM + 逼 Cline 贴真实代码才定位。
**落地规矩**Cline 报告"根因是 X"时,必须要求它贴出支撑该结论的真实代码 / DOM 证据;只要它在用"可能 / 也许 / 应该",就是在猜,打回。
---
## 第十四章 Phase 2 整体收官(2026-05-22
- Task 1 后端 CRUD:✅(冒烟 6/6
- Task 2 Excel 批量导入:✅(冒烟 8/8)
- Task 2 收尾 + yearly_targets 写权限收紧:✅(冒烟 12/12)
- Task 3 前端三界面:✅(六尾巴全闭环)
- **Phase 2 整体收官**,打 tag `phase2-dashboard-done`
- 收官 commit`5839fc6`working tree clean
### 14.1 跨 Phase 遗留(带入后续)
- **题图上传 Task(独立小 Task,下一个起手)**:①题图 banner 化;②仪表盘布局重排——题图做全宽 banner、近 9 期收视紧贴其下做全宽横条(不再挤在中间小格)。**走 Claude Design 流程**Design 出可交互原型 → 制片人定稿 → Opus 翻译成色值 + antd 组件名规格 → 喂 MiniMax**不直接把图甩给 Cline**(见 design_环节备忘.md)。
- **Phase 5 上线前数据清理**:清掉今日测试数据(期次 8888/8889 等、年度目标 2027/2028 等占位),录入真数据。删除走 UI 逐条删或 Cline 写"清业务表保真数据"SQL 经制片人确认后跑。
- backend/ 补 venv;隐患 AImportError 同名)、隐患 BExcelService 返回 Pydantic 化)低优;SECRET_KEY + same_site/https_onlyPhase 5 部署)。
---
*第十三~十四章由 Claude Opus 4.7 协助、MiniMax M2.7 落地,于 2026-05-22 Task 3 收官后追加。*
+197
View File
@@ -0,0 +1,197 @@
# Phase 3 开发日志
> 本文件记录 Phase 3(知识库基础设施)各 Task 的已完成事实。
> 续接快照(待办/决策)见单独文件,不混入本 log。
---
## Task 1 — embedding 最小链路验证 ✅(2026-05-26 完成)
### 目标
用最小代价验证 Phase 3 的命脉:文本能否转成正确维度的向量、整条「解析→embedding→入库→语义检索」链路能否跑通。不碰 schema、不碰前端、不碰 docx。
### 关键结论(地基事实,后续 Task 依赖)
- **embedding 服务确定为 MiniMax embo-01**。前期查证已排除 DeepSeek(官方无 embedding 接口)。
- **embo-01 输出维度 = 1536**,与 `001_init.sql``knowledge_embeddings.embedding vector(1536)` 完全匹配,无需改维度、无需改宪法。
- **embo-01 真实接口结构**(实测确认,非凭印象):
- 请求:`{"model":"embo-01", "texts":[...], "type":"db"|"query"}` —— 注意是 `texts` 数组,不是 OpenAI 的 `input`
- 返回:向量在 `vectors` 字段(`data["vectors"][0]`),不是 OpenAI 的 `data[0].embedding`
- 存文档用 `type="db"`,查询用 `type="query"`,不可混。
- 调用需 `Authorization: Bearer {API_KEY}` + `GroupId` 两个凭证(MiniMax 特有 GroupId)。
- **检索方式**:使用 pgvector 数据库原生余弦距离算子 `<=>`,不在 Python 侧计算。
- **凭证管理**API Key + GroupId 存于 `backend/.env``MINIMAX_EMBED_API_KEY` / `MINIMAX_GROUP_ID`),代码从 .env 读取,未进任何代码、未进 git.env 已在 .gitignore)。
- 注意:embedding 接口地址 `https://api.minimax.chat/v1/embeddings`,与 Cline 写代码所用的 M2.7 chat 接口是两套独立配置,互不影响。
### 新增/改动文件(已 commit & pushcommit 38873ac
- `backend/requirements.txt`+pgvector==0.2.5
- `backend/app/core/config.py`+引导凭证字段)
- `backend/app/models/knowledge.py`(新增,embedding 字段用 pgvector.Vector
- `backend/app/services/embedding_service.py`(新增,embo-01 调用封装)
- `backend/app/services/knowledge_service.py`(新增,写库 + SQL 向量检索)
- `backend/scripts/test_embo01_api.py`(探路脚本)
- `backend/scripts/verify_embedding.py`(全链路验证脚本)
- `backend/sample_md/`(空目录,测试文件已清)
### 收尾
- 验证用测试数据已全部清除:knowledge_items = 0 行,knowledge_embeddings = 0 行,sample_md/ 空。
- 知识库当前为干净空库,等待真实笔记导入。
### 顺手核实到的现状(未处理,留待办)
- episodes 表当前仅 7 行。
- Phase 2 的「批量导入」前端有入口但从未真实导入过收视数据(制片人确认:是流程上没提醒导入,加上收视表里有测试数据不会删)。属 Phase 2 遗留债,记入 backlog,另行处理,不并入 Phase 3。
---
## Task 2 — 知识库管理后台(最小可用版)✅(2026-05-26 完成)
### 目标
做一条端到端跑通的链路:上传 .md 笔记 → 解析 yaml frontmatter → 入库(含语义向量)→ 列表展示 → 删除。不改 schema、不碰认证、不引向量索引以外的新依赖。
### 新增/改动文件(4 次独立 commit)
| 文件 | 动作 | 说明 |
|------|------|------|
| `backend/requirements.txt` | 改 | +python-frontmatter==1.1.0 |
| `backend/app/services/knowledge_service.py` | 扩展 | +parse_md_file() + delete_item() + list_items() + get_distinct_sources();复用 EmbeddingService |
| `backend/app/api/knowledge.py` | 新增 | 4 个接口:POST /upload、GET /items、DELETE /items/{id}、GET /sources |
| `backend/app/main.py` | 改 | +app.include_router(knowledge_router) |
| `frontend/src/services/knowledgeService.js` | 新增 | 封装 4 个 API 调用 |
| `frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx` | 重写 | 上传区(Dragger) + 筛选栏(Select) + 列表(Table) + 删除(Popconfirm) |
### yaml frontmatter 解析映射(按真实样本,写死)
```python
# 类型 → source_type(硬映射,不猜测)
杂志文章/军报 military_report
节目文稿 manuscript
报题单 baoti
# 标题
名称 标题 title
# 作者
作者 编导 author
# 出处详情(存 JSONB 的 source_detail
期刊 + 期号 拼接"航空知识 2026年第1期"
# 播出日期
容错"待补充"等非日期文本 null
# 权重
原样存 JSONB不展示不排序Phase 4 排序用
# 双链 [[...]]
_extract_double_brackets() 原样存入 JSONB 预留给 Phase 4
```
### 关键设计决策
1. **source_detail 存 JSONB 不 ALTER**Task Brief 说"不要新增表字段"`tags` JSONB 列已用于存权重,故 source_detail 也放进来,不 ALTER TABLE。查询时从 `tags->>'source_detail'` 解压。
2. **来源筛选动态从 DB 取**`get_distinct_sources()` 查库里所有 `tags->>'source_detail'` DISTINCT 值,下拉选项不写死,新增一本杂志自动出现。
3. **embedding 复用**:直接调用 `EmbeddingService.embed_single(content_md, embed_type="db")`1536 维向量已验证,不重写。
4. **删除 CASCADE**:数据库层已有 `ON DELETE CASCADE`Python 侧只删 `KnowledgeItem` 即可。
### 测试结果(已验证)
| 验收项 | 结果 |
|--------|------|
| 两篇 md 均成功入库 | ✅ id=27(光辉之路)、id=28(超级战舰) |
| 杂志篇显示作者"钱峰",无播出时间 | ✅ author=钱峰, publish_date=null |
| 节目篇显示编导"左鑫",播出日期"待补充"不显示 | ✅ author=左鑫, publish_date=null |
| source_detail 正确存下("航空知识 2026年第1期" | ✅ tags 中可见 |
| 来源筛选下拉可据此筛 | ✅ /api/knowledge/sources 返回 `航空知识 2026年第1期` |
| knowledge_embeddings 有对应行(向量已生成) | ✅ 两行,19207/19174 字符(1536 维) |
| 删除有二次确认,确认后条目+向量一并消失 | ✅ 删除 id=27 验证,embeddings 级联消失 |
### commit 历史
- `779429a` feat: 添加 python-frontmatter 依赖用于解析 md yaml frontmatter
- `7b6ae24` feat: 知识库管理后台上传/列表/删除API,含frontmatter解析
- `7155a18` feat: 知识库管理前端页面,含上传/列表/筛选/删除
### 潜在风险
1. **windows CRLF 警告**:每次 commit 都有 CRLF→LF 警告,不影响功能但难看,属 git 全局配置问题(core.autocrlf),下次提醒用户设一下。
2. **"待补充"播出日期**:节目文稿的播出日期"待补充"正确存为 null,列表"播出/发表时间"格无值时不显示(render: val || null)。
3. **删除后 sources 下拉残留**:删除后 `get_distinct_sources()` 重新查会去掉已删条目的 source_detail,正常。
4. **embo-01 调用失败时整条上传失败**:当前是 try-except 包装,失败时返回 errors 数组,不污染已入库数据(因为单条原子),但前端需要展示 errors 反馈(已实现)。
---
## Task 3 — 知识库树形视图(路线B 按来源分组)✅(2026-05-27 完成)
> 本段为追加内容,接续 phase3_log 的 Task 1 / Task 2 之后。
> Task 3 实际分三轮(Task 3 主体 + 3.1 修复 + 3.2 调整)完成并验收。
### 目标
在 Task 2 的扁平列表外,包一层"可展开/收起的树形导航",按"来源"两层分组,
点树节点 → 右侧列表联动过滤。保留原有上传/筛选/删除。不改 schema。
### 路线选择(已定)
- **走路线B:按来源分组**(制片人拍板)。理由:现有字段(source_type + source_detail)即可建树,零返工。
- 路线A(按主题分组:涉及装备/技术等)的原料其实已在库(related_entities / tags JSONB),
将来转 A 路大概率无需补字段重传,只改分组层 + 映射表。
- **解耦红线**:交互层(KnowledgeTree.jsx)与数据分组层(useKnowledgeGrouping.js + 后端 get_grouped_items)分离,
转 A 路只改分组层一处,树组件不动。
### 三轮各自做了什么
**Task 3(树主体)**
- 两层树:来源大类 → 二级(出处)。"全部"为根节点;空大类规则、_none_ 不泄漏。
- 交互:展开/收起、全部展开/全部收起、节点高亮、右侧联动。
**Task 3.1(修复)**
- 修出处筛选下拉为空(根因:前端从未调用 listSources,用 items.length 误判;改为调 /sources 动态填充)。
- 修页面宽度跳动(右侧容器加 minWidth:0 约束)。
- 表格列宽规划;上传区收成一行按钮;隐藏"入库时间"列(仅前端隐藏,DB 字段保留)。
**Task 3.2(调整)**
- 大类固定排序:节目文稿 → 杂志文章 → 报题单 → 其他。
- 节目文稿按作者(author)归堆二级(如"左鑫");映射表 SECONDARY_GROUP_FIELD 落地。
- 左右两列对齐(曾尝试 flex→Grid 重构,连续改坏,最终回退 flex 止损,保留功能改进)。
- 空大类(0条)不显示。
### 关键设计决策
1. **二级分组维度按大类不同**manuscript→authormilitary_report→source_detail,其余 None。
做成**可配置映射表**(前端 useKnowledgeGrouping.js + 后端 knowledge_service.py 同名映射),改一处即可调整。
⚠️ 此设计使"第二层维度"在不同大类下含义不同,是制片人知情拍板的临时方案;
将来宜把"按编导看稿"迁为独立筛选(见快照 Backlog#3)。
2. **点击筛选为前端本地计算**displayedItems 基于已拉取全量 items + 选中节点实时算,不发网络请求,稳定。
3. **过滤按大类用对应字段**manuscript 比 authormilitary_report 比 source_detailTask 3.2 最终修对的关键)。
4. **空大类不显示根因**:初始化时 type_groups 预建所有大类空列表,致 continue 失效;改为动态收集真实出现的 source_type。
### 改动文件
| 文件 | 动作 |
|------|------|
| `backend/app/services/knowledge_service.py` | 新增 get_grouped_items()SOURCE_TYPE_ORDERSECONDARY_GROUP_FIELD;空大类动态收集 |
| `backend/app/api/knowledge.py` | 新增 GET /api/knowledge/grouped |
| `frontend/src/services/knowledgeService.js` | 新增 getGroupedItems() |
| `frontend/src/hooks/useKnowledgeGrouping.js` | 新增(数据分组层,解耦核心) |
| `frontend/src/components/KnowledgeTree/KnowledgeTree.jsx` | 新增(树交互组件) |
| `frontend/src/components/KnowledgeTree/KnowledgeTree.css` | 新增 |
| `frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx` | 重构左右分栏;按 type 分支过滤;上传区收小;隐藏入库时间列 |
### 验收结果(制片人真实点页面逐条验,已通过)
| 验收项 | 结果 |
|--------|------|
| 树两层、大类固定顺序(节目文稿在前) | ✅ |
| 节目文稿 → 左鑫 → 点击右侧出现超级战舰 | ✅ |
| 杂志文章 → 航空知识2026年第1期 → 点击右侧出现对应2篇 | ✅ |
| 点"全部"/各节点联动正确 | ✅ |
| 全部展开/全部收起、宽度不跳、上传按钮收小 | ✅ |
| 空大类(报题单/其他 0条)不显示 | ✅ |
| 原有上传/筛选/删除无回归 | ✅ |
### commit 历史
- 树主体与修复多次提交,最终修复 commit `3409d48`(前序含 `769a048` 等)。
### 教训(重要,下次接手避雷)
1. **Cline 自检多次虚标 ✅**:完工报告打勾项实测多坏。纪律:不信自检,制片人真实点页面验;禁止用会误报的检查方式(如跨多行正则)。
2. **key 两端对齐反复踩坑**:树节点 key 格式改动时生成端与过滤端未同步,致点节点筛不出。改 key 属高风险,改后必实测联动。
3. **布局别轻易换实现方式**:flex→Grid 整体重构连续改坏,靠"回退 flex + 保留功能"止损。已验证好用的布局不为对齐而重构。
4. **排查 > 盲改**:Cline 报告自相矛盾或与页面不符时,让制片人直接发源码文件给顾问看,一眼定位胜过反复试错。
5. **见好就收**:像素级打磨边际收益递减,记 backlog 改天连同视觉规范统一弄。
### 潜在风险 / 遗留
1. 界面像素级未到位:上传按钮框与筛选栏框上下精确对齐未做到;整体配色/字号属"能接受"非"精致",记 backlog。
2. 200+ 笔记尚未批量录入:需先试 10 篇验证解析,且宜等 pdf 存储方案定后再全量(见快照 Backlog#1#2)。
3. Phase 3 内"语义搜索"尚未做(向量已就位,随时可开),是下一刀候选。
+116
View File
@@ -0,0 +1,116 @@
# Phase 3 开发日志
> 本文件记录 Phase 3(知识库基础设施)各 Task 的已完成事实。
> 续接快照(待办/决策)见单独文件,不混入本 log。
---
## Task 1 — embedding 最小链路验证 ✅(2026-05-26 完成)
### 目标
用最小代价验证 Phase 3 的命脉:文本能否转成正确维度的向量、整条「解析→embedding→入库→语义检索」链路能否跑通。不碰 schema、不碰前端、不碰 docx。
### 关键结论(地基事实,后续 Task 依赖)
- **embedding 服务确定为 MiniMax embo-01**。前期查证已排除 DeepSeek(官方无 embedding 接口)。
- **embo-01 输出维度 = 1536**,与 `001_init.sql``knowledge_embeddings.embedding vector(1536)` 完全匹配,无需改维度、无需改宪法。
- **embo-01 真实接口结构**(实测确认,非凭印象):
- 请求:`{"model":"embo-01", "texts":[...], "type":"db"|"query"}` —— 注意是 `texts` 数组,不是 OpenAI 的 `input`
- 返回:向量在 `vectors` 字段(`data["vectors"][0]`),不是 OpenAI 的 `data[0].embedding`
- 存文档用 `type="db"`,查询用 `type="query"`,不可混。
- 调用需 `Authorization: Bearer {API_KEY}` + `GroupId` 两个凭证(MiniMax 特有 GroupId)。
- **检索方式**:使用 pgvector 数据库原生余弦距离算子 `<=>`,不在 Python 侧计算。
- **凭证管理**API Key + GroupId 存于 `backend/.env``MINIMAX_EMBED_API_KEY` / `MINIMAX_GROUP_ID`),代码从 .env 读取,未进任何代码、未进 git.env 已在 .gitignore)。
- 注意:embedding 接口地址 `https://api.minimax.chat/v1/embeddings`,与 Cline 写代码所用的 M2.7 chat 接口是两套独立配置,互不影响。
### 新增/改动文件(已 commit & pushcommit 38873ac
- `backend/requirements.txt`+pgvector==0.2.5
- `backend/app/core/config.py`+引导凭证字段)
- `backend/app/models/knowledge.py`(新增,embedding 字段用 pgvector.Vector
- `backend/app/services/embedding_service.py`(新增,embo-01 调用封装)
- `backend/app/services/knowledge_service.py`(新增,写库 + SQL 向量检索)
- `backend/scripts/test_embo01_api.py`(探路脚本)
- `backend/scripts/verify_embedding.py`(全链路验证脚本)
- `backend/sample_md/`(空目录,测试文件已清)
### 收尾
- 验证用测试数据已全部清除:knowledge_items = 0 行,knowledge_embeddings = 0 行,sample_md/ 空。
- 知识库当前为干净空库,等待真实笔记导入。
### 顺手核实到的现状(未处理,留待办)
- episodes 表当前仅 7 行。
- Phase 2 的「批量导入」前端有入口但从未真实导入过收视数据(制片人确认:是流程上没提醒导入,加上收视表里有测试数据不会删)。属 Phase 2 遗留债,记入 backlog,另行处理,不并入 Phase 3。
---
## Task 2 — 知识库管理后台(最小可用版)✅(2026-05-26 完成)
### 目标
做一条端到端跑通的链路:上传 .md 笔记 → 解析 yaml frontmatter → 入库(含语义向量)→ 列表展示 → 删除。不改 schema、不碰认证、不引向量索引以外的新依赖。
### 新增/改动文件(4 次独立 commit)
| 文件 | 动作 | 说明 |
|------|------|------|
| `backend/requirements.txt` | 改 | +python-frontmatter==1.1.0 |
| `backend/app/services/knowledge_service.py` | 扩展 | +parse_md_file() + delete_item() + list_items() + get_distinct_sources();复用 EmbeddingService |
| `backend/app/api/knowledge.py` | 新增 | 4 个接口:POST /upload、GET /items、DELETE /items/{id}、GET /sources |
| `backend/app/main.py` | 改 | +app.include_router(knowledge_router) |
| `frontend/src/services/knowledgeService.js` | 新增 | 封装 4 个 API 调用 |
| `frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx` | 重写 | 上传区(Dragger) + 筛选栏(Select) + 列表(Table) + 删除(Popconfirm) |
### yaml frontmatter 解析映射(按真实样本,写死)
```python
# 类型 → source_type(硬映射,不猜测)
杂志文章/军报 military_report
节目文稿 manuscript
报题单 baoti
# 标题
名称 标题 title
# 作者
作者 编导 author
# 出处详情(存 JSONB 的 source_detail
期刊 + 期号 拼接"航空知识 2026年第1期"
# 播出日期
容错"待补充"等非日期文本 null
# 权重
原样存 JSONB不展示不排序Phase 4 排序用
# 双链 [[...]]
_extract_double_brackets() 原样存入 JSONB 预留给 Phase 4
```
### 关键设计决策
1. **source_detail 存 JSONB 不 ALTER**Task Brief 说"不要新增表字段"`tags` JSONB 列已用于存权重,故 source_detail 也放进来,不 ALTER TABLE。查询时从 `tags->>'source_detail'` 解压。
2. **来源筛选动态从 DB 取**`get_distinct_sources()` 查库里所有 `tags->>'source_detail'` DISTINCT 值,下拉选项不写死,新增一本杂志自动出现。
3. **embedding 复用**:直接调用 `EmbeddingService.embed_single(content_md, embed_type="db")`1536 维向量已验证,不重写。
4. **删除 CASCADE**:数据库层已有 `ON DELETE CASCADE`Python 侧只删 `KnowledgeItem` 即可。
### 测试结果(已验证)
| 验收项 | 结果 |
|--------|------|
| 两篇 md 均成功入库 | ✅ id=27(光辉之路)、id=28(超级战舰) |
| 杂志篇显示作者"钱峰",无播出时间 | ✅ author=钱峰, publish_date=null |
| 节目篇显示编导"左鑫",播出日期"待补充"不显示 | ✅ author=左鑫, publish_date=null |
| source_detail 正确存下("航空知识 2026年第1期" | ✅ tags 中可见 |
| 来源筛选下拉可据此筛 | ✅ /api/knowledge/sources 返回 `航空知识 2026年第1期` |
| knowledge_embeddings 有对应行(向量已生成) | ✅ 两行,19207/19174 字符(1536 维) |
| 删除有二次确认,确认后条目+向量一并消失 | ✅ 删除 id=27 验证,embeddings 级联消失 |
### commit 历史
- `779429a` feat: 添加 python-frontmatter 依赖用于解析 md yaml frontmatter
- `7b6ae24` feat: 知识库管理后台上传/列表/删除API,含frontmatter解析
- `7155a18` feat: 知识库管理前端页面,含上传/列表/筛选/删除
### 潜在风险
1. **windows CRLF 警告**:每次 commit 都有 CRLF→LF 警告,不影响功能但难看,属 git 全局配置问题(core.autocrlf),下次提醒用户设一下。
2. **"待补充"播出日期**:节目文稿的播出日期"待补充"正确存为 null,列表"播出/发表时间"格无值时不显示(render: val || null)。
3. **删除后 sources 下拉残留**:删除后 `get_distinct_sources()` 重新查会去掉已删条目的 source_detail,正常。
4. **embo-01 调用失败时整条上传失败**:当前是 try-except 包装,失败时返回 errors 数组,不污染已入库数据(因为单条原子),但前端需要展示 errors 反馈(已实现)。
+105 -30
View File
@@ -2,7 +2,7 @@
> 项目:TPS(Topic Planning System)中台 — 央视《军事科技》栏目内部工作台 > 项目:TPS(Topic Planning System)中台 — 央视《军事科技》栏目内部工作台
> 仓库:`tps-dashboard` > 仓库:`tps-dashboard`
> 修订版本:v4 · 2026-05-14 > 修订版本:v5 · 2026-05-27
> 制片人:刘通 > 制片人:刘通
> 协作大模型:Claude Opus 4.7(顾问)+ MiniMax M2.7(Plan + Act,Cline 内双角色物理隔离) > 协作大模型:Claude Opus 4.7(顾问)+ MiniMax M2.7(Plan + Act,Cline 内双角色物理隔离)
@@ -14,7 +14,7 @@
技术实施细节、阶段时间线在姊妹文档 `dev_plan.md`;协作规则在 `.clinerules` 技术实施细节、阶段时间线在姊妹文档 `dev_plan.md`;协作规则在 `.clinerules`
**这三份文件长期不变**;每周执行细节进 `logs/phase{N}_log.md`,不进本文。 **这三份文件是宪法,改动应慎重,但不是不可变**——随着开发推进、制片人对系统的理解越来越具体,宪法会迭代升级(如 v5 把 TPS 从"结果列表"升级为"对话式策划助手")。每周执行细节进 `logs/phase{N}_log.md`,不进本文。
--- ---
@@ -44,6 +44,13 @@
这一条贯穿所有模块设计、所有 AI Prompt、所有 UI 决策。任何让 AI 替编导拍板的功能都不要做;任何把编导逼向"按 AI 建议改"的设计都要重做。 这一条贯穿所有模块设计、所有 AI Prompt、所有 UI 决策。任何让 AI 替编导拍板的功能都不要做;任何把编导逼向"按 AI 建议改"的设计都要重做。
**TPS 的产品定位(v5 明确)**:TPS 选题策划系统是一个**"带着栏目全部家底的策划助手"**,不是"主编的数字分身"。这半格之差是产品的命:
- **主编**会说"我建议你这么做"——带判断倾向,替编导拿主意。
- **助手**会说"顺着你这个思路,栏目里有这些相关家底,要不要我帮你理一理"——把判断的原料和脉络铺好,人来拍板。
TPS 做后者。它可以对话、可以加工、可以给有依据的分析("这个角度历史上收视偏低,可能和切入点有关"),但分析永远是**摆事实 + 给关联**,不是下结论("所以别做")。**AI 摆牌给编导看,牌由编导自己出。**
--- ---
## 三、项目目标 ## 三、项目目标
@@ -91,17 +98,34 @@
## 五、产品功能模块(8 个) ## 五、产品功能模块(8 个)
### 模块 A — 选题查重(TPS 核心入口) ### 模块 A — 选题查重与对话式策划(TPS 核心入口)
**痛点**:编导一个选题,担心做过、想知道历史成绩。 **痛点**:编导一个创意或灵感,担心做过、想知道历史成绩;新编导面对一堆历史节目,不知道怎么用、看不出"so what"
**功能**: **形态(v5 升级——从"结果列表"升级为"对话式策划助手")**:
- 编导输入选题简介 → 系统语义检索往期 `topics` + `episodes`
- 列出 5 条相似度最高的历史节目,**展示原文 + 当年收视成绩 + 颜色标识(红蓝绿)**
- 点开任一相似节目 → 联动模块 E,展示**外拍联系人 / 联系方式 / 线索来源**
- 编导自行判断:"这选题做过但收视很高可以重做"或"这个角度我们没碰过"
**AI 哲学**:**不告诉编导"建议放弃 / 建议做"**,只展示事实让其自决。 界面采用**左右双栏**:
- **左栏**:编导与 TPS 的对话区。编导输入只言片语的灵感,可持续追问、逐步把思路聊清楚。
- **右栏**:一份由 AI 加工的**策划参考报告**,随对话深入而越来越贴合。
报告内容(全部基于栏目自己的知识库,有依据、可追溯):
- 这个选题方向**之前有没有人做过、是谁做的、收视成绩怎么样**(联动 `episodes`,颜色判定见第 6.1 节)
- 当时**领导有什么点评、审片意见**(联动模块 D / `review_comments`)
- **有没有可参考的往期节目**(提供节目链接/索引)
- 若涉及外拍,**是哪个单位、联系人是谁**(联动模块 E / `shooting_resources`)
- 相关的**往期文稿 / 专业期刊 / 权威资料**(联动模块 C / `knowledge_items`)
**三条不可动摇的交互红线(经业界研究验证,见 v5 起草说明)**:
1. **求助式,不是投喂式**:报告默认是编导"叫出来"的,而非编导刚敲半句就自动甩一整屏盖住。展开与深入由编导主动触发——主动权握在"要不要展开"这个动作上。
2. **脚注式锚定引用**:报告里每个结论后挂一个不打扰阅读的小角标,点开才看到依据(哪期节目/哪篇期刊)。**不大面积平铺引用**,仿纸质图书脚注。此设计既让编导能一键核实、不盲信,也经研究证明能降低对 AI 的过度依赖。
3. **持续对话沉淀编导的判断**:编导每聊一轮都在投入自己的思路,聊到最后报告里有编导自己的烙印,而非 AI 一次性吐出的成品。聊定后,可一键转入模块 G 生成报题单。
**AI 哲学**:**不告诉编导"建议放弃 / 建议做"**,只摆事实、给关联、做有依据的分析,让编导自决。
**落地顺序(重要)**:此对话式形态属 Phase 4a,且**必须在知识库笔记 + 收视数据真实灌入之后**才上线。库空时此助手"无米下锅",依据全空会毁掉第一印象。Phase 3 内先做**朴素语义检索**(输入灵感→返回最相关的几条带相关度、原文片段、来源),作为此形态的第一块积木。
### 模块 B — 编导画像建议 ### 模块 B — 编导画像建议
@@ -115,16 +139,19 @@
**1.0 形态**:粗框架,文字一段。 **1.0 形态**:粗框架,文字一段。
**2.0 形态**:动态画像,根据每期作品自动迭代。 **2.0 形态**:动态画像,根据每期作品自动迭代。
### 模块 C — 知识库参考 ### 模块 C — 知识库参考(策划报告的依据来源)
**痛点**:写选题时缺背景资料,需要去翻往期文稿、军报、专家观点。 **痛点**:写选题时缺背景资料,需要去翻往期文稿、军报、专家观点;更进一步,编导希望 AI 给的策划建议是"有出处的",不是凭空说
**功能**: **形态(v5 升级)**:模块 C 是模块 A 右栏报告的**知识库依据供给侧**。
- 提交选题时,系统语义检索 `knowledge_items`(往期文稿 + 军报 + 内部资料)
- 列出 3-5 条最相关条目,展示摘要 + 关键词,可点开查看原文
- 编导可选择性引用,**引用关系入库**(为未来"知识溯源"打基础)
**关键技术**:pgvector + DeepSeek embedding(或 OpenAI 兼容 embedding API)+ 1536 维向量。 - 编导在对话中表达灵感时,系统语义检索 `knowledge_items`(往期文稿 + 军报 + 专业期刊 + 内部资料)
- 检索到的条目不再是孤立"列 3-5 条让编导自己翻",而是**融入右栏报告**,作为报告里相关论断的**脚注式引用依据**(见模块 A 红线 2)
- 编导可点开任一脚注查看原文摘要 + 关键词,可选择性引用,**引用关系入库**(为 2.0"知识溯源"打基础)
**关键技术**:pgvector + MiniMax embo-01 embedding + 1536 维向量(embedding 服务已于 Phase 3 Task 1 确定为 MiniMax embo-01,见 phase3_log)。
**朴素检索先行**:Phase 3 内先把"输入文本→返回最相关知识库条目"这条朴素检索链路接通(带相关度、原文片段、来源),它是模块 A 对话式报告的检索底座。
### 模块 D — 历史审片意见展示 ### 模块 D — 历史审片意见展示
@@ -156,13 +183,22 @@
### 模块 G — 报题单生成与下载 ### 模块 G — 报题单生成与下载
**痛点**:报题单格式各编导各异,审核耗时。 **痛点**:报题单格式各编导各异,审核耗时;水平参差的编导写的报题单结构残缺、要素不全、漏洞百出
**形态(v5 明确)**:编导与 TPS 在模块 A 里聊定选题后,点一个按钮,把**编导已定的内容**按《军事科技》报题单格式一键生成 `.docx`,提供下载。
**功能**:
- 编导填好选题信息 + 选好相似参考 + 选好知识库引用 → 一键生成 `.docx` 报题单
- 模板由 docxtpl 渲染,**严格遵守"双段结构"**(引子 + 列举,见 `.clinerules` 第 5.4 节) - 模板由 docxtpl 渲染,**严格遵守"双段结构"**(引子 + 列举,见 `.clinerules` 第 5.4 节)
- AI 通过学习成熟报题单范本,帮助生成的报题单**更规范**:补齐结构、统一格式、提示遗漏要素、校对装备名/概念名——专治"漏洞百出"
- 编导可下载、修改、打印 - 编导可下载、修改、打印
**"一键生成"的分寸线(死规矩,不可越)**:
> 这个按钮做的是**"按范本规范化呈现编导已定的内容"**——排版 + 校对 + 结构补齐 + 遗漏提示。
> AI **不替编导生产选题立意、不代写内容**。规范化只作用于"格式、结构、要素齐全、字词准确"这一层;选题的内容和立意永远是编导的创作。
> 一旦 AI 在内容立意上动手,就从"助手排版"滑回了"代写",违背创作主权红线。
**落地顺序**:属 Phase 4a。
### 模块 H — 编导个人首页 + 热点雷达 ### 模块 H — 编导个人首页 + 热点雷达
**痛点**:编导想知道"今天有什么军事新闻可能值得做",目前靠各自刷微博/微信。 **痛点**:编导想知道"今天有什么军事新闻可能值得做",目前靠各自刷微博/微信。
@@ -204,6 +240,18 @@
默认:**引子(200-300 字)+ 列举(3-5 条 Markdown 序号,每条以 `**加粗关键词**` 开头)**。 默认:**引子(200-300 字)+ 列举(3-5 条 Markdown 序号,每条以 `**加粗关键词**` 开头)**。
备选:纯叙述,**仅当编导明确指定才用**。 备选:纯叙述,**仅当编导明确指定才用**。
### 6.5 AI 摆牌不出牌(创作主权红线,v5 新增)
TPS 是"策划助手"不是"主编数字分身"(见第 2.3 节)。落到每一处 AI 交互:
- **摆事实、给关联、做有依据的分析**——可以。
- **下结论、给倾向、替编导拿主意**("建议放弃/建议这么改")——不可以。
- AI 的所有输出都是**"摆给编导看的牌"**,牌由编导自己出。
- 凡 AI 输出处,UI 必须给编导"忽略 / 我来改写"的否决权。
- 报题单"一键生成"只做规范化呈现,**不代写选题立意**(见模块 G 分寸线)。
> 自检口诀:这句 AI 的话,是"把原料铺给你看",还是"替你做了决定"?后者一律改掉。
--- ---
## 七、技术栈与红线(精简版) ## 七、技术栈与红线(精简版)
@@ -261,11 +309,11 @@
| Phase | 时间 | 核心交付 | 完成度 | | Phase | 时间 | 核心交付 | 完成度 |
|---|---|---|---| |---|---|---|---|
| Phase 0 | Day 1-2 | 环境搭建(Python/Node/PG/Git) | 家用机已完成,单位机重启 | | Phase 0 | Day 1-2 | 环境搭建(Python/Node/PG/Git) | ✅ 已完成 |
| Phase 1 | Week 1 | 后端 + 前端骨架 + 登录跑通 | 家用机后端已完成,前端待 | | Phase 1 | Week 1 | 后端 + 前端骨架 + 登录跑通 | ✅ 已完成 |
| Phase 2 | Week 2-3 | 仪表盘 + 用户管理 + Excel 导入 | 未开始 | | Phase 2 | Week 2-3 | 仪表盘 + 用户管理 + Excel 导入 | ✅ 已完成 |
| Phase 3 | Week 4 | 知识库基础设施 + 报题单导入 | 未开始 | | Phase 3 | Week 4 | 知识库基础设施 + 报题单导入 | 进行中(已完成至 Task 3 知识库树形视图;语义检索尚未做) |
| Phase 4a | Week 5-6 | **MVP 上线**(报题单生成 + 查重 + 知识库参考) | 未开始 | | Phase 4a | Week 5-6 | **MVP 上线**(对话式 TPS + 报题单生成 + 知识库参考) | 未开始 |
| Phase 4b | Week 7-9 | 画像 + 甘特图 + 资源 | 未开始 | | Phase 4b | Week 7-9 | 画像 + 甘特图 + 资源 | 未开始 |
| Phase 4c | Week 10-11 | 热点雷达 | 未开始 | | Phase 4c | Week 10-11 | 热点雷达 | 未开始 |
| Phase 5 | Week 11-12 | 部署上线 + 反向代理整合 + Buffer | 未开始 | | Phase 5 | Week 11-12 | 部署上线 + 反向代理整合 + Buffer | 未开始 |
@@ -291,13 +339,39 @@
**接入信息**:`base_url = https://api.minimaxi.com/v1`,Model ID = `MiniMax-M2.7`(标准版,非 highspeed),API Key 已由制片人申请就绪。 **接入信息**:`base_url = https://api.minimaxi.com/v1`,Model ID = `MiniMax-M2.7`(标准版,非 highspeed),API Key 已由制片人申请就绪。
> 注意:上表是**开发期**的模型分工(谁帮我们写代码)。下文第十一章讲的是**产品运行期**的 AI 调用成本(系统上线后,编导每天用 TPS 烧的钱),两者是两笔账。
--- ---
## 十一、风险与应对 ## 十一、AI 成本与额度原则(v5 新增)
TPS 升级为对话式策划助手后,编导每用一次都要调大模型。8 人长期高频使用,这笔运行成本不小,且对话式产品有个特点:**用户聊得越爽烧得越多,而自己往往不知道在烧钱**。本节定原则,具体收费方式待 MVP 能用后再拍。
### 11.1 成本归属(待确认,但方向先定)
- **优先争取栏目报销**:TPS 是栏目的生产力工具,理应走栏目经费。系统未上线前无法确认能否报销,**先记为待办,不要现在自己垫钱去设计收费系统**。
- **不向编导收费**(明确反对):让编导自己充值或填个人 API Key,等于给"用不用这个工具"装了计价器,跟"对用户像对客户、鼓励新编导多用多练"的初心直接打架。**新编导越该多用,越不能让他舍不得花钱而不敢用。**
### 11.2 1.0 必须预埋的三件事(不贵,但必须从第一天就埋,事后补极难)
> 以下为业界对 LLM 应用控成本的共识做法,已用大白话翻译;**取其神,弃其形**——下面"不要做"那条尤其重要。
1. **按"用量(token)"记,不是按"次数"记**:同样问一次,随口问一句和把整篇文稿贴进去追问,成本差几十倍。所以计量与限额都要按真实消耗的量,而不是"今天能问几次"。
2. **按"人 + 选题"归因**:每次调用都打上"是谁、为哪个选题"的标签。这是"费用不能都制片人出"的技术前提——将来无论报销、分摊还是台里统一付,账都清楚。
3. **保守上限 + 超额提醒管理员**:上线时给每人先卡一个偏紧的月度额度,超了提醒管理员(制片人),看真实用量再松绑。绝不允许"某编导一个通宵烧光全月预算"。
### 11.3 不要做的(防止 Cline 上重型武器)
业界控成本的文章常提"AI 网关 / OpenTelemetry / 可观测性平台"——**那是给几百上千人、多团队大公司用的重武器,8 人小项目完全不需要**。我们要的就是最朴素的:每次调用往一张表记一行(谁、何时、烧多少、为哪个选题),管理员页面看汇总、设上限。**Cline 若提议引入网关或可观测性框架,一律驳回。**
> 落地 Phase:额度机制随对话式 TPS(Phase 4a)一起做;Phase 3 朴素检索只调 embedding(每篇几厘钱量级),暂不涉及此机制。
---
## 十二、风险与应对
| 风险 | 概率 | 应对 | | 风险 | 概率 | 应对 |
|---|---|---| |---|---|---|
| Phase 1 前端骨架延期 | 中 | 单位机有家用机的代码 zip 可参考,降低重写成本 |
| MiniMax M2.7 切入 Plan 后方案质量下降 | 中 | 临时切回 claude.ai 找 Opus 4.7 重新规划,不在 Cline 里换模型 | | MiniMax M2.7 切入 Plan 后方案质量下降 | 中 | 临时切回 claude.ai 找 Opus 4.7 重新规划,不在 Cline 里换模型 |
| 历史 Excel / docx 导入解析失败 | 高 | 解析失败的文件单独列出,提示责编手工修正后重传 | | 历史 Excel / docx 导入解析失败 | 高 | 解析失败的文件单独列出,提示责编手工修正后重传 |
| 编导抗拒使用新工具 | 中 | "辅助不替代"的产品哲学贯穿,绝不强制;先让最积极的 1-2 位编导先用,产生口碑 | | 编导抗拒使用新工具 | 中 | "辅助不替代"的产品哲学贯穿,绝不强制;先让最积极的 1-2 位编导先用,产生口碑 |
@@ -306,7 +380,7 @@
--- ---
## 十、不在 1.0 范围内的事 ## 十、不在 1.0 范围内的事
明确不做、留到 2.0 或更远的: 明确不做、留到 2.0 或更远的:
@@ -322,14 +396,15 @@
--- ---
## 十、版本演进 ## 十、版本演进
| 版本 | 日期 | 主要变化 | | 版本 | 日期 | 主要变化 |
|---|---|---| |---|---|---|
| v1 | 2026-04 | 初版,定模块 A-G(7 个) | | v1 | 2026-04 | 初版,定模块 A-G(7 个) |
| v2 | 2026-05-04 | 加模块 H 热点雷达,增 2.0 兼容预埋说明 | | v2 | 2026-05-04 | 加模块 H 热点雷达,增 2.0 兼容预埋说明 |
| v3 | 2026-05-06 | 加 MiniMax M2.7 接班 Plan 模式分工(第十章)、Git 仓库信息、单位机重启路径、风险表中 Gitea 项;DeepSeek pro 全程在岗 Act | | v3 | 2026-05-06 | 加 MiniMax M2.7 接班 Plan 模式分工(第十章)、Git 仓库信息、单位机重启路径、风险表中 Gitea 项;DeepSeek pro 全程在岗 Act |
| **v4** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** | | v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录 |
| **v5** | **2026-05-27** | **TPS 定位升级:模块 A/C/G 从"结果列表"升级为"对话式策划助手"(左对话右报告、脚注式锚定引用、求助式不投喂);新增第 2.3 节产品定位、6.5 节"AI 摆牌不出牌"红线、第十一章"AI 成本与额度原则";明确朴素语义检索(Phase 3)→ 对话式 TPS(Phase 4a,数据灌入后)的落地顺序;清理过期风险项** |
--- ---