feat: add theme preset system with admin CRUD, public listing, and user theme settings
- Add ChromaticColor (17 Tailwind colors) and NeutralColor (5 grays) enums - Add ThemePreset table with flat color columns and unique name constraint - Add admin theme endpoints (CRUD + set default) at /api/v1/admin/theme - Add public theme listing at /api/v1/site/themes - Add user theme settings (PATCH /theme) with color snapshot on User model - User.color_* columns store per-user overrides; fallback to default preset then builtin - Initialize default theme preset in migration - Remove legacy defaultTheme/themes settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from .policy import admin_policy_router
|
||||
from .share import admin_share_router
|
||||
from .task import admin_task_router
|
||||
from .user import admin_user_router
|
||||
from .theme import admin_theme_router
|
||||
from .vas import admin_vas_router
|
||||
|
||||
|
||||
@@ -46,6 +47,7 @@ admin_router.include_router(admin_file_router)
|
||||
admin_router.include_router(admin_policy_router)
|
||||
admin_router.include_router(admin_share_router)
|
||||
admin_router.include_router(admin_task_router)
|
||||
admin_router.include_router(admin_theme_router)
|
||||
admin_router.include_router(admin_vas_router)
|
||||
|
||||
# 离线下载 /api/admin/aria2
|
||||
|
||||
@@ -53,7 +53,7 @@ async def router_admin_get_share_list(
|
||||
)
|
||||
async def router_admin_get_share(
|
||||
session: SessionDep,
|
||||
share_id: int,
|
||||
share_id: UUID,
|
||||
) -> ResponseBase:
|
||||
"""
|
||||
获取分享详情。
|
||||
@@ -99,7 +99,7 @@ async def router_admin_get_share(
|
||||
)
|
||||
async def router_admin_delete_share(
|
||||
session: SessionDep,
|
||||
share_id: int,
|
||||
share_id: UUID,
|
||||
) -> ResponseBase:
|
||||
"""
|
||||
删除分享。
|
||||
|
||||
199
routers/api/v1/admin/theme/__init__.py
Normal file
199
routers/api/v1/admin/theme/__init__.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from loguru import logger as l
|
||||
from sqlalchemy import update as sql_update
|
||||
|
||||
from middleware.auth import admin_required
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import (
|
||||
ThemePreset,
|
||||
ThemePresetCreateRequest,
|
||||
ThemePresetUpdateRequest,
|
||||
ThemePresetResponse,
|
||||
ThemePresetListResponse,
|
||||
)
|
||||
from utils import http_exceptions
|
||||
|
||||
admin_theme_router = APIRouter(
|
||||
prefix="/theme",
|
||||
tags=["admin", "admin_theme"],
|
||||
dependencies=[Depends(admin_required)],
|
||||
)
|
||||
|
||||
|
||||
@admin_theme_router.get(
|
||||
path='/',
|
||||
summary='获取主题预设列表',
|
||||
)
|
||||
async def router_admin_theme_list(session: SessionDep) -> ThemePresetListResponse:
|
||||
"""
|
||||
获取所有主题预设列表
|
||||
|
||||
认证:需要管理员权限
|
||||
|
||||
响应:
|
||||
- ThemePresetListResponse: 包含所有主题预设的列表
|
||||
"""
|
||||
presets: list[ThemePreset] = await ThemePreset.get(session, fetch_mode="all")
|
||||
return ThemePresetListResponse(
|
||||
themes=[ThemePresetResponse.from_preset(p) for p in presets]
|
||||
)
|
||||
|
||||
|
||||
@admin_theme_router.post(
|
||||
path='/',
|
||||
summary='创建主题预设',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def router_admin_theme_create(
|
||||
session: SessionDep,
|
||||
request: ThemePresetCreateRequest,
|
||||
) -> None:
|
||||
"""
|
||||
创建新的主题预设
|
||||
|
||||
认证:需要管理员权限
|
||||
|
||||
请求体:
|
||||
- name: 预设名称(唯一)
|
||||
- colors: 颜色配置对象
|
||||
|
||||
错误处理:
|
||||
- 409: 名称已存在
|
||||
"""
|
||||
# 检查名称唯一性
|
||||
existing = await ThemePreset.get(session, ThemePreset.name == request.name)
|
||||
if existing:
|
||||
http_exceptions.raise_conflict(f"主题预设名称 '{request.name}' 已存在")
|
||||
|
||||
preset = ThemePreset(
|
||||
name=request.name,
|
||||
**request.colors.model_dump(),
|
||||
)
|
||||
await preset.save(session)
|
||||
l.info(f"管理员创建了主题预设: {request.name}")
|
||||
|
||||
|
||||
@admin_theme_router.patch(
|
||||
path='/{preset_id}',
|
||||
summary='更新主题预设',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def router_admin_theme_update(
|
||||
session: SessionDep,
|
||||
preset_id: UUID,
|
||||
request: ThemePresetUpdateRequest,
|
||||
) -> None:
|
||||
"""
|
||||
部分更新主题预设
|
||||
|
||||
认证:需要管理员权限
|
||||
|
||||
路径参数:
|
||||
- preset_id: 预设UUID
|
||||
|
||||
请求体(均可选):
|
||||
- name: 预设名称
|
||||
- colors: 颜色配置对象
|
||||
|
||||
错误处理:
|
||||
- 404: 预设不存在
|
||||
- 409: 名称已被其他预设使用
|
||||
"""
|
||||
preset: ThemePreset | None = await ThemePreset.get(
|
||||
session, ThemePreset.id == preset_id
|
||||
)
|
||||
if not preset:
|
||||
http_exceptions.raise_not_found("主题预设不存在")
|
||||
|
||||
# 检查名称唯一性(排除自身)
|
||||
if request.name is not None and request.name != preset.name:
|
||||
existing = await ThemePreset.get(session, ThemePreset.name == request.name)
|
||||
if existing:
|
||||
http_exceptions.raise_conflict(f"主题预设名称 '{request.name}' 已存在")
|
||||
preset.name = request.name
|
||||
|
||||
# 更新颜色字段
|
||||
if request.colors is not None:
|
||||
color_data = request.colors.model_dump()
|
||||
for key, value in color_data.items():
|
||||
setattr(preset, key, value)
|
||||
|
||||
await preset.save(session)
|
||||
l.info(f"管理员更新了主题预设: {preset.name}")
|
||||
|
||||
|
||||
@admin_theme_router.delete(
|
||||
path='/{preset_id}',
|
||||
summary='删除主题预设',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def router_admin_theme_delete(
|
||||
session: SessionDep,
|
||||
preset_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
删除主题预设
|
||||
|
||||
认证:需要管理员权限
|
||||
|
||||
路径参数:
|
||||
- preset_id: 预设UUID
|
||||
|
||||
错误处理:
|
||||
- 404: 预设不存在
|
||||
|
||||
副作用:
|
||||
- 关联用户的 theme_preset_id 会被数据库 SET NULL
|
||||
"""
|
||||
preset: ThemePreset | None = await ThemePreset.get(
|
||||
session, ThemePreset.id == preset_id
|
||||
)
|
||||
if not preset:
|
||||
http_exceptions.raise_not_found("主题预设不存在")
|
||||
|
||||
await preset.delete(session)
|
||||
l.info(f"管理员删除了主题预设: {preset.name}")
|
||||
|
||||
|
||||
@admin_theme_router.patch(
|
||||
path='/{preset_id}/default',
|
||||
summary='设为默认主题预设',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def router_admin_theme_set_default(
|
||||
session: SessionDep,
|
||||
preset_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
将指定预设设为默认主题
|
||||
|
||||
认证:需要管理员权限
|
||||
|
||||
路径参数:
|
||||
- preset_id: 预设UUID
|
||||
|
||||
错误处理:
|
||||
- 404: 预设不存在
|
||||
|
||||
逻辑:
|
||||
- 事务中先清除所有旧默认,再设新默认
|
||||
"""
|
||||
preset: ThemePreset | None = await ThemePreset.get(
|
||||
session, ThemePreset.id == preset_id
|
||||
)
|
||||
if not preset:
|
||||
http_exceptions.raise_not_found("主题预设不存在")
|
||||
|
||||
# 清除所有旧默认
|
||||
await session.execute(
|
||||
sql_update(ThemePreset)
|
||||
.where(ThemePreset.is_default == True) # noqa: E712
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
# 设新默认
|
||||
preset.is_default = True
|
||||
await preset.save(session)
|
||||
l.info(f"管理员将主题预设 '{preset.name}' 设为默认")
|
||||
@@ -198,9 +198,17 @@ async def router_admin_delete_users(
|
||||
:param request: 批量删除请求,包含待删除用户的 UUID 列表
|
||||
:return: 删除结果(已删除数 / 总请求数)
|
||||
"""
|
||||
deleted = 0
|
||||
for uid in request.ids:
|
||||
user = await User.get(session, User.id == uid)
|
||||
user = await User.get(session, User.id == uid, load=User.group)
|
||||
|
||||
# 安全检查:默认管理员不允许被删除(通过 Setting 中的 default_admin_id 识别)
|
||||
default_admin_setting = await Setting.get(
|
||||
session,
|
||||
(Setting.type == SettingsType.AUTH) & (Setting.name == "default_admin_id")
|
||||
)
|
||||
if user and default_admin_setting and default_admin_setting.value == str(uid):
|
||||
raise HTTPException(status_code=403, detail=f"默认管理员不允许被删除")
|
||||
|
||||
if user:
|
||||
await User.delete(session, user)
|
||||
l.info(f"管理员删除了用户: {user.email}")
|
||||
@@ -235,6 +243,7 @@ async def router_admin_calibrate_storage(
|
||||
previous_storage = user.storage
|
||||
|
||||
# 计算实际存储量 - 使用 SQL 聚合
|
||||
# [TODO] 不应这么计算,看看 SQLModel_Ext 库怎么解决
|
||||
from sqlmodel import select
|
||||
result = await session.execute(
|
||||
select(func.sum(Object.size), func.count(Object.id)).where(
|
||||
|
||||
Reference in New Issue
Block a user