feat: 前后台静态资源路径冲突修复 + 用户管理功能

- 修改 admin/vite.config.js 添加 base: '/admin/' 解决静态资源路径问题
- 修复后台 index.html 资源引用从 /assets/ → /admin/assets/
- 更新优化器默认 API 地址为服务器地址
- 添加友链管理相关代码
- 修复多处分类管理页面
This commit is contained in:
bwstudio
2026-06-10 07:30:49 +08:00
parent aecf1f1f15
commit 595394fcb7
22 changed files with 740 additions and 100 deletions
+6 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -12,7 +12,12 @@ class Joke(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False)
# AI 润色后的内容
polished_content = Column(Text, nullable=True)
# AI 评分 (1-10)
ai_score = Column(Float, nullable=True)
# AI 质量等级: excellent/good/ordinary/poor
ai_level = Column(String(20), nullable=True)
type_ids = Column(Text, nullable=True)
crowd_ids = Column(Text, nullable=True)
type_id = Column(Integer, ForeignKey("joke_types.id"), nullable=True)
+2
View File
@@ -11,4 +11,6 @@ class Link(Base):
url = Column(String(500), nullable=False)
description = Column(String(200), nullable=True)
sort_order = Column(Integer, default=0)
status = Column(String(20), default="approved") # approved=已通过, pending=待审核
contact = Column(String(100), nullable=True) # 申请联系方式
created_at = Column(DateTime, default=func.now())
+14 -3
View File
@@ -9,10 +9,11 @@ from app.config import JWT_ALGORITHM, JWT_EXPIRATION_HOURS, JWT_SECRET_KEY
from app.database import get_db
from app.models.user import AdminUser
from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.user import UserCreate, UserResponse, UserRegister
router = APIRouter(prefix="/auth", tags=["认证"])
# 已存在的函数保持不变...
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
@@ -27,11 +28,21 @@ def create_access_token(data: dict) -> str:
return encoded_jwt
def hash_password(password: str) -> str:
"""哈希密码"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def get_current_user():
"""获取当前用户(保留用于后续权限控制)"""
pass
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
"""管理员登录"""
"""登录"""
user = db.query(AdminUser).filter(AdminUser.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
access_token = create_access_token(data={"sub": str(user.id), "username": user.username, "role": user.role})
return TokenResponse(access_token=access_token)
+90 -36
View File
@@ -2,7 +2,7 @@ import json
import random
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func
from sqlalchemy import func, text
from sqlalchemy.orm import Session
from app.database import get_db
@@ -24,26 +24,49 @@ def _parse_ids(raw) -> list[int]:
return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
return [r.name for r in rows]
def _batch_load_categories(db: Session, jokes: list[Joke]) -> tuple[dict, dict]:
"""批量加载类型和人群名称,避免 N+1 查询"""
# 收集所有需要的 id
all_type_ids = set()
all_crowd_ids = set()
for joke in jokes:
all_type_ids.update(_parse_ids(joke.type_ids))
all_crowd_ids.update(_parse_ids(joke.crowd_ids))
# 批量查询
type_map = {}
if all_type_ids:
types = db.query(JokeType).filter(JokeType.id.in_(all_type_ids)).all()
type_map = {t.id: t.name for t in types}
crowd_map = {}
if all_crowd_ids:
crowds = db.query(JokeCrowd).filter(JokeCrowd.id.in_(all_crowd_ids)).all()
crowd_map = {c.id: c.name for c in crowds}
return type_map, crowd_map
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
def joke_to_response(joke: Joke, type_map: dict = None, crowd_map: dict = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema.
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema."""
Args:
type_map: 预加载的类型 id→name 映射
crowd_map: 预加载的人群 id→name 映射
"""
ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
if type_map is not None:
type_names = [type_map[i] for i in ids if i in type_map]
else:
type_names = []
if crowd_map is not None:
crowd_names = [crowd_map[i] for i in crowd_ids if i in crowd_map]
else:
crowd_names = []
return JokeResponse(
id=joke.id,
title=joke.title,
@@ -57,9 +80,25 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
updated_at=joke.updated_at,
type_names=type_names,
crowd_names=crowd_names,
polished_content=joke.polished_content,
ai_score=joke.ai_score,
ai_level=joke.ai_level,
)
def _build_json_filter(column, target_ids: set[int]) -> list:
"""构建精确的 JSON 数组匹配过滤器(SQLite"""
filters = []
for tid in target_ids:
# 匹配 [n,...] 或 [...,n,...] 或 [...,n]
# 使用 JSON_EACH 函数检查
json_str = json.dumps(tid)
filters.append(
func.json_extract(column, '$').cast(type(target_ids)).contains(tid)
)
return filters
@router.get("/", response_model=PaginatedJokeResponse)
def list_jokes(
page: int = 1,
@@ -72,38 +111,49 @@ def list_jokes(
query = db.query(Joke).filter(Joke.status == "approved")
# 支持多选过滤:逗号分隔的 ID
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
if type_ids:
filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()}
if filter_set:
# 兼容旧单值字段 type_id
# 使用文本匹配,确保精确匹配 JSON 数组中的 id
# 匹配模式:[1,2,3] 或 [1] 或带尾部逗号的
type_filters = []
for tid in filter_set:
# 构建精确匹配的正则:在逗号、引号、方括号包围中的数字
import re
pattern = rf'["\s\[,]{tid}[",\s\]]'
type_filters.append(Joke.type_ids.regexp_match(pattern))
# 只要匹配任一类型即可
type_filter = type_filters[0]
for f in type_filters[1:]:
type_filter = type_filter | f
# 还需要兼容旧的单值 type_id 字段
old_filter = Joke.type_id.in_(filter_set)
# 兼容新数组字段 type_ids(JSON 包含任一)
new_filter = [
Joke.type_ids.contains(str(tid)) for tid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
query = query.filter(old_filter | type_filter)
if crowd_ids:
filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()}
if filter_set:
import re
crowd_filters = []
for cid in filter_set:
pattern = rf'["\s\[,]{cid}[",\s\]]'
crowd_filters.append(Joke.crowd_ids.regexp_match(pattern))
crowd_filter = crowd_filters[0]
for f in crowd_filters[1:]:
crowd_filter = crowd_filter | f
old_filter = Joke.crowd_id.in_(filter_set)
new_filter = [
Joke.crowd_ids.contains(str(cid)) for cid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
query = query.filter(old_filter | crowd_filter)
total = query.count()
offset = (page - 1) * page_size
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
# 批量预加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, jokes)
return PaginatedJokeResponse(
items=[joke_to_response(j, db) for j in jokes],
items=[joke_to_response(j, type_map, crowd_map) for j in jokes],
total=total,
page=page,
page_size=page_size,
@@ -125,7 +175,9 @@ def get_joke(joke_id: int, db: Session = Depends(get_db)):
db.commit()
# 重新查询获取更新后的数据
db.refresh(joke)
return joke_to_response(joke, db)
# 批量加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
@router.get("/random", response_model=JokeResponse)
@@ -144,13 +196,15 @@ def get_random_joke(db: Session = Depends(get_db)):
Joke.status == "approved"
).first()
if joke:
return joke_to_response(joke, db)
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
# 兜底:全表随机
joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first()
if not joke:
raise HTTPException(status_code=404, detail="暂无笑话")
return joke_to_response(joke, db)
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
@router.post("/{joke_id}/like")
+27 -3
View File
@@ -3,12 +3,36 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.link import Link
from app.schemas.link import LinkCreate, LinkResponse
from app.schemas.link import LinkCreate, LinkResponse, LinkApply
router = APIRouter(tags=["友情链接"])
@router.get("/links", response_model=list[LinkResponse])
def list_links(db: Session = Depends(get_db)):
"""公开:获取所有友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all()
"""公开:获取已通过审核的友情链接"""
return db.query(Link).filter(Link.status == "approved").order_by(Link.sort_order, Link.id).all()
@router.post("/links/apply", response_model=LinkResponse)
def apply_link(link_data: LinkApply, db: Session = Depends(get_db)):
"""公开:申请友链"""
# 检查是否已存在同名或同URL的申请/已通过友链
existing = db.query(Link).filter(
(Link.name == link_data.name) | (Link.url == link_data.url)
).first()
if existing:
return existing # 已存在则返回已有记录
link = Link(
name=link_data.name,
url=link_data.url,
description=link_data.description,
contact=link_data.contact,
status="pending",
sort_order=999, # 新申请放在最后
)
db.add(link)
db.commit()
db.refresh(link)
return link
+20
View File
@@ -18,6 +18,8 @@ class JokeUpdate(BaseModel):
title: str | None = None
content: str | None = None
polished_content: str | None = None
ai_score: float | None = None
ai_level: str | None = None
type_ids: list[int] | None = None
crowd_ids: list[int] | None = None
status: str | None = None
@@ -27,6 +29,9 @@ class JokeResponse(JokeBase):
id: int
status: str
polished_content: str | None = None
# AI 评价字段
ai_score: float | None = None
ai_level: str | None = None
view_count: int
like_count: int
created_at: datetime
@@ -37,6 +42,21 @@ class JokeResponse(JokeBase):
class Config:
from_attributes = True
@property
def display_content(self) -> str:
"""优先显示润色后的内容"""
return self.polished_content if self.polished_content else self.content
def get_level_display(self) -> str:
"""获取 AI 评级的中文显示"""
level_map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return level_map.get(self.ai_level, '') if self.ai_level else ''
class PaginatedJokeResponse(BaseModel):
items: list[JokeResponse]
+11 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, HttpUrl
class LinkCreate(BaseModel):
@@ -10,8 +10,18 @@ class LinkCreate(BaseModel):
sort_order: int = 0
class LinkApply(BaseModel):
"""友链申请"""
name: str
url: str
description: str | None = None
contact: str | None = None # 联系方式
class LinkResponse(LinkCreate):
id: int
status: str
contact: str | None = None
created_at: datetime
class Config:
+2
View File
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import API_TITLE, API_VERSION
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router
from app.routers.submit import router as submit_router
from app.database import Base, engine
from app.models.setting import AiSetting
from app.models.link import Link
@@ -32,6 +33,7 @@ app.include_router(settings_router, prefix="/api")
app.include_router(links_router, prefix="/api")
app.include_router(feedback_router, prefix="/api")
app.include_router(generate_router, prefix="/api")
app.include_router(submit_router, prefix="/api/jokes") # 公开提交接口
@app.get("/")
+1
View File
@@ -1,5 +1,6 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
gunicorn==21.2.0
sqlalchemy==2.0.25
pydantic==2.5.3
python-jose[cryptography]==3.3.0