From a6e8cc25ecb2d997e37f1be239b3cc252441f2bc Mon Sep 17 00:00:00 2001 From: simonkoson <28867558@qq.com> Date: Mon, 25 May 2026 09:44:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=98=E5=9B=BE?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=8E=A5=E5=8F=A3=E5=92=8C=E6=8E=92=E6=92=AD?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/dashboard.py | 125 +++++++++++++++++++++++++++ backend/app/api/schedules.py | 70 +++++++++++++++ backend/app/main.py | 12 +++ backend/app/models/cover_settings.py | 28 ++++++ backend/app/models/schedule.py | 33 +++++++ 5 files changed, 268 insertions(+) create mode 100644 backend/app/api/dashboard.py create mode 100644 backend/app/api/schedules.py create mode 100644 backend/app/models/cover_settings.py create mode 100644 backend/app/models/schedule.py diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..0c7f45f --- /dev/null +++ b/backend/app/api/dashboard.py @@ -0,0 +1,125 @@ +""" +仪表盘 API — 题图上传 / 查询 + +权限: +- POST /cover — 仅 zhipianren / zebian(biandao 返回 403) +- GET /cover — 三角色均可读 +""" + +import os +import uuid +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status +from sqlmodel import Session + +from app.core.deps import get_current_user, require_role +from app.db.session import get_session +from app.models.user import User, UserRole + +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), +): + """查询当前题图设置(三角色均可读)。""" + from app.models.cover_settings import CoverSettings + + 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) + + # 更新数据库 + from app.models.cover_settings import CoverSettings + + 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} \ No newline at end of file diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py new file mode 100644 index 0000000..a8da810 --- /dev/null +++ b/backend/app/api/schedules.py @@ -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 \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index ee2be93..3cf0eed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,11 +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") @@ -42,3 +47,10 @@ 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") diff --git a/backend/app/models/cover_settings.py b/backend/app/models/cover_settings.py new file mode 100644 index 0000000..113df36 --- /dev/null +++ b/backend/app/models/cover_settings.py @@ -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()), + ) \ No newline at end of file diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py new file mode 100644 index 0000000..fc4d91c --- /dev/null +++ b/backend/app/models/schedule.py @@ -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()), + ) \ No newline at end of file