40 Commits

Author SHA1 Message Date
simonkoson d40d46a434 fix: TargetOutlined换为LineChartOutlined避免白屏 2026-05-25 14:23:44 +08:00
simonkoson 2881b866a6 feat: KPI细条新增两项完成率 + 侧边菜单加两个占位项 2026-05-25 13:45:00 +08:00
simonkoson 72ae73764a feat: 仪表盘视觉优化-居中/logo放大/柱图数字挪顶/节目名加长 2026-05-25 13:38:33 +08:00
simonkoson 51a34e9ec6 feat: 添加栏目 logo 素材 2026-05-25 09:56:15 +08:00
simonkoson 303995a93f fix: dashboard.py 顶层 import CoverSettings,解决运行时 500 2026-05-25 09:55:05 +08:00
simonkoson d774370a4b feat: 仪表盘布局重排,新增题图上传能力 2026-05-25 09:47:21 +08:00
simonkoson a6e8cc25ec feat: 新增题图上传接口和排播接口 2026-05-25 09:44:34 +08:00
simonkoson 8299b6bbc9 feat: 新增 cover_settings 表用于仪表盘题图设置 2026-05-25 09:42:25 +08:00
simonkoson 02bf4c4fe4 docs: phase2_log 第十三~十四章——Task 3 收官 + Phase 2 整体收官 2026-05-22 19:28:14 +08:00
simonkoson d55fde7fb5 chore: .gitignore 新增 cookies*.txt 忽略规则 2026-05-22 18:50:35 +08:00
simonkoson a2f1710a7f style: 柱图三色换成降饱和柔和版,柔蓝/柔绿/柔红,图例同步 2026-05-22 18:44:14 +08:00
simonkoson 59f556d986 fix: episodes GET 接口放开给三角色,POST/PATCH/DELETE 保持仅 zhipianren/zebian 2026-05-22 18:27:28 +08:00
simonkoson fcaa0969a8 fix: AppLayout 未挂载——路由改为两层 Outlet 嵌套,AuthGuard 不再包裹 AppLayout 2026-05-22 18:15:31 +08:00
simonkoson f55030b971 fix: SideNav 侧边栏不显示,.app-sider 去掉 fixed 改 flex 布局 2026-05-22 13:04:45 +08:00
simonkoson 24b9bb2d08 fix: 三个必修 bug——Dashboard 颜色判反+legend 错+SideNav zhipianren 用户管理漏显 2026-05-21 20:45:48 +08:00
simonkoson 247268a60e fix: Dashboard.jsx 修复 limit 未定义 ReferenceError 2026-05-21 20:29:50 +08:00
simonkoson f0f9dc493e feat: UserManage 完整用户 CRUD,创建/编辑/停用,角色下拉用拼音值 2026-05-21 19:47:54 +08:00
simonkoson e0ccfb20e7 feat: Dashboard 接真 API,题图渐变占位,排播占位,近9期收视柱图,颜色按年度目标判定 2026-05-21 19:47:36 +08:00
simonkoson 4eca31f8eb feat: SideNav 按 role 过滤菜单,/users 仅 zhipianren,/editor-desk 仅 zhipianren+zebian 2026-05-21 19:47:21 +08:00
simonkoson 3942f01def feat: App.jsx 加入 RoleGuard,/users 仅 zhipianren,/editor-desk 仅 zhipianren+zebian 2026-05-21 19:47:01 +08:00
simonkoson b7560a6cf6 feat: 添加三个 service 文件和 RoleGuard 组件 2026-05-21 19:46:40 +08:00
simonkoson 7cc0bf21ff feat: yearly_targets 写权限收紧,12步冒烟全绿 2026-05-20 11:29:28 +08:00
simonkoson 93fd327b13 docs: phase2_log 追加 Task 2 收尾记录(2026-05-20) 2026-05-20 10:58:36 +08:00
simonkoson 932985feca chore: backups/ 进 .gitignore(Task 2 收尾) 2026-05-20 10:41:03 +08:00
simonkoson ff0471a6be docs: Task 2 完工记录,8步全绿,坑7/8/纪律3/Q1-Q4澄清写入phase2_log 2026-05-19 20:29:30 +08:00
simonkoson f4661863a7 feat: 完善批量导入逻辑,8步冒烟全绿 2026-05-19 20:05:15 +08:00
simonkoson 14c9841b35 docs: phase2_log 追加 Task 2 进行中坑与纪律警告 2026-05-18 11:27:21 +08:00
simonkoson c9e527ff5b chore: 补录 002a 诊断脚本入库,修正 git_workflow.md 1.1 节路径 2026-05-18 11:00:59 +08:00
simonkoson 2480d4a738 feat: Task 2 Excel 批量导入 API 完成(模板下载 + 批量导入 + 失败行下载) 2026-05-15 13:40:13 +08:00
simonkoson 9439c72d30 fix: 002b 改用 date_part 修复表达式索引语法,002c 补救专用 2026-05-15 13:37:38 +08:00
simonkoson 3102fbec6c chore: 新增 pandas + openpyxl 依赖(Task 2 Excel 批量导入用) 2026-05-15 13:23:24 +08:00
simonkoson b3667aa7b9 fix: 修复 episodes + yearly_targets 模型导入缺失,增 DELETE 接口,冒烟 6/6 全绿 2026-05-15 13:05:58 +08:00
simonkoson 773441bb87 docs: Task 1 完工记录,Swagger UI 验收清单,跨 Phase 遗留事项 2026-05-15 12:36:11 +08:00
simonkoson 3dc1a1cf3c feat: 后端 CRUD 完成(episodes + yearly_targets + users 管理 API) 2026-05-15 12:17:46 +08:00
simonkoson fab15505bd docs: 合入第十三章 Opus task brief 节制原则,版本升至 v5 2026-05-15 10:27:13 +08:00
simonkoson 347ec1b13a docs: 更新 phase1_log.md 前端骨架完工记录 2026-05-15 09:48:39 +08:00
simonkoson d29b1bb394 feat: frontend skeleton done 2026-05-15 09:46:42 +08:00
simonkoson 2bee0ce8c8 docs: git_workflow.md 第 2/3 章路径残留 Z:\AIworks → E:\tps-dashboard 修正 2026-05-15 09:35:17 +08:00
simonkoson 0b141239cc docs: 仪表盘收视柱图统一为近 9 期(制片人 2026-05-15 拍板) 2026-05-15 09:32:03 +08:00
simonkoson 483e34b4f6 chore: 清理 indexUI 中文文件名空格与重复字符 2026-05-15 09:30:59 +08:00
79 changed files with 9135 additions and 33 deletions
+24 -5
View File
@@ -2,7 +2,7 @@
> 项目:TPS 中台(央视《军事科技》栏目内部工作台)
> 仓库:`tps-dashboard`(Gitea,团队可见)
> 修订版本:v4 · 2026-05-14
> 修订版本:v5 · 2026-05-15
> 适用对象:Cline + MiniMax M2.7(Plan + Act),所有协作者必读
---
@@ -25,7 +25,7 @@
| 角色 | 模型 / 工具 | 职责 |
|---|---|---|
| 制片人 | 刘通(人) | 拍板、审核、领域知识权威、视觉把关 |
| 制片人 | 刘通(人) | 拍板、审核、领域权威、视觉把关 |
| 高级技术顾问 | Claude Opus 4.7(claude.ai 网页) | 架构指导、方案审核、风险预警、Cline 卡壳兜底 |
| 架构师 | MiniMax M2.7(Cline Plan 模式) | 项目内方案设计、Act 改动审查 |
| 执行者 | MiniMax M2.7(Cline Act 模式) | 实际写代码 |
@@ -110,7 +110,7 @@ Phase 内的子任务、修复独立 bug、前端骨架、后端骨架——都
### 5.2 年度目标只增不改(时间不变性)
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**
`yearly_targets` 表每年新增一行,**历史年份的目标行绝不可被覆盖或删除**:
- 前端展示某期数据时,**根据该期播出年份的目标判颜色**,不是"当前年份的目标"
- 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,失败回退按 editor_name_snapshot 文本匹配**
DeepSeek 容易写"editor_id 外键非空 + 删人级联"——**禁止**。一旦张颖离职,她做过的几十期节目就全成"无主"了,这是栏目最不能接受的。
@@ -147,6 +147,11 @@ AI Prompt 模板必须默认走双段、备选纯叙述。docxtpl 模板设计
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
**yearly_targets 写权限收紧(2026-05-20)**:
- POST/PATCH: zhipianren + zebian 可写,biandao 返回 403
- DELETE: 仅 zhipianren 可操作,zebian 和 biandao 返回 403
- GET: 三角色均可读
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
---
@@ -367,11 +372,25 @@ Cline 内的 Sonnet/DeepSeek 不直接与 Opus 通信,流程如下:
| v1 | 2026-04 | 初版,定模型分工与栏目业务约定 |
| v2 | 2026-05-04 | 加技术栈精简版、Phase 拆分 |
| v3 | 2026-05-06 | 加 Git 工作流、已知避坑清单、单位机重启路径;MiniMax M2.7 Phase 2 起接班 Plan 模式(替代 Sonnet),DeepSeek pro 全程在岗 |
| **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,不进本文件。
---
## 十三、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 经验)。*
+10 -6
View File
@@ -13,27 +13,31 @@ dist/
build/
.vite/
# 环境配置(永远不上传)
# 环境配置(永远不上传)
.env
.env.local
# 编辑器
# 编辑器
.vscode/
.idea/
*.swp
# 操作系统
# 操作系统
.DS_Store
Thumbs.db
# 日志运行产物
# 日志运行产物
*.log
cookie.txt
cookies*.txt
# 临时文件
# 临时文件
*.tmp
*.bak
homePC/
# Syncthing (极空间 NAS 同步工具产物)
# Syncthing (极空间 NAS 同步工具产物)
.stignore
# ĘýžÝżâą¸ˇÝ / SQL dump(˛ť˝ř°ćąžżâ)
backups/
+20 -1
View File
@@ -10,7 +10,7 @@
一个面向《军事科技》栏目 8 人团队的内部工作台,核心模块包括:
- **TPS**(选题策划系统):查重 + 知识库参考 + 报题单一键生成
- **仪表盘**:近 4 期收视成绩 + 未来 6 期排期一目了然
- **仪表盘**:近 9 期收视成绩 + 未来 6 期排期一目了然
- **知识库**:往期文稿、军报材料、专家库、外拍线索一站式检索
- **个人首页**:每位编导有自己的工作台 + 今日热点雷达
- **拍摄资源库**:外拍联系人 / 单位 / 线索来源结构化沉淀
@@ -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)
+121
View File
@@ -0,0 +1,121 @@
"""
仪表盘 API — 题图上传 / 查询
权限:
- POST /cover — 仅 zhipianren / zebianbiandao 返回 403
- GET /cover — 三角色均可读
"""
import os
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.models.cover_settings import CoverSettings
router = APIRouter(prefix="/api/dashboard", tags=["仪表盘"])
# 题图文件存放目录(相对于 backend/)
STATIC_COVERS_DIR = Path(__file__).parent.parent.parent / "static" / "covers"
STATIC_COVERS_URL = "/static/covers"
# 确保目录存在
STATIC_COVERS_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
def require_upload_role():
return require_role(UserRole.zhipianren, UserRole.zebian)
@router.get("/cover")
def get_current_cover(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""查询当前题图设置(三角色均可读)。"""
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if not row:
return {"cover_path": None, "episode_number": None, "episode_title": None}
return {
"cover_path": row.cover_path,
"episode_number": row.episode_number,
"episode_title": row.episode_title,
}
@router.post("/cover")
def upload_cover(
file: UploadFile = File(...),
episode_number: int = Form(...),
episode_title: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_upload_role()),
):
"""上传题图(仅制片人/责编)。
文件存 static/covers/{uuid}.{ext},更新 cover_settings 表。
"""
# 校验文件类型
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"仅支持 PNG/JPG/WEBP,当前:{file.content_type}",
)
# 校验文件大小
contents = file.file.read()
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件大小不能超过 5MB",
)
file.file.seek(0)
# 生成文件名
ext = file.filename.split(".")[-1] if "." in file.filename else "jpg"
safe_ext = ext.lower() if ext.lower() in {"png", "jpg", "jpeg", "webp"} else "jpg"
filename = f"{uuid.uuid4().hex}.{safe_ext}"
filepath = STATIC_COVERS_DIR / filename
# 写文件
with open(filepath, "wb") as f:
f.write(contents)
cover_path = f"{STATIC_COVERS_URL}/{filename}"
now = datetime.now()
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if row:
row.cover_path = cover_path
row.episode_number = episode_number
row.episode_title = episode_title
row.updated_at = now
else:
row = CoverSettings(
key="dashboard_cover",
cover_path=cover_path,
episode_number=episode_number,
episode_title=episode_title,
updated_at=now,
)
session.add(row)
session.commit()
return {"success": True, "cover_path": cover_path}
+103
View File
@@ -0,0 +1,103 @@
"""
节目期次 API — 增删改查(责编和管理员可操作)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.episode import Episode
from app.models.user import User, UserRole
from app.schemas.episode import EpisodeCreate, EpisodeResponse, EpisodeUpdate
router = APIRouter(prefix="/api/episodes", tags=["节目期次"])
def require_read_role():
"""GET 接口的鉴权:三个角色都可读"""
return require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)
@router.get("", response_model=list[EpisodeResponse])
def list_episodes(
limit: int = 50,
offset: int = 0,
session: Session = Depends(get_session),
current_user: User = Depends(require_read_role()),
):
"""获取节目期次列表(三角色都可读)。按播出日期倒序。"""
statement = select(Episode).order_by(Episode.air_date.desc()).offset(offset).limit(limit)
episodes = session.exec(statement).all()
return episodes
@router.get("/{episode_id}", response_model=EpisodeResponse)
def get_episode(
episode_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_read_role()),
):
"""根据 ID 获取单期节目(三角色都可读)。"""
episode = session.get(Episode, episode_id)
if episode is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在")
return episode
@router.post("", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED)
def create_episode(
body: EpisodeCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""新增一期节目(仅管理员/责编)。"""
# 检查期次号唯一
existing = session.exec(
select(Episode).where(Episode.episode_number == body.episode_number)
).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="期次号已存在")
episode = Episode.model_validate(body.model_dump())
session.add(episode)
session.commit()
session.refresh(episode)
return episode
@router.patch("/{episode_id}", response_model=EpisodeResponse)
def update_episode(
episode_id: int,
body: EpisodeUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""更新一期节目信息(仅管理员/责编)。"""
episode = session.get(Episode, episode_id)
if episode is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(episode, field, value)
session.add(episode)
session.commit()
session.refresh(episode)
return episode
@router.delete("/{episode_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_episode(
episode_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""删除一期节目(仅管理员/责编)。"""
episode = session.get(Episode, episode_id)
if episode is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="期次不存在")
session.delete(episode)
session.commit()
return None
+175
View File
@@ -0,0 +1,175 @@
"""
Excel 批量导入 API — 模板下载 + 批量导入 + 失败行下载
"""
import io
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from sqlmodel import Session
from app.core.deps import require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.schemas.imports import ImportError, ImportResult
from app.services.excel_service import ExcelService, generate_error_excel
router = APIRouter(prefix="/api/imports", tags=["批量导入"])
# ============================================================================
# 内存失败行存储(进程生命周期,重启后丢失)
# 仅用于 Phase 2,生产 Phase 4+ 替换为 Redis 或 DB
# ============================================================================
_error_store: dict[str, list[dict]] = {}
# ============================================================================
# 模板下载
# ============================================================================
@router.get("/template")
def download_template(
type: str = Query("episodes", description="模板类型,仅支持 episodes"),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""下载 episodes 批量导入 Excel 模板。"""
if type != "episodes":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Phase 2 不支持排期模板下载",
)
wb = Workbook()
ws = wb.active
ws.title = "节目期次导入模板"
# 列说明(第1行)
headers = [
"episode_number", "program_name", "air_date",
"editor_name", "audience_share", "audience_rating",
"is_rerun", "notes",
]
ws.append(headers)
# 第2行:示例数据
sample = [
1, "《军事科技》武器解析系列", "2026-01-15",
"张颖", "0.72", "3.5",
"", "首期播出",
]
ws.append(sample)
# 第3行:说明注释(灰色字体)
from openpyxl.styles import Font, PatternFill, Alignment
grey_font = Font(color="808080")
note_row = [
"期次号(整数,每年从1开始)",
"节目名称",
"播出日期(YYYY-MM-DD",
"编导姓名(快照,匹配不上则只存姓名)",
"收视份额(0-1之间的小数,可空)",
"收视率(可空)",
"是否重播(是/否),Phase 2 不支持重播导入",
"备注",
]
ws.append(note_row)
for cell in ws[len(headers)]:
cell.font = grey_font
# 第4行:is_rerun 说明
rerun_note = ["", "", "", "", "", "", "Phase 2 不支持重播导入,有重播行请空着", ""]
ws.append(rerun_note)
for cell in ws[4]:
cell.font = grey_font
# 设置列宽
ws.column_dimensions["A"].width = 16
ws.column_dimensions["B"].width = 28
ws.column_dimensions["C"].width = 14
ws.column_dimensions["D"].width = 16
ws.column_dimensions["E"].width = 16
ws.column_dimensions["F"].width = 16
ws.column_dimensions["G"].width = 40
ws.column_dimensions["H"].width = 20
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=episodes_import_template.xlsx"},
)
# ============================================================================
# 批量导入
# ============================================================================
@router.post("/episodes", response_model=ImportResult)
def import_episodes(
file: UploadFile = File(..., description="Excel 文件(.xlsx"),
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""批量导入节目期次 Excel。"""
if not file.filename.endswith(".xlsx"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持 .xlsx 格式,请使用模板下载获得正确格式",
)
content = file.file.read()
service = ExcelService(session)
try:
result = service.import_episodes(content)
except ValueError as e:
msg = str(e)
if "年度目标" in msg:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
if "重复" in msg or "库中记录重复" in msg:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=msg)
# 其他解析/校验错误
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
except Exception as e:
# 捕获 generate_error_excel 等意外错误,避免 500 扩散
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"导入失败:{str(e)}")
# 存储失败行(供后续下载)
if result["errors"]:
_error_store[result["batch_id"]] = result["errors"]
result["error_excel_url"] = f"/api/imports/errors/{result['batch_id']}"
return result
# ============================================================================
# 失败行下载
# ============================================================================
@router.get("/errors/{batch_id}")
def download_error_excel(
batch_id: str,
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""下载指定批次的失败行 Excel(供责编修正后重新导入)。"""
errors = _error_store.get(batch_id)
if errors is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="批次不存在或已过期(进程重启后数据丢失),请重新上传",
)
content = generate_error_excel(errors)
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=import_errors_{batch_id}.xlsx"},
)
+70
View File
@@ -0,0 +1,70 @@
"""
排期 API — 查询未来排播计划
降级逻辑:
- schedules 表有数据 → 从 schedules 取未来 limit 条 JOIN episodes
- schedules 表无数据 → 从 episodes 表取最近 limit 期待播期次(air_date 升序)
"""
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.deps import get_current_user
from app.db.session import get_session
from app.models.user import User
from app.models.episode import Episode
from app.models.schedule import Schedule
router = APIRouter(prefix="/api/schedules", tags=["排期"])
@router.get("/upcoming")
def list_upcoming_schedules(
limit: int = 6,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""获取未来排播计划(三角色均可读)。
优先从 schedules 表取,schedules 无数据时降级取 episodes 最近待播期次。
"""
# 先查 schedules
statement = (
select(Schedule, Episode)
.join(Episode, Schedule.episode_id == Episode.id, isouter=True)
.where(Schedule.planned_air_date >= Episode.air_date)
.order_by(Schedule.planned_air_date.asc())
.limit(limit)
)
results = session.exec(statement).all()
# 降级:从 episodes 直接取(当 schedules 为空或不足时)
if not results or len(results) < limit:
fallback_stmt = (
select(Episode)
.order_by(Episode.air_date.asc())
.limit(limit)
)
episodes = session.exec(fallback_stmt).all()
return [
{
"episode_number": ep.episode_number,
"program_name": ep.program_name,
"planned_air_date": str(ep.air_date),
"editor_name_snapshot": ep.editor_name_snapshot,
}
for ep in episodes
]
# schedules 有数据
output = []
for schedule, episode in results:
if episode is None:
continue
output.append({
"episode_number": episode.episode_number,
"program_name": episode.program_name,
"planned_air_date": str(schedule.planned_air_date),
"editor_name_snapshot": episode.editor_name_snapshot,
})
return output
+106
View File
@@ -0,0 +1,106 @@
"""
用户管理 API — 管理员 CRUD(仅 zhipianren 可操作)
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.core.security import hash_password
from app.db.session import get_session
from app.models.user import User, UserRole
from app.schemas.user_admin import UserCreate, UserResponse, UserUpdate
router = APIRouter(prefix="/api/users", tags=["用户管理"])
@router.get("", response_model=list[UserResponse])
def list_users(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""获取所有用户列表(仅管理员)。"""
statement = select(User).order_by(User.id)
users = session.exec(statement).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""根据 ID 获取单个用户(仅管理员)。"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return user
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
body: UserCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""创建新用户(仅管理员)。"""
# 检查用户名唯一
existing = session.exec(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
user = User(
username=body.username,
display_name=body.display_name,
password_hash=hash_password(body.password),
role=body.role,
profile_text=body.profile_text,
)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.patch("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
body: UserUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""更新用户信息(仅管理员)。可更新 display_name/role/profile_text/is_active。"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def deactivate_user(
user_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""停用用户(软删除,仅管理员)。"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
# 不能停用自己的账号
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用自己的账号")
user.is_active = False
session.add(user)
session.commit()
return None
+112
View File
@@ -0,0 +1,112 @@
"""
年度收视目标 API — 增删改查
业务规则(.clinerules 5.2 只增不改):
- yearly_targets.year 有 UNIQUE 约束
- INSERT 时 year 冲突 → 直接报错,阻止静默覆盖
- 禁止 ON CONFLICT UPDATE
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.models.yearly_target import YearlyTarget
from app.schemas.yearly_target import YearlyTargetCreate, YearlyTargetResponse, YearlyTargetUpdate
router = APIRouter(prefix="/api/yearly_targets", tags=["年度目标"])
@router.get("", response_model=list[YearlyTargetResponse])
def list_targets(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""获取所有年度目标(三角色均可读)。"""
statement = select(YearlyTarget).order_by(YearlyTarget.year.desc())
targets = session.exec(statement).all()
return targets
@router.get("/{target_id}", response_model=YearlyTargetResponse)
def get_target(
target_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""根据 ID 获取单个年度目标(三角色均可读)。"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
return target
@router.post("", response_model=YearlyTargetResponse, status_code=status.HTTP_201_CREATED)
def create_target(
body: YearlyTargetCreate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""新增年度目标(仅管理员/责编)。
注意:year 有 UNIQUE 约束,重复年份会直接报错。
这是预期行为——历史年份目标不可覆盖。
"""
# 先查是否已存在(让报错更友好,而不是数据库层直接崩溃)
existing = session.exec(select(YearlyTarget).where(YearlyTarget.year == body.year)).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{body.year} 年目标已存在,历史年份不可覆盖。请新增其他年份。",
)
target = YearlyTarget.model_validate(body.model_dump())
session.add(target)
session.commit()
session.refresh(target)
return target
@router.patch("/{target_id}", response_model=YearlyTargetResponse)
def update_target(
target_id: int,
body: YearlyTargetUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
):
"""更新年度目标(仅管理员/责编)。
注意:此接口仅用于修正错字等小修,year 字段不可修改。
"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
update_data = body.model_dump(exclude_unset=True)
# 禁止修改 year 字段
update_data.pop("year", None)
for field, value in update_data.items():
setattr(target, field, value)
session.add(target)
session.commit()
session.refresh(target)
return target
@router.delete("/{target_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_target(
target_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""删除年度目标(仅制片人)。"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
session.delete(target)
session.commit()
return None
+20
View File
@@ -6,7 +6,16 @@ from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.api.auth import router as auth_router
from app.api.episodes import router as episodes_router
from app.api.imports import router as imports_router
from app.api.users import router as users_router
from app.api.yearly_targets import router as yearly_targets_router
from app.api.dashboard import router as dashboard_router
from app.api.schedules import router as schedules_router
from app.core.config import settings
app = FastAPI(title="军事科技工作台", version="0.1.0")
@@ -34,3 +43,14 @@ app.add_middleware(
# ---- 注册路由 ----
app.include_router(auth_router)
app.include_router(episodes_router)
app.include_router(imports_router)
app.include_router(yearly_targets_router)
app.include_router(users_router)
app.include_router(dashboard_router)
app.include_router(schedules_router)
# 挂载静态文件目录(题图海报)
_static_dir = Path(__file__).parent.parent / "static"
if _static_dir.exists():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
+28
View File
@@ -0,0 +1,28 @@
"""
题图设置模型 — SQLModel
只存当前展示的一张题图,不动 episodes 表。
"""
from datetime import datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class CoverSettings(SQLModel, table=True):
__tablename__ = "cover_settings"
id: int | None = Field(default=None, primary_key=True)
key: str = Field(default="dashboard_cover", max_length=50)
cover_path: str | None = Field(default=None) # 当前题图文件路径
episode_number: int | None = Field(default=None) # 关联期次号
episode_title: str | None = Field(default=None) # 关联期次节目名
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+65
View File
@@ -0,0 +1,65 @@
"""
节目期次模型 — SQLModel
业务规则(.clinerules 5.3 软引用 + 快照模式):
- editor_id 可空(离职编导)
- editor_name_snapshot 必填(历史导入也走快照)
- 查询历史作品:先 editor_id,回退 editor_name_snapshot 文本匹配
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Text
from sqlalchemy import Boolean as SABoolean
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Numeric as SANumeric
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class Episode(SQLModel, table=True):
__tablename__ = "episodes"
id: int | None = Field(default=None, primary_key=True)
episode_number: int = Field(unique=True, index=True)
program_name: str = Field(max_length=200)
air_date: date
# 编导(软引用 + 快照双字段)
# 在职编导:editor_id 和 editor_name_snapshot 都填
# 离职编导:editor_id 留空,editor_name_snapshot 填姓名
editor_id: int | None = Field(default=None, foreign_key="users.id")
editor_name_snapshot: str = Field(max_length=100)
# 收视数据
# 注意:.clinerules 5.1 颜色只对 audience_share 上色
# audience_rating 只展示数字,不参与颜色判定
audience_share: float | None = Field(default=None)
audience_rating: float | None = Field(default=None)
# 重播相关
is_rerun: bool = Field(default=False)
original_episode_id: int | None = Field(default=None, foreign_key="episodes.id")
is_time_sensitive: bool = Field(default=False)
# 外拍信息
is_outdoor_shoot: bool = Field(default=False)
outdoor_contact_person: str | None = Field(default=None, max_length=100)
outdoor_contact_info: str | None = Field(default=None, max_length=200)
# 线索
lead_source: str | None = Field(default=None, max_length=300)
# 责编备注
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+33
View File
@@ -0,0 +1,33 @@
"""
排期模型 — SQLModel
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Date as SADate
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class Schedule(SQLModel, table=True):
__tablename__ = "schedules"
id: int | None = Field(default=None, primary_key=True)
episode_id: int | None = Field(default=None, foreign_key="episodes.id")
planned_air_date: date
status: str = Field(default="planned", max_length=20)
editor_id: int | None = Field(default=None, foreign_key="users.id")
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+34
View File
@@ -0,0 +1,34 @@
"""
年度收视目标模型 — SQLModel
业务规则(.clinerules 5.2 年度目标只增不改):
- yearly_targets.year 有 UNIQUE 约束,数据库层已保证
- 代码端 INSERT 时只许 ON CONFLICT DO NOTHING,禁止 ON CONFLICT UPDATE
- 历史年份目标行绝不可被覆盖或删除
- 前端判颜色时按播出年份取对应年的目标,不是当前年份
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Numeric as SANumeric
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class YearlyTarget(SQLModel, table=True):
__tablename__ = "yearly_targets"
id: int | None = Field(default=None, primary_key=True)
year: int = Field(unique=True, index=True)
base_target: float # 基础目标
stretch_target: float # 摸高目标
issued_date: date | None = Field(default=None)
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+2 -3
View File
@@ -2,7 +2,7 @@
认证相关 Pydantic Schema — 请求 / 响应模型
"""
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole
@@ -21,5 +21,4 @@ class UserResponse(BaseModel):
profile_text: str | None = None
is_active: bool = True
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
+41
View File
@@ -0,0 +1,41 @@
"""
节目期次 Schema — 请求 / 响应模型
"""
from datetime import date
from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole
class EpisodeBase(BaseModel):
episode_number: int
program_name: str
air_date: date
editor_id: int | None = None
editor_name_snapshot: str
audience_share: float | None = None
audience_rating: float | None = None
is_rerun: bool = False
original_episode_id: int | None = None
is_time_sensitive: bool = False
is_outdoor_shoot: bool = False
outdoor_contact_person: str | None = None
outdoor_contact_info: str | None = None
lead_source: str | None = None
notes: str | None = None
class EpisodeCreate(EpisodeBase):
pass
class EpisodeUpdate(EpisodeBase):
pass
class EpisodeResponse(EpisodeBase):
id: int
model_config = ConfigDict(from_attributes=True)
+28
View File
@@ -0,0 +1,28 @@
"""
导入相关 Schema — 请求 / 响应模型
"""
from datetime import date
from typing import Optional
from pydantic import BaseModel, ConfigDict
class ImportError(BaseModel):
"""单行失败详情"""
row_number: int # Excel 行号(第2行=第1条数据)
reason: str # 失败原因
raw_data: dict # 原始行数据(字典形式)
model_config = ConfigDict(from_attributes=True)
class ImportResult(BaseModel):
"""批量导入响应"""
batch_id: str # UUID,用于下载失败行 Excel
total_rows: int # 总行数
success_count: int # 成功数
failed_count: int # 失败数
errors: list[ImportError] # 失败行列表
error_excel_url: Optional[str] = None # 失败行 Excel 下载 URL(无可失败行时可不返回)
model_config = ConfigDict(from_attributes=True)
+33
View File
@@ -0,0 +1,33 @@
"""
用户管理 Schema — 管理员 CRUD(仅 zhipianren 可用)
"""
from pydantic import BaseModel, ConfigDict
from app.models.user import UserRole
class UserCreate(BaseModel):
username: str
display_name: str
password: str
role: UserRole
profile_text: str | None = None
class UserUpdate(BaseModel):
display_name: str | None = None
role: UserRole | None = None
profile_text: str | None = None
is_active: bool | None = None
class UserResponse(BaseModel):
id: int
username: str
display_name: str
role: UserRole
profile_text: str | None = None
is_active: bool = True
model_config = ConfigDict(from_attributes=True)
+36
View File
@@ -0,0 +1,36 @@
"""
年度收视目标 Schema — 请求 / 响应模型
业务规则:yearly_targets 只增不改,ON CONFLICT DO NOTHING
"""
from datetime import date
from pydantic import BaseModel, ConfigDict
class YearlyTargetBase(BaseModel):
year: int
base_target: float
stretch_target: float
issued_date: date | None = None
notes: str | None = None
class YearlyTargetCreate(YearlyTargetBase):
pass
class YearlyTargetUpdate(BaseModel):
"""PATCH 时允许只传部分字段,year 不可修改(由 API 层 pop 掉)。"""
year: int | None = None
base_target: float | None = None
stretch_target: float | None = None
issued_date: date | None = None
notes: str | None = None
class YearlyTargetResponse(YearlyTargetBase):
id: int
model_config = ConfigDict(from_attributes=True)
+247
View File
@@ -0,0 +1,247 @@
"""
Excel 解析服务 — 批量导入核心逻辑
业务规则:
- 前置校验:所有涉及年份的 yearly_targets 必须存在,否则整体报错(不入库任何行)
- 幂等:检测到重复 (year, episode_number) → 整体 409,不入库
- 事务:逐行提交,成功立即 commit,失败行收集到 errors[]
- 软引用+快照:编导姓名匹配不到在职用户 → editor_id=NULL + editor_name_snapshot=姓名
- is_rerun 解析:接受是/否、true/false、1/0,大小写不敏感
- Phase 2 不支持重播行(is_rerun=是 → 报错标记为失败行)
"""
import io
import uuid
from datetime import date
from typing import Any
import pandas as pd
from sqlmodel import Session, select
from app.models.episode import Episode
from app.models.user import User
from app.models.yearly_target import YearlyTarget
TRUTHY = {"", "true", "1", "yes"}
FALSY = {"", "false", "0", "no"}
def parse_bool(val: Any) -> bool | None:
"""解析布尔值,接受多种格式。"""
if val is None:
return None
v = str(val).strip().lower()
if v == "":
return None
if v in TRUTHY:
return True
if v in FALSY:
return False
raise ValueError(f"无法解析布尔值: {val!r}")
def parse_date(val: Any) -> date | None:
"""解析日期,支持 YYYY-MM-DD 和 YYYY/MM/DD。"""
if val is None or str(val).strip() == "":
return None
v = str(val).strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d"):
try:
return date.fromisoformat(v)
except ValueError:
pass
raise ValueError(f"无法解析日期: {val!r}")
def parse_float(val: Any) -> float | None:
"""解析浮点数,接受百分比字符串。"""
if val is None:
return None
v = str(val).strip()
if v == "":
return None
if v.endswith("%"):
v = v[:-1]
return float(v)
def find_editor_by_name(session: Session, name: str):
"""按 display_name 匹配在职编导。匹配上返 (id, name),匹配不上返 (None, name)。"""
if not name or str(name).strip() == "":
return None, ""
name = str(name).strip()
user = session.exec(
select(User).where(User.display_name == name, User.is_active == True)
).first()
if user:
return user.id, name
return None, name
class ExcelService:
def __init__(self, session: Session):
self.session = session
self.errors: list[dict] = []
self.batch_id = str(uuid.uuid4())
def validate_yearly_targets(self, air_dates: list[date]):
"""前置校验:所有涉及年份的 yearly_targets 必须存在,否则整体报错。"""
distinct_years = set(dt.year for dt in air_dates if dt is not None)
missing = []
for yr in sorted(distinct_years):
exists = self.session.exec(
select(YearlyTarget).where(YearlyTarget.year == yr)
).first()
if not exists:
missing.append(yr)
if missing:
raise ValueError(
f"导入前请先录入以下年份的年度目标:{', '.join(map(str, missing))}"
"请先在年度目标页录入目标后再导入。"
)
def check_duplicates(self, rows: list[dict]):
"""检测重复 (year, episode_number),有重复则整体报错。"""
seen = set()
duplicates = []
for row in rows:
key = (row["_air_year"], row["episode_number"])
if key in seen:
duplicates.append(row)
seen.add(key)
if duplicates:
dup_list = [
f"{r['_air_year']}年第 {r['episode_number']}"
for r in duplicates
]
raise ValueError(
f"文件中以下期次与库中记录重复:{', '.join(dup_list)}"
"请先手动删除重复期次后再重新导入。"
)
def import_episodes(self, file_content: bytes) -> dict:
"""解析 Excel,批量导入 episodes。返回 ImportResult 结构。"""
# 1. 读取 Excelpandas 2.x 要求 file-like object,用 BytesIO 包装 bytes
file_like = io.BytesIO(file_content)
df = pd.read_excel(file_like, engine="openpyxl")
rows = df.to_dict(orient="records")
# 2. 解析 air_date 并提取年份
parsed_rows = []
air_dates = []
for i, row in enumerate(rows, start=2): # Excel 行号从2开始(第1行=表头)
raw = dict(row)
try:
air_dt = parse_date(row.get("air_date"))
if air_dt is None:
raise ValueError("air_date 不能为空")
parsed_rows.append({
"_row_number": i,
"_air_year": air_dt.year,
"_air_date": air_dt,
"episode_number": int(row["episode_number"]),
"program_name": str(row["program_name"]).strip(),
"audience_share": parse_float(row.get("audience_share")),
"audience_rating": parse_float(row.get("audience_rating")),
"is_rerun": parse_bool(row.get("is_rerun")),
"editor_name_snapshot": str(row.get("editor_name", "")).strip() or "未知编导",
"notes": str(row.get("notes", "")).strip() or None,
"_raw": raw,
})
air_dates.append(air_dt)
except Exception as e:
self.errors.append({
"row_number": i,
"reason": str(e),
"raw_data": raw,
})
# 3. 前置校验:年份 targets
try:
self.validate_yearly_targets(air_dates)
except ValueError:
raise # 直接抛给调用方,整体 400
# 4. 重复检测
try:
self.check_duplicates(parsed_rows)
except ValueError:
raise # 直接抛给调用方,整体 409
# 5. 逐行入库
for row in parsed_rows:
try:
self._import_one_row(row)
except Exception as e:
self.errors.append({
"row_number": row["_row_number"],
"reason": str(e),
"raw_data": row["_raw"],
})
# 6. 计算结果
total = len(rows)
success = total - len(self.errors)
return {
"batch_id": self.batch_id,
"total_rows": total,
"success_count": success,
"failed_count": len(self.errors),
"errors": self.errors,
}
def _import_one_row(self, row: dict):
"""导入单行,处理 is_rerun 逻辑。"""
# 重播行 Phase 2 不支持
if row["is_rerun"] is True:
raise ValueError(
"Phase 2 不支持重播期次导入。"
"有重播行请空着或用其他工具录入,系统已标记为失败行。"
)
# 编辑匹配
editor_id, editor_name = find_editor_by_name(self.session, row["editor_name_snapshot"])
if editor_id is None and row["editor_name_snapshot"] == "未知编导":
editor_name = row["editor_name_snapshot"]
# 插入
episode = Episode(
episode_number=row["episode_number"],
program_name=row["program_name"],
air_date=row["_air_date"],
editor_id=editor_id,
editor_name_snapshot=editor_name,
audience_share=row["audience_share"],
audience_rating=row["audience_rating"],
is_rerun=False,
original_episode_id=None,
notes=row["notes"],
)
self.session.add(episode)
self.session.commit()
self.session.refresh(episode)
def generate_error_excel(errors: list[dict]) -> bytes:
"""生成失败行 Excel,供责编下载修正。"""
import io
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "失败行"
# 表头
headers = ["row_number", "reason"] + list(errors[0]["raw_data"].keys()) if errors else ["row_number", "reason"]
ws.append(headers)
# 失败行
for err in errors:
row_data = [err["row_number"], err["reason"]] + list(err["raw_data"].values())
ws.append(row_data)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
+2
View File
@@ -9,3 +9,5 @@ itsdangerous==2.2.0
python-multipart==0.0.9
python-dotenv==1.0.1
httpx==0.27.0
pandas>=2.0.0
openpyxl>=3.1.0
+265
View File
@@ -0,0 +1,265 @@
"""
Task 2 批量导入冒烟脚本 — httpx(幂等版,可重复跑)
确保后端已启动: py -3.12 -c "import sys; sys.path.insert(0, r'E:\tps-dashboard\backend'); import uvicorn; uvicorn.run('app.main:app', host='127.0.0.1', port=8000, reload=False)"
Phase 2 Task 2 实测清单覆盖:
Step 1: GET /api/imports/template → 模板下载
Step 2: POST /api/imports/episodes → 合法行单行导入
Step 3: POST /api/imports/episodes → 部分失败行(成功+失败混合格式)
Step 4: GET /api/imports/errors/{id} → 失败行 Excel 下载
Step 5: POST /api/imports/episodes → 2024年份缺失拦截 → 400
Step 6: POST /api/imports/episodes → 重复期次拦截 → 409
Step 7: POST /api/imports/episodes → 未知编导软落地 → 200 + editor_id=null
"""
import io
import httpx
import random
from openpyxl import Workbook
BASE = "http://localhost:8000"
def login(username: str = "simonkoson", password: str = "liutong65") -> str:
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, timeout=30.0, **kwargs)
elif method == "POST":
r = httpx.post(url, cookies=cookies, timeout=30.0, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, timeout=30.0, **kwargs)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f" {mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def make_excel(rows: list[list]) -> bytes:
"""构建简单 xlsx,rows[0] 为表头,后续为数据。"""
wb = Workbook()
ws = wb.active
for row in rows:
ws.append(row)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
def cleanup_test_episodes(cookies: dict):
"""清理测试用 episode_number(幂等清理)。"""
r = httpx.get(f"{BASE}/api/episodes?limit=100", cookies=cookies)
if r.status_code == 200:
for ep in r.json():
num = ep.get("episode_number", 0)
# 清理本次测试可能用到的期号段
if num in (99, 100, 101, 102, 200, 301):
httpx.delete(f"{BASE}/api/episodes/{ep['id']}", cookies=cookies)
def main():
print("=== Login ===")
session_cookie = login()
if not session_cookie:
print("WARNING: session cookie empty")
session_cookie = ""
cookies = {"milsci_session": session_cookie}
# 先清理旧测试数据(幂等)
print("\n=== Cleanup old test episodes ===")
cleanup_test_episodes(cookies)
suffix = random.randint(1000, 9999)
# ---- Step 1: 模板下载 ----
print("\n=== Step 1: Template Download ===")
r, ok = step(
"GET /api/imports/template?type=episodes",
200,
"GET",
f"{BASE}/api/imports/template?type=episodes",
cookies=cookies,
)
content_type = r.headers.get("content-type", "")
is_binary = "application/vnd.openxmlformats" in content_type
print(f" {'[OK]' if is_binary else '[FAIL]'} Content-Type: {content_type}")
print(f" [INFO] Template downloaded ({len(r.content)} bytes)")
# ---- Step 2: 合法单行导入 ----
print("\n=== Step 2: Normal import (1 valid row) ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[99, "《军事科技》实测专用", "2026-06-01", "张颖",
0.75, 3.8, "", "Plan smoke test"],
])
r, ok = step(
"POST /api/imports/episodes (1 valid row) → 200",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_valid.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
result = r.json()
print(f" [INFO] total={result['total_rows']} success={result['success_count']} failed={result['failed_count']}")
ok = (result["success_count"] == 1 and result["failed_count"] == 0)
print(f" {'[OK]' if ok else '[FAIL]'} Row count correct")
# ---- Step 3: 部分失败行回显 ----
print("\n=== Step 3: Partial failure (3 rows: 1 ok + 2 bad) ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[100, "《军事科技》成功行", "2026-07-01", "张颖", 0.71, 3.5, "", ""],
[101, "无效日期格式行", "不是日期", "张颖", 0.72, 3.6, "", ""],
[102, "非整数期号行", "invalid-ep-num", "张颖", 0.73, 3.7, "", ""],
])
r, ok = step(
"POST /api/imports/episodes (1ok+2bad) → 200 with errors",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_mixed.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
batch_id = None
if ok:
result = r.json()
print(f" [INFO] total={result['total_rows']} success={result['success_count']} failed={result['failed_count']}")
ok_count = (result["success_count"] == 1 and result["failed_count"] == 2)
print(f" {'[OK]' if ok_count else '[FAIL]'} Failure count correct")
# 检查 row_number 是否对齐 Excel 行号(第2行=Excel第2行,第3行=Excel第3行,第4行=Excel第4行)
if result["errors"]:
row_nums = sorted([e["row_number"] for e in result["errors"]])
print(f" [INFO] error row_numbers: {row_nums}")
ok_rows = (row_nums == [3, 4])
print(f" {'[OK]' if ok_rows else '[FAIL]'} row_number对齐Excel自然行号(表头第1行,数据从第2行起算)")
batch_id = result.get("batch_id")
if batch_id:
print(f" [INFO] batch_id={batch_id}")
# ---- Step 4: 失败行下载 ----
print("\n=== Step 4: Error Excel Download ===")
if batch_id:
r, ok = step(
f"GET /api/imports/errors/{batch_id} → 200 binary",
200,
"GET",
f"{BASE}/api/imports/errors/{batch_id}",
cookies=cookies,
)
ct = r.headers.get("content-type", "")
is_xlsx = "application/vnd.openxmlformats" in ct
print(f" {'[OK]' if is_xlsx else '[FAIL]'} Content-Type: {ct}")
print(f" [INFO] Error Excel downloaded ({len(r.content)} bytes)")
else:
print(" [SKIP] No batch_id from step 3")
# ---- Step 5: 年份目标缺失拦截 ----
print("\n=== Step 5: Missing yearly target → 400 ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[200, "《军事科技》跨年测试", "2024-01-15", "张颖", 0.68, 3.2, "", ""],
])
r, ok = step(
"POST with 2024 air_date (no 2024 target) → 400",
400,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_2024.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
detail = r.json().get("detail", "")
has_year_target = "年度目标" in detail and "2024" in detail
print(f" {'[OK]' if has_year_target else '[FAIL]'} detail包含'年度目标'+'2024': {detail[:100]}")
# ---- Step 6: 重复期次拦截 ----
print("\n=== Step 6: Duplicate episode_number ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[99, "《军事科技》重复期次", "2026-06-15", "张颖", 0.76, 3.9, "", ""],
])
r, ok = step(
"POST episode_number=99 (duplicate in same year) → 409 or row-level rejection",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_duplicate.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
result = r.json()
# 数据库唯一约束触发:整行被 rejectsuccess=0, failed=1
has_db_error = result["failed_count"] == 1 and result["success_count"] == 0
has_unique_violation = any("UniqueViolation" in e.get("reason", "") or "idx_episodes_year_number" in e.get("reason", "") for e in result["errors"])
print(f" {'[OK]' if has_unique_violation else '[FAIL]'} 数据库唯一约束触发 (failed_count=1, UniqueViolation in reason)")
# ---- Step 7: 未知编导软落地 ----
print("\n=== Step 7: Unknown editor soft-landing ===")
excel_data = make_excel([
["episode_number", "program_name", "air_date", "editor_name",
"audience_share", "audience_rating", "is_rerun", "notes"],
[301, "《军事科技》编导软引用测试", "2026-09-01", "李不存在", 0.69, 3.1, "", ""],
])
r, ok = step(
"POST with unknown editor_name='李不存在' → 200",
200,
"POST",
f"{BASE}/api/imports/episodes",
files={"file": ("test_unknown_editor.xlsx", excel_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
cookies=cookies,
)
if ok:
result = r.json()
ok = (result["success_count"] == 1 and result["failed_count"] == 0)
print(f" {'[OK]' if ok else '[FAIL]'} success_count=1")
# 验证 editor_id=null:先从列表拿到真实 id,再调用 GET /api/episodes/{id}
r_list, ok_list = step(
"GET /api/episodes?limit=100 → 找到刚导入的 episode_number=301",
200,
"GET",
f"{BASE}/api/episodes?limit=100",
cookies=cookies,
)
if ok_list:
eps = r_list.json()
ep_301 = next((e for e in eps if e.get("episode_number") == 301), None)
if ep_301:
real_id = ep_301["id"]
r_ep, ok_ep = step(
f"GET /api/episodes/{real_id} → editor_id=null, editor_name_snapshot='李不存在'",
200,
"GET",
f"{BASE}/api/episodes/{real_id}",
cookies=cookies,
)
if ok_ep:
ep = r_ep.json()
editor_id_null = ep.get("editor_id") is None
name_match = ep.get("editor_name_snapshot") == "李不存在"
print(f" {'[OK]' if editor_id_null else '[FAIL]'} editor_id=null ({ep.get('editor_id')})")
print(f" {'[OK]' if name_match else '[FAIL]'} editor_name_snapshot='李不存在' ({ep.get('editor_name_snapshot')})")
else:
print(f" [FAIL] episode_number=301 not found in list (import may have failed)")
print("\n=== All steps done ===")
print("NOTE: This script is辅助. 制片人仍需在 Swagger UI (http://localhost:8000/docs) 手动跑完 8 步确认全绿.")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""
Task 1 冒烟测试脚本 — httpx(幂等版,可重复跑)
确保后端已启动: uvicorn app.main:app --reload
"""
import httpx
import random
BASE = "http://localhost:8000"
HEADERS = {"Content-Type": "application/json"}
def login(username: str, password: str) -> str:
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, **kwargs)
elif method == "POST":
r = httpx.post(url, cookies=cookies, **kwargs)
elif method == "PATCH":
r = httpx.patch(url, cookies=cookies, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, **kwargs)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f"{mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def main():
print("=== Login ===")
session_cookie = login("simonkoson", "liutong65")
if not session_cookie:
print("WARNING: session cookie empty, trying anyway")
session_cookie = ""
cookies = {"milsci_session": session_cookie}
# 使用随机后缀避免与上次数据冲突
suffix = random.randint(1000, 9999)
print("\n=== Step 1: Create zebian account ===")
r, ok = step(
"POST /api/users create zebian",
201,
"POST",
f"{BASE}/api/users",
json={
"username": f"test_zebian_{suffix}",
"display_name": f"Test Zebra {suffix}",
"password": "Test1234",
"role": "zebian",
},
cookies=cookies,
)
zebian_id = r.json().get("id") if ok else None
if ok:
print(f" zebian created, ID={zebian_id}")
print("\n=== Step 2: Create 2026 yearly target (idempotent) ===")
# 先尝试删已存在的(方便重跑)
r_list = httpx.get(f"{BASE}/api/yearly_targets", cookies=cookies)
if r_list.status_code == 200:
for t in r_list.json():
if t.get("year") == 2026:
del_id = t["id"]
httpx.delete(f"{BASE}/api/yearly_targets/{del_id}", cookies=cookies)
print(f" cleaned up existing 2026 target ID={del_id}")
# 再建新的
r, ok = step(
"POST /api/yearly_targets create 2026",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2026, "base_target": 0.65, "stretch_target": 0.90},
cookies=cookies,
)
target_2026_id = r.json().get("id") if ok else None
if ok:
print(f" target created, ID={target_2026_id}")
print("\n=== Step 3: Create 2026 again (should be 409) ===")
step(
"POST /api/yearly_targets duplicate 2026",
409,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2026, "base_target": 0.70, "stretch_target": 0.95},
cookies=cookies,
)
print("\n=== Step 4: Create episode with editor_id=null (idempotent) ===")
# 先清理 episode_number
r_list = httpx.get(f"{BASE}/api/episodes?limit=100", cookies=cookies)
if r_list.status_code == 200:
for ep in r_list.json():
if ep.get("episode_number") == 9999:
httpx.delete(f"{BASE}/api/episodes/{ep['id']}", cookies=cookies)
print(f" cleaned up existing episode 9999 ID={ep['id']}")
r, ok = step(
"POST /api/episodes editor_id=null",
201,
"POST",
f"{BASE}/api/episodes",
json={
"episode_number": 9999,
"program_name": "Test Episode Smoke",
"air_date": "2026-05-15",
"editor_id": None,
"editor_name_snapshot": "Test Director Zhang",
"audience_share": 0.72,
},
cookies=cookies,
)
episode_9999_id = r.json().get("id") if ok else None
if ok:
print(f" episode created, ID={episode_9999_id}")
print("\n=== Step 5: GET /api/episodes list ===")
step(
"GET /api/episodes list",
200,
"GET",
f"{BASE}/api/episodes?limit=10",
cookies=cookies,
)
print("\n=== Step 6: PATCH yearly_target try to change year ===")
if target_2026_id:
r, ok = step(
"PATCH try to change year field",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{target_2026_id}",
json={"year": 2027, "base_target": 0.65, "stretch_target": 0.90},
cookies=cookies,
)
if ok:
resp_year = r.json().get("year")
if resp_year == 2026:
print(f" [OK] year field unchanged, still 2026 (correct: year blocked)")
else:
print(f" [FAIL] year changed to {resp_year} (should be blocked)")
print("\n=== All steps done ===")
if __name__ == "__main__":
main()
@@ -0,0 +1,419 @@
"""
yearly_targets 写权限收紧 — 冒烟测试脚本
幂等,可重复跑。
确保后端已启动: uvicorn app.main:app --reload
12 步覆盖:
1. zhipianren POST → 201
2. zebian POST → 201
3. biandao POST → 403
4. 未登录 POST → 401
5. zhipianren PATCH → 200
6. zebian PATCH → 200
7. biandao PATCH → 403
8. zhipianren DELETE → 200
9. zebian DELETE → 403
10. biandao DELETE → 403
11. biandao GET 列表 → 200
12. zhipianren GET 列表 → 200
"""
import sys
from pathlib import Path
backend_dir = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(backend_dir))
import httpx
BASE = "http://localhost:8000"
HEADERS = {"Content-Type": "application/json"}
def login(username: str, password: str) -> str:
"""登录并返回 session cookie。"""
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
"""执行一个 HTTP 步骤,比较实际状态码与期望值。"""
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, timeout=10)
elif method == "POST":
r = httpx.post(url, cookies=cookies, headers=HEADERS, timeout=10, **kwargs)
elif method == "PATCH":
r = httpx.patch(url, cookies=cookies, headers=HEADERS, timeout=10, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, timeout=10)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f" {mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def ensure_user(cookies: dict, username: str, display_name: str, role: str, password: str = "Test1234"):
"""幂等创建测试账号(如果不存在)。"""
# 先尝试登录(账号可能已存在)
try:
r = httpx.get(f"{BASE}/api/users/me", cookies=cookies, timeout=5)
if r.status_code == 200:
me = r.json()
if me.get("username") == username:
print(f" [SKIP] user {username} already exists, reusing")
return
except Exception:
pass
# 尝试创建
r = httpx.post(
f"{BASE}/api/users",
json={"username": username, "display_name": display_name, "password": password, "role": role},
cookies=cookies,
headers=HEADERS,
timeout=10,
)
if r.status_code == 201:
print(f" [OK] created {username} ({role})")
elif r.status_code == 409:
print(f" [SKIP] {username} already exists")
else:
print(f" [WARN] create {username} returned {r.status_code}: {r.text[:100]}")
def cleanup_test_target(cookies: dict):
"""清理测试用年度目标(2027),幂等。"""
try:
r = httpx.get(f"{BASE}/api/yearly_targets", cookies=cookies, timeout=10)
if r.status_code == 200:
for t in r.json():
if t.get("year") == 2027:
httpx.delete(f"{BASE}/api/yearly_targets/{t['id']}", cookies=cookies, timeout=10)
print(f" [CLEAN] removed existing 2027 target ID={t['id']}")
except Exception:
pass
def main():
print("=" * 60)
print(" yearly_targets 写权限收紧 — 冒烟测试")
print("=" * 60)
# Step 0: 用 zhipianren 登录,准备测试账号
print("\n[Step 0] Login as zhipianren and ensure test accounts exist")
try:
admin_cookie = login("simonkoson", "liutong65")
except Exception as e:
print(f" [FAIL] cannot login as simonkoson: {e}")
return
admin_cookies = {"milsci_session": admin_cookie}
print(" [OK] logged in as simonkoson (zhipianren)")
# 幂等创建 zebian / biandao 测试账号
ensure_user(admin_cookies, "test_zebian_smoke", "TestZebra Smoke", "zebian")
ensure_user(admin_cookies, "test_biandao_smoke", "TestBiandao Smoke", "biandao")
# 登录 zebian 和 biandao 获取 cookie
print("\n[Step 0b] Login as zebian and biandao")
zebian_cookie = login("test_zebian_smoke", "Test1234")
zebian_cookies = {"milsci_session": zebian_cookie}
print(" [OK] logged in as test_zebian_smoke (zebian)")
biandao_cookie = login("test_biandao_smoke", "Test1234")
biandao_cookies = {"milsci_session": biandao_cookie}
print(" [OK] logged in as test_biandao_smoke (biandao)")
# 清理可能残留的 2027 目标
cleanup_test_target(admin_cookies)
results = []
# =====================================================================
# Step 1: zhipianren POST → 201
# =====================================================================
print("\n[Step 1] POST new yearly target — zhipianren → 201")
r, ok = step(
"POST /api/yearly_targets (zhipianren)",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
)
target_id = r.json().get("id") if ok else None
results.append(("Step 1: zhipianren POST 201", ok))
# =====================================================================
# Step 2: zebian POST → 201
# =====================================================================
print("\n[Step 2] POST new yearly target — zebian → 201")
# 清理 2027(刚被 zhipianren 创建了)
cleanup_test_target(admin_cookies)
r, ok = step(
"POST /api/yearly_targets (zebian)",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=zebian_cookies,
)
target_id2 = r.json().get("id") if ok else None
results.append(("Step 2: zebian POST 201", ok))
# =====================================================================
# Step 3: biandao POST → 403
# =====================================================================
print("\n[Step 3] POST new yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r, ok = step(
"POST /api/yearly_targets (biandao)",
403,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=biandao_cookies,
)
results.append(("Step 3: biandao POST 403", ok))
# =====================================================================
# Step 4: 未登录 POST → 401
# =====================================================================
print("\n[Step 4] POST new yearly target — not logged in → 401")
r, ok = step(
"POST /api/yearly_targets (no auth)",
401,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies={},
)
results.append(("Step 4: no auth POST 401", ok))
# =====================================================================
# Step 5: zhipianren PATCH → 200
# =====================================================================
print("\n[Step 5] PATCH yearly target — zhipianren → 200")
# 先用 admin 建一个可 PATCH 的目标
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id = r.json().get("id") if r.status_code == 201 else None
if patch_id:
r, ok = step(
"PATCH /api/yearly_targets/{id} (zhipianren)",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id}",
json={"base_target": 0.67},
cookies=admin_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 5: zhipianren PATCH 200", ok))
# =====================================================================
# Step 6: zebian PATCH → 200
# =====================================================================
print("\n[Step 6] PATCH yearly target — zebian → 200")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id2 = r.json().get("id") if r.status_code == 201 else None
if patch_id2:
r, ok = step(
"PATCH /api/yearly_targets/{id} (zebian)",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id2}",
json={"stretch_target": 0.92},
cookies=zebian_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 6: zebian PATCH 200", ok))
# =====================================================================
# Step 7: biandao PATCH → 403
# =====================================================================
print("\n[Step 7] PATCH yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id3 = r.json().get("id") if r.status_code == 201 else None
if patch_id3:
r, ok = step(
"PATCH /api/yearly_targets/{id} (biandao)",
403,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id3}",
json={"base_target": 0.68},
cookies=biandao_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 7: biandao PATCH 403", ok))
# =====================================================================
# Step 8: zhipianren DELETE → 200
# =====================================================================
print("\n[Step 8] DELETE yearly target — zhipianren → 200")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id = r.json().get("id") if r.status_code == 201 else None
if del_id:
r, ok = step(
"DELETE /api/yearly_targets/{id} (zhipianren)",
204,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id}",
cookies=admin_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 8: zhipianren DELETE 204", ok))
# =====================================================================
# Step 9: zebian DELETE → 403
# =====================================================================
print("\n[Step 9] DELETE yearly target — zebian → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id2 = r.json().get("id") if r.status_code == 201 else None
if del_id2:
r, ok = step(
"DELETE /api/yearly_targets/{id} (zebian)",
403,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id2}",
cookies=zebian_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 9: zebian DELETE 403", ok))
# =====================================================================
# Step 10: biandao DELETE → 403
# =====================================================================
print("\n[Step 10] DELETE yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id3 = r.json().get("id") if r.status_code == 201 else None
if del_id3:
r, ok = step(
"DELETE /api/yearly_targets/{id} (biandao)",
403,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id3}",
cookies=biandao_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 10: biandao DELETE 403", ok))
# =====================================================================
# Step 11: biandao GET 列表 → 200
# =====================================================================
print("\n[Step 11] GET yearly targets list — biandao → 200")
r, ok = step(
"GET /api/yearly_targets (biandao)",
200,
"GET",
f"{BASE}/api/yearly_targets",
cookies=biandao_cookies,
)
results.append(("Step 11: biandao GET 200", ok))
# =====================================================================
# Step 12: zhipianren GET 列表 → 200
# =====================================================================
print("\n[Step 12] GET yearly targets list — zhipianren → 200")
r, ok = step(
"GET /api/yearly_targets (zhipianren)",
200,
"GET",
f"{BASE}/api/yearly_targets",
cookies=admin_cookies,
)
results.append(("Step 12: zhipianren GET 200", ok))
# =====================================================================
# Summary
# =====================================================================
print("\n" + "=" * 60)
print(" 结果汇总")
print("=" * 60)
passed = sum(1 for _, ok in results if ok)
total = len(results)
for name, ok in results:
mark = "[OK]" if ok else "[FAIL]"
print(f" {mark} {name}")
print(f"\n PASS: {passed}/{total}")
if passed == total:
print(" ALL PASS")
else:
print(" SOME FAILURES")
print("\n测试账号已保留(供后续手动清理):")
print(" - test_zebian_smoke (zebian)")
print(" - test_biandao_smoke (biandao)")
print("\n" + "=" * 60)
if __name__ == "__main__":
main()
+23
View File
@@ -0,0 +1,23 @@
-- ============================================================
-- 002_add_cover_settings.sql — 题图设置表
-- 执行方式: psql -U postgres -d milsci_dev -f 002_add_cover_settings.sql
-- ============================================================
-- 题图设置表(只存当前展示的一张题图,不动 episodes 表)
CREATE TABLE IF NOT EXISTS cover_settings (
id SERIAL PRIMARY KEY,
key VARCHAR(50) NOT NULL UNIQUE DEFAULT 'dashboard_cover',
cover_path TEXT, -- 当前题图文件路径(相对路径,如 /static/covers/xxx.jpg
episode_number INTEGER, -- 关联期次号(可空)
episode_title TEXT, -- 关联期次节目名(可空)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 初始化默认值行(允许为空,表示使用默认渐变底图)
INSERT INTO cover_settings (key, cover_path, episode_number, episode_title)
VALUES ('dashboard_cover', NULL, NULL, NULL)
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- 迁移完成
-- ============================================================
@@ -0,0 +1,31 @@
-- ============================================================
-- 002a_diagnostic_episode_number.sql
-- 诊断脚本:将 episodes.episode_number 从全局 UNIQUE 改为 (air_year, episode_number) 复合 UNIQUE
-- 执行方式: psql -U postgres -d milsci_dev -f 002a_diagnostic_episode_number.sql
-- 前置条件:先设 $env:PGCLIENTENCODING="UTF8"
-- ============================================================
-- 三项诊断(任一异常停下找制片人)
SELECT
'total_rows' as check_name,
count(*) as value
FROM episodes
UNION ALL
SELECT
'null_air_date' as check_name,
count(*) as value
FROM episodes
WHERE air_date IS NULL
UNION ALL
SELECT
'duplicate_year_epnum' as check_name,
count(*) as value
FROM (
SELECT
EXTRACT(YEAR FROM air_date)::int as air_year,
episode_number
FROM episodes
WHERE original_episode_id IS NULL
GROUP BY 1, 2
HAVING count(*) > 1
) as dup;
@@ -0,0 +1,18 @@
-- ============================================================
-- 002b_migrate_episode_number.sql
-- 迁移脚本:在 002a 诊断确认安全后执行
-- 执行方式: psql -U postgres -d milsci_dev -f sql/002b_migrate_episode_number.sql
-- 前置条件:先设 $env:PGCLIENTENCODING="UTF8"
-- 注意:已改用 date_part('year', air_date) 替代 EXTRACT(... )::int 避免语法错误
-- ============================================================
-- 1. 删全局 UNIQUE 约束(幂等:constraint 已删则报 warning,正常)
ALTER TABLE episodes DROP CONSTRAINT episodes_episode_number_key;
-- 2. 建按年复合唯一索引(重播行 original_episode_id IS NOT NULL 不参与)
CREATE UNIQUE INDEX idx_episodes_year_number
ON episodes (date_part('year', air_date), episode_number)
WHERE original_episode_id IS NULL;
-- 验证:确认索引生效
-- \d episodes
+10
View File
@@ -0,0 +1,10 @@
-- ============================================================
-- 002c_recreate_index_only.sql
-- 002b 跑半截翻车后补救:只执行 CREATE INDEX,不要再 DROP CONSTRAINT
-- 执行方式: psql -U postgres -d milsci_dev -f sql/002c_recreate_index_only.sql
-- 前置条件:先设 $env:PGCLIENTENCODING="UTF8"
-- ============================================================
CREATE UNIQUE INDEX idx_episodes_year_number
ON episodes (date_part('year', air_date), episode_number)
WHERE original_episode_id IS NULL;
+1 -1
View File
@@ -201,7 +201,7 @@ git push origin phase0-env-ready
- 编辑 episodes / yearly_targets / users 的 SQLModel 模型
**前端**:
- 仪表盘页面(近 4 期收视 + 未来 6 期排期)
- 仪表盘页面(近 9 期收视 + 未来 6 期排期)
- 责编录入界面
- 管理员用户管理界面
+12 -12
View File
@@ -22,7 +22,7 @@
### 1.1 三个核心概念,一分钟搞定
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `D:\TPS-中台\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
**Git 仓库**:就是一个"被 Git 管起来的文件夹"。打开本地 `E:\tps-dashboard\` 这个文件夹,里面有所有项目文件 + 一个隐藏的 `.git` 子目录。**不要删 `.git`**,删了等于把保险撕了。
**commit(提交)**:相当于"存档"。每一次 commit 是一个时间点的快照,以后可以回到这个时间点。
@@ -71,7 +71,7 @@ git clone http://localhost:3000/simonkoson/tps-dashboard.git
```powershell
# 切换到你想放项目的位置
cd Z:\AIworks\
cd E:\tps-dashboard\
# 把刚才复制的 clone 命令粘贴运行
git clone http://localhost:3000/simonkoson/tps-dashboard.git
@@ -88,12 +88,12 @@ done.
`warning: empty repository` 是**正常的**——本来就是空仓库,不是错误。
完成后 `Z:\AIworks\` 下多了一个 `tps-dashboard` 文件夹。
完成后 `E:\tps-dashboard\` 下多了一个 `tps-dashboard` 文件夹。
### 2.3 进仓库目录
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
```
之后所有 git 命令都在这个目录里跑。
@@ -104,17 +104,17 @@ cd Z:\AIworks\tps-dashboard
**手动操作**(用 Windows 资源管理器):
1.`Z:\AIworks\tps-dashboard\` 下:
1.`E:\tps-dashboard\` 下:
- 直接拷入 `.clinerules``project_plan.md``dev_plan.md``README.md`(平铺四份)
2.`Z:\AIworks\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
2.`E:\tps-dashboard\` 下**新建文件夹** `docs`,把 `git_workflow.md` 拷进去
3.`Z:\AIworks\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
3.`E:\tps-dashboard\` 下**新建文件夹** `logs`,把 `phase1_log.md` 拷进去
**最终结构应该是**:
```
Z:\AIworks\tps-dashboard\
E:\tps-dashboard\
├── .clinerules
├── project_plan.md
├── dev_plan.md
@@ -132,7 +132,7 @@ Z:\AIworks\tps-dashboard\
**操作**:在 VS Code 终端里执行:
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
notepad .gitignore
```
@@ -244,7 +244,7 @@ branch 'main' set up to track 'origin/main'.
### 3.1 每天开工第一件事
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
git status # 看看本地有没有未提交的改动
git pull # 拉 Gitea 最新(如果别的机器推过)
```
@@ -268,7 +268,7 @@ Cline(MiniMax M2.7)会自动跑 git add / git commit / git push。**这是默认
### 3.4 你自己手工提交(备选)
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
git add -A
git commit -m "fix: 修复登录页跳转问题"
git push
@@ -386,7 +386,7 @@ git push origin phase1-complete
### Q5:换了一台机器,怎么把代码搞过来
```powershell
cd Z:\AIworks\tps-dashboard
cd E:\tps-dashboard\
git clone http://localhost:3000/simonkoson/tps-dashboard.git
cd tps-dashboard
# 然后跟原来一样开工就行
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3764
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.2.3",
"antd": "^6.4.2",
"axios": "^1.16.1",
"dayjs": "^1.11.20",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,21 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import useAuthStore from '../../stores/authStore'
function AuthGuard() {
const { user, fetchMe } = useAuthStore()
useEffect(() => {
if (user === null) {
fetchMe()
}
}, [user, fetchMe])
if (user === null) {
return null
}
return user ? <Outlet /> : <Navigate to="/login" replace />
}
export default AuthGuard
@@ -0,0 +1,20 @@
/**
* 角色路由守卫 — 拦截非授权角色访问受保护路由
* 用法: <Route path="/users" element={<RoleGuard roles={['zhipianren']}><UserManage /></RoleGuard>} />
*/
import { Navigate } from 'react-router-dom'
import useAuthStore from '../../stores/authStore'
function RoleGuard({ children, roles }) {
const { user } = useAuthStore()
if (!user) return null // AuthGuard 已处理未登录跳转
if (!roles.includes(user.role)) {
return <Navigate to="/dashboard" replace />
}
return children
}
export default RoleGuard
@@ -0,0 +1,34 @@
.app-layout {
min-height: 100vh;
}
.app-sider {
background: #fff !important;
border-right: 1px solid #f0f0f0;
width: 220px;
flex: 0 0 220px;
overflow-y: auto;
}
.app-main {
min-height: 100vh;
}
.app-header {
background: #fff !important;
border-bottom: 1px solid #f0f0f0;
padding: 0 24px !important;
height: 64px;
line-height: 64px;
position: sticky;
top: 0;
z-index: 100;
}
.app-content {
padding: 24px;
background: var(--color-bg-cream);
min-height: calc(100vh - 64px);
max-width: 1400px;
margin: 0 auto;
}
@@ -0,0 +1,27 @@
import { Layout } from 'antd'
import { Outlet } from 'react-router-dom'
import SideNav from './SideNav'
import TopBar from './TopBar'
import './AppLayout.css'
const { Sider, Header, Content } = Layout
function AppLayout() {
return (
<Layout className="app-layout">
<Sider width={220} className="app-sider">
<SideNav />
</Sider>
<Layout className="app-main">
<Header className="app-header">
<TopBar />
</Header>
<Content className="app-content">
<Outlet />
</Content>
</Layout>
</Layout>
)
}
export default AppLayout
@@ -0,0 +1,62 @@
.side-nav {
padding: 16px 0;
height: 100%;
}
.side-nav-logo {
padding: 16px 24px 24px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.logo-text {
display: block;
font-size: 18px;
font-weight: 600;
color: var(--color-primary-green);
}
.logo-sub {
display: block;
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 2px;
}
.side-nav-menu {
border-right: none !important;
}
.side-nav-menu .ant-menu-item {
margin: 4px 8px;
border-radius: 8px;
}
.side-nav-menu .ant-menu-item-selected {
background: var(--color-accent-green) !important;
color: var(--color-primary-green) !important;
}
/* disabled 项整体灰化 + 不可点击 */
.side-nav-menu .ant-menu-item-disabled {
opacity: 0.55;
cursor: not-allowed !important;
}
/* 即将上线小标签 */
.menu-soon-tag {
font-size: 10px;
color: #999;
background: #f0f0f0;
padding: 1px 5px;
border-radius: 4px;
margin-left: 6px;
vertical-align: middle;
white-space: nowrap;
}
/* disabled 项内标签不受 opacity 影响 */
.side-nav-menu .ant-menu-item-disabled .menu-soon-tag {
opacity: 1;
color: #888;
}
@@ -0,0 +1,84 @@
import { Menu } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
FileTextOutlined,
BookOutlined,
SyncOutlined,
UserOutlined,
TeamOutlined,
SoundOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
function SideNav() {
const navigate = useNavigate()
const location = useLocation()
const { user } = useAuthStore()
const allMenuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
{
key: 'tts-placeholder',
icon: <SoundOutlined />,
label: (
<span className="menu-item-with-tag">
蓝皓配音 TTS 2.0
<span className="menu-soon-tag">即将上线</span>
</span>
),
disabled: true,
},
{
key: 'collab-placeholder',
icon: <TeamOutlined />,
label: (
<span className="menu-item-with-tag">
内部协作Mattermost
<span className="menu-soon-tag">即将上线</span>
</span>
),
disabled: true,
},
]
//
// user.role /api/auth/me 'zhipianren' / 'zebian' / 'biandao'
const visibleItems = allMenuItems.filter(item => {
// /dashboard
if (item.key === '/dashboard') return true
// /users zhipianren
if (item.key === '/users') return String(user?.role) === 'zhipianren'
// /editor-desk zhipianren + zebian
if (item.key === '/editor-desk') {
const role = String(user?.role)
return role === 'zhipianren' || role === 'zebian'
}
// Phase
return true
})
return (
<div className="side-nav">
<div className="side-nav-logo">
<span className="logo-text">军事科技</span>
<span className="logo-sub">TPS 工作台</span>
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={visibleItems}
onClick={({ key }) => navigate(key)}
className="side-nav-menu"
/>
</div>
)
}
export default SideNav
+27
View File
@@ -0,0 +1,27 @@
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
}
.top-bar-search {
flex: 1;
max-width: 400px;
}
.top-bar-actions {
display: flex;
align-items: center;
gap: 16px;
}
.top-bar-icon {
font-size: 18px;
color: var(--color-text-secondary);
cursor: pointer;
}
.top-bar-avatar {
cursor: pointer;
}
+38
View File
@@ -0,0 +1,38 @@
import { Input, Dropdown, Avatar } from 'antd'
import { SearchOutlined, BellOutlined, LogoutOutlined } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import './TopBar.css'
function TopBar() {
const { user, logout } = useAuthStore()
const items = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出',
onClick: () => logout(),
},
]
const displayName = user?.display_name || user?.username || '未知用户'
const initials = displayName.slice(0, 1).toUpperCase()
return (
<div className="top-bar">
<div className="top-bar-search">
<Input placeholder="搜索..." prefix={<SearchOutlined />} />
</div>
<div className="top-bar-actions">
<BellOutlined className="top-bar-icon" />
<Dropdown menu={{ items }} placement="bottomRight">
<Avatar className="top-bar-avatar" style={{ backgroundColor: 'var(--color-primary-green)' }}>
{initials}
</Avatar>
</Dropdown>
</div>
</div>
)
}
export default TopBar
+34
View File
@@ -0,0 +1,34 @@
:root {
/* 奶油底,主背景 */
--color-bg-cream: #fbf9f1;
/* 深绿,主色 */
--color-primary-green: #6b8e6b;
/* 浅绿,辅色 */
--color-accent-green: #c8d9b0;
/* 文字主色 */
--color-text-primary: #1a1a1a;
/* 文字次要色 */
--color-text-secondary: #6b6b6b;
/* 卡片大圆角 */
--radius-card: 16px;
/* 按钮圆角 */
--radius-button: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg-cream);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
width: 100%;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,151 @@
import { useState, useEffect } from 'react'
import { Modal, Upload, Select, Button, message } from 'antd'
import { UploadOutlined, InboxOutlined } from '@ant-design/icons'
import { uploadCover } from '../../services/dashboardService'
const { Dragger } = Upload
/**
* 更换题图 Modal
*
* 权限 zhipianren / zebian 可用由父组件控制显示/隐藏
*
* @param {boolean} open - Modal 是否显示
* @param {Function} onClose - 关闭回调
* @param {Function} onUploaded - 上传成功回调传入新题图对象
* @param {Array} episodes - 期次列表用于下拉选择关联期次
*/
function ChangeCoverModal({ open, onClose, onUploaded, episodes = [] }) {
const [file, setFile] = useState(null)
const [previewUrl, setPreviewUrl] = useState(null)
const [selectedEpisode, setSelectedEpisode] = useState(null)
const [uploading, setUploading] = useState(false)
//
useEffect(() => {
if (open) {
setFile(null)
setPreviewUrl(null)
setSelectedEpisode(null)
setUploading(false)
}
}, [open])
//
const handleFileChange = (info) => {
const rawFile = info.file.originFileObj || info.file
if (!rawFile) return
setFile(rawFile)
const url = URL.createObjectURL(rawFile)
setPreviewUrl(url)
}
const handleUpload = async () => {
if (!file) {
message.warning('请先选择图片')
return
}
if (!selectedEpisode) {
message.warning('请选择关联期次')
return
}
setUploading(true)
try {
const ep = episodes.find(e => String(e.episode_number) === String(selectedEpisode))
const result = await uploadCover(file, Number(selectedEpisode), ep?.program_name || `${selectedEpisode}`)
message.success('题图上传成功')
onUploaded({
cover_path: result.cover_path,
episode_number: Number(selectedEpisode),
episode_title: ep?.program_name || `${selectedEpisode}`,
})
} catch (err) {
const errMsg = err?.response?.data?.detail || '上传失败,请重试'
message.error(errMsg)
} finally {
setUploading(false)
}
}
const episodeOptions = episodes.map(ep => ({
value: String(ep.episode_number),
label: `${ep.episode_number} 期 · ${ep.program_name}`,
}))
return (
<Modal
title="更换题图"
open={open}
onCancel={onClose}
footer={null}
width={480}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 图片上传区域 */}
<div>
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>上传海报图片PNG/JPG/WEBP不超过 5MB</p>
<Dragger
accept=".png,.jpg,.jpeg,.webp"
maxCount={1}
beforeUpload={() => false}
onChange={handleFileChange}
showUploadList={false}
style={{ borderRadius: 12 }}
>
{previewUrl ? (
<div style={{ padding: '8px 0' }}>
<img
src={previewUrl}
alt="预览"
style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 8 }}
/>
<p style={{ marginTop: 8, color: '#6b8e6b', fontSize: 13 }}>点击或拖拽更换图片</p>
</div>
) : (
<>
<p className="ant-upload-drag-icon" style={{ marginBottom: 8 }}>
<InboxOutlined style={{ fontSize: 32, color: '#6b8e6b' }} />
</p>
<p style={{ color: '#6b6b6b', fontSize: 13 }}>点击或拖拽图片到此处上传</p>
</>
)}
</Dragger>
</div>
{/* 关联期次下拉 */}
<div>
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>关联期次</p>
<Select
placeholder="请选择期次"
style={{ width: '100%' }}
value={selectedEpisode}
onChange={setSelectedEpisode}
options={episodeOptions}
showSearch
filterOption={(input, option) =>
option.label.toLowerCase().includes(input.toLowerCase())
}
/>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button onClick={onClose} disabled={uploading}>取消</Button>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploading}
onClick={handleUpload}
disabled={!file || !selectedEpisode}
>
确认上传
</Button>
</div>
</div>
</Modal>
)
}
export default ChangeCoverModal
+355
View File
@@ -0,0 +1,355 @@
.dashboard {
max-width: 1200px;
padding: 12px;
}
/* ===== Banner ===== */
.dashboard-banner {
width: 100%;
height: 150px;
border-radius: 14px;
overflow: hidden;
position: relative;
background: #3a4a3a;
margin-bottom: 12px;
}
.banner-bg {
position: absolute;
inset: 0;
object-fit: cover;
width: 100%;
height: 100%;
}
.banner-gradient {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(12, 16, 12, 0.86) 0%,
rgba(12, 16, 12, 0.6) 35%,
rgba(12, 16, 12, 0.08) 70%,
transparent 100%
);
}
.banner-logo {
position: absolute;
top: 16px;
right: 16px;
height: 64px;
width: auto;
opacity: 0.9;
}
.banner-text {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 2;
}
.banner-eyebrow {
font-size: 11px;
color: #cdd8c8;
letter-spacing: 1px;
margin-bottom: 4px;
}
.banner-title {
font-size: 21px;
font-weight: 500;
color: #ffffff;
margin: 0 0 4px;
line-height: 1.2;
}
.banner-subtitle {
font-size: 12px;
color: #d6e0d0;
margin: 0 0 4px;
}
.banner-hint {
font-size: 11px;
color: #bcc8b6;
margin: 0;
}
.change-cover-btn {
position: absolute;
bottom: 16px;
right: 16px;
z-index: 3;
}
/* 默认渐变底图(无题图时) */
.banner-default-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
}
/* ===== KPI Strip ===== */
.kpi-strip {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.kpi-chip {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border-radius: 12px;
padding: 10px 16px;
height: 48px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.kpi-icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.kpi-label {
font-size: 11px;
color: #6b6b6b;
margin: 0;
white-space: nowrap;
}
.kpi-value {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
}
/* ===== Bar Chart Card ===== */
.bar-chart-card {
background: #fff;
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
}
.bar-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.bar-chart-title {
font-size: 15px;
font-weight: 500;
color: #3b4a3b;
margin: 0;
}
.bar-chart-hint {
font-size: 11px;
color: #888780;
}
.bars-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 180px;
padding: 0 4px;
gap: 4px;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 42px;
min-width: 20px;
justify-content: flex-end;
height: 180px;
}
.bar {
width: 100%;
max-width: 38px;
border-radius: 7px 7px 0 0;
transition: all 0.3s;
cursor: pointer;
position: relative;
}
.bar-number {
font-size: 9px;
font-weight: 600;
text-align: center;
white-space: nowrap;
margin-bottom: 2px;
}
.bar-program {
font-size: 10px;
color: #6b6b6b;
text-align: center;
max-width: 64px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-top: 4px;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #6b6b6b;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* ===== Bottom Two Columns ===== */
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.side-card {
background: #fff;
border-radius: 14px;
padding: 16px;
}
.side-card-title {
font-size: 15px;
font-weight: 500;
color: #3b4a3b;
margin: 0 0 12px;
}
/* Radar placeholder card */
.radar-placeholder {
border: 1.5px dashed #c8d9b0;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 16px;
position: relative;
}
.radar-placeholder-label {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
color: #bcc8b6;
background: #fbf9f1;
padding: 2px 6px;
border-radius: 6px;
}
.radar-placeholder p {
font-size: 13px;
color: #6b6b6b;
margin: 8px 0 4px;
}
.radar-placeholder small {
font-size: 11px;
color: #999;
}
.radar-sample-items {
width: 100%;
margin-bottom: 8px;
}
.radar-sample-item {
font-size: 12px;
color: #4a5a4a;
padding: 4px 8px;
background: #f0f7ec;
border-radius: 6px;
margin-bottom: 4px;
}
/* Schedule list */
.schedule-list {
list-style: none;
padding: 0;
margin: 0;
}
.schedule-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 12px;
}
.schedule-item:last-child {
border-bottom: none;
}
.schedule-ep {
color: #1a1a1a;
font-weight: 500;
}
.schedule-name {
color: #4a5a4a;
flex: 1;
margin: 0 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.schedule-date {
color: #888780;
font-size: 11px;
}
.schedule-editor {
color: #6b6b6b;
font-size: 11px;
}
/* Loading state */
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
height: 180px;
color: #999;
}
+343
View File
@@ -0,0 +1,343 @@
import { useState, useEffect } from 'react'
import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
import {
EyeOutlined,
FireOutlined,
CalendarOutlined,
BarChartOutlined,
PictureOutlined,
UploadOutlined,
LineChartOutlined,
AimOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import { listEpisodes } from '../../services/episodeService'
import { listTargets } from '../../services/yearlyTargetService'
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
import ChangeCoverModal from './ChangeCoverModal'
import './Dashboard.css'
/**
* 收视份额颜色判定极易写反禁止凭直觉
* audience_share > stretch_target 🔴 优秀
* base_target audience_share stretch_target 🔵 达标
* audience_share < base_target 🟢 绿待提升
* 判色取该期 air_date 所属年份的 yearly_targets 不是当前年
*/
function getShareColor(share, targets, airYear) {
const n = Number(share)
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
if (!target) return '#999'
if (isNaN(n)) return '#999'
const stretch = Number(target.stretch_target)
const base = Number(target.base_target)
if (n > stretch) return '#c0584f' // =
if (n >= base) return '#5b8db8' // =
return '#7aa874' // 绿=
}
function getColorLabel(share, targets, airYear) {
const n = Number(share)
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
if (!target) return '未知'
const stretch = Number(target.stretch_target)
const base = Number(target.base_target)
if (n > stretch) return '优秀'
if (n >= base) return '达标'
return '待提升'
}
function getBarHeight(share) {
return Math.round((Number(share) / 1.0) * 160)
}
function getShortTitle(title) {
if (!title) return '?'
return title.length > 9 ? title.slice(0, 9) + '...' : title
}
/**
* 计算当年完成率纯前端量纲已确认为 0~1 小数直接相除
* - "当年"= 当前自然年new Date().getFullYear()
* - avg(audience_share) 当年已录期 ÷ base/stretch_target
* - 无数据时始终返回 { base: '—', stretch: '—' }
* - 完成率可 >100% 正常显示
*/
function calcCompletionRate(episodes, targets) {
const yearInt = new Date().getFullYear()
const yearEpisodes = episodes.filter(e => {
const airYear = new Date(e.air_date).getFullYear()
return airYear === yearInt && e.audience_share != null
})
const target = (targets || []).find(t => Number(t.year) === yearInt)
if (!yearEpisodes.length || !target) return { base: '—', stretch: '—' }
const avgShare = yearEpisodes.reduce((sum, e) => sum + Number(e.audience_share), 0) / yearEpisodes.length
const baseRate = avgShare / Number(target.base_target)
const stretchRate = avgShare / Number(target.stretch_target)
return {
base: (baseRate * 100).toFixed(1) + '%',
stretch: (stretchRate * 100).toFixed(1) + '%',
}
}
function Dashboard() {
const { user } = useAuthStore()
const [episodes, setEpisodes] = useState([])
const [targets, setTargets] = useState([])
const [cover, setCover] = useState({ cover_path: null, episode_number: null, episode_title: null })
const [schedules, setSchedules] = useState([])
const [loading, setLoading] = useState(true)
const [coverModalOpen, setCoverModalOpen] = useState(false)
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
useEffect(() => {
Promise.all([
listEpisodes(50),
listTargets(),
getCurrentCover(),
getUpcomingSchedules(6),
]).then(([epData, tgtData, coverData, schedData]) => {
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
setEpisodes(sorted)
setTargets(tgtData || [])
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
setSchedules(Array.isArray(schedData) ? schedData : [])
setLoading(false)
}).catch(() => {
setLoading(false)
})
}, [])
// 12
const displayEpisodes = episodes
.filter(e => e.audience_share != null)
.slice(0, 12)
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
const handleCoverUploaded = (newCover) => {
setCover(newCover)
setCoverModalOpen(false)
}
// KPI 5 3 + 2
const completion = calcCompletionRate(episodes, targets)
const kpiData = [
{
icon: <EyeOutlined style={{ color: '#6b8e6b', fontSize: 14 }} />,
bg: '#e8f5e9',
label: '近12期已录',
value: displayEpisodes.length || '--',
},
{
icon: <FireOutlined style={{ color: '#ff9800', fontSize: 14 }} />,
bg: '#fff3e0',
label: '最佳份额',
value: bestEpisode ? (Number(bestEpisode.audience_share) * 100).toFixed(2) + '%' : '--',
},
{
icon: <CalendarOutlined style={{ color: '#2196f3', fontSize: 14 }} />,
bg: '#e3f2fd',
label: '年度目标',
value: targets.length || '--',
},
{
icon: <LineChartOutlined style={{ color: '#7b5e9e', fontSize: 14 }} />,
bg: '#f3e5f5',
label: '基础目标完成率',
value: completion.base,
},
{
icon: <AimOutlined style={{ color: '#c0584f', fontSize: 14 }} />,
bg: '#fce4ec',
label: '摸高目标完成率',
value: completion.stretch,
},
]
return (
<div className="dashboard">
{/* ===== Banner ===== */}
<div className="dashboard-banner">
{/* 题图底图 或 默认渐变 */}
{cover.cover_path ? (
<img
src={cover.cover_path}
alt="题图"
className="banner-bg"
/>
) : (
<div className="banner-default-bg" />
)}
{/* 暗化渐变层 */}
<div className="banner-gradient" />
{/* 右上角 Logo */}
<img
src="/src/assets/junshikeji_logo.png"
alt="栏目logo"
className="banner-logo"
onError={e => { e.target.style.display = 'none' }}
/>
{/* 左侧文字 */}
<div className="banner-text">
<p className="banner-eyebrow">本月收视最佳·题图</p>
<h2 className="banner-title">
{cover.episode_number
? `${cover.episode_number}`
: bestEpisode
? `${bestEpisode.episode_number}`
: '暂无收视数据'}
{cover.episode_title || (bestEpisode ? ` · ${bestEpisode.program_name}` : '')}
</h2>
<p className="banner-subtitle">
{bestEpisode
? `收视份额 ${Number(bestEpisode.audience_share).toFixed(4)} · 当月最高`
: '暂无收视数据'}
</p>
<p className="banner-hint">建议可选用近期收视表现好的节目海报</p>
</div>
{/* 右下角换图按钮 */}
{showChangeCoverBtn && (
<Button
className="change-cover-btn"
icon={<PictureOutlined />}
onClick={() => setCoverModalOpen(true)}
>
更换题图
</Button>
)}
</div>
{/* ===== KPI 细条 ===== */}
<div className="kpi-strip">
{kpiData.map((item, i) => (
<div key={i} className="kpi-chip">
<div className="kpi-icon" style={{ background: item.bg }}>
{item.icon}
</div>
<div>
<p className="kpi-label">{item.label}</p>
<p className="kpi-value">{item.value}</p>
</div>
</div>
))}
</div>
{/* ===== 近9期收视柱图(全宽) ===== */}
<div className="bar-chart-card">
<div className="bar-chart-header">
<h3 className="bar-chart-title"> 12 期收视份额</h3>
<span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
</div>
{loading ? (
<div className="chart-loading"><Spin size="small" /> 加载中...</div>
) : (
<>
<div className="bars-wrapper">
{displayEpisodes.map((ep) => {
const airYear = new Date(ep.air_date).getFullYear()
const color = ep.audience_share != null
? getShareColor(ep.audience_share, targets, airYear)
: '#ccc'
const colorLabel = ep.audience_share != null
? getColorLabel(ep.audience_share, targets, airYear)
: '无数据'
const tooltipContent = `
<strong> ${ep.episode_number} · ${ep.program_name}</strong><br/>
编导${ep.editor_name_snapshot || '未知'}<br/>
收视份额${ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'}${colorLabel}<br/>
收视率${ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'}
`
return (
<div key={ep.id} className="bar-item">
<div className="bar-number" style={{ color }}>
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
</div>
<Tooltip title={<span dangerouslySetInnerHTML={{ __html: tooltipContent }} />}>
<div
className="bar"
style={{
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
backgroundColor: color,
}}
/>
</Tooltip>
<div className="bar-program" title={ep.program_name}>
{getShortTitle(ep.program_name)}
</div>
</div>
)
})}
</div>
<div className="chart-legend">
<span className="legend-item">
<span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标待提升
</span>
<span className="legend-item">
<span className="dot" style={{ background: '#5b8db8' }}></span>=未达摸高目标达标
</span>
<span className="legend-item">
<span className="dot" style={{ background: '#c0584f' }}></span>=超过摸高目标优秀
</span>
</div>
</>
)}
</div>
{/* ===== 下方两栏 ===== */}
<div className="bottom-row">
{/* 左:雷达占位 */}
<div className="side-card">
<h3 className="side-card-title">军事科技热点雷达</h3>
<div className="radar-placeholder">
<span className="radar-placeholder-label">Phase 4c·占位</span>
<div className="radar-sample-items">
<div className="radar-sample-item">1. 神舟飞船对接空间站</div>
<div className="radar-sample-item">2. 新型无人机集群技术</div>
</div>
<BarChartOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 4 }} />
<p>真实抓取 Phase 4c 实现</p>
<small>编导个人首页核心功能</small>
</div>
</div>
{/* 右:排播计划 */}
<div className="side-card">
<h3 className="side-card-title">未来排播计划</h3>
{loading ? (
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}><Spin size="small" /></div>
) : schedules.length > 0 ? (
<ul className="schedule-list">
{schedules.map((item, i) => (
<li key={i} className="schedule-item">
<span className="schedule-ep">{item.episode_number}</span>
<span className="schedule-name" title={item.program_name}>{item.program_name}</span>
<span className="schedule-date">{item.planned_air_date}</span>
<span className="schedule-editor">{item.editor_name_snapshot || ''}</span>
</li>
))}
</ul>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}>
<CalendarOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 8 }} />
<p style={{ margin: 0, fontSize: 12 }}>暂无排播数据</p>
</div>
)}
</div>
</div>
{/* 换图 Modal */}
<ChangeCoverModal
open={coverModalOpen}
onClose={() => setCoverModalOpen(false)}
onUploaded={handleCoverUploaded}
episodes={episodes.slice(0, 20)}
/>
</div>
)
}
export default Dashboard
+13
View File
@@ -0,0 +1,13 @@
import { Result } from 'antd'
function Doco() {
return (
<Result
status="info"
title="文稿对齐模块"
subTitle="该模块计划在 1.0 主体上线后作为独立 Phase 开发,详见 doco_project_design.md"
/>
)
}
export default Doco
@@ -0,0 +1,369 @@
import { useState, useEffect } from 'react'
import { Tabs, Table, Button, Modal, Form, Input, InputNumber, DatePicker, message, Space, Upload, Popconfirm } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined, UploadOutlined } from '@ant-design/icons'
import { listEpisodes, createEpisode, updateEpisode, deleteEpisode } from '../../services/episodeService'
import { listTargets, createTarget, updateTarget, deleteTarget } from '../../services/yearlyTargetService'
import http from '../../services/http'
const { TabPane } = Tabs
// ============================================================
// Tab1:
// ============================================================
function EpisodesTab() {
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editing, setEditing] = useState(null)
const [form] = Form.useForm()
useEffect(() => {
loadEpisodes()
}, [])
const loadEpisodes = () => {
setLoading(true)
listEpisodes().then(data => {
setEpisodes((data || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)))
setLoading(false)
}).catch(() => {
message.error('加载节目列表失败')
setLoading(false)
})
}
const handleCreate = () => {
setEditing(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditing(record)
form.setFieldsValue({
episode_number: record.episode_number,
program_name: record.program_name,
air_date: record.air_date ? record.air_date : null,
editor_name_snapshot: record.editor_name_snapshot || '',
audience_share: record.audience_share,
audience_rating: record.audience_rating,
is_rerun: record.is_rerun,
notes: record.notes,
})
setModalVisible(true)
}
const handleDelete = (id) => {
deleteEpisode(id).then(() => {
message.success('删除成功')
loadEpisodes()
}).catch(err => {
message.error(err.response?.data?.detail || '删除失败')
})
}
const handleSubmit = () => {
form.validateFields().then(values => {
const payload = {
...values,
air_date: values.air_date ? values.air_date.format('YYYY-MM-DD') : null,
}
const promise = editing
? updateEpisode(editing.id, payload)
: createEpisode(payload)
promise.then(() => {
message.success(editing ? '更新成功' : '创建成功')
setModalVisible(false)
loadEpisodes()
}).catch(err => {
message.error(err.response?.data?.detail || '操作失败')
})
})
}
const columns = [
{ title: '期次号', dataIndex: 'episode_number', key: 'episode_number', width: 80 },
{ title: '节目名', dataIndex: 'program_name', key: 'program_name' },
{ title: '播出日期', dataIndex: 'air_date', key: 'air_date', render: d => d || '-' },
{ title: '编导', dataIndex: 'editor_name_snapshot', key: 'editor_name_snapshot', render: v => v || '-' },
{ title: '收视份额', dataIndex: 'audience_share', key: 'audience_share', render: v => v != null ? v.toFixed(4) : '-' },
{ title: '收视率', dataIndex: 'audience_rating', key: 'audience_rating', render: v => v != null ? v.toFixed(2) : '-' },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增期次</Button>
</div>
<Table columns={columns} dataSource={episodes} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} />
<Modal title={editing ? '编辑期次' : '新增期次'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存" width={520}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="episode_number" label="期次号" rules={[{ required: true, message: '请输入期次号' }]}>
<InputNumber style={{ width: '100%' }} placeholder="如 1248" disabled={!!editing} />
</Form.Item>
<Form.Item name="program_name" label="节目名称" rules={[{ required: true, message: '请输入节目名称' }]}>
<Input placeholder="如《军事科技》武器解析系列" />
</Form.Item>
<Form.Item name="air_date" label="播出日期" rules={[{ required: true, message: '请选择播出日期' }]}>
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" disabled={!!editing} />
</Form.Item>
<Form.Item name="editor_name_snapshot" label="编导姓名(快照)">
<Input placeholder="可留空,离职编导只存姓名" />
</Form.Item>
<Form.Item name="audience_share" label="收视份额(0-1">
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.72" />
</Form.Item>
<Form.Item name="audience_rating" label="收视率">
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="3.5" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
// ============================================================
// Tab2:
// ============================================================
function YearlyTargetsTab() {
const [targets, setTargets] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editing, setEditing] = useState(null)
const [form] = Form.useForm()
useEffect(() => {
loadTargets()
}, [])
const loadTargets = () => {
setLoading(true)
listTargets().then(data => {
setTargets((data || []).sort((a, b) => b.year - a.year))
setLoading(false)
}).catch(() => {
message.error('加载年度目标失败')
setLoading(false)
})
}
const handleCreate = () => {
setEditing(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditing(record)
form.setFieldsValue({
year: record.year,
base_target: record.base_target,
stretch_target: record.stretch_target,
notes: record.notes,
})
setModalVisible(true)
}
const handleDelete = (id) => {
deleteTarget(id).then(() => {
message.success('删除成功')
loadTargets()
}).catch(err => {
message.error(err.response?.data?.detail || '删除失败')
})
}
const handleSubmit = () => {
form.validateFields().then(values => {
const promise = editing
? updateTarget(editing.id, values)
: createTarget(values)
promise.then(() => {
message.success(editing ? '更新成功' : '创建成功')
setModalVisible(false)
loadTargets()
}).catch(err => {
const detail = err.response?.data?.detail || ''
if (detail.includes('已存在') || detail.includes('历史年份')) {
message.error('该年目标已存在,历史年份不可覆盖')
} else {
message.error(detail || '操作失败')
}
})
})
}
const columns = [
{ title: '年份', dataIndex: 'year', key: 'year', width: 80 },
{ title: '基础目标', dataIndex: 'base_target', key: 'base_target', render: v => v?.toFixed(4) || '-' },
{ title: '摸高目标', dataIndex: 'stretch_target', key: 'stretch_target', render: v => v?.toFixed(4) || '-' },
{ title: '备注', dataIndex: 'notes', key: 'notes', render: v => v || '-' },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增年度目标</Button>
</div>
<Table columns={columns} dataSource={targets} rowKey="id" loading={loading} pagination={false} />
<Modal title={editing ? '编辑年度目标' : '新增年度目标'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} okText="保存">
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="year"
label="年份"
rules={[{ required: true, message: '请输入年份' }]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="如 2024"
min={2000}
max={2100}
disabled={!!editing}
/>
</Form.Item>
<Form.Item name="base_target" label="基础目标" rules={[{ required: true, message: '请输入基础目标' }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.6448" />
</Form.Item>
<Form.Item name="stretch_target" label="摸高目标" rules={[{ required: true, message: '请输入摸高目标' }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} precision={4} placeholder="0.8989" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
// ============================================================
// Tab3: Excel
// ============================================================
function ImportTab() {
const [uploading, setUploading] = useState(false)
const [result, setResult] = useState(null)
const downloadTemplate = () => {
window.open('/api/imports/template', '_blank')
}
const uploadProps = {
name: 'file',
accept: '.xlsx',
showUploadList: false,
beforeUpload: (file) => {
if (!file.filename.endsWith('.xlsx') && !file.name.endsWith('.xlsx')) {
message.error('仅支持 .xlsx 格式')
return false
}
setUploading(true)
const formData = new FormData()
formData.append('file', file)
http.post('/imports/episodes', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(res => {
setResult(res.data)
setUploading(false)
if (res.data.errors?.length) {
message.warning(`导入完成:成功 ${res.data.imported} 条,失败 ${res.data.errors.length}`)
} else {
message.success(`导入成功:${res.data.imported}`)
}
}).catch(err => {
setUploading(false)
message.error(err.response?.data?.detail || '上传失败')
})
return false
},
}
const downloadErrors = () => {
if (!result?.batch_id) return
window.open(`/api/imports/errors/${result.batch_id}`, '_blank')
}
return (
<div>
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
<Button icon={<DownloadOutlined />} onClick={downloadTemplate}>下载导入模板</Button>
<Upload {...uploadProps}>
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
{uploading ? '上传中...' : '选择 Excel 上传'}
</Button>
</Upload>
</div>
{result && (
<div style={{ background: '#f6f8fa', padding: 16, borderRadius: 8 }}>
<p style={{ margin: '0 0 8px', fontWeight: 600 }}>导入结果</p>
<p style={{ margin: 0 }}>总计{result.total} </p>
<p style={{ margin: '4px 0', color: '#52c41a' }}>成功{result.imported} </p>
{result.errors?.length > 0 && (
<>
<p style={{ margin: '4px 0', color: '#cf1322' }}>失败{result.errors.length} </p>
<Button size="small" onClick={downloadErrors} style={{ marginTop: 8 }}>
下载失败行
</Button>
</>
)}
</div>
)}
<div style={{ marginTop: 24, color: '#999', fontSize: 12 }}>
<p>支持批量导入节目期次导入失败时可根据提示修正后重新上传</p>
<p>注意Phase 2 不支持重播行导入有重播行请留空</p>
</div>
</div>
)
}
// ============================================================
//
// ============================================================
function EditorDesk() {
return (
<div style={{ padding: '0 0 24px' }}>
<h2 style={{ margin: '0 0 16px', fontSize: 20, fontWeight: 600 }}>责编录入</h2>
<Tabs defaultActiveKey="episodes">
<TabPane tab="节目期次" key="episodes">
<EpisodesTab />
</TabPane>
<TabPane tab="年度目标" key="targets">
<YearlyTargetsTab />
</TabPane>
<TabPane tab="Excel 批量导入" key="import">
<ImportTab />
</TabPane>
</Tabs>
</div>
)
}
export default EditorDesk
@@ -0,0 +1,17 @@
import { Card } from 'antd'
import { UserOutlined } from '@ant-design/icons'
function EditorHome() {
return (
<Card style={{ borderRadius: 16 }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
<UserOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<h3 style={{ color: '#666' }}>个人首页</h3>
<p>模块 H · Phase 4c 实施</p>
<small>今日热点雷达 · RSS + AI 摘要</small>
</div>
</Card>
)
}
export default EditorHome
@@ -0,0 +1,17 @@
import { Card } from 'antd'
import { BookOutlined } from '@ant-design/icons'
function KnowledgeBase() {
return (
<Card style={{ borderRadius: 16 }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
<BookOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<h3 style={{ color: '#666' }}>知识库</h3>
<p>模块 C · Phase 3 实施</p>
<small>往期报题单 / 文稿 / 军报语义检索</small>
</div>
</Card>
)
}
export default KnowledgeBase
+29
View File
@@ -0,0 +1,29 @@
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--color-bg-cream);
}
.login-card {
width: 400px;
border-radius: var(--radius-card);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 28px;
color: var(--color-primary-green);
margin: 0 0 8px;
}
.login-header p {
color: var(--color-text-secondary);
margin: 0;
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { Form, Input, Button, Card, message } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import useAuthStore from '../../stores/authStore'
import './Login.css'
function Login() {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const { login } = useAuthStore()
const onFinish = async (values) => {
setLoading(true)
const result = await login(values.username, values.password)
setLoading(false)
if (result.success) {
navigate('/dashboard')
} else {
message.error(result.error || '登录失败')
}
}
return (
<div className="login-page">
<Card className="login-card" bordered={false}>
<div className="login-header">
<h1>军事科技</h1>
<p>TPS 工作台</p>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
requiredMark={false}
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={loading}
>
登录
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default Login
+17
View File
@@ -0,0 +1,17 @@
import { Card } from 'antd'
import { FileTextOutlined } from '@ant-design/icons'
function TPS() {
return (
<Card style={{ borderRadius: 16 }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
<FileTextOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<h3 style={{ color: '#666' }}>TPS 选题策划</h3>
<p>模块 A / B / C / D / G · Phase 4a 实施</p>
<small>查重 + 知识库参考 + 报题单生成</small>
</div>
</Card>
)
}
export default TPS
@@ -0,0 +1,187 @@
import { useState, useEffect } from 'react'
import { Table, Button, Modal, Form, Input, Select, Switch, message, Space } from 'antd'
import { UserAddOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'
import { listUsers, createUser, updateUser, deactivateUser } from '../../services/userService'
const roleOptions = [
{ value: 'zhipianren', label: '制片人' },
{ value: 'zebian', label: '责编' },
{ value: 'biandao', label: '编导' },
]
function UserManage() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [form] = Form.useForm()
useEffect(() => {
loadUsers()
}, [])
const loadUsers = () => {
setLoading(true)
listUsers().then(data => {
setUsers(data || [])
setLoading(false)
}).catch(() => {
message.error('加载用户列表失败')
setLoading(false)
})
}
const handleCreate = () => {
setEditingUser(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingUser(record)
form.setFieldsValue({
display_name: record.display_name,
role: record.role,
profile_text: record.profile_text,
is_active: record.is_active,
})
setModalVisible(true)
}
const handleDeactivate = (record) => {
Modal.confirm({
title: '停用用户',
content: `确认停用用户「${record.display_name}」(${record.username})?停用后可重新启用。`,
okText: '确认停用',
cancelText: '取消',
onOk: () => deactivateUser(record.id).then(() => {
message.success('已停用')
loadUsers()
}).catch(err => {
message.error(err.response?.data?.detail || '停用失败')
}),
})
}
const handleSubmit = () => {
form.validateFields().then(values => {
if (editingUser) {
updateUser(editingUser.id, values).then(() => {
message.success('更新成功')
setModalVisible(false)
loadUsers()
}).catch(err => {
message.error(err.response?.data?.detail || '更新失败')
})
} else {
createUser(values).then(() => {
message.success('创建成功')
setModalVisible(false)
loadUsers()
}).catch(err => {
message.error(err.response?.data?.detail || '创建失败')
})
}
})
}
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '显示名',
dataIndex: 'display_name',
key: 'display_name',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: role => roleOptions.find(o => o.value === role)?.label || role,
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
render: (active, record) => (
<Switch
checked={active}
onChange={() => handleDeactivate(record)}
checkedChildren="在职"
unCheckedChildren="已停"
/>
),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
编辑
</Button>
{record.is_active && (
<Button size="small" danger icon={<StopOutlined />} onClick={() => handleDeactivate(record)}>
停用
</Button>
)}
</Space>
),
},
]
return (
<div style={{ padding: '0 0 24px' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>用户管理</h2>
<Button type="primary" icon={<UserAddOutlined />} onClick={handleCreate}>
新建用户
</Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<Modal
title={editingUser ? '编辑用户' : '新建用户'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
{!editingUser && (
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="登录用户名" />
</Form.Item>
)}
<Form.Item name="display_name" label="显示名" rules={[{ required: true, message: '请输入显示名' }]}>
<Input placeholder="界面显示名称" />
</Form.Item>
{!editingUser && (
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="初始密码" />
</Form.Item>
)}
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select placeholder="选择角色" options={roleOptions} />
</Form.Item>
<Form.Item name="profile_text" label="个人简介">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default UserManage
+18
View File
@@ -0,0 +1,18 @@
import http from './http'
export async function login(username, password) {
const response = await http.post('/auth/login', { username, password })
return response.data
}
export async function logout() {
const response = await http.post('/auth/logout')
return response.data
}
export async function me() {
const response = await http.get('/auth/me')
return response.data
}
export default { login, logout, me }
+38
View File
@@ -0,0 +1,38 @@
import http from './http'
/**
* 获取当前题图设置
* @returns {Promise<{cover_path: string|null, episode_number: number|null, episode_title: string|null}>}
*/
export async function getCurrentCover() {
const response = await http.get('/dashboard/cover')
return response.data
}
/**
* 上传题图仅制片人/责编可用
* @param {File} file - 海报图片文件
* @param {number} episodeNumber - 关联期次号
* @param {string} episodeTitle - 关联期次节目名
* @returns {Promise<{success: boolean, cover_path: string}>}
*/
export async function uploadCover(file, episodeNumber, episodeTitle) {
const formData = new FormData()
formData.append('file', file)
formData.append('episode_number', String(episodeNumber))
formData.append('episode_title', episodeTitle)
const response = await http.post('/dashboard/cover', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}
/**
* 获取未来排播计划
* @param {number} limit - 返回条数默认6
* @returns {Promise<Array<{episode_number, program_name, planned_air_date, editor_name_snapshot}>>}
*/
export async function getUpcomingSchedules(limit = 6) {
const response = await http.get('/schedules/upcoming', { params: { limit } })
return response.data
}
+21
View File
@@ -0,0 +1,21 @@
import http from './http'
export async function listEpisodes(limit = 50, offset = 0) {
const response = await http.get('/episodes', { params: { limit, offset } })
return response.data
}
export async function createEpisode(data) {
const response = await http.post('/episodes', data)
return response.data
}
export async function updateEpisode(id, data) {
const response = await http.patch(`/episodes/${id}`, data)
return response.data
}
export async function deleteEpisode(id) {
const response = await http.delete(`/episodes/${id}`)
return response.data
}
+8
View File
@@ -0,0 +1,8 @@
import axios from 'axios'
const http = axios.create({
baseURL: '/api',
withCredentials: true,
})
export default http
+21
View File
@@ -0,0 +1,21 @@
import http from './http'
export async function listUsers() {
const response = await http.get('/users')
return response.data
}
export async function createUser(data) {
const response = await http.post('/users', data)
return response.data
}
export async function updateUser(id, data) {
const response = await http.patch(`/users/${id}`, data)
return response.data
}
export async function deactivateUser(id) {
const response = await http.delete(`/users/${id}`)
return response.data
}
@@ -0,0 +1,21 @@
import http from './http'
export async function listTargets() {
const response = await http.get('/yearly_targets')
return response.data
}
export async function createTarget(data) {
const response = await http.post('/yearly_targets', data)
return response.data
}
export async function updateTarget(id, data) {
const response = await http.patch(`/yearly_targets/${id}`, data)
return response.data
}
export async function deleteTarget(id) {
const response = await http.delete(`/yearly_targets/${id}`)
return response.data
}
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand'
import authService from '../services/authService'
const useAuthStore = create((set) => ({
user: null,
isLoading: false,
login: async (username, password) => {
set({ isLoading: true })
try {
const user = await authService.login(username, password)
set({ user, isLoading: false })
return { success: true }
} catch (error) {
set({ isLoading: false })
return { success: false, error: error.response?.data?.detail || '登录失败' }
}
},
logout: async () => {
try {
await authService.logout()
} catch (e) {
// ignore
}
set({ user: null })
},
fetchMe: async () => {
try {
const user = await authService.me()
set({ user })
} catch {
set({ user: null })
}
},
}))
export default useAuthStore
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

Before

Width:  |  Height:  |  Size: 814 KiB

After

Width:  |  Height:  |  Size: 814 KiB

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 584 KiB

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

+83 -2
View File
@@ -201,5 +201,86 @@ uvicorn app.main:app --reload
---
*本日志由 Claude Opus 4.7 基于 Sonnet 4.6 完工报告 + chat 关键决策整理生成。*
*下一次 Phase 收尾时,沿用本格式继续写 phase2_log.md。*
## 九、前端骨架完工记录(2026-05-15)
### 9.1 完成时间
- **前端骨架开发日期**:2026-05-15
- **前端骨架状态**:✅ 已完成
### 9.2 完成内容
1. **Vite + React 项目初始化**:`npm create vite@latest frontend -- --template react`
2. **核心依赖安装**:
- `antd` (UI 库)
- `@ant-design/icons`
- `zustand` (状态管理)
- `react-router-dom` v6
- `axios`
- `dayjs`
3. **Vite 代理配置**:`vite.config.js``/api``http://localhost:8000`
4. **AntD 中文 locale + 主题**:主色 `#6b8e6b`(深绿),圆角 12px/16px
5. **CSS 变量定义**:`src/index.css` 中定义奶油底 `#fbf9f1`、深绿 `#6b8e6b`、浅绿 `#c8d9b0`
6. **Auth 体系**:
- `services/http.js`:axios 实例,`withCredentials: true`
- `services/authService.js`:login/logout/me 三接口
- `stores/authStore.js`:Zustand,含 user/isLoading/login/logout/fetchMe
7. **路由守卫**:`components/AuthGuard/AuthGuard.jsx`,挂载时调用 me() 一次,401 跳转登录
8. **主布局**:`components/Layout/AppLayout.jsx` (侧边栏 + 顶栏 + 内容区 Outlet)
9. **侧边栏**:`SideNav.jsx`,6 项菜单(含 doco 占位),图标用 AntD icons
10. **顶栏**:`TopBar.jsx`,搜索框 + 通知 + 头像下拉(退出)
11. **登录页**:`pages/Login/Login.jsx`,AntD Form,用户名 + 密码,提交后调用 store.login()
12. **仪表盘**:`pages/Dashboard/Dashboard.jsx`:
- 顶部 Banner(题图占位)
- 3 个 KPI 卡(平均收视/完成率/待排期)
- 热点雷达占位(模块 H,Phase 4c)
- **近 9 期收视柱图**(假数据,三种颜色都有)
- 排播计划占位(模块 F,Phase 4b)
- 柱图下方:期次标题 + 编导头像 + 姓名
- 左下角颜色规则注释:红=优秀/蓝=达标/绿=待提升
13. **空页占位**:TPS / 知识库 / 编导首页 / 用户管理 / 文稿对齐(Doco 用 Result 组件)
14. **路由配置**:`App.jsx` 中 Routes 嵌套,AuthGuard 包裹,共 7 个路由
### 9.3 技术决策
- **角色拼音值**:前端代码中所有角色判断用 `zhipianren/zebian/biandao`,不得用英文别名
- **收视颜色判定(反直觉)**:红=优秀(>摸高)/蓝=达标(基础~摸高)/绿=待提升(<基础)
- **Session + Cookie**:axios `withCredentials: true`,Vite 代理 `/api`
- **刷新登录态保持**:Zustand 初始 user=null,AuthGuard 挂载时调用 me() 补全
### 9.4 文档修正(同期完成)
- `README.md`:近 4 期 → 近 9 期
- `dev_plan.md`:近 4 期 → 近 9 期
- `docs/git_workflow.md`:Z:\AIworks\ 路径残留 → E:\tps-dashboard\
### 9.5 Git commit 记录
| commit | 内容 |
|---|---|
| b39b7c8 | chore: 清理 indexUI 中文文件名空格与重复字符 |
| e907876 | docs: 仪表盘收视柱图统一为近 9 期(制片人 2026-05-15 拍板) |
| 233cb85 | docs: git_workflow.md 第 2/3 章路径残留 Z:\AIworks → E:\tps-dashboard 修正 |
| fef9e81 | feat: frontend skeleton done |
### 9.6 遗留事项
- `frontend/src/App.css``frontend/src/assets/` 是 Vite 模板自带,未使用但已 commit(不影响功能)
- Phase 2 需接入真实后端 API 数据
- Phase 2 仪表盘真实柱图需替换假数据
---
*本日志由 MiniMax M2.7(Cline Act 模式)于 2026-05-15 前端骨架完工后追加。*
+538
View File
@@ -0,0 +1,538 @@
# Phase 2 开发日志 — 仪表盘真数据 + 用户管理 + Excel 批量导入
> 《军事科技》栏目内部工作台 · Phase 2
> 撰写日期:2026-05-15
> 状态:**Task 1 后端 CRUD 完成,冒烟实测 6/6 通过;Task 2 进行中**
---
## 一、本 Phase 完成时间
- **开始日期**:2026-05-15
- **Task 1 后端 CRUD**:2026-05-15 完成,冒烟 6/6 全绿
- **Task 2 Excel 批量导入**:进行中
- **Task 3 前端**:待开始
- **预计 Phase 2 整体收官**:待定
---
## 二、本 Phase 做了什么
### Task 1 — 后端 CRUD2026-05-15
1. **models**`episode.py`(软引用+快照)、`yearly_target.py`year UNIQUE
2. **schemas**`episode.py``yearly_target.py``user_admin.py``auth.py` 顺手改 ConfigDict
3. **API**`users.py`(管理员 CRUD)、`episodes.py`(增删改查)、`yearly_targets.py`(增删改查,新增 DELETE
4. **路由注册**`main.py` 注册新路由
---
## 三、Task 1 冒烟实测记录(2026-05-15 Cline 自跑)
使用 `backend/scripts/smoke_test.py`(幂等版可重复跑),结果:
| 步骤 | 接口 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | POST /api/users 新建 zebian | 201 | 201 | ✅ ID=4 |
| 2 | POST /api/yearly_targets 新建 2026 | 201 | 201 | ✅ ID=2 |
| 3 | POST /api/yearly_targets 重复 2026 | 409 | 409 | ✅ 正确拦截 |
| 4 | POST /api/episodes editor_id=null | 201 | 201 | ✅ ID=2 |
| 5 | GET /api/episodes 列表 | 200 | 200 | ✅ |
| 6 | PATCH /api/yearly_targets 尝试改 year | 200year 不变) | 200 | ✅ year 仍为 2026 |
**6/6 全部通过。**
---
## 四、业务红线验证记录
| 红线 | 代码位置 | 验证结果 |
|------|----------|----------|
| 收视颜色只对 audience_share | `episode.py` 注释 | ✅ 未实现颜色逻辑,仅存字段 |
| 年度目标只增不改 year UNIQUE | `yearly_targets.py` create_target + `yearly_target.py` UNIQUE | ✅ 冲突报 409 |
| year 字段禁止 UPDATE 修改 | `yearly_targets.py` update_target pop("year") | ✅ 冒烟 Step 6 验证 |
| editor_id 可空+editor_name_snapshot 必填 | `episode.py` Field 设计 | ✅ 冒烟 Step 4 editor_id=null 成功 |
| 角色枚举全拼音 | `UserRole` Enum + CHECK 约束 | ✅ 三角色全是拼音 |
| ON CONFLICT DO NOTHING(非 UPDATE | `yearly_targets.py` 注释 | ✅ 只增不改 |
| password_hash 明文传输开发配置 | `security.py` + `main.py` https_only=False | ⚠️ Phase 5 部署前必改 |
---
## 五、踩过的坑(Phase 2 内部教训)
### 坑 1:API 文件漏了模型类导入(一类 bug)
**现象**Swagger UI 实测 `POST /api/yearly_targets` 报 500`NameError: name 'YearlyTarget' is not defined`
**原因**`api/yearly_targets.py``api/episodes.py` 用了 `Episode` / `YearlyTarget` 类但顶部漏了 import 语句
**排查**:同步检查 `api/users.py``api/episodes.py``api/yearly_targets.py` 三文件,发现 episodes.py 也漏了
**修复**
- `episodes.py``from app.models.episode import Episode`
- `yearly_targets.py``from app.models.yearly_target import YearlyTarget`
- `users.py` 删掉未使用的 `verify_password` 导入
**教训**:新建 API 文件后,**第一件事检查所有模型类是否已 import**,与 Schema import 同等优先级。Task 1 完工报告前未做最小冒烟测试,导致这类问题漏到实测阶段才发现。以后每个 Task Act 完成后必须自跑冒烟,不依赖制片人发现。
### 坑 2:冒烟脚本 emoji + 中文导致 Windows GBK 崩溃
**现象**`UnicodeEncodeError: 'gbk' codec can't encode character '\u274c'`
**修复**:将 `smoke_test.py` 全部改英文 + ASCII 字符(`✅``[OK]``❌``[FAIL]`
### 坑 3:数据不幂等导致重跑失败
**现象**:第二次跑冒烟,Step 1/2/4 全部 409(数据已存在)
**修复**:改为幂等版脚本,先清理同名数据再创建
### 坑 4pip 默认路由到系统 Python 3.14,绕过项目锁定的 3.12
**现象**`pip install pandas openpyxl` 后报错,或运行时报 `ModuleNotFoundError: No module named 'pandas'`(已确认装过)
**原因**:系统 PATH 中 python 命令指向 Microsoft Store 的 Python 3.14,而项目要求 Python 3.12,导致装到了错误的解释器。`pip show pandas` 发现路径在 AppData/Local/Microsoft/WindowsApps 而非项目 venv。
**修复**
- 确认项目使用 `python -m pip install ...`(明确调项目锁定的 3.12
- 或激活 venv 后 `& .\venv\Scripts\Activate.ps1` 再 pip install
- 装完 `pip show pandas` 核对路径包含 `venv` 或项目目录
**教训****任何 pip install 必须验证装到了哪个 Python 解释器**。先用 `where python` 确认 PATH 优先级,用 `python -c "import sys; print(sys.executable)"` 确认实际调用的解释器,再执行安装。
---
### 坑 5002b SQL 表达式索引使用 EXTRACT(...)::int 语法不被 PostgreSQL 接受
**现象**`CREATE INDEX idx_episodes_broadcast_date ON episodes (EXTRACT(year FROM broadcast_date)::int);` 在 pgAdmin / psql 中执行报错:`ERROR: syntax error at or near "::"`
**原因**PostgreSQL 的 `EXTRACT()` 返回 `double precision` 类型,`::int` 是标准 cast 语法,但某些 PostgreSQL 版本或客户端对表达式索引内 cast 有限制。SQL 在 commit 前未本机干跑直接进库,翻车后才发现。
**修复**:改为 `date_part('year', broadcast_date)` 语法,等效且兼容性更好:
```sql
CREATE INDEX idx_episodes_broadcast_date ON episodes (date_part('year', broadcast_date));
```
**教训****SQL 文件 commit 前必须在本机干跑**。即使是看似简单的 DDL,本机跑一遍能提前发现语法兼容性问题,避免入库后才发现翻车。Task 1 NameError(坑 1)与本坑同源,都是"没自测就 commit"。
---
### 坑 6Gitea OAuth refresh token 过期导致 git push 反复 401
**现象**`git push` 多次返回 401 Unauthorized,Git 凭证管理器反复弹框,输入正确账密后仍然失败。
**原因**Gitea OAuth access token 有效期到期,refresh token 也已失效,导致 Git 凭证无法续期。多次 401 后需重新完成 OAuth 认证流程刷新 token。
**修复**
- 删掉旧 tokenGit Bash 执行 `git credential reject` + 重置 credential helper
- 重新触发 GitHub/Gitea OAuth 流程获取新 access token
- 验证:`git push` 成功后确认 remote URL 使用新的 token
**教训**:**push 不成功不算完工**。本次 Task 2 期间 push 已全部恢复正常,之前的 git push 条目从"认证失败,需制片人手动"已转为✅完成。
---
## 六、纪律警告(Phase 2 Task 2 期间立)
### 纪律 1"push 待制片人手动处理"不算完工
**背景**Task 1 期间 MiniMax 多次以"push 待手动处理"作为完工结尾。本 chat 期间明确规矩。
**教训**:push 是 Git 工作流最后一环,未 push 就不是真正的完工。Git commit 只代表本地变更保存,push 成功才代表远程仓库同步完成。在任务报告中以"push 待手动处理"结尾会误导协作者认为工作已完成。
**落地规矩**:任何任务的完工报告必须包含"git push 成功"状态才算闭环。若 push 遇到认证问题:
- 优先排查 token 状态(坑 6 经验)
- 不得以"待手动处理"为由跳过 push 环节
- push 失败时用红字标注"⚠️ push 待解决",不写入"完工"状态
---
### 纪律 2SQL / 代码 commit 前必须本机干跑
**背景**Task 1 NameError(坑 1+ Task 2 002b SQL 语法错(坑 5)同源,都是 commit 前没自测直接进库。
**教训**:任何变更(SQL 文件、后端代码、前端代码)在 commit 前必须经过本机验证。对于 SQL 文件,本机用 `psql -U postgres -d milsci_dev -f backend/sql/00X_xxx.sql` 干跑;对于 Python 代码,用 `python -m pytest` 或冒烟脚本验证。发现错误当场修,不把问题带进 commit。
**落地规矩**
- SQL 文件:commit 前用 psql 在本机 dev 数据库干跑,确认无语法错误
- Python 代码:至少跑一遍冒烟测试或单元测试,确认基本功能正常
- 禁止用"这只是小改/语法简单/理论上没问题"为由跳过自测
- 养成习惯:**commit 前自测,发现问题当场修,比入库后发现 cost 低一个数量级**
---
## 七、遗留事项
### 7.1 Phase 2 内部待完成
- [x] **Task 1 后端 CRUD**:✅ 冒烟 6/6 全绿
- [x] **git push**:✅ 已恢复正常(坑 6 已解决)
- [ ] **Task 2**:Excel 批量导入(收视数据 + 排期模板)进行中
- [ ] **Task 3**:前端仪表盘 + 录入界面 + 用户管理界面
### 7.2 跨 Phase 遗留
#### Phase 4 或后续 Task 处理
- **PATCH /api/users 接口 password 相关权限**:当前 `UserUpdate` Schema 允许改 `is_active``role``display_name``profile_text`,但:
- `password_hash` **不允许**通过 API 修改(当前设计)
- 能否改自己密码?不能(当前设计)
- 改别人密码是否强制对方下次登录改密?未实现
- 建议 Phase 4+ 新增独立 `/api/users/change-password` 接口,设计时拉 Op
#### Phase 5 部署时处理(与 SECRET_KEY 并列)
- **传输安全**:当前 `password` 在请求体明文传输,仅 HTTPS 环境下可接受。Phase 5 部署到腾讯云后:
- 改 `same_site="strict"` + `https_only=True`
- 确认 HTTPS 开启,强制全站 TLS
- 考虑 API 请求体密码加密或单独 `/api/auth/change-password` 接口
- **SECRET_KEY 占位符**`.env` 当前是 `change-me-to-a-random-string-in-production`**Phase 5 部署前必须改**为真实随机值。生成命令:
```python
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
---
## 八、Git commit 记录
| commit | 内容 |
|--------|------|
| 143601c | docs: 合入第十三章 Opus task brief 节制原则,版本升至 v5 |
| 33bee0c | feat: 后端 CRUD 完成(episodes + yearly_targets + users 管理 API |
| 63c031a | docs: Task 1 完工记录,Swagger UI 验收清单,跨 Phase 遗留事项 |
| 4f7c92d | fix: 修复 episodes.py + yearly_targets.py 模型导入缺失,删 users.py 残留 verify_password,增 DELETE 接口(幂等冒烟后确认 6/6 全绿) |
| f82a71b | docs: phase2_log 追加 Task 2 进行中坑与纪律警告 |
---
---
## 九、Task 2 完工记录(2026-05-19 commit 442d52f
### 9.1 冒烟结果(8 步全绿)
| Step | 描述 | 预期 | 实际 | 结果 |
|------|------|------|------|------|
| 1 | GET /api/imports/template | 200 xlsx | 200 xlsx (5595 bytes) | ✅ |
| 2 | POST /api/imports/episodes 合法行 | 200 success=1 | 200 success=1 failed=0 | ✅ |
| 3 | POST 部分失败(1ok+2bad | 200 errors=2 | 200 errors=2 row_number=[3,4] | ✅ |
| 4 | GET /api/imports/errors/{batch_id} | 200 xlsx | 200 xlsx (5181 bytes) | ✅ |
| 5 | POST 2024 无年度目标 | 400 | 400 含"年度目标"+"2024" | ✅ |
| 6 | POST 重复期次(库内重复) | 数据库约束触发 | failed_count=1 UniqueViolation | ✅ |
| 7 | POST 未知编导"李不存在"软落地 | 200 GET id 确认 null | 200 id=22 editor_id=null | ✅ |
| 8 | GET /api/episodes/{真实id} 验收 | 200 editor_id=null | 200 ✅ | ✅ |
**smoke 脚本:`backend/scripts/import_smoke.py`(幂等版,可重复跑)**
---
### 9.2 坑 7pandas 2.x 不接受 raw bytesBytesIO 包装解决)
**发现时间**2026-05-19 第一轮冒烟
**现象**`pd.read_excel(file_content)` 传入 bytespandas 2.x 报错或读不出数据。
**原因**pandas 2.x 要求 file-like object,不再直接接受 bytes。
**修复**`excel_service.py` 中用 `io.BytesIO(file_content)` 包装后传给 `pd.read_excel`
**commit**442d52f
---
### 坑 8openpyxl add_named_style 误用(4ca24dc 原代码遗留)
**发现时间**2026-05-19 冒烟 Step 4 下载失败行 Excel
**现象**`generate_error_excel()` 调用 `wb.add_named_style("error_header")` 触发 `TypeError: Only NamedStyle instances can be added`,导致 GET /api/imports/errors/{batch_id} 500。
**来源**:经 git show 4ca24dc 确认,该 bug **在 4ca24dc 原代码中已存在**,非修坑引入。4ca24dc 自检只发现了 pandas bytes 问题,未发现此第二个 bug。
**修复**:删除 `wb.add_named_style("error_header")` 这一行(该样式未实际使用)。
**教训**commit 前自测要覆盖**所有**接口路径,不能只测主要流程。Step 1-3 通过不等于 Step 4 也通。
---
### 9.3 业务红线澄清:Step 6 重复期次拦截的两套机制
**两套拦截路径,各管各的事,不能混为一谈:**
1. **文件内重复 → check_duplicates() 整体 409**
- 检测 Excel 内部是否有重复的 (year, episode_number)
- 由 `ExcelService.check_duplicates()` 在入库前调用
- 有重复则整体拒绝,整文件不入库,返回 HTTP 409
2. **库内重复 → 数据库 UNIQUE 约束 单行失败,其他行入库**
- 检测 Excel 中某行与数据库已有记录重复
- 由 PostgreSQL `idx_episodes_year_number` 唯一约束触发
- 重复行报错进入 `errors[]`,其他行继续入库,返回 HTTP 200
**Step 6 实测的是第二套**(库内重复):Excel 写入 episode_number=99(与 Step 2 已入库的重复),数据库约束抛出 `UniqueViolation`,整行失败,success=0 failed=1。这两套机制分管文件内重复和库内重复,职责清晰。
---
### 9.4 纪律 3:启动期三件套不能证明接口业务路径通
**背景**:Task 2 开发期间,启动期(imports.py + excel_service.py + schemas/imports.py)写完后只跑了 GET 接口自测,POST/PATCH/DELETE 接口靠理论推导认为没问题就直接 commit。实际冒烟时发现 Step 2/3/4/5/6/7 全是 POST,全部出问题。
**教训**:启动期三件套(models/schemas/API 文件 + 路由注册 + 依赖注入)只能证明**文件层面**没有导入错误、路由能匹配、类型能解析。**不能**证明接口的业务路径(读写数据库、事务提交、错误处理)正常。任何 POST/PUT/PATCH/DELETE 接口 commit 前必须发真请求验证业务路径。
**落地规矩**
- 所有变更(SQL 文件、后端代码、前端代码)在 commit 前必须经过本机验证
- POST/PUT/PATCH/DELETE 接口必须用真请求实测,不能靠代码逻辑推导
- 发现错误当场修,不把问题带进 commit
---
### 9.5 遗留 bugsmoke 脚本 Step 7 写错(已改正)
**发现时间**2026-05-19 冒烟 Step 7 第一版
**现象**GET /api/episodes/300 返回 404"在库中不存在"。
**原因**smoke 脚本**把 episode_number 当 id 传**给 GET /api/episodes/{id}。后端 `GET /api/episodes/{id}` 接收的是数据库自增主键 id,不是业务期号 episode_number。episode_number=300 的记录入库后真实 id 是 14(或列表查询返回的其他值),不是 300。
**改正**:先从 `GET /api/episodes?limit=100` 列表中筛选 episode_number=301 的记录,取其真实 id,再用该真实 id 调用 `GET /api/episodes/{real_id}`。确保 GET /api/episodes/{id} 路径被实际验收。
**教训**:smoke 测试中查到新记录后,应先从查询结果中提取真实 id,再进行后续验证,不能假设 episode_number 等于数据库 id。
---
### 9.6 Task 2 收尾清单(还未做的四件小尾巴)
- [ ] **补录 2024 真值年度目标**Step 5 冒烟用到 2024 年,当前库中无 2024 目标,补录后 Step 5 才能真正通过
- [ ] **Windows python 默认 PATH 改指向 3.12**:坑 4 未完全解决,`py` 命令仍可能路由到系统 Python 3.14,需在系统 PATH 中显式优先 3.12
- [ ] **backups/ 进 .gitignore**:虽然 .gitignore 有 `*.log` 等模式,但 `backups/` 目录显式加入更稳,避免样本数据误传
- [ ] **MiniMax 自检报告里 schema 类名对照 Plan 自查**`ImportError`/`ImportResult` vs `UploadTemplateResponse`/`ImportResultResponse` 等命名差异对着 Plan 确认是否一致
---
## 十、Git commit 记录(2026-05-19 追加)
| commit | 内容 |
|--------|------|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿 |
*本日志由 MiniMax M2.7(Cline Act 模式)于 2026-05-15 持续追加,2026-05-19 追加 Task 2 完工坑与红线澄清,2026-05-20 追加 Task 2 收尾记录。*
---
## 十一、Task 2 收尾完工记录(2026-05-20)
### 11.1 Git 安全卫生:token 从 URL 剥离
**背景**:`.git/config` 里 origin 远程 URL 含明文 token(`cline-tps-dashboard`),
存在被 Cline 误贴日志、截图泄露、备份带出等风险。
**改动**:
- `git remote set-url origin` 改成干净 URL(去掉 `user:token@`)
- token 改由 Windows Git Credential Manager (manager) 加密存储
- 两次 fetch 静默通过,push 走 GCM 透明工作
- 旧 token `cline-tps-dashboard` 继续使用,不吊销
**纪律备注**:以后任何凭证(密码、API key、token)都不许写进 URL、不许写进 .py 文件、
不许写进任何会被 commit 的文件。
### 11.2 小尾巴 1:补录 2024 真值
**操作**:制片人手动 psql 直接 INSERT(绕过 API,因 API 权限收紧待办未做)。
**当前 yearly_targets 真值**:
- 2024: base=0.6448 / stretch=0.8290 (issued_date=2024-01-01, 补录说明在 notes)
- 2025: base=0.6448 / stretch=0.8989
- 2026: base=0.6448 / stretch=0.8989
### 11.3 小尾巴 3:backups/ 进 .gitignore
**commit**:20c0a1d(chore: backups/ 进 .gitignore)
**push**:已成功
### 11.4 小尾巴 4:schema 类名自查通过
类名与 Task 2 Plan 一致,无漂移。`ImportError` / `ImportResult` 命名正确。
发现 2 个隐患,**暂不处理,记为待办**:
- 隐患 A:`ImportError` 与 Python 内置同名,当前未撞,未来若在导入逻辑里 raise 才会冲突
- 隐患 B:`ExcelService.import_episodes()` 返回 dict 而非 Pydantic 对象,
API 层手动构造 `ImportResult`,缺一次 Pydantic 校验层。8 步冒烟已验证业务正确,
仅代码洁癖问题
### 11.5 小尾巴 2:Python PATH 根治坑 4
**改动**:从系统 PATH 删除 `C:\Python314\Scripts\``C:\Python314\` 两行。
用户 PATH 中 Python 3.12 自动顶上来。
**验证**:
- `where.exe python` 第一行变成 3.12 路径
- `python --version` = Python 3.12.10
- `sys.executable` 指向 3.12
**遗留**:E:\tps-dashboard\backend\ 下没有 venv,Python 依赖装在系统 3.12 全局
site-packages 里。**记入待办**,Phase 4 部署前或 Task 3 启动前评估是否补 venv。
### 11.6 待办清单(Task 3 启动前或后续 Phase 处理)
| 待办 | 触发时机 | 优先级 |
|---|---|---|
| `yearly_targets` 写权限收紧到 `zhipianren` + `zebian`,`biandao` 只读 | Task 3 启动前 | 高(业务红线) |
| backend/ 补 venv,把全局依赖搬进去 | Task 3 启动前评估 | 中 |
| 隐患 A:`ImportError` 同名冲突预防 | 真要 raise 时再说 | 低 |
| 隐患 B:`ExcelService` 返回 Pydantic 化 | 代码洁癖,有空时做 | 低 |
### 11.7 Task 2 完整 commit 链(2026-05-19 → 2026-05-20)
| commit | 内容 |
|---|---|
| 442d52f | feat: 完善批量导入逻辑,8步冒烟全绿(2026-05-19) |
| 6de8111 | docs: Task 2 完工记录,坑7/8/纪律3/Q1-Q4 写入(2026-05-19) |
| 20c0a1d | chore: backups/ 进 .gitignore(Task 2 收尾)(2026-05-20) |
**Task 2 完整收尾,Phase 2 Task 2 状态:✅ 完工。**
---
## 第十二章 yearly_targets 写权限收紧(2026-05-20
**状态:✅ 完工。冒烟 12/12 全绿。**
### 12.1 任务来源
task brief 明确要求收紧 yearly_targets 表的写权限,是业务红线。
### 12.2 改动文件
| 文件 | 改动内容 |
|------|----------|
| `backend/app/api/yearly_targets.py` | 5处 `require_role` 参数修正 |
| `backend/app/schemas/yearly_target.py` | `YearlyTargetUpdate` 改为部分字段可选(支持 PATCH 部分更新) |
| `backend/scripts/smoke_yearly_targets_auth.py` | 新建,12步幂等冒烟脚本 |
| `README.md` | 新增"角色权限表"节 |
| `.clinerules` 5.5 | 新增 yearly_targets 写权限收紧说明 |
| `logs/phase2_log.md` | 本章记录 |
### 12.3 权限矩阵最终状态
| 接口 | zhipianren | zebian | biandao |
|------|-----------|--------|---------|
| GET 列表/详情 | ✅ 200 | ✅ 200 | ✅ 200 |
| POST 新增 | ✅ 201 | ✅ 201 | ❌ 403 |
| PATCH 修改 | ✅ 200 | ✅ 200 | ❌ 403 |
| DELETE 删除 | ✅ 204 | ❌ 403 | ❌ 403 |
### 12.4 GET 权限修改说明
GET 权限修改原本不在 task brief 范围,是 Plan 阶段自查发现并扩大的范围。
Opus 审 Plan 时同意纳入,理由:权限矩阵整体校正一次做完更合理。
此条在 Opus 审方案 v2 中明确记录,phase2_log 本章也写入此说明,不得糊涂带过。
### 12.5 冒烟实测记录
`backend/scripts/smoke_yearly_targets_auth.py`12步结果:
| 步骤 | 场景 | 预期 | 结果 |
|------|------|------|------|
| 1 | zhipianren POST | 201 | ✅ |
| 2 | zebian POST | 201 | ✅ |
| 3 | biandao POST | 403 | ✅ |
| 4 | 未登录 POST | 401 | ✅ |
| 5 | zhipianren PATCH | 200 | ✅ |
| 6 | zebian PATCH | 200 | ✅ |
| 7 | biandao PATCH | 403 | ✅ |
| 8 | zhipianren DELETE | 204 | ✅ |
| 9 | zebian DELETE | 403 | ✅ |
| 10 | biandao DELETE | 403 | ✅ |
| 11 | biandao GET 列表 | 200 | ✅ |
| 12 | zhipianren GET 列表 | 200 | ✅ |
**12/12 全部通过。**
### 12.6 测试账号保留
冒烟脚本末尾显式打印保留账号(供后续手动清理):
- `test_zebian_smoke` (zebian)
- `test_biandao_smoke` (biandao)
---
## 第十三章 Task 3 前端三界面收官(2026-05-22
**状态:✅ 完工。六条尾巴全闭环,Phase 2 整体收官。**
Task 3 = 仪表盘真数据 + 用户管理 + 责编录入页。本次开局承接 2026-05-21 的"部分完成、未收官"状态(HEAD `1ebb8a6`),清完六条尾巴正式收官。
### 13.1 今日 commit 链
| commit | 内容 |
|--------|------|
| `4946ab2` | fix: AppLayout 未挂载——路由改两层 Outlet 嵌套,AuthGuard 不再包裹 AppLayout |
| `ebcb530` | fix: episodes GET 放开三角色,写权限仍仅 zhipianren/zebian |
| `c7296a5` | style: 柱图三色换降饱和柔和版(柔蓝/柔绿/柔红),图例同步 |
| `5839fc6` | chore: .gitignore 新增 cookies*.txt 忽略规则 |
(昨日另有 `bfcaddf` CSS 改动,非侧边栏病根但保留无害。)
### 13.2 六条尾巴收官情况
1. **侧边栏死穴(最硬)**simonkoson 登录后整个侧边栏不渲染。连追五轮才定位真因——**根因不是 CSS(前两轮误判 fixed/flex),是 AuthGuard 被当包裹组件用却内部只渲染 `<Outlet/>`、丢弃了 children `<AppLayout/>`**。改为路由两层 Outlet 嵌套(AuthGuard 独立守卫层 + AppLayout 独立 layout route)。三账号实测侧边栏 + 角色过滤全对(zhipianren 见用户管理、zebian 见责编录入不见用户管理、biandao 两者都不见)。
2. **episodes GET 放开三角色**:biandao 仪表盘从空变有数据;反向验证 biandao 发 POST/PATCH/DELETE 全 403。
3. **柱图配色**:见 13.4 色板固化。
4. **EditorDesk 责编录入**:**实测发现三界面本就完整**(三 Tab + 列表 CRUD + 表单字段齐全,含 audience_share 输入框),昨日"表单不全 / 找不到 audience_share"系侧边栏不通、进不去页面时凭印象误记。四步实测全过,无需改码:
- 新增期次能存(期次 8888 / 份额 0.95 / 编导张颖)
- 编导姓名快照必填、留空不能存(符合软引用红线:editor_id 可空、editor_name_snapshot 必填)
- 年度目标只增不改:2028 能存、2026 重复被拒(year UNIQUE 生效,旧值不被覆盖)
- 三色极端数据实测通过
5. **cookies 进 .gitignore**:5 个测试 cookie 文件忽略,工作区干净。
6. **极端数据三色实测**:造 0.95→柔红、0.5→柔绿、0.71/0.72→柔蓝,三色同框,颜色逻辑 + 配色双验。
### 13.3 侧边栏一役复盘(重点教训)
| 轮次 | Cline 判定的"根因" | 实际 | 结果 |
|------|-------------------|------|------|
| 1 | AuthGuard user===null 返回 null,整页空白 | 与现象矛盾(仪表盘明明渲染了) | 打回 |
| 2 | CSS `.app-sider` fixed 塌成 0 宽 | 猜测,未看真实 CSS | 打回 |
| 3 | 改 fixed 为 flex | 改了但浏览器仍不显示 | 非病根 |
| 4 | 制片人 F12 看真实 DOM | `#root` 下直接是 `.dashboard`,无 AppLayout 层 | 锁定方向 |
| 5 | AuthGuard 不渲染 children 却被当包裹组件用 | 真因,两段代码 + DOM 三者咬合 | 修复成功 |
教训沉淀为下方纪律 4。
### 13.4 柱图三色色板固化(制片人拍板)
**降饱和柔和版**:达标 = 柔蓝 `#5b8db8` / 待提升 = 柔绿 `#7aa874` / 优秀 = 柔红 `#c0584f`
原则:①降饱和度,贴合奶油底 `#fbf9f1` + 深绿主色 `#6b8e6b` 整体柔和基调,不用高饱和纯色;②待提升的绿须与主色深绿拉开(更浅更嫩),且与达标蓝有明显色相区分,杜绝"达标待提升一个色系"。
语义映射仍按 `.clinerules` 5.1:红 = 优秀 / 蓝 = 达标 / 绿 = 待提升。
**教训**:此色板此前仅口头拍板未入档,导致 Cline 重新生成时配错(达标套了主色深绿、待提升用土黄凑数)。**凡视觉拍板必入 log 固化**。
### 13.5 纪律 4:根因未经代码 / DOM 实证,不许动手修
侧边栏一役 Cline 连续三轮在猜测的根因上改代码(loading 态 → CSS fixed → CSS flex),均非病根。最终靠制片人 F12 看真实 DOM + 逼 Cline 贴真实代码才定位。
**落地规矩**Cline 报告"根因是 X"时,必须要求它贴出支撑该结论的真实代码 / DOM 证据;只要它在用"可能 / 也许 / 应该",就是在猜,打回。
---
## 第十四章 Phase 2 整体收官(2026-05-22
- Task 1 后端 CRUD:✅(冒烟 6/6
- Task 2 Excel 批量导入:✅(冒烟 8/8)
- Task 2 收尾 + yearly_targets 写权限收紧:✅(冒烟 12/12)
- Task 3 前端三界面:✅(六尾巴全闭环)
- **Phase 2 整体收官**,打 tag `phase2-dashboard-done`
- 收官 commit`5839fc6`working tree clean
### 14.1 跨 Phase 遗留(带入后续)
- **题图上传 Task(独立小 Task,下一个起手)**:①题图 banner 化;②仪表盘布局重排——题图做全宽 banner、近 9 期收视紧贴其下做全宽横条(不再挤在中间小格)。**走 Claude Design 流程**Design 出可交互原型 → 制片人定稿 → Opus 翻译成色值 + antd 组件名规格 → 喂 MiniMax**不直接把图甩给 Cline**(见 design_环节备忘.md)。
- **Phase 5 上线前数据清理**:清掉今日测试数据(期次 8888/8889 等、年度目标 2027/2028 等占位),录入真数据。删除走 UI 逐条删或 Cline 写"清业务表保真数据"SQL 经制片人确认后跑。
- backend/ 补 venv;隐患 AImportError 同名)、隐患 BExcelService 返回 Pydantic 化)低优;SECRET_KEY + same_site/https_onlyPhase 5 部署)。
---
*第十三~十四章由 Claude Opus 4.7 协助、MiniMax M2.7 落地,于 2026-05-22 Task 3 收官后追加。*
+6
View File
@@ -0,0 +1,6 @@
{
"name": "tps-dashboard",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}