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

@@ -1,12 +1,17 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
import sqlmodels
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from sqlmodels import (
BUILTIN_DEFAULT_COLORS, ThemePreset, UserThemeUpdateRequest,
)
from sqlmodels.color import ThemeColorsBase
from utils import JWT, Password, http_exceptions
from utils.password.pwd import PasswordStatus, TwoFactorResponse, TwoFactorVerifyRequest
user_settings_router = APIRouter(
prefix='/settings',
@@ -67,15 +72,50 @@ def router_user_settings_tasks() -> sqlmodels.ResponseBase:
summary='获取当前用户设定',
description='Get current user settings.',
)
def router_user_settings(
async def router_user_settings(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> sqlmodels.UserSettingResponse:
"""
Get current user settings.
获取当前用户设定
Returns:
dict: A dictionary containing the current user settings.
主题颜色合并策略:
1. 用户有颜色快照7个字段均有值→ 直接使用快照
2. 否则查找默认预设 → 使用默认预设颜色
3. 无默认预设 → 使用内置默认值
"""
# 计算主题颜色
has_snapshot = all([
user.color_primary, user.color_secondary, user.color_success,
user.color_info, user.color_warning, user.color_error, user.color_neutral,
])
if has_snapshot:
theme_colors = ThemeColorsBase(
primary=user.color_primary,
secondary=user.color_secondary,
success=user.color_success,
info=user.color_info,
warning=user.color_warning,
error=user.color_error,
neutral=user.color_neutral,
)
else:
default_preset: ThemePreset | None = await ThemePreset.get(
session, ThemePreset.is_default == True # noqa: E712
)
if default_preset:
theme_colors = ThemeColorsBase(
primary=default_preset.primary,
secondary=default_preset.secondary,
success=default_preset.success,
info=default_preset.info,
warning=default_preset.warning,
error=default_preset.error,
neutral=default_preset.neutral,
)
else:
theme_colors = BUILTIN_DEFAULT_COLORS
return sqlmodels.UserSettingResponse(
id=user.id,
email=user.email,
@@ -86,6 +126,8 @@ def router_user_settings(
timezone=user.timezone,
group_expires=user.group_expires,
two_factor=user.two_factor is not None,
theme_preset_id=user.theme_preset_id,
theme_colors=theme_colors,
)
@@ -110,8 +152,9 @@ def router_user_settings_avatar() -> sqlmodels.ResponseBase:
summary='设定为Gravatar头像',
description='Set user avatar to Gravatar.',
dependencies=[Depends(auth_required)],
status_code=204,
)
def router_user_settings_avatar_gravatar() -> sqlmodels.ResponseBase:
def router_user_settings_avatar_gravatar() -> None:
"""
Set user avatar to Gravatar.
@@ -121,13 +164,56 @@ def router_user_settings_avatar_gravatar() -> sqlmodels.ResponseBase:
http_exceptions.raise_not_implemented()
@user_settings_router.patch(
path='/theme',
summary='更新用户主题设置',
status_code=status.HTTP_204_NO_CONTENT,
)
async def router_user_settings_theme(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
request: UserThemeUpdateRequest,
) -> None:
"""
更新用户主题设置
请求体(均可选):
- theme_preset_id: 主题预设UUID
- theme_colors: 颜色配置对象(写入颜色快照)
错误处理:
- 404: 指定的主题预设不存在
"""
# 验证 preset_id 存在性
if request.theme_preset_id is not None:
preset: ThemePreset | None = await ThemePreset.get(
session, ThemePreset.id == request.theme_preset_id
)
if not preset:
http_exceptions.raise_not_found("主题预设不存在")
user.theme_preset_id = request.theme_preset_id
# 将颜色解构到快照列
if request.theme_colors is not None:
user.color_primary = request.theme_colors.primary
user.color_secondary = request.theme_colors.secondary
user.color_success = request.theme_colors.success
user.color_info = request.theme_colors.info
user.color_warning = request.theme_colors.warning
user.color_error = request.theme_colors.error
user.color_neutral = request.theme_colors.neutral
await user.save(session)
@user_settings_router.patch(
path='/{option}',
summary='更新用户设定',
description='Update user settings.',
dependencies=[Depends(auth_required)],
status_code=204,
)
def router_user_settings_patch(option: str) -> sqlmodels.ResponseBase:
def router_user_settings_patch(option: str) -> None:
"""
Update user settings.
@@ -148,17 +234,13 @@ def router_user_settings_patch(option: str) -> sqlmodels.ResponseBase:
)
async def router_user_settings_2fa(
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
) -> sqlmodels.ResponseBase:
) -> TwoFactorResponse:
"""
Get two-factor authentication initialization information.
获取两步验证初始化信息
Returns:
dict: A dictionary containing two-factor authentication setup information.
返回 setup_token用于后续验证请求和 uri用于生成二维码
"""
return sqlmodels.ResponseBase(
data=await Password.generate_totp(user.email)
)
return await Password.generate_totp(name=user.email)
@user_settings_router.post(
@@ -166,38 +248,30 @@ async def router_user_settings_2fa(
summary='启用两步验证',
description='Enable two-factor authentication.',
dependencies=[Depends(auth_required)],
status_code=204,
)
async def router_user_settings_2fa_enable(
session: SessionDep,
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
setup_token: str,
code: str,
) -> sqlmodels.ResponseBase:
request: TwoFactorVerifyRequest,
) -> None:
"""
Enable two-factor authentication for the user.
启用两步验证
Returns:
dict: A dictionary containing the result of enabling two-factor authentication.
请求体包含 setup_tokenGET /2fa 返回的令牌)和 code6 位验证码)。
"""
serializer = URLSafeTimedSerializer(JWT.SECRET_KEY)
try:
# 1. 解包 Token设置有效期例如 600秒
secret = serializer.loads(setup_token, salt="2fa-setup-salt", max_age=600)
secret = serializer.loads(request.setup_token, salt="2fa-setup-salt", max_age=600)
except SignatureExpired:
raise HTTPException(status_code=400, detail="Setup session expired")
except BadSignature:
raise HTTPException(status_code=400, detail="Invalid token")
# 2. 验证用户输入的 6 位验证码
if not Password.verify_totp(secret, code):
if Password.verify_totp(secret, request.code) != PasswordStatus.VALID:
raise HTTPException(status_code=400, detail="Invalid OTP code")
# 3. 将 secret 存储到用户的数据库记录中,启用 2FA
user.two_factor = secret
user = await user.save(session)
return sqlmodels.ResponseBase(
data={"message": "Two-factor authentication enabled successfully"}
)
user = await user.save(session)