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,161 @@
"""
回收站路由
提供回收站管理功能:列出、恢复、永久删除、清空。
路由前缀:/trash
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from loguru import logger as l
from middleware.auth import auth_required
from middleware.dependencies import SessionDep
from sqlmodels import Object, User
from sqlmodels.object import TrashDeleteRequest, TrashItemResponse, TrashRestoreRequest
from service.storage.object import (
permanently_delete_objects,
restore_objects,
soft_delete_objects,
)
trash_router = APIRouter(
prefix="/trash",
tags=["trash"],
)
@trash_router.get(
path='/',
summary='列出回收站内容',
description='获取当前用户回收站中的所有顶层对象。',
)
async def router_trash_list(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
) -> list[TrashItemResponse]:
"""
列出回收站内容
认证:需要 JWT token
返回回收站中被直接删除的顶层对象列表,
不包含其子对象(子对象在恢复/永久删除时会随顶层对象一起处理)。
"""
items = await Object.get_trash_items(session, user.id)
return [
TrashItemResponse(
id=item.id,
name=item.name,
type=item.type,
size=item.size,
deleted_at=item.deleted_at,
original_parent_id=item.deleted_original_parent_id,
)
for item in items
]
@trash_router.patch(
path='/restore',
summary='恢复对象',
description='从回收站恢复一个或多个对象到原位置。如果原位置不存在则恢复到根目录。',
status_code=204,
)
async def router_trash_restore(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: TrashRestoreRequest,
) -> None:
"""
从回收站恢复对象
认证:需要 JWT token
流程:
1. 验证对象存在且在回收站中deleted_at IS NOT NULL
2. 检查原父目录是否存在且未删除
3. 存在 → 恢复到原位置;不存在 → 恢复到根目录
4. 处理同名冲突(自动重命名)
5. 清除 deleted_at 和 deleted_original_parent_id
"""
user_id = user.id
objects_to_restore: list[Object] = []
for obj_id in request.ids:
obj = await Object.get(
session,
(Object.id == obj_id) & (Object.owner_id == user_id) & (Object.deleted_at != None)
)
if obj:
objects_to_restore.append(obj)
if objects_to_restore:
restored_count = await restore_objects(session, objects_to_restore, user_id)
l.info(f"用户 {user_id} 从回收站恢复了 {restored_count} 个对象")
@trash_router.delete(
path='/',
summary='永久删除对象',
description='永久删除回收站中的指定对象,包括物理文件和数据库记录。',
status_code=204,
)
async def router_trash_delete(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: TrashDeleteRequest,
) -> None:
"""
永久删除回收站中的对象
认证:需要 JWT token
流程:
1. 验证对象存在且在回收站中
2. BFS 收集所有子文件的 PhysicalFile
3. 处理引用计数,引用为 0 时物理删除文件
4. 硬删除根 ObjectCASCADE 清理子对象)
5. 更新用户存储配额
"""
user_id = user.id
objects_to_delete: list[Object] = []
for obj_id in request.ids:
obj = await Object.get(
session,
(Object.id == obj_id) & (Object.owner_id == user_id) & (Object.deleted_at != None)
)
if obj:
objects_to_delete.append(obj)
if objects_to_delete:
deleted_count = await permanently_delete_objects(session, objects_to_delete, user_id)
l.info(f"用户 {user_id} 永久删除了 {deleted_count} 个对象")
@trash_router.delete(
path='/empty',
summary='清空回收站',
description='永久删除回收站中的所有对象。',
status_code=204,
)
async def router_trash_empty(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
) -> None:
"""
清空回收站
认证:需要 JWT token
获取回收站中所有顶层对象,逐个执行永久删除。
"""
user_id = user.id
trash_items = await Object.get_trash_items(session, user_id)
if trash_items:
deleted_count = await permanently_delete_objects(session, trash_items, user_id)
l.info(f"用户 {user_id} 清空回收站,共删除 {deleted_count} 个对象")