298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""
|
|
栏目管理 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
|