Compare commits
82 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 | |||
| 5217d09044 | |||
| 3fa00ea393 | |||
| d8d04790fd | |||
| a43a3e48a3 | |||
| 2da78975e6 | |||
| a53017dd58 | |||
| 048f8bc158 | |||
| 31fb490b6d | |||
| f2f38f2b45 | |||
| 58e1183d05 | |||
| 7fa06c6a37 | |||
| 99c1139fbf | |||
| 6c7d88d063 | |||
| ab1a152dda | |||
| c266a5de5c | |||
| 061e07f842 | |||
| 1179246f2d | |||
| c0a23c2610 | |||
| d20548b846 | |||
| a9d1b0e416 | |||
| 110e26f752 | |||
| 906936a2d1 | |||
| 75691c8496 |
@@ -2,8 +2,8 @@
|
||||
|
||||
> 项目:TPS 中台(央视《军事科技》栏目内部工作台)
|
||||
> 仓库:`tps-dashboard`(Gitea,团队可见)
|
||||
> 修订版本:v3 · 2026-05-06
|
||||
> 适用对象:Cline + Sonnet(Plan)+ DeepSeek pro(Act),所有协作者必读
|
||||
> 修订版本:v5 · 2026-05-15
|
||||
> 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读
|
||||
|
||||
---
|
||||
|
||||
@@ -25,33 +25,23 @@
|
||||
|
||||
| 角色 | 模型 / 工具 | 职责 |
|
||||
|---|---|---|
|
||||
| 制片人 | 刘统(人) | 拍板、审核、领域知识权威、视觉把关 |
|
||||
| 制片人 | 刘通(人) | 拍板、审核、领域权威、视觉把关 |
|
||||
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 |
|
||||
| **架构师(Phase 0-1)** | **Sonnet 4.6**(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
|
||||
| **架构师(Phase 2 起)** | **MiniMax M2.7**(Cline Plan 模式)| **Sonnet 余额耗尽后接班 Plan,API Key 已 ready** |
|
||||
| 执行者 | DeepSeek v4 pro(Cline Act 模式) | 实际写代码,**全程不变** |
|
||||
| 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
|
||||
| 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 |
|
||||
| 备选执行者 | DeepSeek v4 flash | 仅日常小改(改字段、加字段、改文案) |
|
||||
|
||||
**模型阶段性安排(2026-05-06 拍板)**:
|
||||
**当前模型配置(2026-05-14 拍板)**:
|
||||
|
||||
- **Phase 0-1(骨架期)**:**Plan = Sonnet 4.6**,Act = DeepSeek pro
|
||||
- **Phase 2 起**:**Plan = MiniMax M2.7**(标准版,非 highspeed),Act 仍 DeepSeek pro
|
||||
- **切换的核心动机**:Sonnet 单次调用昂贵且 Plan 模式调用频次最高,换 MiniMax M2.7 后单次成本降至 1/50
|
||||
- DeepSeek 全程在岗,不退役
|
||||
- **Plan 模式**:MiniMax M2.7(标准版,非 highspeed)
|
||||
- **Act 模式**:MiniMax M2.7
|
||||
- 家用机已退役,唯一开发环境为单位 4090D(E:\tps-dashboard)
|
||||
- API 配置:`base_url = https://api.minimaxi.com/v1`,Model ID = `MiniMax-M2.7`
|
||||
|
||||
**MiniMax M2.7 接入信息(配置在 Cline 的 Plan 模式)**:
|
||||
**模型降级口诀**:
|
||||
|
||||
- 官方 API:`base_url = https://api.minimaxi.com/v1`(注意是 minimaxi)
|
||||
- Model ID:`MiniMax-M2.7`(标准版)
|
||||
- **不用** highspeed 版(贵一倍,Plan 模式不需要那点速度)
|
||||
- API Key 已由制片人申请就绪
|
||||
|
||||
**模型切换的判断口诀**(Phase 2 起):
|
||||
|
||||
- Plan 出方案、审 Act 改动 → **MiniMax M2.7**
|
||||
- Act 写新代码 / 建结构 / 改架构 → **DeepSeek pro**
|
||||
- Act 改小 bug / 加字段 / 改文案 → **DeepSeek flash**(可选)
|
||||
- Plan 连续两次方案质量明显下降 → **临时切回 Sonnet 顶住**(允许)
|
||||
- Plan 连续两次方案质量明显下降 → **临时切回 claude.ai 找 Opus 4.7 重新规划,不在 Cline 里换模型**
|
||||
- Act 连续两次实现质量明显下降 → **优先切回 claude.ai 找 Opus 4.7 审 Plan,看是不是任务拆得太大;如果是小改任务,可临时切到备选执行者 DeepSeek v4 flash;不在 Cline 里硬撑**
|
||||
|
||||
---
|
||||
|
||||
@@ -59,8 +49,8 @@
|
||||
|
||||
### 3.1 Plan → Act → 完工三阶段
|
||||
|
||||
1. **Plan 阶段**:Sonnet 出方案,**复杂方案先发 Opus 4.7 审一遍**,审过后再切 Act
|
||||
2. **Act 阶段**:DeepSeek pro 严格按 Plan 实施,**禁止自作主张扩展范围**
|
||||
1. **Plan 阶段**:MiniMax M2.7 出方案,**复杂方案先发 Opus 4.7 审一遍**,审过后再切 Act
|
||||
2. **Act 阶段**:MiniMax M2.7 严格按 Plan 实施,**禁止自作主张扩展范围**
|
||||
3. **完工阶段**:Act 必须给出"完工报告",列出所有改动文件、设计决策、潜在风险
|
||||
|
||||
### 3.2 哪些事 Act 必须停下让 Plan 审
|
||||
@@ -120,7 +110,7 @@ Phase 内的子任务、修复独立 bug、前端骨架、后端骨架——都
|
||||
|
||||
### 5.2 年度目标只增不改(时间不变性)
|
||||
|
||||
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**。
|
||||
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**:
|
||||
|
||||
- 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标"
|
||||
- 2024 年播的节目永远按 2024 年的 0.6448 / 0.8989 判,2026 年回看也不会变色
|
||||
@@ -134,7 +124,7 @@ SQL 端:`yearly_targets.year` 加 UNIQUE 约束,代码端 INSERT 时用 `ON CONF
|
||||
|
||||
- **在职编导**:两字段都填(冗余但稳)
|
||||
- **离职编导**:`editor_id` 留空(NULL),只填 `editor_name_snapshot`(如"张颖")
|
||||
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照
|
||||
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照,不报错不丢数据
|
||||
- **查询某编导历史作品**:**先按 editor_id,失败回退按 editor_name_snapshot 文本匹配**
|
||||
|
||||
DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。
|
||||
@@ -157,6 +147,11 @@ AI Prompt 模板必须默认走双段、备选纯叙述。docxtpl 模板设计
|
||||
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
|
||||
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
|
||||
|
||||
**yearly_targets 写权限收紧(2026-05-20)**:
|
||||
- POST/PATCH: zhipianren + zebian 可写,biandao 返回 403
|
||||
- DELETE: 仅 zhipianren 可操作,zebian 和 biandao 返回 403
|
||||
- GET: 三角色均可读
|
||||
|
||||
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
|
||||
|
||||
---
|
||||
@@ -350,7 +345,7 @@ allow_origins=["http://localhost:5173"] # 必须显式列,不能用 ["*"]
|
||||
|
||||
**单位机器 Phase 1 后端可选路径**:
|
||||
|
||||
- **路径 A**:从 0 重写(Sonnet + DeepSeek 重新生成)
|
||||
- **路径 A**:从 0 重写(MiniMax M2.7 重新生成所有后端文件)
|
||||
- **路径 B**(推荐):用户把家用机的 backend 目录打包 zip 拷到单位机,解压进新仓库,Cline 跑一遍跑通即可
|
||||
|
||||
由制片人在 Phase 1 启动时拍板选 A 或 B。
|
||||
@@ -376,11 +371,26 @@ Cline 内的 Sonnet/DeepSeek 不直接与 Opus 通信,流程如下:
|
||||
|---|---|---|
|
||||
| v1 | 2026-04 | 初版,定模型分工与栏目业务约定 |
|
||||
| v2 | 2026-05-04 | 加技术栈精简版、Phase 拆分 |
|
||||
| **v3** | **2026-05-06** | **加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗** |
|
||||
| v3 | 2026-05-06 | 加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗 |
|
||||
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D(E:\tps-dashboard);Phase 2 起 MiniMax M2.7 正式日常使用;config.py 改为 dotenv 直接加载,pydantic-settings 从 requirements.txt 移除 |
|
||||
| **v5** | **2026-05-15** | **合入第十三章"Opus 4.7 起草 task brief 的节制原则";Phase 2 正式开工** |
|
||||
|
||||
**修订原则**:.clinerules 是宪法不是变更日志,改动应慎重。临时执行细节进 phase{N}_log.md,不进本文件。
|
||||
|
||||
---
|
||||
|
||||
## 十三、Opus 4.7 起草 task brief 的节制原则
|
||||
|
||||
Opus 4.7 给 MiniMax 写 task brief 时遵守:
|
||||
|
||||
- **一屏内为目标**(约 60-80 行,不超过 100 行)
|
||||
- **写四类东西**:任务范围、核心约束、业务/纪律红线、验收清单
|
||||
- **不写四类东西**:代码片段、目录结构细节、变量名、字段命名建议
|
||||
- 把"怎么实现"留给 MiniMax Plan 模式自己长出来,Opus 只审 Plan 不替 Plan
|
||||
- 自检口诀:这条是品味判断还是代码细节?品味判断留,代码细节砍
|
||||
- 例外:业务红线里涉及具体字段名(如 `audience_share` / `editor_id` / `editor_name_snapshot` / `yearly_targets.year`)必须点名,因为写错字段名就是写错业务
|
||||
|
||||
---
|
||||
|
||||
*本文件最高优先级。任何冲突以本文件为准。*
|
||||
*下次修订建议时机:Phase 2 末(评估 MiniMax 切换、整理 Phase 2 经验)。*
|
||||
*下次修订建议时机:Phase 2 末(评估 MiniMax 切换、整理 Phase 2 经验)。*
|
||||
@@ -13,23 +13,39 @@ dist/
|
||||
build/
|
||||
.vite/
|
||||
|
||||
# 环境配置(永远不上传)
|
||||
# 莽聨呕暮藰聝茅聟聧莽藵沤(膰掳赂膷偶聹盲赂聧盲赂聤盲藕聽)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 编辑器
|
||||
# 莽藕聳膷啪聭暮聶篓
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# 操作系统
|
||||
# 膰聯聧盲藵聹莽艂钮莽钮聼
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 日志运行产物
|
||||
# 膰聴慕暮偶聴膷偶聬膷膭聦盲艧搂莽聣艩
|
||||
*.log
|
||||
cookie.txt
|
||||
cookies*.txt
|
||||
|
||||
# 临时文件
|
||||
# 盲赂麓膰聴艣膰聳聡盲钮艣
|
||||
*.tmp
|
||||
*.bak
|
||||
*.bak
|
||||
homePC/
|
||||
|
||||
# Syncthing (膰聻聛莽艩艧茅聴麓 NAS 暮聬聦膰颅慕暮藝慕暮聟藝盲艧搂莽聣艩)
|
||||
.stignore
|
||||
|
||||
# 臉媒啪脻偶芒膮赂藝脻 / SQL dump(藳钮藵艡掳膰膮啪偶芒)
|
||||
backups/
|
||||
|
||||
# 鏁忔劅鑴氭湰(姘镐笉涓婁紶)
|
||||
backend/scripts/reset_password.py
|
||||
|
||||
# 前端测试/占位图片(题图上传测试产物)
|
||||
frontend/src/assets/*.jpg
|
||||
frontend/src/assets/*.jpeg
|
||||
frontend/src/assets/*.png
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
一个面向《军事科技》栏目 8 人团队的内部工作台,核心模块包括:
|
||||
|
||||
- **TPS**(选题策划系统):查重 + 知识库参考 + 报题单一键生成
|
||||
- **仪表盘**:近 4 期收视成绩 + 未来 6 期排期一目了然
|
||||
- **仪表盘**:近 9 期收视成绩 + 未来 6 期排期一目了然
|
||||
- **知识库**:往期文稿、军报材料、专家库、外拍线索一站式检索
|
||||
- **个人首页**:每位编导有自己的工作台 + 今日热点雷达
|
||||
- **拍摄资源库**:外拍联系人 / 单位 / 线索来源结构化沉淀
|
||||
@@ -47,10 +47,11 @@
|
||||
|
||||
| Phase | 状态 |
|
||||
|---|---|
|
||||
| Phase 0 环境 + Git | 单位机进行中 |
|
||||
| Phase 1 后端骨架 | 家用机已完成,单位机走"路径 B 迁移" |
|
||||
| Phase 1 前端骨架 | 未开始 |
|
||||
| Phase 2-5 | 未开始 |
|
||||
| Phase 0 环境 + Git | ✅ 已完成 |
|
||||
| Phase 1 后端骨架 + 前端骨架 | ✅ 已完成 |
|
||||
| Phase 2 仪表盘 + 批量导入 + 写权限收紧 | ✅ 已完成 |
|
||||
| Phase 3 知识库基础设施 | 进行中 — 已完成至 Task 3(知识库树形视图按来源分组),语义搜索尚未做 |
|
||||
| Phase 4–5 | 未开始 |
|
||||
|
||||
---
|
||||
|
||||
@@ -58,15 +59,35 @@
|
||||
|
||||
| 角色 | 谁 / 什么 | 做什么 |
|
||||
|---|---|---|
|
||||
| 制片人 | 刘统(人) | 拍板、产品决策、领域知识 |
|
||||
| 制片人 | 刘通(人) | 拍板、产品决策、领域知识 |
|
||||
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、关键决策审核 |
|
||||
| 架构师 | Sonnet 4.6 → MiniMax M2.7(Phase 2 起) | Cline Plan 模式,出方案 |
|
||||
| 执行者 | DeepSeek v4 pro | Cline Act 模式,写代码 |
|
||||
| 架构师 | MiniMax M2.7 | Cline Plan 模式,出方案 |
|
||||
| 执行者 | MiniMax M2.7 | Cline Act 模式,写代码 |
|
||||
| 备选执行者 | DeepSeek v4 flash | 仅日常小改(改字段、加字段、改文案) |
|
||||
|
||||
详见 `.clinerules` 第二章。
|
||||
|
||||
---
|
||||
|
||||
## 角色权限表
|
||||
|
||||
> 数据来源:`.clinerules` 5.5 节 + `project_plan.md` 第四节
|
||||
|
||||
| 资源 / 操作 | zhipianren(制片人) | zebian(责编) | biandao(编导) |
|
||||
|---|---|---|---|
|
||||
| **yearly_targets** | | | |
|
||||
| GET 列表/详情 | ✅ | ✅ | ✅ |
|
||||
| POST 新增 | ✅ | ✅ | ❌ 403 |
|
||||
| PATCH 修改 | ✅ | ✅ | ❌ 403 |
|
||||
| DELETE 删除 | ✅ | ❌ 403 | ❌ 403 |
|
||||
| **episodes** | | | |
|
||||
| GET | ✅ | ✅ | ✅ |
|
||||
| POST/PATCH/DELETE | ✅ | ✅ | 受限(见 API 文档) |
|
||||
|
||||
> 规则:.clinerules 5.5 角色枚举锁定拼音值(zhipianren / zebian / biandao),不许新增枚举。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈速览
|
||||
|
||||
- 前端:React 18 + Vite + Ant Design 5 + Zustand(JavaScript,不用 TS)
|
||||
@@ -153,10 +174,10 @@ uvicorn app.main:app --reload
|
||||
|
||||
| 状况 | 找谁 |
|
||||
|---|---|
|
||||
| 项目方向 / 产品决策 | 刘统(制片人) |
|
||||
| 项目方向 / 产品决策 | 刘通(制片人) |
|
||||
| 关键架构 / 风险 / 卡壳兜底 | Claude Opus 4.7(claude.ai 网页) |
|
||||
| 日常方案 / 改动审查 | Sonnet 4.6(VS Code Cline Plan) |
|
||||
| 写代码 | DeepSeek v4 pro(VS Code Cline Act) |
|
||||
| 日常方案 / 改动审查 | MiniMax M2.7(VS Code Cline Plan) |
|
||||
| 写代码 | MiniMax M2.7(VS Code Cline Act) |
|
||||
| Git 历史搞乱了 / 不小心 commit 了 .env | **立刻找 Claude Opus 4.7,不要自己瞎搞** |
|
||||
|
||||
---
|
||||
@@ -166,6 +187,8 @@ uvicorn app.main:app --reload
|
||||
| 版本 | 日期 | 变化 |
|
||||
|---|---|---|
|
||||
| v1 | 2026-05-06 | 初版,作为单位机仓库门牌 |
|
||||
| **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
|
||||
| **v3** | **2026-05-27** | **进度校准:Phase 1/2 标为完成;Phase 3 Task 3 知识库树形视图已完成;清理"家用机/路径B"历史表述,不再保留迁移叙事** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
认证路由 — 登录 / 登出 / 当前用户
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.security import verify_password
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserResponse)
|
||||
def login(body: LoginRequest, request: Request, session: Session = Depends(get_session)):
|
||||
"""账号登录。验证成功后写入 session,返回用户信息。"""
|
||||
statement = select(User).where(User.username == body.username)
|
||||
user = session.exec(statement).first()
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
if not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
request.session["user_id"] = user.id
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
"""退出登录,清空当前 session。"""
|
||||
request.session.clear()
|
||||
return {"message": "已退出登录"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(user: User = Depends(get_current_user)):
|
||||
"""获取当前登录用户的信息。"""
|
||||
return user
|
||||
@@ -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
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
应用配置 — 手动从 .env 读取,不依赖 pydantic-settings 自动 env_file
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 强制加载 backend/.env 到 os.environ(用 __file__ 定位脚本所在目录,避免 cwd 漂移)
|
||||
# __file__ = e:\tps-dashboard\backend\app\core\config.py → parent.parent.parent = e:\tps-dashboard\backend
|
||||
_env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(str(_env_path), override=True)
|
||||
|
||||
# 直接从 os.environ 读取,手动兜底
|
||||
_DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||
_SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-to-a-random-string-in-production")
|
||||
_SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
|
||||
|
||||
# MiniMax Embedding API 凭证
|
||||
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
||||
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
|
||||
|
||||
# 验证必需配置
|
||||
if not _DATABASE_URL:
|
||||
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
||||
|
||||
# 简单命名空间对象(替代 pydantic-settings,避免 env 读取失效)
|
||||
class Settings:
|
||||
DATABASE_URL: str = _DATABASE_URL
|
||||
SECRET_KEY: str = _SECRET_KEY
|
||||
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
||||
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
|
||||
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
FastAPI 依赖注入工具 — 获取当前用户、角色鉴权
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> User:
|
||||
"""从 Session 中取 user_id,查库返回当前用户。
|
||||
|
||||
未登录或用户不存在 / 已停用时抛出 401。
|
||||
"""
|
||||
user_id = request.session.get("user_id")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录",
|
||||
)
|
||||
user = session.get(User, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在或已被停用",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*allowed_roles: UserRole):
|
||||
"""返回一个 Depends 依赖,要求当前用户拥有指定的角色之一。
|
||||
|
||||
使用示例:
|
||||
@router.get("/admin-only")
|
||||
def admin_endpoint(user=Depends(require_role(UserRole.zhipianren))):
|
||||
...
|
||||
|
||||
@router.get("/staff")
|
||||
def staff_endpoint(user=Depends(require_role(UserRole.zhipianren, UserRole.zebian))):
|
||||
...
|
||||
"""
|
||||
|
||||
def check_role(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,无法执行此操作",
|
||||
)
|
||||
return user
|
||||
|
||||
return check_role
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
认证安全工具 — passlib + bcrypt
|
||||
"""
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""用 bcrypt 生成密码哈希。"""
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""验证明文密码与哈希是否匹配。"""
|
||||
return pwd_context.verify(plain, hashed)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
数据库会话 — SQLModel 同步引擎 + Session 工厂
|
||||
"""
|
||||
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, echo=False)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""创建所有 SQLModel 表(仅开发用,生产由手写 SQL 管理)。"""
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session():
|
||||
"""FastAPI 依赖注入: 每个请求获取一个 Session,用完后关闭。"""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
FastAPI 应用入口 — 挂载中间件、路由
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.episodes import router as episodes_router
|
||||
from app.api.imports import router as imports_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.yearly_targets import router as yearly_targets_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.schedules import router as schedules_router
|
||||
from app.api.knowledge import router as knowledge_router
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
||||
|
||||
# ---- CORS 中间件 ----
|
||||
# 显式列 origins 不能用 ['*'],否则 credentials=True 时浏览器拒绝携带 cookie
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---- Session 中间件 ----
|
||||
# 密钥来自 .env,用于签名 session cookie
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SECRET_KEY,
|
||||
session_cookie="milsci_session",
|
||||
max_age=settings.SESSION_MAX_AGE,
|
||||
same_site="lax",
|
||||
https_only=False, # 本地开发用 HTTP
|
||||
)
|
||||
|
||||
# ---- 注册路由 ----
|
||||
app.include_router(auth_router)
|
||||
app.include_router(episodes_router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(yearly_targets_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(schedules_router)
|
||||
app.include_router(knowledge_router)
|
||||
|
||||
# 挂载静态文件目录(题图海报)
|
||||
_static_dir = Path(__file__).parent.parent / "static"
|
||||
if _static_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
||||
@@ -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,44 @@
|
||||
"""
|
||||
用户模型 — SQLModel
|
||||
|
||||
角色体系(不再改):
|
||||
- zhipianren = 制片人 / 管理员 = 全部权限
|
||||
- zebian = 责编 = 维护仪表盘 / 知识库 / 排期
|
||||
- biandao = 编导 = 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import DateTime as SADateTime
|
||||
from sqlalchemy.sql import func as sa_func
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""用户角色枚举,值与数据库 CHECK 约束一致。"""
|
||||
zhipianren = "zhipianren" # 制片人 / 管理员
|
||||
zebian = "zebian" # 责编
|
||||
biandao = "biandao" # 编导
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
username: str = Field(max_length=50, unique=True, index=True)
|
||||
display_name: str = Field(max_length=100)
|
||||
password_hash: str = Field(max_length=128)
|
||||
role: UserRole = Field(default=UserRole.biandao, max_length=20)
|
||||
profile_text: str | None = Field(default=None)
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
created_at: datetime | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||
)
|
||||
updated_at: datetime | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||
)
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
认证相关 Pydantic Schema — 请求 / 响应模型
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""返回给前端的用户信息(不含 password_hash)。"""
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
role: UserRole
|
||||
profile_text: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -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,
|
||||
}]
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
sqlmodel==0.0.19
|
||||
psycopg2-binary==2.9.9
|
||||
pgvector==0.2.5
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.9
|
||||
python-dotenv==1.0.1
|
||||
httpx==0.27.0
|
||||
python-frontmatter==1.1.0
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.1.0
|
||||
@@ -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,81 @@
|
||||
"""
|
||||
创建管理员账号 — 交互式命令行脚本
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
py -3.12 scripts/create_admin.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保 backend/ 目录在 sys.path 中,使 app.* 导入正常工作
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from getpass import getpass
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import engine
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" 军事科技工作台 — 创建管理员账号")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
username = input("请输入用户名: ").strip()
|
||||
if not username:
|
||||
print("❌ 用户名不能为空")
|
||||
sys.exit(1)
|
||||
|
||||
display_name = input("请输入显示名(中文姓名): ").strip()
|
||||
if not display_name:
|
||||
print("❌ 显示名不能为空")
|
||||
sys.exit(1)
|
||||
|
||||
password = getpass("请输入密码(输入不显示): ").strip()
|
||||
if not password:
|
||||
print("❌ 密码不能为空")
|
||||
sys.exit(1)
|
||||
|
||||
password_confirm = getpass("请再次输入密码确认: ").strip()
|
||||
if password != password_confirm:
|
||||
print("❌ 两次输入的密码不一致")
|
||||
sys.exit(1)
|
||||
|
||||
with Session(engine) as session:
|
||||
# 检查用户名是否已存在
|
||||
existing = session.exec(
|
||||
select(User).where(User.username == username)
|
||||
).first()
|
||||
if existing:
|
||||
print(f"❌ 用户名「{username}」已存在,请换一个用户名")
|
||||
sys.exit(1)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
password_hash=hash_password(password),
|
||||
role=UserRole.zhipianren,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
print()
|
||||
print("✅ 管理员账号创建成功!")
|
||||
print(f" 用户名: {user.username}")
|
||||
print(f" 显示名: {user.display_name}")
|
||||
print(f" 角色: {user.role.value}(制片人/管理员)")
|
||||
print(f" ID: {user.id}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,238 @@
|
||||
-- ============================================================
|
||||
-- 001_init.sql — 《军事科技》栏目内部工作台 数据库初始化脚本
|
||||
-- 一次性建完所有核心表骨架(Phase 1-4 全部表)
|
||||
-- 执行方式: psql -U postgres -d milsci_dev -f 001_init.sql
|
||||
-- ============================================================
|
||||
|
||||
-- 扩展: pgvector(向量检索)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. users — 用户表(Phase 1 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
password_hash VARCHAR(128) NOT NULL,
|
||||
-- 角色: zhipianren=制片人/管理员, zebian=责编, biandao=编导
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'biandao'
|
||||
CHECK (role IN ('zhipianren', 'zebian', 'biandao')),
|
||||
profile_text TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_role ON users (role);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. episodes — 节目期次(Phase 2 用,字段完整按 dev_plan.md 8.1 节)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
episode_number INTEGER NOT NULL UNIQUE,
|
||||
program_name VARCHAR(200) NOT NULL,
|
||||
air_date DATE NOT NULL,
|
||||
-- 编导(软引用 + 快照模式)
|
||||
editor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
editor_name_snapshot VARCHAR(100) NOT NULL,
|
||||
-- 收视数据
|
||||
audience_share NUMERIC(6,4),
|
||||
audience_rating NUMERIC(6,4),
|
||||
-- 重播相关
|
||||
is_rerun BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
original_episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL,
|
||||
is_time_sensitive BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- 外拍信息
|
||||
is_outdoor_shoot BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
outdoor_contact_person VARCHAR(100),
|
||||
outdoor_contact_info VARCHAR(200),
|
||||
-- 线索
|
||||
lead_source VARCHAR(300),
|
||||
-- 责编备注
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_episodes_air_date ON episodes (air_date);
|
||||
CREATE INDEX idx_episodes_editor_id ON episodes (editor_id);
|
||||
CREATE INDEX idx_episodes_is_rerun ON episodes (is_rerun);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. yearly_targets — 年度收视目标(Phase 2 用,只增不改)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS yearly_targets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INTEGER NOT NULL UNIQUE,
|
||||
base_target NUMERIC(6,4) NOT NULL,
|
||||
stretch_target NUMERIC(6,4) NOT NULL,
|
||||
issued_date DATE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. topics — 选题 / 报题单(Phase 4a 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
-- 内容概要(双段结构: 引子 + 列举)
|
||||
content_summary TEXT,
|
||||
-- 编导关联(软引用 + 快照)
|
||||
editor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
editor_name_snapshot VARCHAR(100),
|
||||
-- 选题状态: draft=草稿, submitted=已提交, reviewing=审阅中, approved=通过, rejected=退回
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'submitted', 'reviewing', 'approved', 'rejected')),
|
||||
-- AI 反馈(JSON 格式,含查重/画像/知识库参考等模块结果)
|
||||
ai_feedback JSONB,
|
||||
-- 关联的 episodes / knowledge_items id 列表
|
||||
related_episodes JSONB,
|
||||
related_knowledge JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_topics_editor_id ON topics (editor_id);
|
||||
CREATE INDEX idx_topics_status ON topics (status);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. topic_embeddings — 选题语义向量(Phase 4a 用,pgvector)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS topic_embeddings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
embedding vector(1536) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_topic_embeddings_topic_id ON topic_embeddings (topic_id);
|
||||
-- pgvector 向量索引(IVFFlat,数据量上去后可用 HNSW)
|
||||
-- CREATE INDEX idx_topic_embeddings_vector ON topic_embeddings USING ivfflat (embedding vector_cosine_ops);
|
||||
|
||||
-- ============================================================
|
||||
-- 6. schedules — 排期(Phase 4b 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
episode_id INTEGER REFERENCES episodes(id) ON DELETE SET NULL,
|
||||
planned_air_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'planned'
|
||||
CHECK (status IN ('planned', 'confirmed', 'completed', 'cancelled')),
|
||||
editor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_schedules_air_date ON schedules (planned_air_date);
|
||||
|
||||
-- ============================================================
|
||||
-- 7. knowledge_items — 知识库条目(Phase 3 用,2.0 兼容预埋)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS knowledge_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
-- Markdown 格式正文(向 2.0 Karpathy LLM Wiki 形态靠拢)
|
||||
content_md TEXT,
|
||||
-- 来源信息
|
||||
source_type VARCHAR(30) NOT NULL DEFAULT 'manual'
|
||||
CHECK (source_type IN ('baoti', 'manuscript', 'military_report', 'manual', 'rss')),
|
||||
source_file_name VARCHAR(300),
|
||||
source_url VARCHAR(1000),
|
||||
-- 作者 / 编导
|
||||
author VARCHAR(100),
|
||||
publish_date DATE,
|
||||
-- ⚠️ 2.0 兼容预埋字段(1.0 不用但要建)
|
||||
tags JSONB DEFAULT '[]'::jsonb,
|
||||
related_entities JSONB DEFAULT '[]'::jsonb,
|
||||
related_concepts JSONB DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_knowledge_items_source_type ON knowledge_items (source_type);
|
||||
CREATE INDEX idx_knowledge_items_tags ON knowledge_items USING gin (tags);
|
||||
|
||||
-- ============================================================
|
||||
-- 8. knowledge_embeddings — 知识库向量(Phase 3 用,pgvector)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS knowledge_embeddings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
knowledge_id INTEGER NOT NULL REFERENCES knowledge_items(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_text TEXT NOT NULL,
|
||||
embedding vector(1536) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_knowledge_embeddings_knowledge_id ON knowledge_embeddings (knowledge_id);
|
||||
-- pgvector 向量索引
|
||||
-- CREATE INDEX idx_knowledge_embeddings_vector ON knowledge_embeddings USING ivfflat (embedding vector_cosine_ops);
|
||||
|
||||
-- ============================================================
|
||||
-- 9. review_comments — 审片意见(Phase 4b 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS review_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
comment_text TEXT NOT NULL,
|
||||
reviewer VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_review_comments_episode_id ON review_comments (episode_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 10. shooting_resources — 拍摄资源 / 人脉(Phase 4b 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS shooting_resources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
contact_person VARCHAR(100) NOT NULL,
|
||||
contact_info VARCHAR(200),
|
||||
organization VARCHAR(200),
|
||||
-- 资源类型: personnel=人员, location=场地, equipment=设备, other=其他
|
||||
resource_type VARCHAR(20) DEFAULT 'other'
|
||||
CHECK (resource_type IN ('personnel', 'location', 'equipment', 'other')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 11. radar_items — 热点雷达条目(Phase 4c 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS radar_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
source_url VARCHAR(1000),
|
||||
source_name VARCHAR(100),
|
||||
summary TEXT,
|
||||
-- AI 处理结果
|
||||
ai_summary TEXT,
|
||||
style_summary TEXT,
|
||||
published_at TIMESTAMPTZ,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 12. radar_user_actions — 编导对热点的操作(Phase 4c 用)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS radar_user_actions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
radar_item_id INTEGER NOT NULL REFERENCES radar_items(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- 操作类型: deep_dive=深入了解, bookmark=收藏, ignore=忽略
|
||||
action_type VARCHAR(20) NOT NULL
|
||||
CHECK (action_type IN ('deep_dive', 'bookmark', 'ignore')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (radar_item_id, user_id, action_type)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 初始化完成
|
||||
-- ============================================================
|
||||
@@ -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-dashboard`(Gitea)
|
||||
> 修订版本:v3 · 2026-05-06
|
||||
> 修订版本:v5 · 2026-05-27
|
||||
> 配套文档:`project_plan.md`(产品宪法)+ `.clinerules`(协作规则)+ `docs/git_workflow.md`(Git 操作手册)
|
||||
|
||||
---
|
||||
@@ -62,7 +62,7 @@
|
||||
**路径 B — 迁移(推荐)**:
|
||||
1. 制片人在家用机把 `backend/` 整个目录打包 zip
|
||||
2. 拷到单位机,解压进 `tps-dashboard/backend/`
|
||||
3. Cline 跑一遍 Plan,Sonnet 审一遍代码确认与新版宪法一致
|
||||
3. Cline 跑一遍 Plan,MiniMax M2.7 审一遍代码确认与新版宪法一致
|
||||
4. 修任何不一致(目前看应无重大不一致)
|
||||
5. 跑一遍 `pip install + psql + create_admin + uvicorn` 验证
|
||||
|
||||
@@ -90,8 +90,7 @@
|
||||
| 向量检索 | **pgvector**(PG 扩展) | 不用独立向量库 |
|
||||
| 向量维度 | **1536**(为 2.0 wiki 兼容) | 不可改 |
|
||||
| AI 嵌入模型 | DeepSeek 或 OpenAI 兼容 embedding API | — |
|
||||
| AI 编程模型 | Phase 0-1: Sonnet Plan + DeepSeek pro Act | 详见 `.clinerules` 第二章 |
|
||||
| AI 编程模型 | Phase 2 起: MiniMax M2.7 Plan + DeepSeek pro Act | — |
|
||||
| AI 编程模型 | **MiniMax M2.7**(Cline Plan + Act 模式) | 详见 `.clinerules` 第二章 |
|
||||
| 认证 | **Session + Cookie** | 不用 JWT |
|
||||
| Word 生成 | docxtpl | — |
|
||||
| 甘特图前端库 | frappe-gantt | 不自己用 D3 画 |
|
||||
@@ -142,7 +141,7 @@
|
||||
**Day 2:开发工具**
|
||||
|
||||
- VS Code + Cline 扩展
|
||||
- Cline 配置 Plan 模式(Sonnet)+ Act 模式(DeepSeek pro)
|
||||
- Cline 配置 Plan 模式(MiniMax M2.7)+ Act 模式(MiniMax M2.7),Plan 和 Act 用同一模型但物理隔离两个心智阶段(纪律性)
|
||||
- VS Code 终端打开测试:能跑 `python --version` / `node --version` / `git --version` / `psql --version`
|
||||
|
||||
**Phase 0 收尾**:
|
||||
@@ -163,7 +162,7 @@ git push origin phase0-env-ready
|
||||
|
||||
**Day 3-4:后端骨架**
|
||||
|
||||
- **路径 B 推荐**:从家用机迁移 backend 整目录,Sonnet 审一遍,跑通验证
|
||||
- **路径 B 推荐**:从家用机迁移 backend 整目录,MiniMax M2.7 审一遍,跑通验证
|
||||
- **路径 A 备选**:从零开始重写
|
||||
- 验收:`uvicorn app.main:app --reload` 能起,`/docs` 页面能开,`/api/auth/login` + `/api/auth/me` + `/api/auth/logout` 三接口验证通过
|
||||
|
||||
@@ -202,7 +201,7 @@ git push origin phase0-env-ready
|
||||
- 编辑 episodes / yearly_targets / users 的 SQLModel 模型
|
||||
|
||||
**前端**:
|
||||
- 仪表盘页面(近 4 期收视 + 未来 6 期排期)
|
||||
- 仪表盘页面(近 9 期收视 + 未来 6 期排期)
|
||||
- 责编录入界面
|
||||
- 管理员用户管理界面
|
||||
|
||||
@@ -213,7 +212,7 @@ git push origin phase0-env-ready
|
||||
- 后端用 pandas 解析,批量 INSERT
|
||||
- **死规矩**:导入时检测 yearly_targets 是否对应年份齐全,缺则报错让责编先填(防止颜色判定时找不到目标)
|
||||
|
||||
**Phase 2 起 Plan 模式切换为 MiniMax M2.7**:Sonnet 余额耗尽前最后一个由 Sonnet 出方案的 Phase,Phase 2 末做切换的复盘评估。
|
||||
**Phase 2 已进入**:Plan = Act = MiniMax M2.7,DeepSeek v4 pro 保留为备选。
|
||||
|
||||
**Phase 2 里程碑**:
|
||||
- 责编可以开始录入(或导入)历史收视数据
|
||||
@@ -250,19 +249,23 @@ git push origin phase0-env-ready
|
||||
|
||||
---
|
||||
|
||||
### Phase 4a:核心 MVP(Week 5-6)
|
||||
### Phase 4a:核心 MVP — 对话式 TPS(Week 5-6)
|
||||
|
||||
**模块 G**:报题单生成与下载(docxtpl 模板)
|
||||
- 模板严格遵守"双段结构"(`.clinerules` 5.4)
|
||||
> v5 升级:模块 A/C/G 合成"对话式策划助手"形态(左对话右报告、脚注式引用),详见 `project_plan.md` 模块 A/C/G + 第 2.3 节 + 6.5 节红线。
|
||||
|
||||
**模块 A**:选题查重(语义检索 + 历史收视展示)
|
||||
- 联动模块 E 展示外拍资源
|
||||
**前置条件(硬门槛,不满足不开工)**:
|
||||
- 知识库笔记已批量录入(Backlog#2),收视数据已真实导入(Phase 2 遗留债)。**库空时对话助手"无米下锅",依据全空会毁掉第一印象。**
|
||||
- Phase 3 的**朴素语义检索**已先行跑通(输入文本→返回最相关条目,带相关度/原文片段/来源),作为对话式报告的检索底座。
|
||||
|
||||
**模块 C**:知识库参考(检索往期文稿 / 军报)
|
||||
**模块 A(对话式策划主入口)**:左栏编导对话、右栏 AI 加工的策划参考报告(历史是否做过/收视/审片意见/可参考节目/外拍资源/知识库依据)。严守三条交互红线:求助式不投喂、脚注式锚定引用、持续对话沉淀编导判断(见 `project_plan.md` 模块 A)。
|
||||
|
||||
**前端**:选题提交表单 + AI 反馈页面布局
|
||||
**模块 C(知识库依据供给)**:检索结果融入右栏报告作脚注依据,而非孤立列表。
|
||||
|
||||
**🎯 Week 6 末上线 MVP**——团队真的可以用了:登录、看仪表盘、提交选题、看到查重和知识库结果、生成报题单 docx 下载。
|
||||
**模块 G(报题单生成)**:聊定后一键按范本规范化生成 docx。**只做规范化呈现,不代写立意**(见模块 G 分寸线 + 6.5 红线)。模板严守"双段结构"(`.clinerules` 5.4)。
|
||||
|
||||
**AI 成本机制**:本 Phase 起对话调用大模型,按第十一章"AI 成本与额度原则"落地——按 token 记、按人+选题归因、保守上限 + 超额提醒管理员。**朴素的一张用量表即可,严禁上 AI 网关/可观测性框架等大公司重型武器。**
|
||||
|
||||
**🎯 Week 6 末上线 MVP**——团队真的可以用了:登录、看仪表盘、与 TPS 对话策划选题、看到有依据的策划报告、聊定后一键生成报题单 docx 下载。
|
||||
|
||||
**收尾**:写 `logs/phase4a_log.md`,打 `phase4a-mvp-launch` tag。**这是项目第一次"对内发布",务必郑重打 tag**。
|
||||
|
||||
@@ -486,7 +489,9 @@ tps-dashboard/ ← Git 仓库根
|
||||
|---|---|---|
|
||||
| v1 | 2026-04 | 初版 |
|
||||
| v2 | 2026-05-04 | 精简技术栈、Phase 拆分(避免 Phase 4 大爆炸)、明确每周里程碑 |
|
||||
| **v3** | **2026-05-06** | **加 Git + Gitea 工作流;Phase 0 重排(纳入 Git);Phase 2 起 Plan 切 MiniMax M2.7;明确单位机重启路径(路径 B 推荐);新增 docs/ 目录与 phase{N} tag 收尾仪式** |
|
||||
| v3 | 2026-05-06 | 加 Git + Gitea 工作流;Phase 0 重排(纳入 Git);Phase 2 起 Plan 切 MiniMax M2.7;明确单位机重启路径(路径 B 推荐);新增 docs/ 目录与 phase{N} tag 收尾仪式 |
|
||||
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录 |
|
||||
| **v5** | **2026-05-27** | **Phase 4a 升级为"对话式 TPS"(对齐 project_plan v5):模块 A/C/G 合成左对话右报告形态;补硬门槛(数据灌入 + Phase 3 朴素检索先行);加 AI 成本机制落地说明** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# backlog.md — TPS 工作台待办池(不丢视野,非排期)
|
||||
|
||||
> 维护人:刘通 + 顾问 Opus
|
||||
> 用途:记录"暂不做但不能漏"的事项。区别于 phase 计划,这里是池子,
|
||||
> 每个 Phase 收尾时回看一遍,决定捞哪条进下个 Phase。
|
||||
|
||||
---
|
||||
|
||||
## A. 定位澄清(认知,非待办)
|
||||
|
||||
- 本系统对外称"中台/工作台",实质是**单栏目业务系统**(一条业务线、8 用户)。
|
||||
- 不套真中台那套(多租户/服务网格/配置中心/跨线统一权限)——对 8 人是纯负担。
|
||||
- 真正定位:把散在 Excel/Word/微信/个人电脑/师徒口传的工作流,收拢成
|
||||
有沉淀、有权限、可检索的系统。核心价值 = 经验从"靠人"变"靠系统"。
|
||||
- 现有克制选择(不用 TS / 不用 Alembic / pgvector 不用独立向量库 /
|
||||
Session 不用 JWT)方向正确,保持。
|
||||
|
||||
---
|
||||
|
||||
## B. 工作台核心能力缺口(按优先级)
|
||||
|
||||
### B1. 选题状态流转【优先级:高】
|
||||
- 现状:episodes 存收视+元数据,但"提报→立项→排期→拍摄→制作→播出→复盘"
|
||||
这条状态线是散的。
|
||||
- 建议:给 topics/episodes 加状态机字段,制片人一眼看到每个选题卡在哪环。
|
||||
- 与模块 F 甘特图区别:甘特是排期视角,这是业务状态视角,两回事。
|
||||
- 触发时机:Phase 4b(TPS 闭环时一并补最自然)。
|
||||
|
||||
### B2. 操作留痕 / 审计【优先级:高,成本极低,建议早埋】
|
||||
- 现状:谁改收视、谁删选题、谁换题图,全靠信任,无记录。
|
||||
- 建议:核心业务表(episodes/topics/yearly_targets)加
|
||||
created_by / updated_by / updated_at 字段即可,不必做复杂日志系统。
|
||||
- 触发时机:越早埋越好,建议 Phase 3 起手时顺带加(改表成本最低)。
|
||||
|
||||
### B3. 数据导出 / 备份【优先级:中】
|
||||
- 现状:Phase 2 做了 Excel 导入,导出是它的镜像,迟早要。
|
||||
- 收视、选题是栏目资产,将来要能导出 Excel、能定期备份。
|
||||
- 触发时机:Phase 5 上线前后。
|
||||
|
||||
### B4. 通知 / 待办提醒【优先级:低】
|
||||
- "选题被审了""该交稿了"这类。小团队可能微信/Mattermost 群就解决了。
|
||||
|
||||
- 若 B 见下方与 Mattermost 打通,可借 IM 发通知,不必自建。
|
||||
|
||||
- 触发时机:看实际使用反馈再定,可能不做。
|
||||
|
||||
### B5.关联【优先级:低】
|
||||
|
||||
题图"自动算近5期最高份额提示"本期做的是静态文案占位, 自动计算挪二期(原计划)。
|
||||
|
||||
---
|
||||
|
||||
## C. 跨系统整合(与现有其他系统合服务器、打通)
|
||||
|
||||
### C1. 蓝皓配音 TTS 2.0 合服务器【待商议】
|
||||
- 背景:TTS 2.0 现为独立网站/服务器,单租服务器费钱。
|
||||
- 设想:与 TPS 中台挪到同一台服务器,省一份租金。
|
||||
- 待办:制片人把 TTS 2.0 源代码给 Opus → 一起评估
|
||||
①技术栈是否冲突(端口/依赖/数据库)②Nginx 反代能否共存
|
||||
③资源够不够(4核8G 轻量服务器跑两套+PG+pgvector 要算内存账)。
|
||||
- 注:dev_plan 部署章已写"Nginx 反代整合 TPS / 蓝皓 TTS / MMIM",本条是落实。
|
||||
- 触发时机:Phase 5 部署规划时。
|
||||
|
||||
### C2. Mattermost(内部 IM) 打通【待商议】
|
||||
- 背景:团队内部 IM 用 Mattermost,部署在与 TTS 2.0 同一服务器。
|
||||
- 设想:TPS 的通知/待办(见 B4)可推到 Mattermost,不必自建通知系统。
|
||||
- 待办:评估 Mattermost incoming webhook 接入成本(通常很低)。
|
||||
- 触发时机:B4 真要做时一并考虑。
|
||||
|
||||
---
|
||||
|
||||
## D. 已知技术隐患(从 phase log 汇集,不漏)
|
||||
|
||||
- backend/ 补 venv(全局依赖搬进去)。
|
||||
- 隐患 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 给方向不给答案"。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 项目:TPS 中台
|
||||
> 仓库:`tps-dashboard`(单位 Gitea)
|
||||
> 文档版本:v1 · 2026-05-06
|
||||
> 文档版本:v2 · 2026-05-14
|
||||
> 受众:**制片人(不会 Git)** + Cline(自动遵守此规则)
|
||||
|
||||
---
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
### 1.1 三个核心概念,一分钟搞定
|
||||
|
||||
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `D:\TPS-中台\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
|
||||
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `E:\tps-dashboard\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
|
||||
|
||||
**commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。
|
||||
|
||||
@@ -71,7 +71,7 @@ git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
||||
|
||||
```powershell
|
||||
# 切换到你想放项目的位置
|
||||
cd Z:\AIworks\
|
||||
cd E:\tps-dashboard\
|
||||
|
||||
# 把刚才复制的 clone 命令粘贴运行
|
||||
git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
||||
@@ -88,12 +88,12 @@ done.
|
||||
|
||||
`warning: empty repository` 是**正常的**——本来就是空仓库,不是错误。
|
||||
|
||||
完成后 `Z:\AIworks\` 下多了一个 `tps-dashboard` 文件夹。
|
||||
完成后 `E:\tps-dashboard\` 下多了一个 `tps-dashboard` 文件夹。
|
||||
|
||||
### 2.3 进仓库目录
|
||||
|
||||
```powershell
|
||||
cd Z:\AIworks\tps-dashboard
|
||||
cd E:\tps-dashboard\
|
||||
```
|
||||
|
||||
之后所有 git 命令都在这个目录里跑。
|
||||
@@ -104,17 +104,17 @@ cd Z:\AIworks\tps-dashboard
|
||||
|
||||
**手动操作**(用 Windows 资源管理器):
|
||||
|
||||
1. 在 `Z:\AIworks\tps-dashboard\` 下:
|
||||
1. 在 `E:\tps-dashboard\` 下:
|
||||
- 直接拷入 `.clinerules`、`project_plan.md`、`dev_plan.md`、`README.md`(平铺四份)
|
||||
|
||||
2. 在 `Z:\AIworks\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
|
||||
2. 在 `E:\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
|
||||
|
||||
3. 在 `Z:\AIworks\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
|
||||
3. 在 `E:\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
|
||||
|
||||
**最终结构应该是**:
|
||||
|
||||
```
|
||||
Z:\AIworks\tps-dashboard\
|
||||
E:\tps-dashboard\
|
||||
├── .clinerules
|
||||
├── project_plan.md
|
||||
├── dev_plan.md
|
||||
@@ -132,7 +132,7 @@ Z:\AIworks\tps-dashboard\
|
||||
**操作**:在 VS Code 终端里执行:
|
||||
|
||||
```powershell
|
||||
cd Z:\AIworks\tps-dashboard
|
||||
cd E:\tps-dashboard\
|
||||
notepad .gitignore
|
||||
```
|
||||
|
||||
@@ -244,7 +244,7 @@ branch 'main' set up to track 'origin/main'.
|
||||
### 3.1 每天开工第一件事
|
||||
|
||||
```powershell
|
||||
cd Z:\AIworks\tps-dashboard
|
||||
cd E:\tps-dashboard\
|
||||
git status # 看看本地有没有未提交的改动
|
||||
git pull # 拉 Gitea 最新(如果别的机器推过)
|
||||
```
|
||||
@@ -259,16 +259,16 @@ git status # 看看有没有未提交的
|
||||
|
||||
### 3.3 让 Cline 帮你提交
|
||||
|
||||
最方便的方式——**让 Sonnet 在 Plan 里下命令**:
|
||||
最方便的方式——**让 MiniMax M2.7 在 Plan 里下命令**:
|
||||
|
||||
> "现在改完了,请帮我 commit + push,commit message 写: feat: 添加 XXX 功能"
|
||||
|
||||
Cline(Sonnet → DeepSeek)会自动跑 git add / git commit / git push。**这是默认推荐方式**。
|
||||
Cline(MiniMax M2.7)会自动跑 git add / git commit / git push。**这是默认推荐方式**。
|
||||
|
||||
### 3.4 你自己手工提交(备选)
|
||||
|
||||
```powershell
|
||||
cd Z:\AIworks\tps-dashboard
|
||||
cd E:\tps-dashboard\
|
||||
git add -A
|
||||
git commit -m "fix: 修复登录页跳转问题"
|
||||
git push
|
||||
@@ -386,7 +386,7 @@ git push origin phase1-complete
|
||||
### Q5:换了一台机器,怎么把代码搞过来
|
||||
|
||||
```powershell
|
||||
cd Z:\AIworks\tps-dashboard
|
||||
cd E:\tps-dashboard\
|
||||
git clone http://localhost:3000/simonkoson/tps-dashboard.git
|
||||
cd tps-dashboard
|
||||
# 然后跟原来一样开工就行
|
||||
@@ -404,9 +404,17 @@ git reset HEAD~1 # 撤回最近一次 commit,文件改动保留
|
||||
|
||||
---
|
||||
|
||||
## 第 7 章:多设备同步(家用机 ↔ 单位机)
|
||||
## 第 7 章:多设备同步(已废弃)
|
||||
|
||||
### 离开 A 机器前
|
||||
> ⚠️ **2026-05-14 更新**:家用机已于 2026-05-14 退役,项目统一在单位 4090D 开发。
|
||||
> 本章描述的多设备同步模式**已废弃**,保留仅供历史参考。
|
||||
> 当前工作流:单一开发机,每日开工 `git pull`,离开前 `git push`。
|
||||
|
||||
---
|
||||
|
||||
### 7.1 历史模式(已废弃)
|
||||
|
||||
#### 离开 A 机器前
|
||||
|
||||
```powershell
|
||||
git status # 看有没有未提交的
|
||||
@@ -432,6 +440,7 @@ git status # 确认干净
|
||||
| 版本 | 日期 | 变化 |
|
||||
|---|---|---|
|
||||
| v1 | 2026-05-06 | 初版 |
|
||||
| **v2** | **2026-05-14** | **项目目录从 NAS(Z:\AIworks\)迁移至本地 SSD(E:\tps-dashboard\);家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||