""" 栏目管理 API 路由 """ import re from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import func from pydantic import BaseModel, Field from pypinyin import lazy_pinyin from app.models.column import ColumnModel from app.routers.auth import get_current_admin from app.database import get_db router = APIRouter(tags=["栏目管理"]) def generate_slug(text: str) -> str: """将中文标题转为拼音 slug""" if not text: return "" py = lazy_pinyin(text, style=0) slug = "-".join(py) slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower() slug = re.sub(r"-+", "-", slug).strip("-") return slug or f"column-{int(__import__('time').time())}" # ========== Pydantic 模型 ========== class ColumnCreate(BaseModel): name: str = Field(..., min_length=1, max_length=50) slug: Optional[str] = Field(None, max_length=50) description: Optional[str] = Field(None, max_length=200) parent_id: Optional[int] = Field(None) sort_order: int = Field(default=0) is_enabled: bool = Field(default=True) display_mode: str = Field(default="list", pattern="^(list|card)$") layout_style: str = Field(default="left", pattern="^(left|right|top)$") icon_name: Optional[str] = Field(None, max_length=50) class ColumnUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=50) slug: Optional[str] = Field(None, min_length=1, max_length=50) description: Optional[str] = Field(None, max_length=200) parent_id: Optional[int] = Field(None) sort_order: Optional[int] = None is_enabled: Optional[bool] = None display_mode: Optional[str] = Field(None, pattern="^(list|card)$") layout_style: Optional[str] = Field(None, pattern="^(left|right|top)$") icon_name: Optional[str] = Field(None, max_length=50) class ColumnTreeOut(BaseModel): """树形栏目输出""" id: int name: str slug: str description: Optional[str] parent_id: Optional[int] sort_order: int is_enabled: bool display_mode: str layout_style: str children: List["ColumnTreeOut"] = [] class Config: from_attributes = True # ========== 辅助函数 ========== def _build_tree(columns: list, parent_id: Optional[int] = None) -> list: """递归构建栏目树""" tree = [] for col in columns: if col.parent_id == parent_id: node = col.to_dict() node["children"] = _build_tree(columns, col.id) tree.append(node) return tree def _validate_parent(column: ColumnModel, new_parent_id: Optional[int], db: Session): """校验 parent_id 是否合法:最多两级""" if new_parent_id is None: return # 不能把自己设为自己的父栏目 if column and new_parent_id == column.id: raise HTTPException(status_code=400, detail="不能将栏目设为自己的上级栏目") # 父栏目必须存在 parent = db.query(ColumnModel).filter(ColumnModel.id == new_parent_id).first() if not parent: raise HTTPException(status_code=404, detail="上级栏目不存在") # 父栏目本身不能是二级栏目(最多两级) if parent.parent_id is not None: raise HTTPException(status_code=400, detail="最多支持两级栏目,不能将二级栏目设为上级") def _has_children(column_id: int, db: Session) -> bool: """检查栏目是否有子栏目""" return db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).first() is not None # ========== API ========== @router.get("/admin/columns") def list_columns( tree: bool = Query(default=False, description="是否返回树形结构"), db: Session = Depends(get_db), current_admin=Depends(get_current_admin), ): """获取栏目列表(后台管理)""" columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all() if tree: return {"items": _build_tree(columns), "total": len(columns)} return {"items": [col.to_dict() for col in columns], "total": len(columns)} @router.get("/admin/columns/tree") def get_column_tree( db: Session = Depends(get_db), current_admin=Depends(get_current_admin), ): """获取树形栏目列表(用于下拉选择,后台管理)""" columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all() return {"items": _build_tree(columns), "total": len(columns)} @router.get("/admin/columns/{column_id}") def get_column( column_id: int, db: Session = Depends(get_db), current_admin=Depends(get_current_admin), ): """获取栏目详情(后台管理)""" column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first() if not column: raise HTTPException(status_code=404, detail="栏目不存在") return column.to_dict() @router.post("/admin/columns") def create_column( data: ColumnCreate, db: Session = Depends(get_db), current_admin=Depends(get_current_admin), ): """创建栏目(后台管理)""" # 校验上级栏目 _validate_parent(None, data.parent_id, db) # 自动生成 slug slug = data.slug or generate_slug(data.name) if not slug: raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写") # 检查 slug 唯一性 existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first() if existing: raise HTTPException(status_code=400, detail="栏目标识已存在") column = ColumnModel( name=data.name, slug=slug, description=data.description, parent_id=data.parent_id, sort_order=data.sort_order, is_enabled=data.is_enabled, display_mode=data.display_mode, layout_style=data.layout_style, icon_name=data.icon_name, ) db.add(column) db.commit() db.refresh(column) return column.to_dict() @router.put("/admin/columns/{column_id}") def update_column( column_id: int, data: ColumnUpdate, db: Session = Depends(get_db), current_admin=Depends(get_current_admin), ): """更新栏目(后台管理)""" column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first() if not column: raise HTTPException(status_code=404, detail="栏目不存在") # 校验上级栏目 if data.parent_id is not None: _validate_parent(column, data.parent_id, db) # 检查 slug 唯一性 slug = data.slug if slug is not None: if not slug.strip(): slug = generate_slug(data.name or column.name) if slug != column.slug: existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first() if existing: raise HTTPException(status_code=400, detail="栏目标识已存在") update_data = data.model_dump(exclude_unset=True) if slug is not None: update_data["slug"] = slug for key, value in update_data.items(): setattr(column, key, value) db.commit() db.refresh(column) return column.to_dict() @router.delete("/admin/columns/{column_id}") def delete_column( column_id: int, force: bool = Query(default=False, description="强制删除关联内容"), db: Session = Depends(get_db), current_admin=Depends(get_current_admin), ): """删除栏目(后台管理)""" column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first() if not column: raise HTTPException(status_code=404, detail="栏目不存在") # 检查是否有子栏目 child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count() if child_count > 0: if not force: raise HTTPException( status_code=400, detail=f"该栏目存在 {child_count} 个子栏目,请先删除子栏目或勾选强制删除" ) # 强制删除:递归删除所有子栏目 db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).delete(synchronize_session=False) # TODO: 检查关联的内容数量(等 articles 模型创建后补充) # content_count = db.query(Article).filter(Article.column_id == column_id).count() # if content_count > 0: # if not force: # raise HTTPException(status_code=400, detail=f"该栏目下还有 {content_count} 条内容,是否强制删除?") # db.query(Article).filter(Article.column_id == column_id).delete(synchronize_session=False) db.delete(column) db.commit() return {"message": "删除成功"} # ========== 访客端 Public API ========== @router.get("/public/columns") def list_public_columns( db: Session = Depends(get_db), ): """访客端:获取启用的栏目树""" columns = db.query(ColumnModel).filter( ColumnModel.is_enabled == True ).order_by(ColumnModel.sort_order.asc()).all() return {"items": _build_tree(columns), "total": len(columns)} @router.get("/public/columns/{slug}") def get_public_column( slug: str, db: Session = Depends(get_db), ): """访客端:获取栏目详情(包含子栏目)""" column = db.query(ColumnModel).filter( ColumnModel.slug == slug, ColumnModel.is_enabled == True ).first() if not column: raise HTTPException(status_code=404, detail="栏目不存在") result = column.to_dict() # 加载子栏目 children = db.query(ColumnModel).filter( ColumnModel.parent_id == column.id, ColumnModel.is_enabled == True ).order_by(ColumnModel.sort_order.asc()).all() result["children"] = [c.to_dict() for c in children] # 加载栏目下的内容数量 from app.models.article import Article article_count = db.query(Article).filter( Article.column_id == column.id, Article.status == "published" ).count() result["article_count"] = article_count return result