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:
2026-02-12 19:34:41 +08:00
parent a99091ea7a
commit 4c1b7a8aad
29 changed files with 1832 additions and 404 deletions

View 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}' 设为默认")