Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+24
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 项目:TPS 中台(央视《军事科技》栏目内部工作台)
|
> 项目:TPS 中台(央视《军事科技》栏目内部工作台)
|
||||||
> 仓库:`tps-dashboard`(Gitea,团队可见)
|
> 仓库:`tps-dashboard`(Gitea,团队可见)
|
||||||
> 修订版本:v4 · 2026-05-14
|
> 修订版本:v5 · 2026-05-15
|
||||||
> 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读
|
> 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
| 角色 | 模型 / 工具 | 职责 |
|
| 角色 | 模型 / 工具 | 职责 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 制片人 | 刘通(人) | 拍板、审核、领域知识权威、视觉把关 |
|
| 制片人 | 刘通(人) | 拍板、审核、领域权威、视觉把关 |
|
||||||
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 |
|
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 |
|
||||||
| 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
|
| 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
|
||||||
| 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 |
|
| 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 |
|
||||||
@@ -110,7 +110,7 @@ Phase 内的子任务、修复独立 bug、前端骨架、后端骨架——都
|
|||||||
|
|
||||||
### 5.2 年度目标只增不改(时间不变性)
|
### 5.2 年度目标只增不改(时间不变性)
|
||||||
|
|
||||||
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**。
|
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**:
|
||||||
|
|
||||||
- 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标"
|
- 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标"
|
||||||
- 2024 年播的节目永远按 2024 年的 0.6448 / 0.8989 判,2026 年回看也不会变色
|
- 2024 年播的节目永远按 2024 年的 0.6448 / 0.8989 判,2026 年回看也不会变色
|
||||||
@@ -124,7 +124,7 @@ SQL 端:`yearly_targets.year` 加 UNIQUE 约束,代码端 INSERT 时用 `ON CONF
|
|||||||
|
|
||||||
- **在职编导**:两字段都填(冗余但稳)
|
- **在职编导**:两字段都填(冗余但稳)
|
||||||
- **离职编导**:`editor_id` 留空(NULL),只填 `editor_name_snapshot`(如"张颖")
|
- **离职编导**:`editor_id` 留空(NULL),只填 `editor_name_snapshot`(如"张颖")
|
||||||
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照
|
- **历史导入**:按姓名匹配在职用户,匹配不上只填快照,不报错不丢数据
|
||||||
- **查询某编导历史作品**:**先按 editor_id,失败回退按 editor_name_snapshot 文本匹配**
|
- **查询某编导历史作品**:**先按 editor_id,失败回退按 editor_name_snapshot 文本匹配**
|
||||||
|
|
||||||
DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。
|
DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。
|
||||||
@@ -147,6 +147,11 @@ AI Prompt 模板必须默认走双段、备选纯叙述。docxtpl 模板设计
|
|||||||
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
|
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
|
||||||
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
|
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
|
||||||
|
|
||||||
|
**yearly_targets 写权限收紧(2026-05-20)**:
|
||||||
|
- POST/PATCH: zhipianren + zebian 可写,biandao 返回 403
|
||||||
|
- DELETE: 仅 zhipianren 可操作,zebian 和 biandao 返回 403
|
||||||
|
- GET: 三角色均可读
|
||||||
|
|
||||||
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
|
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -367,11 +372,25 @@ Cline 内的 Sonnet/DeepSeek 不直接与 Opus 通信,流程如下:
|
|||||||
| v1 | 2026-04 | 初版,定模型分工与栏目业务约定 |
|
| v1 | 2026-04 | 初版,定模型分工与栏目业务约定 |
|
||||||
| v2 | 2026-05-04 | 加技术栈精简版、Phase 拆分 |
|
| v2 | 2026-05-04 | 加技术栈精简版、Phase 拆分 |
|
||||||
| v3 | 2026-05-06 | 加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗 |
|
| v3 | 2026-05-06 | 加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗 |
|
||||||
| **v4** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D(E:\tps-dashboard);Phase 2 起 MiniMax M2.7 正式日常使用;config.py 改为 dotenv 直接加载,pydantic-settings 从 requirements.txt 移除** |
|
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D(E:\tps-dashboard);Phase 2 起 MiniMax M2.7 正式日常使用;config.py 改为 dotenv 直接加载,pydantic-settings 从 requirements.txt 移除 |
|
||||||
|
| **v5** | **2026-05-15** | **合入第十三章"Opus 4.7 起草 task brief 的节制原则";Phase 2 正式开工** |
|
||||||
|
|
||||||
**修订原则**:.clinerules 是宪法不是变更日志,改动应慎重。临时执行细节进 phase{N}_log.md,不进本文件。
|
**修订原则**:.clinerules 是宪法不是变更日志,改动应慎重。临时执行细节进 phase{N}_log.md,不进本文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 十三、Opus 4.7 起草 task brief 的节制原则
|
||||||
|
|
||||||
|
Opus 4.7 给 MiniMax 写 task brief 时遵守:
|
||||||
|
|
||||||
|
- **一屏内为目标**(约 60-80 行,不超过 100 行)
|
||||||
|
- **写四类东西**:任务范围、核心约束、业务/纪律红线、验收清单
|
||||||
|
- **不写四类东西**:代码片段、目录结构细节、变量名、字段命名建议
|
||||||
|
- 把"怎么实现"留给 MiniMax Plan 模式自己长出来,Opus 只审 Plan 不替 Plan
|
||||||
|
- 自检口诀:这条是品味判断还是代码细节?品味判断留,代码细节砍
|
||||||
|
- 例外:业务红线里涉及具体字段名(如 `audience_share` / `editor_id` / `editor_name_snapshot` / `yearly_targets.year`)必须点名,因为写错字段名就是写错业务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*本文件最高优先级。任何冲突以本文件为准。*
|
*本文件最高优先级。任何冲突以本文件为准。*
|
||||||
*下次修订建议时机:Phase 2 末(评估 MiniMax 切换、整理 Phase 2 经验)。*
|
*下次修订建议时机:Phase 2 末(评估 MiniMax 切换、整理 Phase 2 经验)。*
|
||||||
+10
-6
@@ -13,27 +13,31 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
# 环境配置(永远不上传)
|
# çŻĺ˘é
罎(ć°¸čżä¸ä¸äź )
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# 编辑器
|
# çźčžĺ¨
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# 操作系统
|
# ćä˝çłťçť
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# 日志运行产物
|
# ćĽĺżčżčĄäş§çŠ
|
||||||
*.log
|
*.log
|
||||||
cookie.txt
|
cookie.txt
|
||||||
|
cookies*.txt
|
||||||
|
|
||||||
# 临时文件
|
# 临ćśćäťś
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
homePC/
|
homePC/
|
||||||
|
|
||||||
# Syncthing (极空间 NAS 同步工具产物)
|
# Syncthing (ćçŠşé´ NAS ĺćĽĺˇĽĺ
ˇäş§çŠ)
|
||||||
.stignore
|
.stignore
|
||||||
|
|
||||||
|
# ĘýžÝżâą¸ˇÝ / SQL dump(˛ť˝ř°ćąžżâ)
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -68,6 +68,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 角色权限表
|
||||||
|
|
||||||
|
> 数据来源:`.clinerules` 5.5 节 + `project_plan.md` 第四节
|
||||||
|
|
||||||
|
| 资源 / 操作 | zhipianren(制片人) | zebian(责编) | biandao(编导) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **yearly_targets** | | | |
|
||||||
|
| GET 列表/详情 | ✅ | ✅ | ✅ |
|
||||||
|
| POST 新增 | ✅ | ✅ | ❌ 403 |
|
||||||
|
| PATCH 修改 | ✅ | ✅ | ❌ 403 |
|
||||||
|
| DELETE 删除 | ✅ | ❌ 403 | ❌ 403 |
|
||||||
|
| **episodes** | | | |
|
||||||
|
| GET | ✅ | ✅ | ✅ |
|
||||||
|
| POST/PATCH/DELETE | ✅ | ✅ | 受限(见 API 文档) |
|
||||||
|
|
||||||
|
> 规则:.clinerules 5.5 角色枚举锁定拼音值(zhipianren / zebian / biandao),不许新增枚举。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 技术栈速览
|
## 技术栈速览
|
||||||
|
|
||||||
- 前端:React 18 + Vite + Ant Design 5 + Zustand(JavaScript,不用 TS)
|
- 前端:React 18 + Vite + Ant Design 5 + Zustand(JavaScript,不用 TS)
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -7,6 +7,10 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
|
from app.api.episodes import router as episodes_router
|
||||||
|
from app.api.imports import router as imports_router
|
||||||
|
from app.api.users import router as users_router
|
||||||
|
from app.api.yearly_targets import router as yearly_targets_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
||||||
@@ -34,3 +38,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
# ---- 注册路由 ----
|
# ---- 注册路由 ----
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(episodes_router)
|
||||||
|
app.include_router(imports_router)
|
||||||
|
app.include_router(yearly_targets_router)
|
||||||
|
app.include_router(users_router)
|
||||||
|
|||||||
@@ -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,34 @@
|
|||||||
|
"""
|
||||||
|
年度收视目标模型 — SQLModel
|
||||||
|
|
||||||
|
业务规则(.clinerules 5.2 年度目标只增不改):
|
||||||
|
- yearly_targets.year 有 UNIQUE 约束,数据库层已保证
|
||||||
|
- 代码端 INSERT 时只许 ON CONFLICT DO NOTHING,禁止 ON CONFLICT UPDATE
|
||||||
|
- 历史年份目标行绝不可被覆盖或删除
|
||||||
|
- 前端判颜色时按播出年份取对应年的目标,不是当前年份
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import DateTime as SADateTime
|
||||||
|
from sqlalchemy import Integer as SAInteger
|
||||||
|
from sqlalchemy import Numeric as SANumeric
|
||||||
|
from sqlalchemy.sql import func as sa_func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyTarget(SQLModel, table=True):
|
||||||
|
__tablename__ = "yearly_targets"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
year: int = Field(unique=True, index=True)
|
||||||
|
base_target: float # 基础目标
|
||||||
|
stretch_target: float # 摸高目标
|
||||||
|
issued_date: date | None = Field(default=None)
|
||||||
|
notes: str | None = Field(default=None)
|
||||||
|
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
认证相关 Pydantic Schema — 请求 / 响应模型
|
认证相关 Pydantic Schema — 请求 / 响应模型
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
from app.models.user import UserRole
|
from app.models.user import UserRole
|
||||||
|
|
||||||
@@ -21,5 +21,4 @@ class UserResponse(BaseModel):
|
|||||||
profile_text: str | None = None
|
profile_text: str | None = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
节目期次 Schema — 请求 / 响应模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeBase(BaseModel):
|
||||||
|
episode_number: int
|
||||||
|
program_name: str
|
||||||
|
air_date: date
|
||||||
|
editor_id: int | None = None
|
||||||
|
editor_name_snapshot: str
|
||||||
|
audience_share: float | None = None
|
||||||
|
audience_rating: float | None = None
|
||||||
|
is_rerun: bool = False
|
||||||
|
original_episode_id: int | None = None
|
||||||
|
is_time_sensitive: bool = False
|
||||||
|
is_outdoor_shoot: bool = False
|
||||||
|
outdoor_contact_person: str | None = None
|
||||||
|
outdoor_contact_info: str | None = None
|
||||||
|
lead_source: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeCreate(EpisodeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeUpdate(EpisodeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeResponse(EpisodeBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
导入相关 Schema — 请求 / 响应模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ImportError(BaseModel):
|
||||||
|
"""单行失败详情"""
|
||||||
|
row_number: int # Excel 行号(第2行=第1条数据)
|
||||||
|
reason: str # 失败原因
|
||||||
|
raw_data: dict # 原始行数据(字典形式)
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportResult(BaseModel):
|
||||||
|
"""批量导入响应"""
|
||||||
|
batch_id: str # UUID,用于下载失败行 Excel
|
||||||
|
total_rows: int # 总行数
|
||||||
|
success_count: int # 成功数
|
||||||
|
failed_count: int # 失败数
|
||||||
|
errors: list[ImportError] # 失败行列表
|
||||||
|
error_excel_url: Optional[str] = None # 失败行 Excel 下载 URL(无可失败行时可不返回)
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
用户管理 Schema — 管理员 CRUD(仅 zhipianren 可用)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
password: str
|
||||||
|
role: UserRole
|
||||||
|
profile_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
display_name: str | None = None
|
||||||
|
role: UserRole | None = None
|
||||||
|
profile_text: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
role: UserRole
|
||||||
|
profile_text: str | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
年度收视目标 Schema — 请求 / 响应模型
|
||||||
|
|
||||||
|
业务规则:yearly_targets 只增不改,ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyTargetBase(BaseModel):
|
||||||
|
year: int
|
||||||
|
base_target: float
|
||||||
|
stretch_target: float
|
||||||
|
issued_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyTargetCreate(YearlyTargetBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyTargetUpdate(BaseModel):
|
||||||
|
"""PATCH 时允许只传部分字段,year 不可修改(由 API 层 pop 掉)。"""
|
||||||
|
year: int | None = None
|
||||||
|
base_target: float | None = None
|
||||||
|
stretch_target: float | None = None
|
||||||
|
issued_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class YearlyTargetResponse(YearlyTargetBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -9,3 +9,5 @@ itsdangerous==2.2.0
|
|||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
pandas>=2.0.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
@@ -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,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;
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
### 1.1 三个核心概念,一分钟搞定
|
### 1.1 三个核心概念,一分钟搞定
|
||||||
|
|
||||||
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `D:\TPS-中台\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
|
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `E:\tps-dashboard\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
|
||||||
|
|
||||||
**commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。
|
**commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。
|
||||||
|
|
||||||
|
|||||||
+21
-9
@@ -9,7 +9,9 @@ import TPS from './pages/TPS/TPS'
|
|||||||
import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
|
import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
|
||||||
import Doco from './pages/Doco/Doco'
|
import Doco from './pages/Doco/Doco'
|
||||||
import UserManage from './pages/UserManage/UserManage'
|
import UserManage from './pages/UserManage/UserManage'
|
||||||
|
import EditorDesk from './pages/EditorDesk/EditorDesk'
|
||||||
import AuthGuard from './components/AuthGuard/AuthGuard'
|
import AuthGuard from './components/AuthGuard/AuthGuard'
|
||||||
|
import RoleGuard from './components/AuthGuard/RoleGuard'
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
token: {
|
token: {
|
||||||
@@ -25,21 +27,31 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route
|
<Route path="/" element={<AuthGuard />}>
|
||||||
path="/"
|
<Route element={<AppLayout />}>
|
||||||
element={
|
|
||||||
<AuthGuard>
|
|
||||||
<AppLayout />
|
|
||||||
</AuthGuard>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="tps" element={<TPS />} />
|
<Route path="tps" element={<TPS />} />
|
||||||
<Route path="knowledge" element={<KnowledgeBase />} />
|
<Route path="knowledge" element={<KnowledgeBase />} />
|
||||||
<Route path="doco" element={<Doco />} />
|
<Route path="doco" element={<Doco />} />
|
||||||
<Route path="editor-home" element={<EditorHome />} />
|
<Route path="editor-home" element={<EditorHome />} />
|
||||||
<Route path="users" element={<UserManage />} />
|
<Route
|
||||||
|
path="users"
|
||||||
|
element={
|
||||||
|
<RoleGuard roles={['zhipianren']}>
|
||||||
|
<UserManage />
|
||||||
|
</RoleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="editor-desk"
|
||||||
|
element={
|
||||||
|
<RoleGuard roles={['zhipianren', 'zebian']}>
|
||||||
|
<EditorDesk />
|
||||||
|
</RoleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,15 +5,12 @@
|
|||||||
.app-sider {
|
.app-sider {
|
||||||
background: #fff !important;
|
background: #fff !important;
|
||||||
border-right: 1px solid #f0f0f0;
|
border-right: 1px solid #f0f0f0;
|
||||||
position: fixed !important;
|
width: 220px;
|
||||||
left: 0;
|
flex: 0 0 220px;
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
margin-left: 220px;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +8,38 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
|
||||||
const menuItems = [
|
function SideNav() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
const allMenuItems = [
|
||||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
|
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
|
||||||
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
|
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
|
||||||
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
|
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
|
||||||
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
|
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
|
||||||
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
|
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
|
||||||
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function SideNav() {
|
// 按角色过滤菜单项
|
||||||
const navigate = useNavigate()
|
// user.role 值来自后端 /api/auth/me,应为字符串 'zhipianren' / 'zebian' / 'biandao'
|
||||||
const location = useLocation()
|
const visibleItems = allMenuItems.filter(item => {
|
||||||
|
// /dashboard 三角色都可见
|
||||||
|
if (item.key === '/dashboard') return true
|
||||||
|
// /users 仅 zhipianren
|
||||||
|
if (item.key === '/users') return String(user?.role) === 'zhipianren'
|
||||||
|
// /editor-desk 仅 zhipianren + zebian
|
||||||
|
if (item.key === '/editor-desk') {
|
||||||
|
const role = String(user?.role)
|
||||||
|
return role === 'zhipianren' || role === 'zebian'
|
||||||
|
}
|
||||||
|
// 其余三角色都可见(保留后期 Phase 入口)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="side-nav">
|
<div className="side-nav">
|
||||||
@@ -31,7 +50,7 @@ function SideNav() {
|
|||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[location.pathname]}
|
||||||
items={menuItems}
|
items={visibleItems}
|
||||||
onClick={({ key }) => navigate(key)}
|
onClick={({ key }) => navigate(key)}
|
||||||
className="side-nav-menu"
|
className="side-nav-menu"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,6 +34,33 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--color-primary-green);
|
color: var(--color-primary-green);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-placeholder-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, #6b8e6b 0%, #a8c89a 50%, #d4e6b5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-cover-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 2;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI Cards */
|
/* KPI Cards */
|
||||||
|
|||||||
@@ -1,32 +1,35 @@
|
|||||||
import { Row, Col, Card, Avatar, Tooltip } from 'antd'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Row, Col, Card, Avatar, Tooltip, Button } from 'antd'
|
||||||
import {
|
import {
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
|
PictureOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
import { listEpisodes } from '../../services/episodeService'
|
||||||
|
import { listTargets } from '../../services/yearlyTargetService'
|
||||||
import './Dashboard.css'
|
import './Dashboard.css'
|
||||||
|
|
||||||
// 假数据:近 9 期收视(基础目标 0.6,摸高目标 0.9)
|
/**
|
||||||
const fakeEpisodes = [
|
* 收视份额颜色判定(极易写反,禁止凭直觉):
|
||||||
{ id: 1, title: '第 1247 期 现代防空反导大对决', editor: 'simonkoson', share: 0.72 },
|
* audience_share > stretch_target → 🔴 红(优秀)
|
||||||
{ id: 2, title: '第 1246 期 深海暗战', editor: 'simonkoson', share: 0.85 },
|
* base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
|
||||||
{ id: 3, title: '第 1245 期 隐形战场', editor: 'simonkoson', share: 0.94 },
|
* audience_share < base_target → 🟢 绿(待提升)
|
||||||
{ id: 4, title: '第 1244 期 无人机革命', editor: 'simonkoson', share: 0.65 },
|
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
||||||
{ id: 5, title: '第 1243 期 核潜艇传奇', editor: 'simonkoson', share: 0.58 },
|
*/
|
||||||
{ id: 6, title: '第 1242 期 电子战风暴', editor: 'simonkoson', share: 0.88 },
|
function getShareColor(share, targets, airYear) {
|
||||||
{ id: 7, title: '第 1241 期 太空博弈', editor: 'simonkoson', share: 0.91 },
|
// 强制转数值,避免后端返回字符串导致数值比较失败
|
||||||
{ id: 8, title: '第 1240 期 装甲对决', editor: 'simonkoson', share: 0.67 },
|
const n = Number(share)
|
||||||
{ id: 9, title: '第 1239 期 隐身战机', editor: 'simonkoson', share: 0.55 },
|
const target = targets.find(t => Number(t.year) === Number(airYear))
|
||||||
]
|
if (!target) return '#999'
|
||||||
|
if (isNaN(n)) return '#999'
|
||||||
const BASE_TARGET = 0.6
|
const stretch = Number(target.stretch_target)
|
||||||
const STRETCH_TARGET = 0.9
|
const base = Number(target.base_target)
|
||||||
|
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
|
||||||
function getBarColor(share) {
|
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
|
||||||
if (share > STRETCH_TARGET) return '#cf1322' // 红=优秀
|
return '#7aa874' // 绿=未达基础目标(待提升)
|
||||||
if (share >= BASE_TARGET) return '#6b8e6b' // 蓝=达标
|
|
||||||
return '#d4a017' // 绿=待提升
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBarHeight(share) {
|
function getBarHeight(share) {
|
||||||
@@ -38,16 +41,63 @@ function getShortTitle(title) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const [episodes, setEpisodes] = useState([])
|
||||||
|
const [targets, setTargets] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
listEpisodes(9),
|
||||||
|
listTargets(),
|
||||||
|
]).then(([epData, tgtData]) => {
|
||||||
|
// 按 air_date 倒序取前 9
|
||||||
|
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)).slice(0, 9)
|
||||||
|
setEpisodes(sorted)
|
||||||
|
setTargets(tgtData || [])
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 显示最近有份额数据的期次(最多9个,不够用真实数据补位)
|
||||||
|
const hasShare = episodes.filter(e => e.audience_share != null)
|
||||||
|
const displayEpisodes = hasShare.length >= 5 ? hasShare.slice(0, 9) : episodes.slice(0, Math.max(9, hasShare.length || 5))
|
||||||
|
|
||||||
|
const bestEpisode = [...displayEpisodes].filter(e => e.audience_share != null).sort((a, b) => b.audience_share - a.audience_share)[0]
|
||||||
|
|
||||||
|
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
{/* 顶部 Banner */}
|
{/* 顶部 Banner */}
|
||||||
<div className="dashboard-banner">
|
<div className="dashboard-banner">
|
||||||
<div className="banner-text">
|
<div className="banner-text">
|
||||||
<h2>本月收视最佳</h2>
|
<h2>本月收视最佳</h2>
|
||||||
<p>第 1245 期 隐形战场 · 收视份额 0.94</p>
|
{bestEpisode ? (
|
||||||
|
<p>
|
||||||
|
第 {bestEpisode.episode_number} 期 {bestEpisode.program_name} ·
|
||||||
|
收视份额 {bestEpisode.audience_share}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>暂无收视数据</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 题图渐变占位 */}
|
||||||
<div className="banner-image-placeholder">
|
<div className="banner-image-placeholder">
|
||||||
<span>题图位 · 待 Phase 2 实装</span>
|
<div className="banner-placeholder-gradient" />
|
||||||
|
{showChangeCoverBtn && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
disabled
|
||||||
|
className="change-cover-btn"
|
||||||
|
>
|
||||||
|
更换题图
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="placeholder-label">敬请期待</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,8 +109,8 @@ function Dashboard() {
|
|||||||
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} />
|
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="kpi-info">
|
<div className="kpi-info">
|
||||||
<span className="kpi-value">0.82</span>
|
<span className="kpi-value">{displayEpisodes.filter(e => e.audience_share).length || '--'}</span>
|
||||||
<span className="kpi-label">本期平均收视</span>
|
<span className="kpi-label">近 9 期已录</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -70,8 +120,10 @@ function Dashboard() {
|
|||||||
<FireOutlined style={{ color: '#ff9800', fontSize: 24 }} />
|
<FireOutlined style={{ color: '#ff9800', fontSize: 24 }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="kpi-info">
|
<div className="kpi-info">
|
||||||
<span className="kpi-value">98.6%</span>
|
<span className="kpi-value">
|
||||||
<span className="kpi-label">完成率</span>
|
{bestEpisode ? (bestEpisode.audience_share * 100).toFixed(1) + '%' : '--'}
|
||||||
|
</span>
|
||||||
|
<span className="kpi-label">最佳收视份额</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -81,8 +133,8 @@ function Dashboard() {
|
|||||||
<CalendarOutlined style={{ color: '#2196f3', fontSize: 24 }} />
|
<CalendarOutlined style={{ color: '#2196f3', fontSize: 24 }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="kpi-info">
|
<div className="kpi-info">
|
||||||
<span className="kpi-value">6</span>
|
<span className="kpi-value">{targets.length || '--'}</span>
|
||||||
<span className="kpi-label">未来待排期</span>
|
<span className="kpi-label">年度目标条数</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -95,40 +147,48 @@ function Dashboard() {
|
|||||||
<Card className="content-card" title="热点雷达">
|
<Card className="content-card" title="热点雷达">
|
||||||
<div className="placeholder-text">
|
<div className="placeholder-text">
|
||||||
<BarChartOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
<BarChartOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
||||||
<p>模块 H · Phase 4c 实施</p>
|
<p>热点雷达 · Phase 4c</p>
|
||||||
<small>热点雷达将在 Phase 4c 开发,作为编导个人首页的核心功能</small>
|
<small>编导个人首页核心功能,Phase 4c 实施</small>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* 近 9 期收视柱图 */}
|
{/* 近 9 期收视柱图 */}
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Card className="content-card" title="近 9 期收视">
|
<Card className="content-card" title="近 9 期收视" loading={loading}>
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<div className="bars-wrapper">
|
<div className="bars-wrapper">
|
||||||
{fakeEpisodes.map((ep) => (
|
{displayEpisodes.map((ep) => {
|
||||||
|
const airYear = new Date(ep.air_date).getFullYear()
|
||||||
|
const color = ep.audience_share != null
|
||||||
|
? getShareColor(ep.audience_share, targets, airYear)
|
||||||
|
: '#ccc'
|
||||||
|
return (
|
||||||
<div key={ep.id} className="bar-item">
|
<div key={ep.id} className="bar-item">
|
||||||
<Tooltip title={`${ep.title} · ${ep.share}`}>
|
<Tooltip title={`${ep.program_name} · ${ep.audience_share ?? '无数据'}`}>
|
||||||
<div
|
<div
|
||||||
className="bar"
|
className="bar"
|
||||||
style={{
|
style={{
|
||||||
height: getBarHeight(ep.share),
|
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
|
||||||
backgroundColor: getBarColor(ep.share),
|
backgroundColor: color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="bar-label-top">{getShortTitle(ep.title)}</div>
|
<div className="bar-label-top">{getShortTitle(ep.program_name)}</div>
|
||||||
<div className="bar-label-bottom">
|
<div className="bar-label-bottom">
|
||||||
<Avatar size={20} style={{ fontSize: 10 }}>{ep.editor[0]}</Avatar>
|
<Avatar size={20} style={{ fontSize: 10 }}>
|
||||||
<span>{ep.editor}</span>
|
{(ep.editor_name_snapshot || '?')[0]}
|
||||||
|
</Avatar>
|
||||||
|
<span>{ep.editor_name_snapshot || '未知'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-legend">
|
<div className="chart-legend">
|
||||||
<span className="legend-item"><span className="dot" style={{ background: '#cf1322' }}></span>红=优秀</span>
|
<span className="legend-item"><span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标(待提升)</span>
|
||||||
<span className="legend-item"><span className="dot" style={{ background: '#6b8e6b' }}></span>蓝=达标</span>
|
<span className="legend-item"><span className="dot" style={{ background: '#5b8db8' }}></span>蓝=未达摸高目标(达标)</span>
|
||||||
<span className="legend-item"><span className="dot" style={{ background: '#d4a017' }}></span>绿=待提升</span>
|
<span className="legend-item"><span className="dot" style={{ background: '#c0584f' }}></span>红=超过摸高目标(优秀)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -136,11 +196,11 @@ function Dashboard() {
|
|||||||
|
|
||||||
{/* 排播计划 */}
|
{/* 排播计划 */}
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Card className="content-card" title="排播计划">
|
<Card className="content-card" title="未来排播计划">
|
||||||
<div className="placeholder-text">
|
<div className="placeholder-text">
|
||||||
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
||||||
<p>模块 F · Phase 4b 实施</p>
|
<p>排播计划 · Phase 4b</p>
|
||||||
<small>甘特图排期将在 Phase 4b 开发,使用 frappe-gantt 实现拖拽排期</small>
|
<small>甘特图排期使用 frappe-gantt 实现,Phase 4b 开发</small>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Tabs, Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Space, Upload, Popconfirm } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
|
import { listEpisodes, createEpisode, updateEpisode, deleteEpisode } from '../../services/episodeService'
|
||||||
|
import { listTargets, createTarget, updateTarget, deleteTarget } from '../../services/yearlyTargetService'
|
||||||
|
import http from '../../services/http'
|
||||||
|
|
||||||
|
const { TabPane } = Tabs
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab1: 节目期次
|
||||||
|
// ============================================================
|
||||||
|
function EpisodesTab() {
|
||||||
|
const [episodes, setEpisodes] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEpisodes()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadEpisodes = () => {
|
||||||
|
setLoading(true)
|
||||||
|
listEpisodes().then(data => {
|
||||||
|
setEpisodes((data || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)))
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('加载节目列表失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditing(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
episode_number: record.episode_number,
|
||||||
|
program_name: record.program_name,
|
||||||
|
air_date: record.air_date ? record.air_date : null,
|
||||||
|
editor_name_snapshot: record.editor_name_snapshot || '',
|
||||||
|
audience_share: record.audience_share,
|
||||||
|
audience_rating: record.audience_rating,
|
||||||
|
is_rerun: record.is_rerun,
|
||||||
|
notes: record.notes,
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
deleteEpisode(id).then(() => {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadEpisodes()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '删除失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
const payload = {
|
||||||
|
...values,
|
||||||
|
air_date: values.air_date ? values.air_date.format('YYYY-MM-DD') : null,
|
||||||
|
}
|
||||||
|
const promise = editing
|
||||||
|
? updateEpisode(editing.id, payload)
|
||||||
|
: createEpisode(payload)
|
||||||
|
promise.then(() => {
|
||||||
|
message.success(editing ? '更新成功' : '创建成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadEpisodes()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '操作失败')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '期次号', dataIndex: 'episode_number', key: 'episode_number', width: 80 },
|
||||||
|
{ title: '节目名', dataIndex: 'program_name', key: 'program_name' },
|
||||||
|
{ title: '播出日期', dataIndex: 'air_date', key: 'air_date', render: d => d || '-' },
|
||||||
|
{ title: '编导', dataIndex: 'editor_name_snapshot', key: 'editor_name_snapshot', render: v => v || '-' },
|
||||||
|
{ title: '收视份额', dataIndex: 'audience_share', key: 'audience_share', render: v => v != null ? v.toFixed(4) : '-' },
|
||||||
|
{ title: '收视率', dataIndex: 'audience_rating', key: 'audience_rating', render: v => v != null ? v.toFixed(2) : '-' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增期次</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={episodes} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} />
|
||||||
|
<Modal title={editing ? '编辑期次' : '新增期次'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存" width={520}>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="episode_number" label="期次号" rules={[{ required: true, message: '请输入期次号' }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} placeholder="如 1248" disabled={!!editing} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="program_name" label="节目名称" rules={[{ required: true, message: '请输入节目名称' }]}>
|
||||||
|
<Input placeholder="如《军事科技》武器解析系列" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="air_date" label="播出日期" rules={[{ required: true, message: '请选择播出日期' }]}>
|
||||||
|
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" disabled={!!editing} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="editor_name_snapshot" label="编导姓名(快照)">
|
||||||
|
<Input placeholder="可留空,离职编导只存姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="audience_share" label="收视份额(0-1)">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.72" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="audience_rating" label="收视率">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="3.5" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab2: 年度目标
|
||||||
|
// ============================================================
|
||||||
|
function YearlyTargetsTab() {
|
||||||
|
const [targets, setTargets] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTargets()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTargets = () => {
|
||||||
|
setLoading(true)
|
||||||
|
listTargets().then(data => {
|
||||||
|
setTargets((data || []).sort((a, b) => b.year - a.year))
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('加载年度目标失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditing(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditing(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
year: record.year,
|
||||||
|
base_target: record.base_target,
|
||||||
|
stretch_target: record.stretch_target,
|
||||||
|
notes: record.notes,
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
deleteTarget(id).then(() => {
|
||||||
|
message.success('删除成功')
|
||||||
|
loadTargets()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '删除失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
const promise = editing
|
||||||
|
? updateTarget(editing.id, values)
|
||||||
|
: createTarget(values)
|
||||||
|
promise.then(() => {
|
||||||
|
message.success(editing ? '更新成功' : '创建成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadTargets()
|
||||||
|
}).catch(err => {
|
||||||
|
const detail = err.response?.data?.detail || ''
|
||||||
|
if (detail.includes('已存在') || detail.includes('历史年份')) {
|
||||||
|
message.error('该年目标已存在,历史年份不可覆盖')
|
||||||
|
} else {
|
||||||
|
message.error(detail || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '年份', dataIndex: 'year', key: 'year', width: 80 },
|
||||||
|
{ title: '基础目标', dataIndex: 'base_target', key: 'base_target', render: v => v?.toFixed(4) || '-' },
|
||||||
|
{ title: '摸高目标', dataIndex: 'stretch_target', key: 'stretch_target', render: v => v?.toFixed(4) || '-' },
|
||||||
|
{ title: '备注', dataIndex: 'notes', key: 'notes', render: v => v || '-' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增年度目标</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={targets} rowKey="id" loading={loading} pagination={false} />
|
||||||
|
<Modal title={editing ? '编辑年度目标' : '新增年度目标'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存">
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="year"
|
||||||
|
label="年份"
|
||||||
|
rules={[{ required: true, message: '请输入年份' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="如 2024"
|
||||||
|
min={2000}
|
||||||
|
max={2100}
|
||||||
|
disabled={!!editing}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="base_target" label="基础目标" rules={[{ required: true, message: '请输入基础目标' }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.6448" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="stretch_target" label="摸高目标" rules={[{ required: true, message: '请输入摸高目标' }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.8989" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tab3: Excel 批量导入
|
||||||
|
// ============================================================
|
||||||
|
function ImportTab() {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [result, setResult] = useState(null)
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
window.open('/api/imports/template', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadProps = {
|
||||||
|
name: 'file',
|
||||||
|
accept: '.xlsx',
|
||||||
|
showUploadList: false,
|
||||||
|
beforeUpload: (file) => {
|
||||||
|
if (!file.filename.endsWith('.xlsx') && !file.name.endsWith('.xlsx')) {
|
||||||
|
message.error('仅支持 .xlsx 格式')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
http.post('/imports/episodes', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then(res => {
|
||||||
|
setResult(res.data)
|
||||||
|
setUploading(false)
|
||||||
|
if (res.data.errors?.length) {
|
||||||
|
message.warning(`导入完成:成功 ${res.data.imported} 条,失败 ${res.data.errors.length} 条`)
|
||||||
|
} else {
|
||||||
|
message.success(`导入成功:${res.data.imported} 条`)
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
setUploading(false)
|
||||||
|
message.error(err.response?.data?.detail || '上传失败')
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadErrors = () => {
|
||||||
|
if (!result?.batch_id) return
|
||||||
|
window.open(`/api/imports/errors/${result.batch_id}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>下载导入模板</Button>
|
||||||
|
<Upload {...uploadProps}>
|
||||||
|
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
|
||||||
|
{uploading ? '上传中...' : '选择 Excel 上传'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div style={{ background: '#f6f8fa', padding: 16, borderRadius: 8 }}>
|
||||||
|
<p style={{ margin: '0 0 8px', fontWeight: 600 }}>导入结果</p>
|
||||||
|
<p style={{ margin: 0 }}>总计:{result.total} 条</p>
|
||||||
|
<p style={{ margin: '4px 0', color: '#52c41a' }}>成功:{result.imported} 条</p>
|
||||||
|
{result.errors?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '4px 0', color: '#cf1322' }}>失败:{result.errors.length} 条</p>
|
||||||
|
<Button size="small" onClick={downloadErrors} style={{ marginTop: 8 }}>
|
||||||
|
下载失败行
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, color: '#999', fontSize: 12 }}>
|
||||||
|
<p>支持批量导入节目期次,导入失败时可根据提示修正后重新上传。</p>
|
||||||
|
<p>注意:Phase 2 不支持重播行导入,有重播行请留空。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主组件
|
||||||
|
// ============================================================
|
||||||
|
function EditorDesk() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 0 24px' }}>
|
||||||
|
<h2 style={{ margin: '0 0 16px', fontSize: 20, fontWeight: 600 }}>责编录入</h2>
|
||||||
|
<Tabs defaultActiveKey="episodes">
|
||||||
|
<TabPane tab="节目期次" key="episodes">
|
||||||
|
<EpisodesTab />
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="年度目标" key="targets">
|
||||||
|
<YearlyTargetsTab />
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="Excel 批量导入" key="import">
|
||||||
|
<ImportTab />
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditorDesk
|
||||||
@@ -1,16 +1,186 @@
|
|||||||
import { Card } from 'antd'
|
import { useState, useEffect } from 'react'
|
||||||
import { TeamOutlined } from '@ant-design/icons'
|
import { Table, Button, Modal, Form, Input, Select, Switch, message, Space } from 'antd'
|
||||||
|
import { UserAddOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'
|
||||||
|
import { listUsers, createUser, updateUser, deactivateUser } from '../../services/userService'
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'zhipianren', label: '制片人' },
|
||||||
|
{ value: 'zebian', label: '责编' },
|
||||||
|
{ value: 'biandao', label: '编导' },
|
||||||
|
]
|
||||||
|
|
||||||
function UserManage() {
|
function UserManage() {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadUsers = () => {
|
||||||
|
setLoading(true)
|
||||||
|
listUsers().then(data => {
|
||||||
|
setUsers(data || [])
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => {
|
||||||
|
message.error('加载用户列表失败')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingUser(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditingUser(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
display_name: record.display_name,
|
||||||
|
role: record.role,
|
||||||
|
profile_text: record.profile_text,
|
||||||
|
is_active: record.is_active,
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = (record) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '停用用户',
|
||||||
|
content: `确认停用用户「${record.display_name}」(${record.username})?停用后可重新启用。`,
|
||||||
|
okText: '确认停用',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => deactivateUser(record.id).then(() => {
|
||||||
|
message.success('已停用')
|
||||||
|
loadUsers()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '停用失败')
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
if (editingUser) {
|
||||||
|
updateUser(editingUser.id, values).then(() => {
|
||||||
|
message.success('更新成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadUsers()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '更新失败')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
createUser(values).then(() => {
|
||||||
|
message.success('创建成功')
|
||||||
|
setModalVisible(false)
|
||||||
|
loadUsers()
|
||||||
|
}).catch(err => {
|
||||||
|
message.error(err.response?.data?.detail || '创建失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '用户名',
|
||||||
|
dataIndex: 'username',
|
||||||
|
key: 'username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '显示名',
|
||||||
|
dataIndex: 'display_name',
|
||||||
|
key: 'display_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
key: 'role',
|
||||||
|
render: role => roleOptions.find(o => o.value === role)?.label || role,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'is_active',
|
||||||
|
key: 'is_active',
|
||||||
|
render: (active, record) => (
|
||||||
|
<Switch
|
||||||
|
checked={active}
|
||||||
|
onChange={() => handleDeactivate(record)}
|
||||||
|
checkedChildren="在职"
|
||||||
|
unCheckedChildren="已停"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{record.is_active && (
|
||||||
|
<Button size="small" danger icon={<StopOutlined />} onClick={() => handleDeactivate(record)}>
|
||||||
|
停用
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ borderRadius: 16 }}>
|
<div style={{ padding: '0 0 24px' }}>
|
||||||
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<TeamOutlined style={{ fontSize: 48, marginBottom: 16 }} />
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>用户管理</h2>
|
||||||
<h3 style={{ color: '#666' }}>用户管理</h3>
|
<Button type="primary" icon={<UserAddOutlined />} onClick={handleCreate}>
|
||||||
<p>Phase 2 实施</p>
|
新建用户
|
||||||
<small>管理员创建账号 / 角色分配(仅 zhipianren 可见)</small>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingUser ? '编辑用户' : '新建用户'}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
{!editingUser && (
|
||||||
|
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input placeholder="登录用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item name="display_name" label="显示名" rules={[{ required: true, message: '请输入显示名' }]}>
|
||||||
|
<Input placeholder="界面显示名称" />
|
||||||
|
</Form.Item>
|
||||||
|
{!editingUser && (
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="初始密码" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
|
||||||
|
<Select placeholder="选择角色" options={roleOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="profile_text" label="个人简介">
|
||||||
|
<Input.TextArea rows={3} placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ export async function me() {
|
|||||||
const response = await http.get('/auth/me')
|
const response = await http.get('/auth/me')
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default { login, logout, me }
|
||||||
@@ -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,21 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
export async function listUsers() {
|
||||||
|
const response = await http.get('/users')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data) {
|
||||||
|
const response = await http.post('/users', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id, data) {
|
||||||
|
const response = await http.patch(`/users/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateUser(id) {
|
||||||
|
const response = await http.delete(`/users/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
export async function listTargets() {
|
||||||
|
const response = await http.get('/yearly_targets')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTarget(data) {
|
||||||
|
const response = await http.post('/yearly_targets', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTarget(id, data) {
|
||||||
|
const response = await http.patch(`/yearly_targets/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTarget(id) {
|
||||||
|
const response = await http.delete(`/yearly_targets/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
# Phase 2 开发日志 — 仪表盘真数据 + 用户管理 + Excel 批量导入
|
||||||
|
|
||||||
|
> 《军事科技》栏目内部工作台 · Phase 2
|
||||||
|
> 撰写日期:2026-05-15
|
||||||
|
> 状态:**Task 1 后端 CRUD 完成,冒烟实测 6/6 通过;Task 2 进行中**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、本 Phase 完成时间
|
||||||
|
|
||||||
|
- **开始日期**:2026-05-15
|
||||||
|
- **Task 1 后端 CRUD**:2026-05-15 完成,冒烟 6/6 全绿
|
||||||
|
- **Task 2 Excel 批量导入**:进行中
|
||||||
|
- **Task 3 前端**:待开始
|
||||||
|
- **预计 Phase 2 整体收官**:待定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、本 Phase 做了什么
|
||||||
|
|
||||||
|
### Task 1 — 后端 CRUD(2026-05-15)
|
||||||
|
|
||||||
|
1. **models**:`episode.py`(软引用+快照)、`yearly_target.py`(year UNIQUE)
|
||||||
|
2. **schemas**:`episode.py`、`yearly_target.py`、`user_admin.py`、`auth.py` 顺手改 ConfigDict
|
||||||
|
3. **API**:`users.py`(管理员 CRUD)、`episodes.py`(增删改查)、`yearly_targets.py`(增删改查,新增 DELETE)
|
||||||
|
4. **路由注册**:`main.py` 注册新路由
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Task 1 冒烟实测记录(2026-05-15 Cline 自跑)
|
||||||
|
|
||||||
|
使用 `backend/scripts/smoke_test.py`(幂等版可重复跑),结果:
|
||||||
|
|
||||||
|
| 步骤 | 接口 | 预期 | 实际 | 结果 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| 1 | POST /api/users 新建 zebian | 201 | 201 | ✅ ID=4 |
|
||||||
|
| 2 | POST /api/yearly_targets 新建 2026 | 201 | 201 | ✅ ID=2 |
|
||||||
|
| 3 | POST /api/yearly_targets 重复 2026 | 409 | 409 | ✅ 正确拦截 |
|
||||||
|
| 4 | POST /api/episodes editor_id=null | 201 | 201 | ✅ ID=2 |
|
||||||
|
| 5 | GET /api/episodes 列表 | 200 | 200 | ✅ |
|
||||||
|
| 6 | PATCH /api/yearly_targets 尝试改 year | 200(year 不变) | 200 | ✅ year 仍为 2026 |
|
||||||
|
|
||||||
|
**6/6 全部通过。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、业务红线验证记录
|
||||||
|
|
||||||
|
| 红线 | 代码位置 | 验证结果 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 收视颜色只对 audience_share | `episode.py` 注释 | ✅ 未实现颜色逻辑,仅存字段 |
|
||||||
|
| 年度目标只增不改 year UNIQUE | `yearly_targets.py` create_target + `yearly_target.py` UNIQUE | ✅ 冲突报 409 |
|
||||||
|
| year 字段禁止 UPDATE 修改 | `yearly_targets.py` update_target pop("year") | ✅ 冒烟 Step 6 验证 |
|
||||||
|
| editor_id 可空+editor_name_snapshot 必填 | `episode.py` Field 设计 | ✅ 冒烟 Step 4 editor_id=null 成功 |
|
||||||
|
| 角色枚举全拼音 | `UserRole` Enum + CHECK 约束 | ✅ 三角色全是拼音 |
|
||||||
|
| ON CONFLICT DO NOTHING(非 UPDATE) | `yearly_targets.py` 注释 | ✅ 只增不改 |
|
||||||
|
| password_hash 明文传输开发配置 | `security.py` + `main.py` https_only=False | ⚠️ Phase 5 部署前必改 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、踩过的坑(Phase 2 内部教训)
|
||||||
|
|
||||||
|
### 坑 1:API 文件漏了模型类导入(一类 bug)
|
||||||
|
|
||||||
|
**现象**:Swagger UI 实测 `POST /api/yearly_targets` 报 500,`NameError: name 'YearlyTarget' is not defined`
|
||||||
|
|
||||||
|
**原因**:`api/yearly_targets.py` 和 `api/episodes.py` 用了 `Episode` / `YearlyTarget` 类但顶部漏了 import 语句
|
||||||
|
|
||||||
|
**排查**:同步检查 `api/users.py`、`api/episodes.py`、`api/yearly_targets.py` 三文件,发现 episodes.py 也漏了
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- `episodes.py` 加 `from app.models.episode import Episode`
|
||||||
|
- `yearly_targets.py` 加 `from app.models.yearly_target import YearlyTarget`
|
||||||
|
- `users.py` 删掉未使用的 `verify_password` 导入
|
||||||
|
|
||||||
|
**教训**:新建 API 文件后,**第一件事检查所有模型类是否已 import**,与 Schema import 同等优先级。Task 1 完工报告前未做最小冒烟测试,导致这类问题漏到实测阶段才发现。以后每个 Task Act 完成后必须自跑冒烟,不依赖制片人发现。
|
||||||
|
|
||||||
|
### 坑 2:冒烟脚本 emoji + 中文导致 Windows GBK 崩溃
|
||||||
|
|
||||||
|
**现象**:`UnicodeEncodeError: 'gbk' codec can't encode character '\u274c'`
|
||||||
|
|
||||||
|
**修复**:将 `smoke_test.py` 全部改英文 + ASCII 字符(`✅`→`[OK]`,`❌`→`[FAIL]`)
|
||||||
|
|
||||||
|
### 坑 3:数据不幂等导致重跑失败
|
||||||
|
|
||||||
|
**现象**:第二次跑冒烟,Step 1/2/4 全部 409(数据已存在)
|
||||||
|
|
||||||
|
**修复**:改为幂等版脚本,先清理同名数据再创建
|
||||||
|
|
||||||
|
### 坑 4:pip 默认路由到系统 Python 3.14,绕过项目锁定的 3.12
|
||||||
|
|
||||||
|
**现象**:`pip install pandas openpyxl` 后报错,或运行时报 `ModuleNotFoundError: No module named 'pandas'`(已确认装过)
|
||||||
|
|
||||||
|
**原因**:系统 PATH 中 python 命令指向 Microsoft Store 的 Python 3.14,而项目要求 Python 3.12,导致装到了错误的解释器。`pip show pandas` 发现路径在 AppData/Local/Microsoft/WindowsApps 而非项目 venv。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 确认项目使用 `python -m pip install ...`(明确调项目锁定的 3.12)
|
||||||
|
- 或激活 venv 后 `& .\venv\Scripts\Activate.ps1` 再 pip install
|
||||||
|
- 装完 `pip show pandas` 核对路径包含 `venv` 或项目目录
|
||||||
|
|
||||||
|
**教训**:**任何 pip install 必须验证装到了哪个 Python 解释器**。先用 `where python` 确认 PATH 优先级,用 `python -c "import sys; print(sys.executable)"` 确认实际调用的解释器,再执行安装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 5:002b SQL 表达式索引使用 EXTRACT(...)::int 语法不被 PostgreSQL 接受
|
||||||
|
|
||||||
|
**现象**:`CREATE INDEX idx_episodes_broadcast_date ON episodes (EXTRACT(year FROM broadcast_date)::int);` 在 pgAdmin / psql 中执行报错:`ERROR: syntax error at or near "::"`
|
||||||
|
|
||||||
|
**原因**:PostgreSQL 的 `EXTRACT()` 返回 `double precision` 类型,`::int` 是标准 cast 语法,但某些 PostgreSQL 版本或客户端对表达式索引内 cast 有限制。SQL 在 commit 前未本机干跑直接进库,翻车后才发现。
|
||||||
|
|
||||||
|
**修复**:改为 `date_part('year', broadcast_date)` 语法,等效且兼容性更好:
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_episodes_broadcast_date ON episodes (date_part('year', broadcast_date));
|
||||||
|
```
|
||||||
|
|
||||||
|
**教训**:**SQL 文件 commit 前必须在本机干跑**。即使是看似简单的 DDL,本机跑一遍能提前发现语法兼容性问题,避免入库后才发现翻车。Task 1 NameError(坑 1)与本坑同源,都是"没自测就 commit"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 6:Gitea OAuth refresh token 过期导致 git push 反复 401
|
||||||
|
|
||||||
|
**现象**:`git push` 多次返回 401 Unauthorized,Git 凭证管理器反复弹框,输入正确账密后仍然失败。
|
||||||
|
|
||||||
|
**原因**:Gitea OAuth access token 有效期到期,refresh token 也已失效,导致 Git 凭证无法续期。多次 401 后需重新完成 OAuth 认证流程刷新 token。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 删掉旧 token:Git Bash 执行 `git credential reject` + 重置 credential helper
|
||||||
|
- 重新触发 GitHub/Gitea OAuth 流程获取新 access token
|
||||||
|
- 验证:`git push` 成功后确认 remote URL 使用新的 token
|
||||||
|
|
||||||
|
**教训**:**push 不成功不算完工**。本次 Task 2 期间 push 已全部恢复正常,之前的 git push 条目从"认证失败,需制片人手动"已转为✅完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、纪律警告(Phase 2 Task 2 期间立)
|
||||||
|
|
||||||
|
### 纪律 1:"push 待制片人手动处理"不算完工
|
||||||
|
|
||||||
|
**背景**:Task 1 期间 MiniMax 多次以"push 待手动处理"作为完工结尾。本 chat 期间明确规矩。
|
||||||
|
|
||||||
|
**教训**:push 是 Git 工作流最后一环,未 push 就不是真正的完工。Git commit 只代表本地变更保存,push 成功才代表远程仓库同步完成。在任务报告中以"push 待手动处理"结尾会误导协作者认为工作已完成。
|
||||||
|
|
||||||
|
**落地规矩**:任何任务的完工报告必须包含"git push 成功"状态才算闭环。若 push 遇到认证问题:
|
||||||
|
- 优先排查 token 状态(坑 6 经验)
|
||||||
|
- 不得以"待手动处理"为由跳过 push 环节
|
||||||
|
- push 失败时用红字标注"⚠️ push 待解决",不写入"完工"状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 纪律 2:SQL / 代码 commit 前必须本机干跑
|
||||||
|
|
||||||
|
**背景**:Task 1 NameError(坑 1)+ Task 2 002b SQL 语法错(坑 5)同源,都是 commit 前没自测直接进库。
|
||||||
|
|
||||||
|
**教训**:任何变更(SQL 文件、后端代码、前端代码)在 commit 前必须经过本机验证。对于 SQL 文件,本机用 `psql -U postgres -d milsci_dev -f backend/sql/00X_xxx.sql` 干跑;对于 Python 代码,用 `python -m pytest` 或冒烟脚本验证。发现错误当场修,不把问题带进 commit。
|
||||||
|
|
||||||
|
**落地规矩**:
|
||||||
|
- SQL 文件:commit 前用 psql 在本机 dev 数据库干跑,确认无语法错误
|
||||||
|
- Python 代码:至少跑一遍冒烟测试或单元测试,确认基本功能正常
|
||||||
|
- 禁止用"这只是小改/语法简单/理论上没问题"为由跳过自测
|
||||||
|
- 养成习惯:**commit 前自测,发现问题当场修,比入库后发现 cost 低一个数量级**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、遗留事项
|
||||||
|
|
||||||
|
### 7.1 Phase 2 内部待完成
|
||||||
|
|
||||||
|
- [x] **Task 1 后端 CRUD**:✅ 冒烟 6/6 全绿
|
||||||
|
- [x] **git push**:✅ 已恢复正常(坑 6 已解决)
|
||||||
|
- [ ] **Task 2**:Excel 批量导入(收视数据 + 排期模板)进行中
|
||||||
|
- [ ] **Task 3**:前端仪表盘 + 录入界面 + 用户管理界面
|
||||||
|
|
||||||
|
### 7.2 跨 Phase 遗留
|
||||||
|
|
||||||
|
#### Phase 4 或后续 Task 处理
|
||||||
|
|
||||||
|
- **PATCH /api/users 接口 password 相关权限**:当前 `UserUpdate` Schema 允许改 `is_active`、`role`、`display_name`、`profile_text`,但:
|
||||||
|
- `password_hash` **不允许**通过 API 修改(当前设计)
|
||||||
|
- 能否改自己密码?不能(当前设计)
|
||||||
|
- 改别人密码是否强制对方下次登录改密?未实现
|
||||||
|
- 建议 Phase 4+ 新增独立 `/api/users/change-password` 接口,设计时拉 Op
|
||||||
|
|
||||||
|
#### Phase 5 部署时处理(与 SECRET_KEY 并列)
|
||||||
|
|
||||||
|
- **传输安全**:当前 `password` 在请求体明文传输,仅 HTTPS 环境下可接受。Phase 5 部署到腾讯云后:
|
||||||
|
- 改 `same_site="strict"` + `https_only=True`
|
||||||
|
- 确认 HTTPS 开启,强制全站 TLS
|
||||||
|
- 考虑 API 请求体密码加密或单独 `/api/auth/change-password` 接口
|
||||||
|
|
||||||
|
- **SECRET_KEY 占位符**:`.env` 当前是 `change-me-to-a-random-string-in-production`,**Phase 5 部署前必须改**为真实随机值。生成命令:
|
||||||
|
```python
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Git commit 记录
|
||||||
|
|
||||||
|
| commit | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| 143601c | docs: 合入第十三章 Opus task brief 节制原则,版本升至 v5 |
|
||||||
|
| 33bee0c | feat: 后端 CRUD 完成(episodes + yearly_targets + users 管理 API) |
|
||||||
|
| 63c031a | docs: Task 1 完工记录,Swagger UI 验收清单,跨 Phase 遗留事项 |
|
||||||
|
| 4f7c92d | fix: 修复 episodes.py + yearly_targets.py 模型导入缺失,删 users.py 残留 verify_password,增 DELETE 接口(幂等冒烟后确认 6/6 全绿) |
|
||||||
|
| f82a71b | docs: phase2_log 追加 Task 2 进行中坑与纪律警告 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、Task 2 完工记录(2026-05-19 commit 442d52f)
|
||||||
|
|
||||||
|
### 9.1 冒烟结果(8 步全绿)
|
||||||
|
|
||||||
|
| Step | 描述 | 预期 | 实际 | 结果 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| 1 | GET /api/imports/template | 200 xlsx | 200 xlsx (5595 bytes) | ✅ |
|
||||||
|
| 2 | POST /api/imports/episodes 合法行 | 200 success=1 | 200 success=1 failed=0 | ✅ |
|
||||||
|
| 3 | POST 部分失败(1ok+2bad) | 200 errors=2 | 200 errors=2 row_number=[3,4] | ✅ |
|
||||||
|
| 4 | GET /api/imports/errors/{batch_id} | 200 xlsx | 200 xlsx (5181 bytes) | ✅ |
|
||||||
|
| 5 | POST 2024 无年度目标 | 400 | 400 含"年度目标"+"2024" | ✅ |
|
||||||
|
| 6 | POST 重复期次(库内重复) | 数据库约束触发 | failed_count=1 UniqueViolation | ✅ |
|
||||||
|
| 7 | POST 未知编导"李不存在"软落地 | 200 GET id 确认 null | 200 id=22 editor_id=null | ✅ |
|
||||||
|
| 8 | GET /api/episodes/{真实id} 验收 | 200 editor_id=null | 200 ✅ | ✅ |
|
||||||
|
|
||||||
|
**smoke 脚本:`backend/scripts/import_smoke.py`(幂等版,可重复跑)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 坑 7:pandas 2.x 不接受 raw bytes(BytesIO 包装解决)
|
||||||
|
|
||||||
|
**发现时间**:2026-05-19 第一轮冒烟
|
||||||
|
|
||||||
|
**现象**:`pd.read_excel(file_content)` 传入 bytes,pandas 2.x 报错或读不出数据。
|
||||||
|
|
||||||
|
**原因**:pandas 2.x 要求 file-like object,不再直接接受 bytes。
|
||||||
|
|
||||||
|
**修复**:`excel_service.py` 中用 `io.BytesIO(file_content)` 包装后传给 `pd.read_excel`。
|
||||||
|
|
||||||
|
**commit**:442d52f
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 8:openpyxl add_named_style 误用(4ca24dc 原代码遗留)
|
||||||
|
|
||||||
|
**发现时间**:2026-05-19 冒烟 Step 4 下载失败行 Excel
|
||||||
|
|
||||||
|
**现象**:`generate_error_excel()` 调用 `wb.add_named_style("error_header")` 触发 `TypeError: Only NamedStyle instances can be added`,导致 GET /api/imports/errors/{batch_id} 500。
|
||||||
|
|
||||||
|
**来源**:经 git show 4ca24dc 确认,该 bug **在 4ca24dc 原代码中已存在**,非修坑引入。4ca24dc 自检只发现了 pandas bytes 问题,未发现此第二个 bug。
|
||||||
|
|
||||||
|
**修复**:删除 `wb.add_named_style("error_header")` 这一行(该样式未实际使用)。
|
||||||
|
|
||||||
|
**教训**:commit 前自测要覆盖**所有**接口路径,不能只测主要流程。Step 1-3 通过不等于 Step 4 也通。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.3 业务红线澄清:Step 6 重复期次拦截的两套机制
|
||||||
|
|
||||||
|
**两套拦截路径,各管各的事,不能混为一谈:**
|
||||||
|
|
||||||
|
1. **文件内重复 → check_duplicates() 整体 409**
|
||||||
|
- 检测 Excel 内部是否有重复的 (year, episode_number)
|
||||||
|
- 由 `ExcelService.check_duplicates()` 在入库前调用
|
||||||
|
- 有重复则整体拒绝,整文件不入库,返回 HTTP 409
|
||||||
|
|
||||||
|
2. **库内重复 → 数据库 UNIQUE 约束 单行失败,其他行入库**
|
||||||
|
- 检测 Excel 中某行与数据库已有记录重复
|
||||||
|
- 由 PostgreSQL `idx_episodes_year_number` 唯一约束触发
|
||||||
|
- 重复行报错进入 `errors[]`,其他行继续入库,返回 HTTP 200
|
||||||
|
|
||||||
|
**Step 6 实测的是第二套**(库内重复):Excel 写入 episode_number=99(与 Step 2 已入库的重复),数据库约束抛出 `UniqueViolation`,整行失败,success=0 failed=1。这两套机制分管文件内重复和库内重复,职责清晰。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.4 纪律 3:启动期三件套不能证明接口业务路径通
|
||||||
|
|
||||||
|
**背景**:Task 2 开发期间,启动期(imports.py + excel_service.py + schemas/imports.py)写完后只跑了 GET 接口自测,POST/PATCH/DELETE 接口靠理论推导认为没问题就直接 commit。实际冒烟时发现 Step 2/3/4/5/6/7 全是 POST,全部出问题。
|
||||||
|
|
||||||
|
**教训**:启动期三件套(models/schemas/API 文件 + 路由注册 + 依赖注入)只能证明**文件层面**没有导入错误、路由能匹配、类型能解析。**不能**证明接口的业务路径(读写数据库、事务提交、错误处理)正常。任何 POST/PUT/PATCH/DELETE 接口 commit 前必须发真请求验证业务路径。
|
||||||
|
|
||||||
|
**落地规矩**:
|
||||||
|
- 所有变更(SQL 文件、后端代码、前端代码)在 commit 前必须经过本机验证
|
||||||
|
- POST/PUT/PATCH/DELETE 接口必须用真请求实测,不能靠代码逻辑推导
|
||||||
|
- 发现错误当场修,不把问题带进 commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.5 遗留 bug:smoke 脚本 Step 7 写错(已改正)
|
||||||
|
|
||||||
|
**发现时间**:2026-05-19 冒烟 Step 7 第一版
|
||||||
|
|
||||||
|
**现象**:GET /api/episodes/300 返回 404,"在库中不存在"。
|
||||||
|
|
||||||
|
**原因**:smoke 脚本**把 episode_number 当 id 传**给 GET /api/episodes/{id}。后端 `GET /api/episodes/{id}` 接收的是数据库自增主键 id,不是业务期号 episode_number。episode_number=300 的记录入库后真实 id 是 14(或列表查询返回的其他值),不是 300。
|
||||||
|
|
||||||
|
**改正**:先从 `GET /api/episodes?limit=100` 列表中筛选 episode_number=301 的记录,取其真实 id,再用该真实 id 调用 `GET /api/episodes/{real_id}`。确保 GET /api/episodes/{id} 路径被实际验收。
|
||||||
|
|
||||||
|
**教训**:smoke 测试中查到新记录后,应先从查询结果中提取真实 id,再进行后续验证,不能假设 episode_number 等于数据库 id。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.6 Task 2 收尾清单(还未做的四件小尾巴)
|
||||||
|
|
||||||
|
- [ ] **补录 2024 真值年度目标**:Step 5 冒烟用到 2024 年,当前库中无 2024 目标,补录后 Step 5 才能真正通过
|
||||||
|
- [ ] **Windows python 默认 PATH 改指向 3.12**:坑 4 未完全解决,`py` 命令仍可能路由到系统 Python 3.14,需在系统 PATH 中显式优先 3.12
|
||||||
|
- [ ] **backups/ 进 .gitignore**:虽然 .gitignore 有 `*.log` 等模式,但 `backups/` 目录显式加入更稳,避免样本数据误传
|
||||||
|
- [ ] **MiniMax 自检报告里 schema 类名对照 Plan 自查**:`ImportError`/`ImportResult` vs `UploadTemplateResponse`/`ImportResultResponse` 等命名差异对着 Plan 确认是否一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、Git commit 记录(2026-05-19 追加)
|
||||||
|
|
||||||
|
| commit | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿 |
|
||||||
|
|
||||||
|
*本日志由 MiniMax M2.7(Cline Act 模式)于 2026-05-15 持续追加,2026-05-19 追加 Task 2 完工坑与红线澄清,2026-05-20 追加 Task 2 收尾记录。*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、Task 2 收尾完工记录(2026-05-20)
|
||||||
|
|
||||||
|
### 11.1 Git 安全卫生:token 从 URL 剥离
|
||||||
|
|
||||||
|
**背景**:`.git/config` 里 origin 远程 URL 含明文 token(`cline-tps-dashboard`),
|
||||||
|
存在被 Cline 误贴日志、截图泄露、备份带出等风险。
|
||||||
|
|
||||||
|
**改动**:
|
||||||
|
- `git remote set-url origin` 改成干净 URL(去掉 `user:token@`)
|
||||||
|
- token 改由 Windows Git Credential Manager (manager) 加密存储
|
||||||
|
- 两次 fetch 静默通过,push 走 GCM 透明工作
|
||||||
|
- 旧 token `cline-tps-dashboard` 继续使用,不吊销
|
||||||
|
|
||||||
|
**纪律备注**:以后任何凭证(密码、API key、token)都不许写进 URL、不许写进 .py 文件、
|
||||||
|
不许写进任何会被 commit 的文件。
|
||||||
|
|
||||||
|
### 11.2 小尾巴 1:补录 2024 真值
|
||||||
|
|
||||||
|
**操作**:制片人手动 psql 直接 INSERT(绕过 API,因 API 权限收紧待办未做)。
|
||||||
|
|
||||||
|
**当前 yearly_targets 真值**:
|
||||||
|
- 2024: base=0.6448 / stretch=0.8290 (issued_date=2024-01-01, 补录说明在 notes)
|
||||||
|
- 2025: base=0.6448 / stretch=0.8989
|
||||||
|
- 2026: base=0.6448 / stretch=0.8989
|
||||||
|
|
||||||
|
### 11.3 小尾巴 3:backups/ 进 .gitignore
|
||||||
|
|
||||||
|
**commit**:20c0a1d(chore: backups/ 进 .gitignore)
|
||||||
|
**push**:已成功
|
||||||
|
|
||||||
|
### 11.4 小尾巴 4:schema 类名自查通过
|
||||||
|
|
||||||
|
类名与 Task 2 Plan 一致,无漂移。`ImportError` / `ImportResult` 命名正确。
|
||||||
|
|
||||||
|
发现 2 个隐患,**暂不处理,记为待办**:
|
||||||
|
- 隐患 A:`ImportError` 与 Python 内置同名,当前未撞,未来若在导入逻辑里 raise 才会冲突
|
||||||
|
- 隐患 B:`ExcelService.import_episodes()` 返回 dict 而非 Pydantic 对象,
|
||||||
|
API 层手动构造 `ImportResult`,缺一次 Pydantic 校验层。8 步冒烟已验证业务正确,
|
||||||
|
仅代码洁癖问题
|
||||||
|
|
||||||
|
### 11.5 小尾巴 2:Python PATH 根治坑 4
|
||||||
|
|
||||||
|
**改动**:从系统 PATH 删除 `C:\Python314\Scripts\` 和 `C:\Python314\` 两行。
|
||||||
|
用户 PATH 中 Python 3.12 自动顶上来。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- `where.exe python` 第一行变成 3.12 路径
|
||||||
|
- `python --version` = Python 3.12.10
|
||||||
|
- `sys.executable` 指向 3.12
|
||||||
|
|
||||||
|
**遗留**:E:\tps-dashboard\backend\ 下没有 venv,Python 依赖装在系统 3.12 全局
|
||||||
|
site-packages 里。**记入待办**,Phase 4 部署前或 Task 3 启动前评估是否补 venv。
|
||||||
|
|
||||||
|
### 11.6 待办清单(Task 3 启动前或后续 Phase 处理)
|
||||||
|
|
||||||
|
| 待办 | 触发时机 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| `yearly_targets` 写权限收紧到 `zhipianren` + `zebian`,`biandao` 只读 | Task 3 启动前 | 高(业务红线) |
|
||||||
|
| backend/ 补 venv,把全局依赖搬进去 | Task 3 启动前评估 | 中 |
|
||||||
|
| 隐患 A:`ImportError` 同名冲突预防 | 真要 raise 时再说 | 低 |
|
||||||
|
| 隐患 B:`ExcelService` 返回 Pydantic 化 | 代码洁癖,有空时做 | 低 |
|
||||||
|
|
||||||
|
### 11.7 Task 2 完整 commit 链(2026-05-19 → 2026-05-20)
|
||||||
|
|
||||||
|
| commit | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿(2026-05-19) |
|
||||||
|
| 6de8111 | docs: Task 2 完工记录,坑7/8/纪律3/Q1-Q4 写入(2026-05-19) |
|
||||||
|
| 20c0a1d | chore: backups/ 进 .gitignore(Task 2 收尾)(2026-05-20) |
|
||||||
|
|
||||||
|
**Task 2 完整收尾,Phase 2 Task 2 状态:✅ 完工。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第十二章 yearly_targets 写权限收紧(2026-05-20)
|
||||||
|
|
||||||
|
**状态:✅ 完工。冒烟 12/12 全绿。**
|
||||||
|
|
||||||
|
### 12.1 任务来源
|
||||||
|
|
||||||
|
task brief 明确要求收紧 yearly_targets 表的写权限,是业务红线。
|
||||||
|
|
||||||
|
### 12.2 改动文件
|
||||||
|
|
||||||
|
| 文件 | 改动内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `backend/app/api/yearly_targets.py` | 5处 `require_role` 参数修正 |
|
||||||
|
| `backend/app/schemas/yearly_target.py` | `YearlyTargetUpdate` 改为部分字段可选(支持 PATCH 部分更新) |
|
||||||
|
| `backend/scripts/smoke_yearly_targets_auth.py` | 新建,12步幂等冒烟脚本 |
|
||||||
|
| `README.md` | 新增"角色权限表"节 |
|
||||||
|
| `.clinerules` 5.5 | 新增 yearly_targets 写权限收紧说明 |
|
||||||
|
| `logs/phase2_log.md` | 本章记录 |
|
||||||
|
|
||||||
|
### 12.3 权限矩阵最终状态
|
||||||
|
|
||||||
|
| 接口 | zhipianren | zebian | biandao |
|
||||||
|
|------|-----------|--------|---------|
|
||||||
|
| GET 列表/详情 | ✅ 200 | ✅ 200 | ✅ 200 |
|
||||||
|
| POST 新增 | ✅ 201 | ✅ 201 | ❌ 403 |
|
||||||
|
| PATCH 修改 | ✅ 200 | ✅ 200 | ❌ 403 |
|
||||||
|
| DELETE 删除 | ✅ 204 | ❌ 403 | ❌ 403 |
|
||||||
|
|
||||||
|
### 12.4 GET 权限修改说明
|
||||||
|
|
||||||
|
GET 权限修改原本不在 task brief 范围,是 Plan 阶段自查发现并扩大的范围。
|
||||||
|
|
||||||
|
Opus 审 Plan 时同意纳入,理由:权限矩阵整体校正一次做完更合理。
|
||||||
|
|
||||||
|
此条在 Opus 审方案 v2 中明确记录,phase2_log 本章也写入此说明,不得糊涂带过。
|
||||||
|
|
||||||
|
### 12.5 冒烟实测记录
|
||||||
|
|
||||||
|
`backend/scripts/smoke_yearly_targets_auth.py`,12步结果:
|
||||||
|
|
||||||
|
| 步骤 | 场景 | 预期 | 结果 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | zhipianren POST | 201 | ✅ |
|
||||||
|
| 2 | zebian POST | 201 | ✅ |
|
||||||
|
| 3 | biandao POST | 403 | ✅ |
|
||||||
|
| 4 | 未登录 POST | 401 | ✅ |
|
||||||
|
| 5 | zhipianren PATCH | 200 | ✅ |
|
||||||
|
| 6 | zebian PATCH | 200 | ✅ |
|
||||||
|
| 7 | biandao PATCH | 403 | ✅ |
|
||||||
|
| 8 | zhipianren DELETE | 204 | ✅ |
|
||||||
|
| 9 | zebian DELETE | 403 | ✅ |
|
||||||
|
| 10 | biandao DELETE | 403 | ✅ |
|
||||||
|
| 11 | biandao GET 列表 | 200 | ✅ |
|
||||||
|
| 12 | zhipianren GET 列表 | 200 | ✅ |
|
||||||
|
|
||||||
|
**12/12 全部通过。**
|
||||||
|
|
||||||
|
### 12.6 测试账号保留
|
||||||
|
|
||||||
|
冒烟脚本末尾显式打印保留账号(供后续手动清理):
|
||||||
|
- `test_zebian_smoke` (zebian)
|
||||||
|
- `test_biandao_smoke` (biandao)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第十三章 Task 3 前端三界面收官(2026-05-22)
|
||||||
|
|
||||||
|
**状态:✅ 完工。六条尾巴全闭环,Phase 2 整体收官。**
|
||||||
|
|
||||||
|
Task 3 = 仪表盘真数据 + 用户管理 + 责编录入页。本次开局承接 2026-05-21 的"部分完成、未收官"状态(HEAD `1ebb8a6`),清完六条尾巴正式收官。
|
||||||
|
|
||||||
|
### 13.1 今日 commit 链
|
||||||
|
|
||||||
|
| commit | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| `4946ab2` | fix: AppLayout 未挂载——路由改两层 Outlet 嵌套,AuthGuard 不再包裹 AppLayout |
|
||||||
|
| `ebcb530` | fix: episodes GET 放开三角色,写权限仍仅 zhipianren/zebian |
|
||||||
|
| `c7296a5` | style: 柱图三色换降饱和柔和版(柔蓝/柔绿/柔红),图例同步 |
|
||||||
|
| `5839fc6` | chore: .gitignore 新增 cookies*.txt 忽略规则 |
|
||||||
|
|
||||||
|
(昨日另有 `bfcaddf` CSS 改动,非侧边栏病根但保留无害。)
|
||||||
|
|
||||||
|
### 13.2 六条尾巴收官情况
|
||||||
|
|
||||||
|
1. **侧边栏死穴(最硬)**:simonkoson 登录后整个侧边栏不渲染。连追五轮才定位真因——**根因不是 CSS(前两轮误判 fixed/flex),是 AuthGuard 被当包裹组件用却内部只渲染 `<Outlet/>`、丢弃了 children `<AppLayout/>`**。改为路由两层 Outlet 嵌套(AuthGuard 独立守卫层 + AppLayout 独立 layout route)。三账号实测侧边栏 + 角色过滤全对(zhipianren 见用户管理、zebian 见责编录入不见用户管理、biandao 两者都不见)。
|
||||||
|
2. **episodes GET 放开三角色**:biandao 仪表盘从空变有数据;反向验证 biandao 发 POST/PATCH/DELETE 全 403。
|
||||||
|
3. **柱图配色**:见 13.4 色板固化。
|
||||||
|
4. **EditorDesk 责编录入**:**实测发现三界面本就完整**(三 Tab + 列表 CRUD + 表单字段齐全,含 audience_share 输入框),昨日"表单不全 / 找不到 audience_share"系侧边栏不通、进不去页面时凭印象误记。四步实测全过,无需改码:
|
||||||
|
- 新增期次能存(期次 8888 / 份额 0.95 / 编导张颖)
|
||||||
|
- 编导姓名快照必填、留空不能存(符合软引用红线:editor_id 可空、editor_name_snapshot 必填)
|
||||||
|
- 年度目标只增不改:2028 能存、2026 重复被拒(year UNIQUE 生效,旧值不被覆盖)
|
||||||
|
- 三色极端数据实测通过
|
||||||
|
5. **cookies 进 .gitignore**:5 个测试 cookie 文件忽略,工作区干净。
|
||||||
|
6. **极端数据三色实测**:造 0.95→柔红、0.5→柔绿、0.71/0.72→柔蓝,三色同框,颜色逻辑 + 配色双验。
|
||||||
|
|
||||||
|
### 13.3 侧边栏一役复盘(重点教训)
|
||||||
|
|
||||||
|
| 轮次 | Cline 判定的"根因" | 实际 | 结果 |
|
||||||
|
|------|-------------------|------|------|
|
||||||
|
| 1 | AuthGuard user===null 返回 null,整页空白 | 与现象矛盾(仪表盘明明渲染了) | 打回 |
|
||||||
|
| 2 | CSS `.app-sider` fixed 塌成 0 宽 | 猜测,未看真实 CSS | 打回 |
|
||||||
|
| 3 | 改 fixed 为 flex | 改了但浏览器仍不显示 | 非病根 |
|
||||||
|
| 4 | 制片人 F12 看真实 DOM | `#root` 下直接是 `.dashboard`,无 AppLayout 层 | 锁定方向 |
|
||||||
|
| 5 | AuthGuard 不渲染 children 却被当包裹组件用 | 真因,两段代码 + DOM 三者咬合 | 修复成功 |
|
||||||
|
|
||||||
|
教训沉淀为下方纪律 4。
|
||||||
|
|
||||||
|
### 13.4 柱图三色色板固化(制片人拍板)
|
||||||
|
|
||||||
|
**降饱和柔和版**:达标 = 柔蓝 `#5b8db8` / 待提升 = 柔绿 `#7aa874` / 优秀 = 柔红 `#c0584f`。
|
||||||
|
|
||||||
|
原则:①降饱和度,贴合奶油底 `#fbf9f1` + 深绿主色 `#6b8e6b` 整体柔和基调,不用高饱和纯色;②待提升的绿须与主色深绿拉开(更浅更嫩),且与达标蓝有明显色相区分,杜绝"达标待提升一个色系"。
|
||||||
|
|
||||||
|
语义映射仍按 `.clinerules` 5.1:红 = 优秀 / 蓝 = 达标 / 绿 = 待提升。
|
||||||
|
|
||||||
|
**教训**:此色板此前仅口头拍板未入档,导致 Cline 重新生成时配错(达标套了主色深绿、待提升用土黄凑数)。**凡视觉拍板必入 log 固化**。
|
||||||
|
|
||||||
|
### 13.5 纪律 4:根因未经代码 / DOM 实证,不许动手修
|
||||||
|
|
||||||
|
侧边栏一役 Cline 连续三轮在猜测的根因上改代码(loading 态 → CSS fixed → CSS flex),均非病根。最终靠制片人 F12 看真实 DOM + 逼 Cline 贴真实代码才定位。
|
||||||
|
|
||||||
|
**落地规矩**:Cline 报告"根因是 X"时,必须要求它贴出支撑该结论的真实代码 / DOM 证据;只要它在用"可能 / 也许 / 应该",就是在猜,打回。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第十四章 Phase 2 整体收官(2026-05-22)
|
||||||
|
|
||||||
|
- Task 1 后端 CRUD:✅(冒烟 6/6)
|
||||||
|
- Task 2 Excel 批量导入:✅(冒烟 8/8)
|
||||||
|
- Task 2 收尾 + yearly_targets 写权限收紧:✅(冒烟 12/12)
|
||||||
|
- Task 3 前端三界面:✅(六尾巴全闭环)
|
||||||
|
- **Phase 2 整体收官**,打 tag `phase2-dashboard-done`
|
||||||
|
- 收官 commit:`5839fc6`,working tree clean
|
||||||
|
|
||||||
|
### 14.1 跨 Phase 遗留(带入后续)
|
||||||
|
|
||||||
|
- **题图上传 Task(独立小 Task,下一个起手)**:①题图 banner 化;②仪表盘布局重排——题图做全宽 banner、近 9 期收视紧贴其下做全宽横条(不再挤在中间小格)。**走 Claude Design 流程**:Design 出可交互原型 → 制片人定稿 → Opus 翻译成色值 + antd 组件名规格 → 喂 MiniMax,**不直接把图甩给 Cline**(见 design_环节备忘.md)。
|
||||||
|
- **Phase 5 上线前数据清理**:清掉今日测试数据(期次 8888/8889 等、年度目标 2027/2028 等占位),录入真数据。删除走 UI 逐条删或 Cline 写"清业务表保真数据"SQL 经制片人确认后跑。
|
||||||
|
- backend/ 补 venv;隐患 A(ImportError 同名)、隐患 B(ExcelService 返回 Pydantic 化)低优;SECRET_KEY + same_site/https_only(Phase 5 部署)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*第十三~十四章由 Claude Opus 4.7 协助、MiniMax M2.7 落地,于 2026-05-22 Task 3 收官后追加。*
|
||||||
Reference in New Issue
Block a user