Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a86c936ee | |||
| 9acb9d380d | |||
| 47c9d60f02 | |||
| cd4dcd06f8 | |||
| 783e212bb1 | |||
| 0c7d2d7400 | |||
| 70784fc70b | |||
| 347a06e48b | |||
| fff2cfe395 | |||
| a00bcd6c73 | |||
| b3157216bb | |||
| d849bc1539 | |||
| 74a0508755 | |||
| 7999fd14c8 | |||
| 5ef1b8ec8d | |||
| 855f103ce8 | |||
| d81969b4b5 | |||
| 04a35faba3 | |||
| 1325807257 | |||
| d40d46a434 | |||
| 2881b866a6 | |||
| 72ae73764a | |||
| 51a34e9ec6 | |||
| 303995a93f | |||
| d774370a4b | |||
| a6e8cc25ec | |||
| 8299b6bbc9 | |||
| 02bf4c4fe4 | |||
| d55fde7fb5 | |||
| a2f1710a7f | |||
| 59f556d986 | |||
| fcaa0969a8 | |||
| f55030b971 | |||
| 24b9bb2d08 | |||
| 247268a60e | |||
| f0f9dc493e | |||
| e0ccfb20e7 | |||
| 4eca31f8eb | |||
| 3942f01def | |||
| b7560a6cf6 | |||
| 7cc0bf21ff | |||
| 93fd327b13 | |||
| 932985feca | |||
| ff0471a6be | |||
| f4661863a7 | |||
| 14c9841b35 | |||
| c9e527ff5b | |||
| 2480d4a738 | |||
| 9439c72d30 | |||
| 3102fbec6c | |||
| b3667aa7b9 | |||
| 773441bb87 | |||
| 3dc1a1cf3c | |||
| fab15505bd | |||
| 347ec1b13a | |||
| d29b1bb394 | |||
| 2bee0ce8c8 | |||
| 0b141239cc | |||
| 483e34b4f6 |
@@ -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 经验)。*
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
一个面向《军事科技》栏目 8 人团队的内部工作台,核心模块包括:
|
一个面向《军事科技》栏目 8 人团队的内部工作台,核心模块包括:
|
||||||
|
|
||||||
- **TPS**(选题策划系统):查重 + 知识库参考 + 报题单一键生成
|
- **TPS**(选题策划系统):查重 + 知识库参考 + 报题单一键生成
|
||||||
- **仪表盘**:近 4 期收视成绩 + 未来 6 期排期一目了然
|
- **仪表盘**:近 9 期收视成绩 + 未来 6 期排期一目了然
|
||||||
- **知识库**:往期文稿、军报材料、专家库、外拍线索一站式检索
|
- **知识库**:往期文稿、军报材料、专家库、外拍线索一站式检索
|
||||||
- **个人首页**:每位编导有自己的工作台 + 今日热点雷达
|
- **个人首页**:每位编导有自己的工作台 + 今日热点雷达
|
||||||
- **拍摄资源库**:外拍联系人 / 单位 / 线索来源结构化沉淀
|
- **拍摄资源库**:外拍联系人 / 单位 / 线索来源结构化沉淀
|
||||||
@@ -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 4–5 | 未开始 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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"历史表述,不再保留迁移叙事** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
仪表盘 API — 题图上传 / 查询
|
||||||
|
|
||||||
|
权限:
|
||||||
|
- POST /cover — 仅 zhipianren / zebian(biandao 返回 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}
|
||||||
@@ -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
|
||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -33,4 +43,16 @@ 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")
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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,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
|
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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]
|
||||||
@@ -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. 读取 Excel(pandas 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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
# 调用 embedding(type="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,
|
||||||
|
}]
|
||||||
@@ -8,4 +8,7 @@ bcrypt==4.0.1
|
|||||||
itsdangerous==2.2.0
|
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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
# 数据库唯一约束触发:整行被 reject,success=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()
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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 的指令要用代码块封装起来,方便复制。
|
||||||
@@ -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 操作手册)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -201,7 +201,7 @@ git push origin phase0-env-ready
|
|||||||
- 编辑 episodes / yearly_targets / users 的 SQLModel 模型
|
- 编辑 episodes / yearly_targets / users 的 SQLModel 模型
|
||||||
|
|
||||||
**前端**:
|
**前端**:
|
||||||
- 仪表盘页面(近 4 期收视 + 未来 6 期排期)
|
- 仪表盘页面(近 9 期收视 + 未来 6 期排期)
|
||||||
- 责编录入界面
|
- 责编录入界面
|
||||||
- 管理员用户管理界面
|
- 管理员用户管理界面
|
||||||
|
|
||||||
@@ -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(全局依赖搬进去)。
|
||||||
|
- 隐患 A:ImportError 与 Python 内置同名(低优)。
|
||||||
|
- 隐患 B:ExcelService 返回 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 给方向不给答案"。
|
||||||
@@ -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 是一个时间点的快照,以后可以回到这个时间点。
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 切换到你想放项目的位置
|
# 切换到你想放项目的位置
|
||||||
cd Z:\AIworks\
|
cd E:\tps-dashboard\
|
||||||
|
|
||||||
# 把刚才复制的 clone 命令粘贴运行
|
# 把刚才复制的 clone 命令粘贴运行
|
||||||
git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
||||||
@@ -88,12 +88,12 @@ done.
|
|||||||
|
|
||||||
`warning: empty repository` 是**正常的**——本来就是空仓库,不是错误。
|
`warning: empty repository` 是**正常的**——本来就是空仓库,不是错误。
|
||||||
|
|
||||||
完成后 `Z:\AIworks\` 下多了一个 `tps-dashboard` 文件夹。
|
完成后 `E:\tps-dashboard\` 下多了一个 `tps-dashboard` 文件夹。
|
||||||
|
|
||||||
### 2.3 进仓库目录
|
### 2.3 进仓库目录
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd Z:\AIworks\tps-dashboard
|
cd E:\tps-dashboard\
|
||||||
```
|
```
|
||||||
|
|
||||||
之后所有 git 命令都在这个目录里跑。
|
之后所有 git 命令都在这个目录里跑。
|
||||||
@@ -104,17 +104,17 @@ cd Z:\AIworks\tps-dashboard
|
|||||||
|
|
||||||
**手动操作**(用 Windows 资源管理器):
|
**手动操作**(用 Windows 资源管理器):
|
||||||
|
|
||||||
1. 在 `Z:\AIworks\tps-dashboard\` 下:
|
1. 在 `E:\tps-dashboard\` 下:
|
||||||
- 直接拷入 `.clinerules`、`project_plan.md`、`dev_plan.md`、`README.md`(平铺四份)
|
- 直接拷入 `.clinerules`、`project_plan.md`、`dev_plan.md`、`README.md`(平铺四份)
|
||||||
|
|
||||||
2. 在 `Z:\AIworks\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
|
2. 在 `E:\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
|
||||||
|
|
||||||
3. 在 `Z:\AIworks\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
|
3. 在 `E:\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
|
||||||
|
|
||||||
**最终结构应该是**:
|
**最终结构应该是**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Z:\AIworks\tps-dashboard\
|
E:\tps-dashboard\
|
||||||
├── .clinerules
|
├── .clinerules
|
||||||
├── project_plan.md
|
├── project_plan.md
|
||||||
├── dev_plan.md
|
├── dev_plan.md
|
||||||
@@ -132,7 +132,7 @@ Z:\AIworks\tps-dashboard\
|
|||||||
**操作**:在 VS Code 终端里执行:
|
**操作**:在 VS Code 终端里执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd Z:\AIworks\tps-dashboard
|
cd E:\tps-dashboard\
|
||||||
notepad .gitignore
|
notepad .gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ branch 'main' set up to track 'origin/main'.
|
|||||||
### 3.1 每天开工第一件事
|
### 3.1 每天开工第一件事
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd Z:\AIworks\tps-dashboard
|
cd E:\tps-dashboard\
|
||||||
git status # 看看本地有没有未提交的改动
|
git status # 看看本地有没有未提交的改动
|
||||||
git pull # 拉 Gitea 最新(如果别的机器推过)
|
git pull # 拉 Gitea 最新(如果别的机器推过)
|
||||||
```
|
```
|
||||||
@@ -268,7 +268,7 @@ Cline(MiniMax M2.7)会自动跑 git add / git commit / git push。**这是默认
|
|||||||
### 3.4 你自己手工提交(备选)
|
### 3.4 你自己手工提交(备选)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd Z:\AIworks\tps-dashboard
|
cd E:\tps-dashboard\
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "fix: 修复登录页跳转问题"
|
git commit -m "fix: 修复登录页跳转问题"
|
||||||
git push
|
git push
|
||||||
@@ -386,7 +386,7 @@ git push origin phase1-complete
|
|||||||
### Q5:换了一台机器,怎么把代码搞过来
|
### Q5:换了一台机器,怎么把代码搞过来
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd Z:\AIworks\tps-dashboard
|
cd E:\tps-dashboard\
|
||||||
git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
||||||
cd tps-dashboard
|
cd tps-dashboard
|
||||||
# 然后跟原来一样开工就行
|
# 然后跟原来一样开工就行
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.2.3",
|
||||||
|
"antd": "^6.4.2",
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.15.1",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { ConfigProvider } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import AppLayout from './components/Layout/AppLayout'
|
||||||
|
import Login from './pages/Login/Login'
|
||||||
|
import Dashboard from './pages/Dashboard/Dashboard'
|
||||||
|
import EditorHome from './pages/EditorHome/EditorHome'
|
||||||
|
import TPS from './pages/TPS/TPS'
|
||||||
|
import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
|
||||||
|
import Doco from './pages/Doco/Doco'
|
||||||
|
import UserManage from './pages/UserManage/UserManage'
|
||||||
|
import EditorDesk from './pages/EditorDesk/EditorDesk'
|
||||||
|
import AuthGuard from './components/AuthGuard/AuthGuard'
|
||||||
|
import RoleGuard from './components/AuthGuard/RoleGuard'
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#6b8e6b',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderRadiusLG: 16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN} theme={theme}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<AuthGuard />}>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="tps" element={<TPS />} />
|
||||||
|
<Route path="knowledge" element={<KnowledgeBase />} />
|
||||||
|
<Route path="doco" element={<Doco />} />
|
||||||
|
<Route path="editor-home" element={<EditorHome />} />
|
||||||
|
<Route
|
||||||
|
path="users"
|
||||||
|
element={
|
||||||
|
<RoleGuard roles={['zhipianren']}>
|
||||||
|
<UserManage />
|
||||||
|
</RoleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="editor-desk"
|
||||||
|
element={
|
||||||
|
<RoleGuard roles={['zhipianren', 'zebian']}>
|
||||||
|
<EditorDesk />
|
||||||
|
</RoleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
|
||||||
|
function AuthGuard() {
|
||||||
|
const { user, fetchMe } = useAuthStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
fetchMe()
|
||||||
|
}
|
||||||
|
}, [user, fetchMe])
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? <Outlet /> : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthGuard
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 角色路由守卫 — 拦截非授权角色访问受保护路由
|
||||||
|
* 用法: <Route path="/users" element={<RoleGuard roles={['zhipianren']}><UserManage /></RoleGuard>} />
|
||||||
|
*/
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
|
||||||
|
function RoleGuard({ children, roles }) {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
if (!user) return null // AuthGuard 已处理未登录跳转
|
||||||
|
|
||||||
|
if (!roles.includes(user.role)) {
|
||||||
|
return <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleGuard
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.kt-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kt-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kt-tree-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kt-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 120px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节点文字 */
|
||||||
|
.kt-node-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3b4a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全部根节点高亮 */
|
||||||
|
.kt-node-root {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b4a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree 选中态 */
|
||||||
|
.kt-container .ant-tree-node-selected .kt-node-title {
|
||||||
|
color: #6b8e6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 知识库树形导航组件(交互层)
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 树的展开/收起/记忆状态
|
||||||
|
* - 节点高亮 + 联动回调
|
||||||
|
* - 「全部展开 / 全部收起」操作按钮
|
||||||
|
*
|
||||||
|
* 数据分组逻辑完全委托给后端 get_grouped_items(数据分组层),
|
||||||
|
* 本组件只管交互,不感知分组的业务含义。
|
||||||
|
*
|
||||||
|
* 二级节点 key 格式(由后端控制):
|
||||||
|
* - 大类节点: "manuscript"
|
||||||
|
* - 二级节点(新格式): "manuscript|author|左鑫"
|
||||||
|
* type|二级字段名|字段值
|
||||||
|
* - 二级节点(出处,杂志文章): "military_report|source_detail|航空知识..."
|
||||||
|
*
|
||||||
|
* 回调给 KnowledgeBase:统一返回 { type, detail }
|
||||||
|
* - 大类节点 → { type: "manuscript", detail: null }
|
||||||
|
* - 作者二级节点 → { type: "manuscript", detail: "左鑫" }
|
||||||
|
* - 出处二级节点 → { type: "military_report", detail: "航空知识..." }
|
||||||
|
* (detail=author名 或 source_detail原文,与 displayedItems 过滤逻辑对齐)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Tree, Button, Space } from 'antd'
|
||||||
|
import { CaretDownFilled, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
|
import './KnowledgeTree.css'
|
||||||
|
|
||||||
|
const { TreeNode } = Tree
|
||||||
|
|
||||||
|
export default function KnowledgeTree({
|
||||||
|
treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
|
||||||
|
onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部)
|
||||||
|
selectedKey, // 当前选中的 key(用于高亮)
|
||||||
|
}) {
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState(() => {
|
||||||
|
// 默认全部展开
|
||||||
|
if (!treeData || treeData.length === 0) return []
|
||||||
|
const root = treeData[0]
|
||||||
|
return [root.key, ...(root.children || []).map(c => c.key)]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全部展开
|
||||||
|
const handleExpandAll = useCallback(() => {
|
||||||
|
if (!treeData || treeData.length === 0) return
|
||||||
|
const root = treeData[0]
|
||||||
|
const allKeys = [root.key]
|
||||||
|
for (const child of root.children || []) {
|
||||||
|
allKeys.push(child.key)
|
||||||
|
if (child.children) {
|
||||||
|
for (const grandchild of child.children) {
|
||||||
|
allKeys.push(grandchild.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedKeys(allKeys)
|
||||||
|
}, [treeData])
|
||||||
|
|
||||||
|
// 全部收起
|
||||||
|
const handleCollapseAll = useCallback(() => {
|
||||||
|
setExpandedKeys([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 节点选择
|
||||||
|
const handleSelect = useCallback((selectedKeys, info) => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
onNodeSelect(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = selectedKeys[0]
|
||||||
|
|
||||||
|
// 解析 key 格式:
|
||||||
|
// "all" → 全部根节点
|
||||||
|
// "manuscript" → 大类节点
|
||||||
|
// "manuscript|author|左鑫" → 二级节点(新格式:type|fieldName|fieldValue)
|
||||||
|
// "military_report|source_detail|..." → 二级节点(出处)
|
||||||
|
if (key === 'all') {
|
||||||
|
onNodeSelect(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = key.split('|')
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// 大类节点
|
||||||
|
onNodeSelect({ key, type: parts[0], detail: null })
|
||||||
|
} else {
|
||||||
|
// 二级节点:parts[0]=type, parts[1]=字段名(废弃不用), parts[2]=字段值
|
||||||
|
// 统一以 parts[2] 作为 detail(作者名 或 出处名),与 displayedItems 过滤逻辑对齐
|
||||||
|
onNodeSelect({ key, type: parts[0], detail: parts[2] })
|
||||||
|
}
|
||||||
|
}, [onNodeSelect])
|
||||||
|
|
||||||
|
// 渲染节点
|
||||||
|
const renderNode = (node, isRoot = false) => {
|
||||||
|
return (
|
||||||
|
<TreeNode
|
||||||
|
key={node.key}
|
||||||
|
title={
|
||||||
|
<span className={`kt-node-title ${isRoot ? 'kt-node-root' : ''}`}>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
icon={isRoot ? <NodeIndexOutlined /> : undefined}
|
||||||
|
>
|
||||||
|
{(node.children || []).map(child => renderNode(child, false))}
|
||||||
|
</TreeNode>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!treeData || treeData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="kt-empty">
|
||||||
|
暂无知识库条目
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kt-container">
|
||||||
|
<div className="kt-header">
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" onClick={handleExpandAll}>
|
||||||
|
全部展开
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={handleCollapseAll}>
|
||||||
|
全部收起
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="kt-tree-wrapper">
|
||||||
|
<Tree
|
||||||
|
showIcon
|
||||||
|
showLine={{ showLeafIcon: false }}
|
||||||
|
expandedKeys={expandedKeys}
|
||||||
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
|
onExpand={(keys) => setExpandedKeys(keys)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
blockNode
|
||||||
|
switcherIcon={<CaretDownFilled />}
|
||||||
|
>
|
||||||
|
{treeData.map(node => renderNode(node, true))}
|
||||||
|
</Tree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.app-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sider {
|
||||||
|
background: #fff !important;
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
width: 220px;
|
||||||
|
flex: 0 0 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: #fff !important;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding: 0 24px !important;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--color-bg-cream);
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Layout } from 'antd'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import SideNav from './SideNav'
|
||||||
|
import TopBar from './TopBar'
|
||||||
|
import './AppLayout.css'
|
||||||
|
|
||||||
|
const { Sider, Header, Content } = Layout
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
return (
|
||||||
|
<Layout className="app-layout">
|
||||||
|
<Sider width={220} className="app-sider">
|
||||||
|
<SideNav />
|
||||||
|
</Sider>
|
||||||
|
<Layout className="app-main">
|
||||||
|
<Header className="app-header">
|
||||||
|
<TopBar />
|
||||||
|
</Header>
|
||||||
|
<Content className="app-content">
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.side-nav {
|
||||||
|
padding: 16px 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-logo {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-menu {
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-menu .ant-menu-item {
|
||||||
|
margin: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-menu .ant-menu-item-selected {
|
||||||
|
background: var(--color-accent-green) !important;
|
||||||
|
color: var(--color-primary-green) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* disabled 项整体灰化 + 不可点击 */
|
||||||
|
.side-nav-menu .ant-menu-item-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 即将上线小标签 */
|
||||||
|
.menu-soon-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #999;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* disabled 项内标签不受 opacity 影响 */
|
||||||
|
.side-nav-menu .ant-menu-item-disabled .menu-soon-tag {
|
||||||
|
opacity: 1;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Menu } from 'antd'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
|
||||||
|
function SideNav() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
const allMenuItems = [
|
||||||
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
|
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
|
||||||
|
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
|
||||||
|
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
|
||||||
|
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
|
||||||
|
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
|
||||||
|
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
|
||||||
|
{
|
||||||
|
key: 'tts-placeholder',
|
||||||
|
icon: <SoundOutlined />,
|
||||||
|
label: (
|
||||||
|
<span className="menu-item-with-tag">
|
||||||
|
蓝皓配音 TTS 2.0
|
||||||
|
<span className="menu-soon-tag">即将上线</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collab-placeholder',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: (
|
||||||
|
<span className="menu-item-with-tag">
|
||||||
|
内部协作(Mattermost)
|
||||||
|
<span className="menu-soon-tag">即将上线</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 按角色过滤菜单项
|
||||||
|
// user.role 值来自后端 /api/auth/me,应为字符串 'zhipianren' / 'zebian' / 'biandao'
|
||||||
|
const visibleItems = allMenuItems.filter(item => {
|
||||||
|
// /dashboard 三角色都可见
|
||||||
|
if (item.key === '/dashboard') return true
|
||||||
|
// /users 仅 zhipianren
|
||||||
|
if (item.key === '/users') return String(user?.role) === 'zhipianren'
|
||||||
|
// /editor-desk 仅 zhipianren + zebian
|
||||||
|
if (item.key === '/editor-desk') {
|
||||||
|
const role = String(user?.role)
|
||||||
|
return role === 'zhipianren' || role === 'zebian'
|
||||||
|
}
|
||||||
|
// 其余三角色都可见(保留后期 Phase 入口)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="side-nav">
|
||||||
|
<div className="side-nav-logo">
|
||||||
|
<span className="logo-text">军事科技</span>
|
||||||
|
<span className="logo-sub">TPS 工作台</span>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={visibleItems}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
className="side-nav-menu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SideNav
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-avatar {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Input, Dropdown, Avatar } from 'antd'
|
||||||
|
import { SearchOutlined, BellOutlined, LogoutOutlined } from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
import './TopBar.css'
|
||||||
|
|
||||||
|
function TopBar() {
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出',
|
||||||
|
onClick: () => logout(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const displayName = user?.display_name || user?.username || '未知用户'
|
||||||
|
const initials = displayName.slice(0, 1).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="top-bar">
|
||||||
|
<div className="top-bar-search">
|
||||||
|
<Input placeholder="搜索..." prefix={<SearchOutlined />} />
|
||||||
|
</div>
|
||||||
|
<div className="top-bar-actions">
|
||||||
|
<BellOutlined className="top-bar-icon" />
|
||||||
|
<Dropdown menu={{ items }} placement="bottomRight">
|
||||||
|
<Avatar className="top-bar-avatar" style={{ backgroundColor: 'var(--color-primary-green)' }}>
|
||||||
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopBar
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 知识库数据分组层(交互层与分组维度完全解耦)
|
||||||
|
*
|
||||||
|
* 本模块职责:
|
||||||
|
* - 接收 items 数组,按指定维度生成分组树数据
|
||||||
|
* - 完全不涉及 UI(无 React 组件、无 Ant Design 引用)
|
||||||
|
*
|
||||||
|
* 将来切换为「按主题/装备分组」时:
|
||||||
|
* - 只改本文件中的 groupItemsBySource 函数
|
||||||
|
* - 前端 KnowledgeTree 组件一行都不用动
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 📌 二级分组维度映射表(核心配置,一处可改)
|
||||||
|
* ============================================================
|
||||||
|
* key = source_type,value = 用来做二级分组的字段名(取 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,34 @@
|
|||||||
|
:root {
|
||||||
|
/* 奶油底,主背景 */
|
||||||
|
--color-bg-cream: #fbf9f1;
|
||||||
|
/* 深绿,主色 */
|
||||||
|
--color-primary-green: #6b8e6b;
|
||||||
|
/* 浅绿,辅色 */
|
||||||
|
--color-accent-green: #c8d9b0;
|
||||||
|
/* 文字主色 */
|
||||||
|
--color-text-primary: #1a1a1a;
|
||||||
|
/* 文字次要色 */
|
||||||
|
--color-text-secondary: #6b6b6b;
|
||||||
|
/* 卡片大圆角 */
|
||||||
|
--radius-card: 16px;
|
||||||
|
/* 按钮圆角 */
|
||||||
|
--radius-button: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--color-bg-cream);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Modal, Upload, Select, Button, message } from 'antd'
|
||||||
|
import { UploadOutlined, InboxOutlined } from '@ant-design/icons'
|
||||||
|
import { uploadCover } from '../../services/dashboardService'
|
||||||
|
|
||||||
|
const { Dragger } = Upload
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更换题图 Modal
|
||||||
|
*
|
||||||
|
* 权限:仅 zhipianren / zebian 可用(由父组件控制显示/隐藏)
|
||||||
|
*
|
||||||
|
* @param {boolean} open - Modal 是否显示
|
||||||
|
* @param {Function} onClose - 关闭回调
|
||||||
|
* @param {Function} onUploaded - 上传成功回调,传入新题图对象
|
||||||
|
* @param {Array} episodes - 期次列表(用于下拉选择关联期次)
|
||||||
|
*/
|
||||||
|
function ChangeCoverModal({ open, onClose, onUploaded, episodes = [] }) {
|
||||||
|
const [file, setFile] = useState(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState(null)
|
||||||
|
const [selectedEpisode, setSelectedEpisode] = useState(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
// 每次打开时重置状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFile(null)
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setSelectedEpisode(null)
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// 选择文件后生成预览
|
||||||
|
const handleFileChange = (info) => {
|
||||||
|
const rawFile = info.file.originFileObj || info.file
|
||||||
|
if (!rawFile) return
|
||||||
|
setFile(rawFile)
|
||||||
|
const url = URL.createObjectURL(rawFile)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) {
|
||||||
|
message.warning('请先选择图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedEpisode) {
|
||||||
|
message.warning('请选择关联期次')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const ep = episodes.find(e => String(e.episode_number) === String(selectedEpisode))
|
||||||
|
const result = await uploadCover(file, Number(selectedEpisode), ep?.program_name || `第${selectedEpisode}期`)
|
||||||
|
message.success('题图上传成功')
|
||||||
|
onUploaded({
|
||||||
|
cover_path: result.cover_path,
|
||||||
|
episode_number: Number(selectedEpisode),
|
||||||
|
episode_title: ep?.program_name || `第${selectedEpisode}期`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err?.response?.data?.detail || '上传失败,请重试'
|
||||||
|
message.error(errMsg)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeOptions = episodes.map(ep => ({
|
||||||
|
value: String(ep.episode_number),
|
||||||
|
label: `第 ${ep.episode_number} 期 · ${ep.program_name}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="更换题图"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={480}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* 图片上传区域 */}
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>上传海报图片(PNG/JPG/WEBP,不超过 5MB)</p>
|
||||||
|
<Dragger
|
||||||
|
accept=".png,.jpg,.jpeg,.webp"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={() => false}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
showUploadList={false}
|
||||||
|
style={{ borderRadius: 12 }}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="预览"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<p style={{ marginTop: 8, color: '#6b8e6b', fontSize: 13 }}>点击或拖拽更换图片</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="ant-upload-drag-icon" style={{ marginBottom: 8 }}>
|
||||||
|
<InboxOutlined style={{ fontSize: 32, color: '#6b8e6b' }} />
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#6b6b6b', fontSize: 13 }}>点击或拖拽图片到此处上传</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dragger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关联期次下拉 */}
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>关联期次</p>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择期次"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedEpisode}
|
||||||
|
onChange={setSelectedEpisode}
|
||||||
|
options={episodeOptions}
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={onClose} disabled={uploading}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
loading={uploading}
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || !selectedEpisode}
|
||||||
|
>
|
||||||
|
确认上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangeCoverModal
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
.dashboard {
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Banner ===== */
|
||||||
|
.dashboard-banner {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #3a4a3a;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(12, 16, 12, 0.86) 0%,
|
||||||
|
rgba(12, 16, 12, 0.6) 35%,
|
||||||
|
rgba(12, 16, 12, 0.08) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
height: 64px;
|
||||||
|
width: auto;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cdd8c8;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #d6e0d0;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bcc8b6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-cover-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 默认渐变底图(无题图时) */
|
||||||
|
.banner-default-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== KPI Strip ===== */
|
||||||
|
.kpi-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-chip {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
height: 48px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Bar Chart Card ===== */
|
||||||
|
.bar-chart-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3b4a3b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888780;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 180px;
|
||||||
|
padding: 0 4px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 42px;
|
||||||
|
min-width: 20px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 38px;
|
||||||
|
border-radius: 7px 7px 0 0;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-number {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-program {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 64px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Bottom Two Columns ===== */
|
||||||
|
.bottom-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3b4a3b;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radar placeholder card */
|
||||||
|
.radar-placeholder {
|
||||||
|
border: 1.5px dashed #c8d9b0;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-placeholder-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bcc8b6;
|
||||||
|
background: #fbf9f1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-placeholder p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-placeholder small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-sample-items {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-sample-item {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4a5a4a;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #f0f7ec;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schedule list */
|
||||||
|
.schedule-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-ep {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-name {
|
||||||
|
color: #4a5a4a;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-date {
|
||||||
|
color: #888780;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-editor {
|
||||||
|
color: #6b6b6b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 180px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
|
||||||
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
FireOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
AimOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
import { listEpisodes } from '../../services/episodeService'
|
||||||
|
import { listTargets } from '../../services/yearlyTargetService'
|
||||||
|
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
|
||||||
|
import ChangeCoverModal from './ChangeCoverModal'
|
||||||
|
import './Dashboard.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收视份额颜色判定(极易写反,禁止凭直觉):
|
||||||
|
* audience_share > stretch_target → 🔴 红(优秀)
|
||||||
|
* base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
|
||||||
|
* audience_share < base_target → 🟢 绿(待提升)
|
||||||
|
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
||||||
|
*/
|
||||||
|
function getShareColor(share, targets, airYear) {
|
||||||
|
const n = Number(share)
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||||||
|
if (!target) return '#999'
|
||||||
|
if (isNaN(n)) return '#999'
|
||||||
|
const stretch = Number(target.stretch_target)
|
||||||
|
const base = Number(target.base_target)
|
||||||
|
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
|
||||||
|
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
|
||||||
|
return '#7aa874' // 绿=未达基础目标(待提升)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorLabel(share, targets, airYear) {
|
||||||
|
const n = Number(share)
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||||||
|
if (!target) return '未知'
|
||||||
|
const stretch = Number(target.stretch_target)
|
||||||
|
const base = Number(target.base_target)
|
||||||
|
if (n > stretch) return '优秀'
|
||||||
|
if (n >= base) return '达标'
|
||||||
|
return '待提升'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarHeight(share) {
|
||||||
|
return Math.round((Number(share) / 1.0) * 160)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShortTitle(title) {
|
||||||
|
if (!title) return '?'
|
||||||
|
return title.length > 9 ? title.slice(0, 9) + '...' : title
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算当年完成率(纯前端,量纲已确认为 0~1 小数直接相除)
|
||||||
|
* - "当年"= 当前自然年(new Date().getFullYear())
|
||||||
|
* - avg(audience_share) 当年已录期 ÷ base/stretch_target
|
||||||
|
* - 无数据时始终返回 { base: '—', stretch: '—' }
|
||||||
|
* - 完成率可 >100% 正常显示
|
||||||
|
*/
|
||||||
|
function calcCompletionRate(episodes, targets) {
|
||||||
|
const yearInt = new Date().getFullYear()
|
||||||
|
const yearEpisodes = episodes.filter(e => {
|
||||||
|
const airYear = new Date(e.air_date).getFullYear()
|
||||||
|
return airYear === yearInt && e.audience_share != null
|
||||||
|
})
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === yearInt)
|
||||||
|
|
||||||
|
if (!yearEpisodes.length || !target) return { base: '—', stretch: '—' }
|
||||||
|
|
||||||
|
const avgShare = yearEpisodes.reduce((sum, e) => sum + Number(e.audience_share), 0) / yearEpisodes.length
|
||||||
|
const baseRate = avgShare / Number(target.base_target)
|
||||||
|
const stretchRate = avgShare / Number(target.stretch_target)
|
||||||
|
return {
|
||||||
|
base: (baseRate * 100).toFixed(1) + '%',
|
||||||
|
stretch: (stretchRate * 100).toFixed(1) + '%',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const [episodes, setEpisodes] = useState([])
|
||||||
|
const [targets, setTargets] = useState([])
|
||||||
|
const [cover, setCover] = useState({ cover_path: null, episode_number: null, episode_title: null })
|
||||||
|
const [schedules, setSchedules] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [coverModalOpen, setCoverModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
listEpisodes(50),
|
||||||
|
listTargets(),
|
||||||
|
getCurrentCover(),
|
||||||
|
getUpcomingSchedules(6),
|
||||||
|
]).then(([epData, tgtData, coverData, schedData]) => {
|
||||||
|
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
|
||||||
|
setEpisodes(sorted)
|
||||||
|
setTargets(tgtData || [])
|
||||||
|
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
|
||||||
|
setSchedules(Array.isArray(schedData) ? schedData : [])
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 显示最近有份额数据的期次(最多12个)
|
||||||
|
const displayEpisodes = episodes
|
||||||
|
.filter(e => e.audience_share != null)
|
||||||
|
.slice(0, 12)
|
||||||
|
|
||||||
|
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
|
||||||
|
|
||||||
|
const handleCoverUploaded = (newCover) => {
|
||||||
|
setCover(newCover)
|
||||||
|
setCoverModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI 数据(扩为 5 项:原有 3 项 + 年度完成率 2 项)
|
||||||
|
const completion = calcCompletionRate(episodes, targets)
|
||||||
|
const kpiData = [
|
||||||
|
{
|
||||||
|
icon: <EyeOutlined style={{ color: '#6b8e6b', fontSize: 14 }} />,
|
||||||
|
bg: '#e8f5e9',
|
||||||
|
label: '近12期已录',
|
||||||
|
value: displayEpisodes.length || '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FireOutlined style={{ color: '#ff9800', fontSize: 14 }} />,
|
||||||
|
bg: '#fff3e0',
|
||||||
|
label: '最佳份额',
|
||||||
|
value: bestEpisode ? (Number(bestEpisode.audience_share) * 100).toFixed(2) + '%' : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CalendarOutlined style={{ color: '#2196f3', fontSize: 14 }} />,
|
||||||
|
bg: '#e3f2fd',
|
||||||
|
label: '年度目标',
|
||||||
|
value: targets.length || '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <LineChartOutlined style={{ color: '#7b5e9e', fontSize: 14 }} />,
|
||||||
|
bg: '#f3e5f5',
|
||||||
|
label: '基础目标完成率',
|
||||||
|
value: completion.base,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <AimOutlined style={{ color: '#c0584f', fontSize: 14 }} />,
|
||||||
|
bg: '#fce4ec',
|
||||||
|
label: '摸高目标完成率',
|
||||||
|
value: completion.stretch,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
{/* ===== Banner ===== */}
|
||||||
|
<div className="dashboard-banner">
|
||||||
|
{/* 题图底图 或 默认渐变 */}
|
||||||
|
{cover.cover_path ? (
|
||||||
|
<img
|
||||||
|
src={cover.cover_path}
|
||||||
|
alt="题图"
|
||||||
|
className="banner-bg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="banner-default-bg" />
|
||||||
|
)}
|
||||||
|
{/* 暗化渐变层 */}
|
||||||
|
<div className="banner-gradient" />
|
||||||
|
{/* 右上角 Logo */}
|
||||||
|
<img
|
||||||
|
src="/src/assets/junshikeji_logo.png"
|
||||||
|
alt="栏目logo"
|
||||||
|
className="banner-logo"
|
||||||
|
onError={e => { e.target.style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
{/* 左侧文字 */}
|
||||||
|
<div className="banner-text">
|
||||||
|
<p className="banner-eyebrow">本月收视最佳·题图</p>
|
||||||
|
<h2 className="banner-title">
|
||||||
|
{cover.episode_number
|
||||||
|
? `第 ${cover.episode_number} 期`
|
||||||
|
: bestEpisode
|
||||||
|
? `第 ${bestEpisode.episode_number} 期`
|
||||||
|
: '暂无收视数据'}
|
||||||
|
{cover.episode_title || (bestEpisode ? ` · ${bestEpisode.program_name}` : '')}
|
||||||
|
</h2>
|
||||||
|
<p className="banner-subtitle">
|
||||||
|
{bestEpisode
|
||||||
|
? `收视份额 ${Number(bestEpisode.audience_share).toFixed(4)} · 当月最高`
|
||||||
|
: '暂无收视数据'}
|
||||||
|
</p>
|
||||||
|
<p className="banner-hint">建议:可选用近期收视表现好的节目海报</p>
|
||||||
|
</div>
|
||||||
|
{/* 右下角换图按钮 */}
|
||||||
|
{showChangeCoverBtn && (
|
||||||
|
<Button
|
||||||
|
className="change-cover-btn"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
onClick={() => setCoverModalOpen(true)}
|
||||||
|
>
|
||||||
|
更换题图
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== KPI 细条 ===== */}
|
||||||
|
<div className="kpi-strip">
|
||||||
|
{kpiData.map((item, i) => (
|
||||||
|
<div key={i} className="kpi-chip">
|
||||||
|
<div className="kpi-icon" style={{ background: item.bg }}>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="kpi-label">{item.label}</p>
|
||||||
|
<p className="kpi-value">{item.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 近9期收视柱图(全宽) ===== */}
|
||||||
|
<div className="bar-chart-card">
|
||||||
|
<div className="bar-chart-header">
|
||||||
|
<h3 className="bar-chart-title">近 12 期收视份额</h3>
|
||||||
|
<span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="chart-loading"><Spin size="small" /> 加载中...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bars-wrapper">
|
||||||
|
{displayEpisodes.map((ep) => {
|
||||||
|
const airYear = new Date(ep.air_date).getFullYear()
|
||||||
|
const color = ep.audience_share != null
|
||||||
|
? getShareColor(ep.audience_share, targets, airYear)
|
||||||
|
: '#ccc'
|
||||||
|
const colorLabel = ep.audience_share != null
|
||||||
|
? getColorLabel(ep.audience_share, targets, airYear)
|
||||||
|
: '无数据'
|
||||||
|
const tooltipContent = `
|
||||||
|
<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>
|
||||||
|
编导:${ep.editor_name_snapshot || '未知'}<br/>
|
||||||
|
收视份额:${ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'}(${colorLabel})<br/>
|
||||||
|
收视率:${ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'}
|
||||||
|
`
|
||||||
|
return (
|
||||||
|
<div key={ep.id} className="bar-item">
|
||||||
|
<div className="bar-number" style={{ color }}>
|
||||||
|
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
|
||||||
|
</div>
|
||||||
|
<Tooltip title={<span dangerouslySetInnerHTML={{ __html: tooltipContent }} />}>
|
||||||
|
<div
|
||||||
|
className="bar"
|
||||||
|
style={{
|
||||||
|
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="bar-program" title={ep.program_name}>
|
||||||
|
{getShortTitle(ep.program_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="chart-legend">
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标(待提升)
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot" style={{ background: '#5b8db8' }}></span>蓝=未达摸高目标(达标)
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot" style={{ background: '#c0584f' }}></span>红=超过摸高目标(优秀)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 下方两栏 ===== */}
|
||||||
|
<div className="bottom-row">
|
||||||
|
{/* 左:雷达占位 */}
|
||||||
|
<div className="side-card">
|
||||||
|
<h3 className="side-card-title">军事科技热点雷达</h3>
|
||||||
|
<div className="radar-placeholder">
|
||||||
|
<span className="radar-placeholder-label">Phase 4c·占位</span>
|
||||||
|
<div className="radar-sample-items">
|
||||||
|
<div className="radar-sample-item">1. 神舟飞船对接空间站</div>
|
||||||
|
<div className="radar-sample-item">2. 新型无人机集群技术</div>
|
||||||
|
</div>
|
||||||
|
<BarChartOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 4 }} />
|
||||||
|
<p>真实抓取 Phase 4c 实现</p>
|
||||||
|
<small>编导个人首页核心功能</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:排播计划 */}
|
||||||
|
<div className="side-card">
|
||||||
|
<h3 className="side-card-title">未来排播计划</h3>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}><Spin size="small" /></div>
|
||||||
|
) : schedules.length > 0 ? (
|
||||||
|
<ul className="schedule-list">
|
||||||
|
{schedules.map((item, i) => (
|
||||||
|
<li key={i} className="schedule-item">
|
||||||
|
<span className="schedule-ep">第{item.episode_number}期</span>
|
||||||
|
<span className="schedule-name" title={item.program_name}>{item.program_name}</span>
|
||||||
|
<span className="schedule-date">{item.planned_air_date}</span>
|
||||||
|
<span className="schedule-editor">{item.editor_name_snapshot || ''}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}>
|
||||||
|
<CalendarOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 8 }} />
|
||||||
|
<p style={{ margin: 0, fontSize: 12 }}>暂无排播数据</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 换图 Modal */}
|
||||||
|
<ChangeCoverModal
|
||||||
|
open={coverModalOpen}
|
||||||
|
onClose={() => setCoverModalOpen(false)}
|
||||||
|
onUploaded={handleCoverUploaded}
|
||||||
|
episodes={episodes.slice(0, 20)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Result } from 'antd'
|
||||||
|
|
||||||
|
function Doco() {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="info"
|
||||||
|
title="文稿对齐模块"
|
||||||
|
subTitle="该模块计划在 1.0 主体上线后作为独立 Phase 开发,详见 doco_project_design.md"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Doco
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Tabs, Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Space, Upload, Popconfirm } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
|
import { listEpisodes, createEpisode, updateEpisode, deleteEpisode } from '../../services/episodeService'
|
||||||
|
import { listTargets, createTarget, updateTarget, deleteTarget } from '../../services/yearlyTargetService'
|
||||||
|
import http from '../../services/http'
|
||||||
|
|
||||||
|
const { TabPane } = Tabs
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab1: 节目期次
|
||||||
|
// ============================================================
|
||||||
|
function EpisodesTab() {
|
||||||
|
const [episodes, setEpisodes] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEpisodes()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadEpisodes = () => {
|
||||||
|
setLoading(true)
|
||||||
|
listEpisodes().then(data => {
|
||||||
|
setEpisodes((data || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)))
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('加载节目列表失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditing(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
episode_number: record.episode_number,
|
||||||
|
program_name: record.program_name,
|
||||||
|
air_date: record.air_date ? record.air_date : null,
|
||||||
|
editor_name_snapshot: record.editor_name_snapshot || '',
|
||||||
|
audience_share: record.audience_share,
|
||||||
|
audience_rating: record.audience_rating,
|
||||||
|
is_rerun: record.is_rerun,
|
||||||
|
notes: record.notes,
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
deleteEpisode(id).then(() => {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadEpisodes()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '删除失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
air_date: values.air_date ? values.air_date.format('YYYY-MM-DD') : null,
|
||||||
|
}
|
||||||
|
const promise = editing
|
||||||
|
? updateEpisode(editing.id, payload)
|
||||||
|
: createEpisode(payload)
|
||||||
|
promise.then(() => {
|
||||||
|
message.success(editing ? '更新成功' : '创建成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadEpisodes()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '操作失败')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '期次号', dataIndex: 'episode_number', key: 'episode_number', width: 80 },
|
||||||
|
{ title: '节目名', dataIndex: 'program_name', key: 'program_name' },
|
||||||
|
{ title: '播出日期', dataIndex: 'air_date', key: 'air_date', render: d => d || '-' },
|
||||||
|
{ title: '编导', dataIndex: 'editor_name_snapshot', key: 'editor_name_snapshot', render: v => v || '-' },
|
||||||
|
{ title: '收视份额', dataIndex: 'audience_share', key: 'audience_share', render: v => v != null ? v.toFixed(4) : '-' },
|
||||||
|
{ title: '收视率', dataIndex: 'audience_rating', key: 'audience_rating', render: v => v != null ? v.toFixed(2) : '-' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增期次</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={episodes} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} />
|
||||||
|
<Modal title={editing ? '编辑期次' : '新增期次'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存" width={520}>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="episode_number" label="期次号" rules={[{ required: true, message: '请输入期次号' }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} placeholder="如 1248" disabled={!!editing} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="program_name" label="节目名称" rules={[{ required: true, message: '请输入节目名称' }]}>
|
||||||
|
<Input placeholder="如《军事科技》武器解析系列" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="air_date" label="播出日期" rules={[{ required: true, message: '请选择播出日期' }]}>
|
||||||
|
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" disabled={!!editing} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="editor_name_snapshot" label="编导姓名(快照)">
|
||||||
|
<Input placeholder="可留空,离职编导只存姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="audience_share" label="收视份额(0-1)">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.72" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="audience_rating" label="收视率">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="3.5" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab2: 年度目标
|
||||||
|
// ============================================================
|
||||||
|
function YearlyTargetsTab() {
|
||||||
|
const [targets, setTargets] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTargets()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTargets = () => {
|
||||||
|
setLoading(true)
|
||||||
|
listTargets().then(data => {
|
||||||
|
setTargets((data || []).sort((a, b) => b.year - a.year))
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('加载年度目标失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditing(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
year: record.year,
|
||||||
|
base_target: record.base_target,
|
||||||
|
stretch_target: record.stretch_target,
|
||||||
|
notes: record.notes,
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
deleteTarget(id).then(() => {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadTargets()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '删除失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
const promise = editing
|
||||||
|
? updateTarget(editing.id, values)
|
||||||
|
: createTarget(values)
|
||||||
|
promise.then(() => {
|
||||||
|
message.success(editing ? '更新成功' : '创建成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadTargets()
|
||||||
|
}).catch(err => {
|
||||||
|
const detail = err.response?.data?.detail || ''
|
||||||
|
if (detail.includes('已存在') || detail.includes('历史年份')) {
|
||||||
|
message.error('该年目标已存在,历史年份不可覆盖')
|
||||||
|
} else {
|
||||||
|
message.error(detail || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '年份', dataIndex: 'year', key: 'year', width: 80 },
|
||||||
|
{ title: '基础目标', dataIndex: 'base_target', key: 'base_target', render: v => v?.toFixed(4) || '-' },
|
||||||
|
{ title: '摸高目标', dataIndex: 'stretch_target', key: 'stretch_target', render: v => v?.toFixed(4) || '-' },
|
||||||
|
{ title: '备注', dataIndex: 'notes', key: 'notes', render: v => v || '-' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增年度目标</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={targets} rowKey="id" loading={loading} pagination={false} />
|
||||||
|
<Modal title={editing ? '编辑年度目标' : '新增年度目标'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存">
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="year"
|
||||||
|
label="年份"
|
||||||
|
rules={[{ required: true, message: '请输入年份' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="如 2024"
|
||||||
|
min={2000}
|
||||||
|
max={2100}
|
||||||
|
disabled={!!editing}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="base_target" label="基础目标" rules={[{ required: true, message: '请输入基础目标' }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.6448" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="stretch_target" label="摸高目标" rules={[{ required: true, message: '请输入摸高目标' }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.8989" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab3: Excel 批量导入
|
||||||
|
// ============================================================
|
||||||
|
function ImportTab() {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [result, setResult] = useState(null)
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
window.open('/api/imports/template', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadProps = {
|
||||||
|
name: 'file',
|
||||||
|
accept: '.xlsx',
|
||||||
|
showUploadList: false,
|
||||||
|
beforeUpload: (file) => {
|
||||||
|
if (!file.filename.endsWith('.xlsx') && !file.name.endsWith('.xlsx')) {
|
||||||
|
message.error('仅支持 .xlsx 格式')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
http.post('/imports/episodes', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(res => {
|
||||||
|
setResult(res.data)
|
||||||
|
setUploading(false)
|
||||||
|
if (res.data.errors?.length) {
|
||||||
|
message.warning(`导入完成:成功 ${res.data.imported} 条,失败 ${res.data.errors.length} 条`)
|
||||||
|
} else {
|
||||||
|
message.success(`导入成功:${res.data.imported} 条`)
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
setUploading(false)
|
||||||
|
message.error(err.response?.data?.detail || '上传失败')
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadErrors = () => {
|
||||||
|
if (!result?.batch_id) return
|
||||||
|
window.open(`/api/imports/errors/${result.batch_id}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>下载导入模板</Button>
|
||||||
|
<Upload {...uploadProps}>
|
||||||
|
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
|
||||||
|
{uploading ? '上传中...' : '选择 Excel 上传'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div style={{ background: '#f6f8fa', padding: 16, borderRadius: 8 }}>
|
||||||
|
<p style={{ margin: '0 0 8px', fontWeight: 600 }}>导入结果</p>
|
||||||
|
<p style={{ margin: 0 }}>总计:{result.total} 条</p>
|
||||||
|
<p style={{ margin: '4px 0', color: '#52c41a' }}>成功:{result.imported} 条</p>
|
||||||
|
{result.errors?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '4px 0', color: '#cf1322' }}>失败:{result.errors.length} 条</p>
|
||||||
|
<Button size="small" onClick={downloadErrors} style={{ marginTop: 8 }}>
|
||||||
|
下载失败行
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, color: '#999', fontSize: 12 }}>
|
||||||
|
<p>支持批量导入节目期次,导入失败时可根据提示修正后重新上传。</p>
|
||||||
|
<p>注意:Phase 2 不支持重播行导入,有重播行请留空。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主组件
|
||||||
|
// ============================================================
|
||||||
|
function EditorDesk() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 0 24px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 16px', fontSize: 20, fontWeight: 600 }}>责编录入</h2>
|
||||||
|
<Tabs defaultActiveKey="episodes">
|
||||||
|
<TabPane tab="节目期次" key="episodes">
|
||||||
|
<EpisodesTab />
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="年度目标" key="targets">
|
||||||
|
<YearlyTargetsTab />
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="Excel 批量导入" key="import">
|
||||||
|
<ImportTab />
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditorDesk
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Card } from 'antd'
|
||||||
|
import { UserOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
function EditorHome() {
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 16 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
|
||||||
|
<UserOutlined style={{ fontSize: 48, marginBottom: 16 }} />
|
||||||
|
<h3 style={{ color: '#666' }}>个人首页</h3>
|
||||||
|
<p>模块 H · Phase 4c 实施</p>
|
||||||
|
<small>今日热点雷达 · RSS + AI 摘要</small>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditorHome
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Input } from 'antd'
|
||||||
|
import { DeleteOutlined, ReloadOutlined, UploadOutlined, SearchOutlined, ClearOutlined } from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
import knowledgeService from '../../services/knowledgeService'
|
||||||
|
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
||||||
|
|
||||||
|
const { Dragger } = Upload
|
||||||
|
|
||||||
|
// source_type 枚举值(固定五类,不写死但供 Select 用)
|
||||||
|
const SOURCE_TYPE_OPTIONS = [
|
||||||
|
{ label: '全部类型', value: '' },
|
||||||
|
{ label: '杂志文章', value: 'military_report' },
|
||||||
|
{ label: '节目文稿', value: 'manuscript' },
|
||||||
|
{ label: '报题单', value: 'baoti' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// source_type 中文标签
|
||||||
|
const SOURCE_TYPE_LABEL = {
|
||||||
|
military_report: '杂志文章',
|
||||||
|
manuscript: '节目文稿',
|
||||||
|
baoti: '报题单',
|
||||||
|
manual: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeBase() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [treeData, setTreeData] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [sources, setSources] = useState([]) // 出处下拉选项
|
||||||
|
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
|
||||||
|
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
|
||||||
|
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.listItems(sourceTypeFilter || null)
|
||||||
|
setItems(data)
|
||||||
|
} catch {
|
||||||
|
message.error('加载知识库失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTree = async () => {
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.getGroupedItems()
|
||||||
|
setTreeData(data || [])
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSources = async () => {
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.listSources()
|
||||||
|
// 接口返回 [{source: "航空知识 2026年第1期"}, ...],提取 source 字段
|
||||||
|
setSources(data.map(s => s.source).filter(Boolean))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems()
|
||||||
|
fetchTree()
|
||||||
|
fetchSources()
|
||||||
|
}, [sourceTypeFilter])
|
||||||
|
|
||||||
|
// 上传
|
||||||
|
const handleUpload = async (file) => {
|
||||||
|
if (!file.name.endsWith('.md')) {
|
||||||
|
message.warning('仅支持 .md 文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const result = await knowledgeService.uploadFiles([file])
|
||||||
|
const { uploaded, errors } = result
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
message.success(`成功入库 ${uploaded.length} 篇`)
|
||||||
|
fetchItems()
|
||||||
|
fetchTree()
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errors.forEach(e => message.error(`${e.file}: ${e.error}`))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('上传失败')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
return false // 阻止默认上传行为
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await knowledgeService.deleteItem(id)
|
||||||
|
message.success('删除成功')
|
||||||
|
fetchItems()
|
||||||
|
fetchTree()
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSearchResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.searchItems(searchQuery.trim())
|
||||||
|
setSearchResults(data.results || [])
|
||||||
|
} catch {
|
||||||
|
message.error('搜索失败')
|
||||||
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空搜索 → 恢复树形浏览(不丢树状态)
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 树节点选中 → 联动过滤
|
||||||
|
const handleNodeSelect = (node) => {
|
||||||
|
setSelectedTreeNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据树节点过滤列表
|
||||||
|
const displayedItems = (() => {
|
||||||
|
let result = items
|
||||||
|
|
||||||
|
// 树节点过滤优先(覆盖原有 Select 筛选)
|
||||||
|
if (selectedTreeNode) {
|
||||||
|
const { type, detail } = selectedTreeNode
|
||||||
|
result = result.filter(i => {
|
||||||
|
if (i.source_type !== type) return false
|
||||||
|
if (detail !== null) {
|
||||||
|
// 按大类决定用哪个字段比对(节目文稿=author,杂志文章=source_detail)
|
||||||
|
if (type === 'manuscript') {
|
||||||
|
if (i.author !== detail) return false
|
||||||
|
} else {
|
||||||
|
// 杂志文章等用 source_detail
|
||||||
|
if (i.source_detail !== detail) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} else if (sourceDetailFilter) {
|
||||||
|
// 保留原有的 source_detail 筛选逻辑
|
||||||
|
result = result.filter(i => i.source_detail === sourceDetailFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
width: 280,
|
||||||
|
render: (text) => (
|
||||||
|
<span style={{ fontWeight: 500, color: '#3b4a3b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{text}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '作者',
|
||||||
|
dataIndex: 'author',
|
||||||
|
key: 'author',
|
||||||
|
width: 100,
|
||||||
|
render: (val) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '播出/发表时间',
|
||||||
|
dataIndex: 'publish_date',
|
||||||
|
key: 'publish_date',
|
||||||
|
width: 160,
|
||||||
|
render: (val) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '出处',
|
||||||
|
dataIndex: 'source_detail',
|
||||||
|
key: 'source_detail',
|
||||||
|
width: 200,
|
||||||
|
render: (val) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'source_type',
|
||||||
|
key: 'source_type',
|
||||||
|
width: 96,
|
||||||
|
render: (val) => (
|
||||||
|
<Tag color="default" style={{ borderRadius: 8 }}>
|
||||||
|
{SOURCE_TYPE_LABEL[val] || val}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="删除后不可恢复,该篇及其向量将一并清除"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确认删除"
|
||||||
|
cancelText="取消"
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, padding: '12px 16px' }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区(收小为一行按钮) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||||
|
<Dragger
|
||||||
|
accept=".md"
|
||||||
|
multiple
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} size="middle" style={{ borderRadius: 10 }}>
|
||||||
|
{uploading ? '正在上传并生成向量…' : '上传 md 文件入库'}
|
||||||
|
</Button>
|
||||||
|
</Dragger>
|
||||||
|
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传,系统自动解析 yaml frontmatter 并生成语义向量</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索栏(搜索模式/浏览模式互斥,清空恢复树形浏览不丢状态) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入一段文字,语义搜索知识库…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
style={{ width: 340, borderRadius: 10 }}
|
||||||
|
prefix={<SearchOutlined style={{ color: '#aaa' }} />}
|
||||||
|
suffix={
|
||||||
|
searchQuery ? (
|
||||||
|
<ClearOutlined
|
||||||
|
style={{ color: '#aaa', cursor: 'pointer' }}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
loading={searchLoading}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
{searchQuery && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>
|
||||||
|
{searchResults.length > 0
|
||||||
|
? `找到 ${searchResults.length} 条相关结果`
|
||||||
|
: '无相关结果'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果列表(搜索模式显示,浏览模式隐藏) */}
|
||||||
|
{searchQuery && searchResults.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
搜索结果
|
||||||
|
</div>
|
||||||
|
{searchResults.map(item => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
size="small"
|
||||||
|
style={{ borderRadius: 10, marginBottom: 8 }}
|
||||||
|
bodyStyle={{ padding: '12px 16px' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: '#3b4a3b', marginBottom: 4 }}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Tag color="default" style={{ borderRadius: 6, fontSize: 12 }}>
|
||||||
|
{SOURCE_TYPE_LABEL[item.source_type] || item.source_type}
|
||||||
|
</Tag>
|
||||||
|
{item.author && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>作者:{item.author}</span>
|
||||||
|
)}
|
||||||
|
{item.source_detail && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>出处:{item.source_detail}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#555',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.snippet}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginLeft: 16,
|
||||||
|
minWidth: 56,
|
||||||
|
textAlign: 'center',
|
||||||
|
background: item.similarity >= 0.7 ? '#e6f4ff' : '#f5f5f5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '4px 8px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: item.similarity >= 0.7 ? '#1677ff' : '#666' }}>
|
||||||
|
{Math.max(0, Math.round(item.similarity * 100))}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主体:左侧树 + 右侧列表(flex 左右并列,宽度稳定) */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
|
{/* 左侧树 */}
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
|
||||||
|
bodyStyle={{ padding: 0, height: '100%' }}
|
||||||
|
>
|
||||||
|
<KnowledgeTree
|
||||||
|
treeData={treeData}
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 右侧列表 + 筛选栏 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
|
||||||
|
<Space size="middle" wrap>
|
||||||
|
<span style={{ fontSize: 14, color: '#666' }}>筛选:</span>
|
||||||
|
<Select
|
||||||
|
placeholder="按类型"
|
||||||
|
options={SOURCE_TYPE_OPTIONS}
|
||||||
|
value={sourceTypeFilter}
|
||||||
|
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="按出处"
|
||||||
|
options={[{ label: '全部出处', value: '' }, ...sources.map(s => ({ label: s, value: s }))]}
|
||||||
|
value={sourceDetailFilter}
|
||||||
|
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => { fetchItems(); fetchTree() }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<Card style={{ borderRadius: 14 }}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={displayedItems}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10, size: 'small' }}
|
||||||
|
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg-cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 400px;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--color-primary-green);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Form, Input, Button, Card, message } from 'antd'
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
import './Login.css'
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login } = useAuthStore()
|
||||||
|
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await login(values.username, values.password)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/dashboard')
|
||||||
|
} else {
|
||||||
|
message.error(result.error || '登录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<Card className="login-card" bordered={false}>
|
||||||
|
<div className="login-header">
|
||||||
|
<h1>军事科技</h1>
|
||||||
|
<p>TPS 工作台</p>
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
name="login"
|
||||||
|
onFinish={onFinish}
|
||||||
|
autoComplete="off"
|
||||||
|
layout="vertical"
|
||||||
|
requiredMark={false}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="用户名"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="密码"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Card } from 'antd'
|
||||||
|
import { FileTextOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
|
function TPS() {
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 16 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
|
||||||
|
<FileTextOutlined style={{ fontSize: 48, marginBottom: 16 }} />
|
||||||
|
<h3 style={{ color: '#666' }}>TPS 选题策划</h3>
|
||||||
|
<p>模块 A / B / C / D / G · Phase 4a 实施</p>
|
||||||
|
<small>查重 + 知识库参考 + 报题单生成</small>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TPS
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Table, Button, Modal, Form, Input, Select, Switch, message, Space } from 'antd'
|
||||||
|
import { UserAddOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'
|
||||||
|
import { listUsers, createUser, updateUser, deactivateUser } from '../../services/userService'
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'zhipianren', label: '制片人' },
|
||||||
|
{ value: 'zebian', label: '责编' },
|
||||||
|
{ value: 'biandao', label: '编导' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function UserManage() {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadUsers = () => {
|
||||||
|
setLoading(true)
|
||||||
|
listUsers().then(data => {
|
||||||
|
setUsers(data || [])
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('加载用户列表失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingUser(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditingUser(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
display_name: record.display_name,
|
||||||
|
role: record.role,
|
||||||
|
profile_text: record.profile_text,
|
||||||
|
is_active: record.is_active,
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = (record) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '停用用户',
|
||||||
|
content: `确认停用用户「${record.display_name}」(${record.username})?停用后可重新启用。`,
|
||||||
|
okText: '确认停用',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => deactivateUser(record.id).then(() => {
|
||||||
|
message.success('已停用')
|
||||||
|
loadUsers()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '停用失败')
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
if (editingUser) {
|
||||||
|
updateUser(editingUser.id, values).then(() => {
|
||||||
|
message.success('更新成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadUsers()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '更新失败')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
createUser(values).then(() => {
|
||||||
|
message.success('创建成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadUsers()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '创建失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '用户名',
|
||||||
|
dataIndex: 'username',
|
||||||
|
key: 'username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '显示名',
|
||||||
|
dataIndex: 'display_name',
|
||||||
|
key: 'display_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
key: 'role',
|
||||||
|
render: role => roleOptions.find(o => o.value === role)?.label || role,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'is_active',
|
||||||
|
key: 'is_active',
|
||||||
|
render: (active, record) => (
|
||||||
|
<Switch
|
||||||
|
checked={active}
|
||||||
|
onChange={() => handleDeactivate(record)}
|
||||||
|
checkedChildren="在职"
|
||||||
|
unCheckedChildren="已停"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{record.is_active && (
|
||||||
|
<Button size="small" danger icon={<StopOutlined />} onClick={() => handleDeactivate(record)}>
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 0 24px' }}>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>用户管理</h2>
|
||||||
|
<Button type="primary" icon={<UserAddOutlined />} onClick={handleCreate}>
|
||||||
|
新建用户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingUser ? '编辑用户' : '新建用户'}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
{!editingUser && (
|
||||||
|
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input placeholder="登录用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item name="display_name" label="显示名" rules={[{ required: true, message: '请输入显示名' }]}>
|
||||||
|
<Input placeholder="界面显示名称" />
|
||||||
|
</Form.Item>
|
||||||
|
{!editingUser && (
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="初始密码" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
|
||||||
|
<Select placeholder="选择角色" options={roleOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="profile_text" label="个人简介">
|
||||||
|
<Input.TextArea rows={3} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserManage
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
export async function login(username, password) {
|
||||||
|
const response = await http.post('/auth/login', { username, password })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const response = await http.post('/auth/logout')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function me() {
|
||||||
|
const response = await http.get('/auth/me')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { login, logout, me }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default http
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import authService from '../services/authService'
|
||||||
|
|
||||||
|
const useAuthStore = create((set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
login: async (username, password) => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
try {
|
||||||
|
const user = await authService.login(username, password)
|
||||||
|
set({ user, isLoading: false })
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false })
|
||||||
|
return { success: false, error: error.response?.data?.detail || '登录失败' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await authService.logout()
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
set({ user: null })
|
||||||
|
},
|
||||||
|
fetchMe: async () => {
|
||||||
|
try {
|
||||||
|
const user = await authService.me()
|
||||||
|
set({ user })
|
||||||
|
} catch {
|
||||||
|
set({ user: null })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useAuthStore
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/static': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Before Width: | Height: | Size: 814 KiB After Width: | Height: | Size: 814 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
@@ -201,5 +201,86 @@ uvicorn app.main:app --reload
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*本日志由 Claude Opus 4.7 基于 Sonnet 4.6 完工报告 + chat 关键决策整理生成。*
|
## 九、前端骨架完工记录(2026-05-15)
|
||||||
*下一次 Phase 收尾时,沿用本格式继续写 phase2_log.md。*
|
|
||||||
|
### 9.1 完成时间
|
||||||
|
- **前端骨架开发日期**:2026-05-15
|
||||||
|
- **前端骨架状态**:✅ 已完成
|
||||||
|
|
||||||
|
### 9.2 完成内容
|
||||||
|
|
||||||
|
1. **Vite + React 项目初始化**:`npm create vite@latest frontend -- --template react`
|
||||||
|
|
||||||
|
2. **核心依赖安装**:
|
||||||
|
- `antd` (UI 库)
|
||||||
|
- `@ant-design/icons`
|
||||||
|
- `zustand` (状态管理)
|
||||||
|
- `react-router-dom` v6
|
||||||
|
- `axios`
|
||||||
|
- `dayjs`
|
||||||
|
|
||||||
|
3. **Vite 代理配置**:`vite.config.js` 中 `/api` → `http://localhost:8000`
|
||||||
|
|
||||||
|
4. **AntD 中文 locale + 主题**:主色 `#6b8e6b`(深绿),圆角 12px/16px
|
||||||
|
|
||||||
|
5. **CSS 变量定义**:`src/index.css` 中定义奶油底 `#fbf9f1`、深绿 `#6b8e6b`、浅绿 `#c8d9b0`
|
||||||
|
|
||||||
|
6. **Auth 体系**:
|
||||||
|
- `services/http.js`:axios 实例,`withCredentials: true`
|
||||||
|
- `services/authService.js`:login/logout/me 三接口
|
||||||
|
- `stores/authStore.js`:Zustand,含 user/isLoading/login/logout/fetchMe
|
||||||
|
|
||||||
|
7. **路由守卫**:`components/AuthGuard/AuthGuard.jsx`,挂载时调用 me() 一次,401 跳转登录
|
||||||
|
|
||||||
|
8. **主布局**:`components/Layout/AppLayout.jsx` (侧边栏 + 顶栏 + 内容区 Outlet)
|
||||||
|
|
||||||
|
9. **侧边栏**:`SideNav.jsx`,6 项菜单(含 doco 占位),图标用 AntD icons
|
||||||
|
|
||||||
|
10. **顶栏**:`TopBar.jsx`,搜索框 + 通知 + 头像下拉(退出)
|
||||||
|
|
||||||
|
11. **登录页**:`pages/Login/Login.jsx`,AntD Form,用户名 + 密码,提交后调用 store.login()
|
||||||
|
|
||||||
|
12. **仪表盘**:`pages/Dashboard/Dashboard.jsx`:
|
||||||
|
- 顶部 Banner(题图占位)
|
||||||
|
- 3 个 KPI 卡(平均收视/完成率/待排期)
|
||||||
|
- 热点雷达占位(模块 H,Phase 4c)
|
||||||
|
- **近 9 期收视柱图**(假数据,三种颜色都有)
|
||||||
|
- 排播计划占位(模块 F,Phase 4b)
|
||||||
|
- 柱图下方:期次标题 + 编导头像 + 姓名
|
||||||
|
- 左下角颜色规则注释:红=优秀/蓝=达标/绿=待提升
|
||||||
|
|
||||||
|
13. **空页占位**:TPS / 知识库 / 编导首页 / 用户管理 / 文稿对齐(Doco 用 Result 组件)
|
||||||
|
|
||||||
|
14. **路由配置**:`App.jsx` 中 Routes 嵌套,AuthGuard 包裹,共 7 个路由
|
||||||
|
|
||||||
|
### 9.3 技术决策
|
||||||
|
|
||||||
|
- **角色拼音值**:前端代码中所有角色判断用 `zhipianren/zebian/biandao`,不得用英文别名
|
||||||
|
- **收视颜色判定(反直觉)**:红=优秀(>摸高)/蓝=达标(基础~摸高)/绿=待提升(<基础)
|
||||||
|
- **Session + Cookie**:axios `withCredentials: true`,Vite 代理 `/api`
|
||||||
|
- **刷新登录态保持**:Zustand 初始 user=null,AuthGuard 挂载时调用 me() 补全
|
||||||
|
|
||||||
|
### 9.4 文档修正(同期完成)
|
||||||
|
|
||||||
|
- `README.md`:近 4 期 → 近 9 期
|
||||||
|
- `dev_plan.md`:近 4 期 → 近 9 期
|
||||||
|
- `docs/git_workflow.md`:Z:\AIworks\ 路径残留 → E:\tps-dashboard\
|
||||||
|
|
||||||
|
### 9.5 Git commit 记录
|
||||||
|
|
||||||
|
| commit | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| b39b7c8 | chore: 清理 indexUI 中文文件名空格与重复字符 |
|
||||||
|
| e907876 | docs: 仪表盘收视柱图统一为近 9 期(制片人 2026-05-15 拍板) |
|
||||||
|
| 233cb85 | docs: git_workflow.md 第 2/3 章路径残留 Z:\AIworks → E:\tps-dashboard 修正 |
|
||||||
|
| fef9e81 | feat: frontend skeleton done |
|
||||||
|
|
||||||
|
### 9.6 遗留事项
|
||||||
|
|
||||||
|
- `frontend/src/App.css` 和 `frontend/src/assets/` 是 Vite 模板自带,未使用但已 commit(不影响功能)
|
||||||
|
- Phase 2 需接入真实后端 API 数据
|
||||||
|
- Phase 2 仪表盘真实柱图需替换假数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本日志由 MiniMax M2.7(Cline Act 模式)于 2026-05-15 前端骨架完工后追加。*
|
||||||
|
|||||||
@@ -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 — 后端 CRUD(2026-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 | 200(year 不变) | 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(数据已存在)
|
||||||
|
|
||||||
|
**修复**:改为幂等版脚本,先清理同名数据再创建
|
||||||
|
|
||||||
|
### 坑 4:pip 默认路由到系统 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)"` 确认实际调用的解释器,再执行安装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 5:002b 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"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 6:Gitea OAuth refresh token 过期导致 git push 反复 401
|
||||||
|
|
||||||
|
**现象**:`git push` 多次返回 401 Unauthorized,Git 凭证管理器反复弹框,输入正确账密后仍然失败。
|
||||||
|
|
||||||
|
**原因**:Gitea OAuth access token 有效期到期,refresh token 也已失效,导致 Git 凭证无法续期。多次 401 后需重新完成 OAuth 认证流程刷新 token。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 删掉旧 token:Git 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 待解决",不写入"完工"状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 纪律 2:SQL / 代码 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 坑 7:pandas 2.x 不接受 raw bytes(BytesIO 包装解决)
|
||||||
|
|
||||||
|
**发现时间**:2026-05-19 第一轮冒烟
|
||||||
|
|
||||||
|
**现象**:`pd.read_excel(file_content)` 传入 bytes,pandas 2.x 报错或读不出数据。
|
||||||
|
|
||||||
|
**原因**:pandas 2.x 要求 file-like object,不再直接接受 bytes。
|
||||||
|
|
||||||
|
**修复**:`excel_service.py` 中用 `io.BytesIO(file_content)` 包装后传给 `pd.read_excel`。
|
||||||
|
|
||||||
|
**commit**:442d52f
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 8:openpyxl 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 遗留 bug:smoke 脚本 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;隐患 A(ImportError 同名)、隐患 B(ExcelService 返回 Pydantic 化)低优;SECRET_KEY + same_site/https_only(Phase 5 部署)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*第十三~十四章由 Claude Opus 4.7 协助、MiniMax M2.7 落地,于 2026-05-22 Task 3 收官后追加。*
|
||||||
@@ -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 & push,commit 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→author,military_report→source_detail,其余 None。
|
||||||
|
做成**可配置映射表**(前端 useKnowledgeGrouping.js + 后端 knowledge_service.py 同名映射),改一处即可调整。
|
||||||
|
⚠️ 此设计使"第二层维度"在不同大类下含义不同,是制片人知情拍板的临时方案;
|
||||||
|
将来宜把"按编导看稿"迁为独立筛选(见快照 Backlog#3)。
|
||||||
|
2. **点击筛选为前端本地计算**:displayedItems 基于已拉取全量 items + 选中节点实时算,不发网络请求,稳定。
|
||||||
|
3. **过滤按大类用对应字段**:manuscript 比 author,military_report 比 source_detail(Task 3.2 最终修对的关键)。
|
||||||
|
4. **空大类不显示根因**:初始化时 type_groups 预建所有大类空列表,致 continue 失效;改为动态收集真实出现的 source_type。
|
||||||
|
|
||||||
|
### 改动文件
|
||||||
|
| 文件 | 动作 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/services/knowledge_service.py` | 新增 get_grouped_items();SOURCE_TYPE_ORDER;SECONDARY_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 内"语义搜索"尚未做(向量已就位,随时可开),是下一刀候选。
|
||||||
@@ -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 & push,commit 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 反馈(已实现)。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "tps-dashboard",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -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,数据灌入后)的落地顺序;清理过期风险项** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||