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

@@ -13,14 +13,21 @@ from .user import (
UserPublic,
UserResponse,
UserSettingResponse,
UserThemeUpdateRequest,
WebAuthnInfo,
UserTwoFactorResponse,
# 管理员DTO
UserAdminUpdateRequest,
UserCalibrateResponse,
UserAdminDetailResponse,
)
from .user_authn import AuthnResponse, UserAuthn
from .color import ThemeResponse
from .color import ChromaticColor, NeutralColor, ThemeColorsBase, BUILTIN_DEFAULT_COLORS
from .theme_preset import (
ThemePreset, ThemePresetBase,
ThemePresetCreateRequest, ThemePresetUpdateRequest,
ThemePresetResponse, ThemePresetListResponse,
)
from .download import (
Download,
@@ -68,6 +75,10 @@ from .object import (
AdminFileResponse,
AdminFileListResponse,
FileBanRequest,
# 回收站DTO
TrashItemResponse,
TrashRestoreRequest,
TrashDeleteRequest,
)
from .physical_file import PhysicalFile, PhysicalFileBase
from .uri import DiskNextURI, FileSystemNamespace
@@ -80,7 +91,11 @@ from .setting import (
# 管理员DTO
SettingItem, SettingsListResponse, SettingsUpdateRequest, SettingsUpdateResponse,
)
from .share import Share, ShareBase, ShareCreateRequest, ShareResponse, AdminShareListItem
from .share import (
Share, ShareBase, ShareCreateRequest, CreateShareResponse, ShareResponse,
ShareOwnerInfo, ShareObjectItem, ShareDetailResponse,
AdminShareListItem,
)
from .source_link import SourceLink
from .storage_pack import StoragePack
from .tag import Tag, TagType

View File

@@ -1,7 +1,71 @@
from enum import StrEnum
from .base import SQLModelBase
class ThemeResponse(SQLModelBase):
"""主题响应 DTO"""
pass
class ChromaticColor(StrEnum):
"""有彩色枚举17种 Tailwind 调色板颜色)"""
RED = "red"
ORANGE = "orange"
AMBER = "amber"
YELLOW = "yellow"
LIME = "lime"
GREEN = "green"
EMERALD = "emerald"
TEAL = "teal"
CYAN = "cyan"
SKY = "sky"
BLUE = "blue"
INDIGO = "indigo"
VIOLET = "violet"
PURPLE = "purple"
FUCHSIA = "fuchsia"
PINK = "pink"
ROSE = "rose"
class NeutralColor(StrEnum):
"""无彩色枚举5种灰色调"""
SLATE = "slate"
GRAY = "gray"
ZINC = "zinc"
NEUTRAL = "neutral"
STONE = "stone"
class ThemeColorsBase(SQLModelBase):
"""嵌套颜色 DTOAPI 请求/响应层使用"""
primary: ChromaticColor
"""主色调"""
secondary: ChromaticColor
"""辅助色"""
success: ChromaticColor
"""成功色"""
info: ChromaticColor
"""信息色"""
warning: ChromaticColor
"""警告色"""
error: ChromaticColor
"""错误色"""
neutral: NeutralColor
"""中性色"""
BUILTIN_DEFAULT_COLORS = ThemeColorsBase(
primary=ChromaticColor.GREEN,
secondary=ChromaticColor.BLUE,
success=ChromaticColor.GREEN,
info=ChromaticColor.BLUE,
warning=ChromaticColor.YELLOW,
error=ChromaticColor.RED,
neutral=NeutralColor.ZINC,
)

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@ from uuid import UUID
from enum import StrEnum
from sqlalchemy import BigInteger
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index, text
from sqlmodel import Field, Relationship, CheckConstraint, Index, text
from .base import SQLModelBase
from .mixin import UUIDTableBaseMixin
@@ -195,8 +195,13 @@ class Object(ObjectBase, UUIDTableBaseMixin):
"""
__table_args__ = (
# 同一父目录下名称唯一(包括 parent_id 为 NULL 的情况
UniqueConstraint("owner_id", "parent_id", "name", name="uq_object_parent_name"),
# 同一父目录下名称唯一(仅对未删除记录生效
Index(
"uq_object_parent_name_active",
"owner_id", "parent_id", "name",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
# 名称不能包含斜杠(根目录 parent_id IS NULL 除外,因为根目录 name="/"
CheckConstraint(
"parent_id IS NULL OR (name NOT LIKE '%/%' AND name NOT LIKE '%\\%')",
@@ -207,6 +212,8 @@ class Object(ObjectBase, UUIDTableBaseMixin):
Index("ix_object_parent_updated", "parent_id", "updated_at"),
Index("ix_object_owner_type", "owner_id", "type"),
Index("ix_object_owner_size", "owner_id", "size"),
# 回收站查询索引
Index("ix_object_owner_deleted", "owner_id", "deleted_at"),
)
# ==================== 基础字段 ====================
@@ -280,6 +287,18 @@ class Object(ObjectBase, UUIDTableBaseMixin):
ban_reason: str | None = Field(default=None, max_length=500)
"""封禁原因"""
# ==================== 软删除相关字段 ====================
deleted_at: datetime | None = Field(default=None, index=True)
"""软删除时间戳NULL 表示未删除"""
deleted_original_parent_id: UUID | None = Field(
default=None,
foreign_key="object.id",
ondelete="SET NULL",
)
"""软删除前的原始父目录UUID恢复时用于还原位置"""
# ==================== 关系 ====================
owner: "User" = Relationship(
@@ -299,13 +318,19 @@ class Object(ObjectBase, UUIDTableBaseMixin):
# 自引用关系
parent: "Object" = Relationship(
back_populates="children",
sa_relationship_kwargs={"remote_side": "Object.id"},
sa_relationship_kwargs={
"remote_side": "Object.id",
"foreign_keys": "[Object.parent_id]",
},
)
"""父目录"""
children: list["Object"] = Relationship(
back_populates="parent",
sa_relationship_kwargs={"cascade": "all, delete-orphan"}
sa_relationship_kwargs={
"cascade": "all, delete-orphan",
"foreign_keys": "[Object.parent_id]",
},
)
"""子对象(文件和子目录)"""
@@ -367,7 +392,7 @@ class Object(ObjectBase, UUIDTableBaseMixin):
"""
return await cls.get(
session,
(cls.owner_id == user_id) & (cls.parent_id == None)
(cls.owner_id == user_id) & (cls.parent_id == None) & (cls.deleted_at == None)
)
@classmethod
@@ -416,7 +441,8 @@ class Object(ObjectBase, UUIDTableBaseMixin):
session,
(cls.owner_id == user_id) &
(cls.parent_id == current.id) &
(cls.name == part)
(cls.name == part) &
(cls.deleted_at == None)
)
return current
@@ -424,7 +450,23 @@ class Object(ObjectBase, UUIDTableBaseMixin):
@classmethod
async def get_children(cls, session, user_id: UUID, parent_id: UUID) -> list["Object"]:
"""
获取目录下的所有子对象
获取目录下的所有子对象(不包含已软删除的)
:param session: 数据库会话
:param user_id: 用户UUID
:param parent_id: 父目录UUID
:return: 子对象列表
"""
return await cls.get(
session,
(cls.owner_id == user_id) & (cls.parent_id == parent_id) & (cls.deleted_at == None),
fetch_mode="all"
)
@classmethod
async def get_all_children(cls, session, user_id: UUID, parent_id: UUID) -> list["Object"]:
"""
获取目录下的所有子对象(包含已软删除的,用于永久删除场景)
:param session: 数据库会话
:param user_id: 用户UUID
@@ -437,6 +479,24 @@ class Object(ObjectBase, UUIDTableBaseMixin):
fetch_mode="all"
)
@classmethod
async def get_trash_items(cls, session, user_id: UUID) -> list["Object"]:
"""
获取用户回收站中的顶层对象
只返回被直接软删除的顶层对象deleted_at 非 NULL
不返回其子对象(子对象的 deleted_at 为 NULL通过 parent 关系间接处于回收站中)。
:param session: 数据库会话
:param user_id: 用户UUID
:return: 回收站顶层对象列表
"""
return await cls.get(
session,
(cls.owner_id == user_id) & (cls.deleted_at != None),
fetch_mode="all"
)
@classmethod
async def resolve_uri(
cls,
@@ -805,3 +865,41 @@ class AdminFileListResponse(SQLModelBase):
total: int = 0
"""总数"""
# ==================== 回收站相关 DTO ====================
class TrashItemResponse(SQLModelBase):
"""回收站对象响应 DTO"""
id: UUID
"""对象UUID"""
name: str
"""对象名称"""
type: ObjectType
"""对象类型"""
size: int
"""文件大小(字节)"""
deleted_at: datetime
"""删除时间"""
original_parent_id: UUID | None
"""原始父目录UUID"""
class TrashRestoreRequest(SQLModelBase):
"""恢复对象请求 DTO"""
ids: list[UUID]
"""待恢复对象UUID列表"""
class TrashDeleteRequest(SQLModelBase):
"""永久删除对象请求 DTO"""
ids: list[UUID]
"""待永久删除对象UUID列表"""

View File

@@ -1,5 +1,6 @@
from enum import StrEnum
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import Field, Relationship
@@ -24,7 +25,7 @@ class Report(SQLModelBase, TableBaseMixin):
description: str | None = Field(default=None, max_length=255, description="补充描述")
# 外键
share_id: int = Field(
share_id: UUID = Field(
foreign_key="share.id",
index=True,
ondelete="CASCADE"

View File

@@ -6,7 +6,9 @@ from uuid import UUID
from sqlmodel import Field, Relationship, text, UniqueConstraint, Index
from .base import SQLModelBase
from .mixin import TableBaseMixin
from .model_base import ResponseBase
from .mixin import UUIDTableBaseMixin
from .object import ObjectType
if TYPE_CHECKING:
from .user import User
@@ -34,13 +36,13 @@ class ShareBase(SQLModelBase):
preview_enabled: bool = True
"""是否允许预览"""
score: int = 0
score: int = Field(default=0, ge=0)
"""兑换此分享所需的积分"""
# ==================== 数据库模型 ====================
class Share(SQLModelBase, TableBaseMixin):
class Share(SQLModelBase, UUIDTableBaseMixin):
"""分享模型"""
__table_args__ = (
@@ -81,7 +83,7 @@ class Share(SQLModelBase, TableBaseMixin):
source_name: str | None = Field(default=None, max_length=255)
"""源名称(冗余字段,便于展示)"""
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"})
score: int = Field(default=0, ge=0)
"""兑换此分享所需的积分"""
# 外键
@@ -119,10 +121,17 @@ class ShareCreateRequest(ShareBase):
pass
class ShareResponse(SQLModelBase):
"""分享响应 DTO"""
class CreateShareResponse(ResponseBase):
"""创建分享响应 DTO"""
id: int
share_id: UUID
"""新创建的分享记录 ID"""
class ShareResponse(SQLModelBase):
"""查看分享响应 DTO"""
id: UUID
"""分享ID"""
code: str
@@ -162,10 +171,67 @@ class ShareResponse(SQLModelBase):
"""是否有密码"""
class ShareOwnerInfo(SQLModelBase):
"""分享者公开信息 DTO"""
nickname: str | None
"""昵称"""
avatar: str
"""头像"""
class ShareObjectItem(SQLModelBase):
"""分享中的文件/文件夹信息 DTO"""
id: UUID
"""对象UUID"""
name: str
"""名称"""
type: ObjectType
"""类型file 或 folder"""
size: int
"""文件大小(字节),目录为 0"""
created_at: datetime
"""创建时间"""
updated_at: datetime
"""修改时间"""
class ShareDetailResponse(SQLModelBase):
"""获取分享详情响应 DTO面向访客隐藏内部统计数据"""
expires: datetime | None
"""过期时间"""
preview_enabled: bool
"""是否允许预览"""
score: int
"""积分"""
created_at: datetime
"""创建时间"""
owner: ShareOwnerInfo
"""分享者信息"""
object: ShareObjectItem
"""分享的根对象"""
children: list[ShareObjectItem]
"""子文件/文件夹列表(仅目录分享有内容)"""
class ShareListItemBase(SQLModelBase):
"""分享列表项基础字段"""
id: int
id: UUID
"""分享ID"""
code: str

117
sqlmodels/theme_preset.py Normal file
View File

@@ -0,0 +1,117 @@
from datetime import datetime
from uuid import UUID
from sqlmodel import Field
from .base import SQLModelBase
from .color import ChromaticColor, NeutralColor, ThemeColorsBase
from .mixin import UUIDTableBaseMixin
class ThemePresetBase(SQLModelBase):
"""主题预设基础字段"""
name: str = Field(max_length=100)
"""预设名称"""
is_default: bool = False
"""是否为默认预设"""
primary: ChromaticColor
"""主色调"""
secondary: ChromaticColor
"""辅助色"""
success: ChromaticColor
"""成功色"""
info: ChromaticColor
"""信息色"""
warning: ChromaticColor
"""警告色"""
error: ChromaticColor
"""错误色"""
neutral: NeutralColor
"""中性色"""
class ThemePreset(ThemePresetBase, UUIDTableBaseMixin):
"""主题预设表"""
name: str = Field(max_length=100, unique=True)
"""预设名称(唯一约束)"""
# ==================== DTO ====================
class ThemePresetCreateRequest(SQLModelBase):
"""创建主题预设请求 DTO"""
name: str = Field(max_length=100)
"""预设名称"""
colors: ThemeColorsBase
"""颜色配置"""
class ThemePresetUpdateRequest(SQLModelBase):
"""更新主题预设请求 DTO"""
name: str | None = Field(default=None, max_length=100)
"""预设名称(可选)"""
colors: ThemeColorsBase | None = None
"""颜色配置(可选)"""
class ThemePresetResponse(SQLModelBase):
"""主题预设响应 DTO"""
id: UUID
"""预设UUID"""
name: str
"""预设名称"""
is_default: bool
"""是否为默认预设"""
colors: ThemeColorsBase
"""颜色配置"""
created_at: datetime
"""创建时间"""
updated_at: datetime
"""更新时间"""
@classmethod
def from_preset(cls, preset: ThemePreset) -> 'ThemePresetResponse':
"""从数据库模型转换为响应 DTO平铺列 → 嵌套 colors 对象)"""
return cls(
id=preset.id,
name=preset.name,
is_default=preset.is_default,
colors=ThemeColorsBase(
primary=preset.primary,
secondary=preset.secondary,
success=preset.success,
info=preset.info,
warning=preset.warning,
error=preset.error,
neutral=preset.neutral,
),
created_at=preset.created_at,
updated_at=preset.updated_at,
)
class ThemePresetListResponse(SQLModelBase):
"""主题预设列表响应 DTO"""
themes: list[ThemePresetResponse]
"""主题预设列表"""

View File

@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.main import RelationshipInfo
from .base import SQLModelBase
from .color import ChromaticColor, NeutralColor, ThemeColorsBase
from .model_base import ResponseBase
from .mixin import UUIDTableBaseMixin, TableViewRequest, ListResponse
@@ -34,13 +35,6 @@ class AvatarType(StrEnum):
GRAVATAR = "gravatar"
FILE = "file"
class ThemeType(StrEnum):
"""主题类型枚举"""
LIGHT = "light"
DARK = "dark"
SYSTEM = "system"
class UserStatus(StrEnum):
"""用户状态枚举"""
@@ -99,7 +93,7 @@ class LoginRequest(SQLModelBase):
captcha: str | None = None
"""验证码"""
two_fa_code: int | None = Field(default=None, min_length=6, max_length=6)
two_fa_code: str | None = Field(default=None, min_length=6, max_length=6)
"""两步验证代码"""
@@ -297,6 +291,29 @@ class UserSettingResponse(SQLModelBase):
two_factor: bool = False
"""是否启用两步验证"""
theme_preset_id: UUID | None = None
"""选用的主题预设UUID"""
theme_colors: ThemeColorsBase | None = None
"""当前生效的颜色配置"""
class UserThemeUpdateRequest(SQLModelBase):
"""用户更新主题请求 DTO"""
theme_preset_id: UUID | None = None
"""主题预设UUID"""
theme_colors: ThemeColorsBase | None = None
"""颜色配置"""
class UserTwoFactorResponse(SQLModelBase):
"""用户两步验证信息 DTO"""
two_factor_key: str
"""两步验证密钥"""
# ==================== 管理员用户管理 DTO ====================
@@ -428,8 +445,31 @@ class User(UserBase, UUIDTableBaseMixin):
"""当前用户组过期时间"""
# Option 相关字段
# theme: ThemeType = Field(default=ThemeType.SYSTEM)
# """主题类型: light/dark/system"""
theme_preset_id: UUID | None = Field(
default=None, foreign_key="themepreset.id", ondelete="SET NULL"
)
"""选用的主题预设UUID"""
color_primary: ChromaticColor | None = None
"""颜色快照:主色调"""
color_secondary: ChromaticColor | None = None
"""颜色快照:辅助色"""
color_success: ChromaticColor | None = None
"""颜色快照:成功色"""
color_info: ChromaticColor | None = None
"""颜色快照:信息色"""
color_warning: ChromaticColor | None = None
"""颜色快照:警告色"""
color_error: ChromaticColor | None = None
"""颜色快照:错误色"""
color_neutral: NeutralColor | None = None
"""颜色快照:中性色"""
language: str = Field(default="zh-CN", max_length=5)
"""语言偏好"""