# RFC 123: 文件夹分类枚举设计 > **状态**: Draft > **创建日期**: 2025-01-21 > **作者**: System > **相关文档**: [文件夹服务文档](../../requirements/backend/04-services/project/folder-service.md) --- ## 概述 为了避免为每个用户创建"我的项目"和"协作项目"两个系统文件夹,采用 **SMALLINT + 代码枚举** 的方式实现文件夹分类。 ### 核心设计 - "我的项目"和"协作项目"是**前端虚拟视图**,不是数据库记录 - 使用 `folder_category` 字段(SMALLINT 类型)区分文件夹所属分类 - 根文件夹(`parent_folder_id = NULL`)必须指定 `folder_category` - 子文件夹自动继承父文件夹的 `folder_category` - 前端负责虚拟显示"我的项目"和"协作项目"作为顶层导航 --- ## 数据库设计 ### 1. 添加 folder_category 字段 ```sql -- 添加文件夹分类字段 ALTER TABLE folders ADD COLUMN folder_category SMALLINT NOT NULL DEFAULT 1 CHECK (folder_category IN (1, 2)); -- 添加注释 COMMENT ON COLUMN folders.folder_category IS '文件夹分类:1=我的项目,2=协作项目'; -- 添加索引(复合索引,优化查询性能) CREATE INDEX idx_folders_category_owner ON folders (folder_category, owner_id, parent_folder_id) WHERE deleted_at IS NULL; ``` ### 2. 自动继承触发器 ```sql -- 子文件夹自动继承父文件夹的 folder_category CREATE OR REPLACE FUNCTION inherit_folder_category() RETURNS TRIGGER AS $$ DECLARE parent_category SMALLINT; BEGIN -- 如果是根文件夹,必须指定 folder_category IF NEW.parent_folder_id IS NULL THEN IF NEW.folder_category IS NULL THEN RAISE EXCEPTION '创建根文件夹时必须指定 folder_category'; END IF; -- 验证 folder_category 的有效性 IF NEW.folder_category NOT IN (1, 2) THEN RAISE EXCEPTION '无效的 folder_category: %', NEW.folder_category; END IF; ELSE -- 子文件夹自动继承父文件夹的 folder_category SELECT folder_category INTO parent_category FROM folders WHERE id = NEW.parent_folder_id AND deleted_at IS NULL; IF parent_category IS NULL THEN RAISE EXCEPTION '父文件夹不存在或已删除'; END IF; NEW.folder_category = parent_category; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_inherit_folder_category BEFORE INSERT ON folders FOR EACH ROW EXECUTE FUNCTION inherit_folder_category(); ``` ### 3. 数据迁移脚本 ```sql -- 为现有文件夹设置默认分类(一次性迁移) -- 假设现有的根文件夹都是"我的项目" UPDATE folders SET folder_category = 1 WHERE parent_folder_id IS NULL AND deleted_at IS NULL; -- 子文件夹继承父文件夹的分类 WITH RECURSIVE folder_tree AS ( -- 根文件夹 SELECT id, folder_category, parent_folder_id FROM folders WHERE parent_folder_id IS NULL AND deleted_at IS NULL UNION ALL -- 递归获取子文件夹 SELECT f.id, ft.folder_category, f.parent_folder_id FROM folders f INNER JOIN folder_tree ft ON f.parent_folder_id = ft.id WHERE f.deleted_at IS NULL ) UPDATE folders f SET folder_category = ft.folder_category FROM folder_tree ft WHERE f.id = ft.id AND f.parent_folder_id IS NOT NULL; ``` --- ## 后端实现 ### 1. 枚举定义 ```python # app/models/folder.py from enum import IntEnum class FolderCategory(IntEnum): """文件夹分类枚举""" MY_PROJECTS = 1 # 我的项目 COLLABORATIVE_PROJECTS = 2 # 协作项目 # 预留 3-255 用于未来扩展 @classmethod def get_display_name(cls, value: int) -> str: """获取显示名称""" names = { cls.MY_PROJECTS: "我的项目", cls.COLLABORATIVE_PROJECTS: "协作项目" } return names.get(value, "未知分类") @classmethod def is_valid(cls, value: int) -> bool: """验证分类值是否有效""" return value in [cls.MY_PROJECTS, cls.COLLABORATIVE_PROJECTS] ``` ### 2. Folder 模型 ```python # app/models/folder.py from sqlmodel import SQLModel, Field from typing import Optional from datetime import datetime class Folder(SQLModel, table=True): __tablename__ = "folders" id: str = Field(primary_key=True) name: str = Field(max_length=255) description: Optional[str] = None parent_folder_id: Optional[str] = Field(default=None, foreign_key='folders.id') owner_id: str = Field(foreign_key='users.user_id') # 路径信息 path: str = Field(default="/") level: int = Field(default=0) # 文件夹分类(SMALLINT) folder_category: int = Field(default=FolderCategory.MY_PROJECTS) # 排序 sort_order: int = Field(default=0) # 样式 color: Optional[str] = None icon: Optional[str] = None cover_image_id: Optional[str] = None # 共享 is_shared: bool = Field(default=False) # 时间戳 created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) deleted_at: Optional[datetime] = None ``` ### 3. Schema 定义 ```python # app/schemas/folder.py from pydantic import BaseModel, Field, validator from typing import Optional from datetime import datetime class FolderCreate(BaseModel): """创建文件夹请求""" name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None parent_folder_id: Optional[str] = None folder_category: Optional[int] = Field(None, ge=1, le=2, description="文件夹分类:1=我的项目,2=协作项目") color: Optional[str] = Field(None, pattern="^#[0-9A-Fa-f]{6}$") icon: Optional[str] = None @validator('folder_category') def validate_folder_category(cls, v, values): # 如果是根文件夹(parent_folder_id 为 None),必须指定 folder_category if values.get('parent_folder_id') is None and v is None: raise ValueError('创建根文件夹时必须指定 folder_category') return v class FolderResponse(BaseModel): """文件夹响应""" id: str name: str description: Optional[str] parent_folder_id: Optional[str] = Field(None, alias="parentFolderId") owner_id: str = Field(alias="ownerId") path: str level: int folder_category: int = Field(alias="folderCategory") folder_category_name: str = Field(alias="folderCategoryName") sort_order: int = Field(alias="sortOrder") color: Optional[str] icon: Optional[str] is_shared: bool = Field(alias="isShared") created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") # 统计信息 subfolder_count: Optional[int] = Field(None, alias="subfolderCount") project_count: Optional[int] = Field(None, alias="projectCount") class Config: from_attributes = True populate_by_name = True ``` ### 4. Service 实现 ```python # app/services/folder_service.py from app.models.folder import FolderCategory class FolderService: async def get_folders( self, user_id: str, parent_id: Optional[str] = None, folder_category: Optional[int] = None, page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: """获取文件夹列表""" # 如果查询根文件夹,必须指定 folder_category if parent_id is None and folder_category is None: raise ValidationError("查询根文件夹时必须指定 folder_category") # 验证 folder_category if folder_category is not None and not FolderCategory.is_valid(folder_category): raise ValidationError(f"无效的 folder_category: {folder_category}") # 查询文件夹 folders = await self.repository.get_by_parent( parent_id, user_id, folder_category, page, page_size ) # 构建响应 result = [] for folder in folders: folder_data = { "id": folder.id, "name": folder.name, "folderCategory": folder.folder_category, "folderCategoryName": FolderCategory.get_display_name(folder.folder_category), # ... 其他字段 } result.append(folder_data) return {"items": result, "total": len(result), ...} async def create_folder( self, user_id: str, folder_data: FolderCreate ) -> Folder: """创建文件夹""" # 如果是根文件夹,必须指定 folder_category if folder_data.parent_folder_id is None: if folder_data.folder_category is None: raise ValidationError("创建根文件夹时必须指定 folder_category") if not FolderCategory.is_valid(folder_data.folder_category): raise ValidationError(f"无效的 folder_category: {folder_data.folder_category}") # 创建文件夹(子文件夹的 folder_category 由触发器自动继承) folder = Folder( name=folder_data.name, description=folder_data.description, parent_folder_id=folder_data.parent_folder_id, owner_id=user_id, folder_category=folder_data.folder_category or FolderCategory.MY_PROJECTS, color=folder_data.color, icon=folder_data.icon ) return await self.repository.create(folder) ``` ### 5. API 接口 ```python # app/api/v1/folders.py @router.get("", response_model=FolderListResponse) async def get_folders( parent_id: Optional[str] = Query(None, description="父文件夹 ID"), folder_category: Optional[int] = Query(None, ge=1, le=2, description="文件夹分类:1=我的项目,2=协作项目"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session) ): """ 获取文件夹列表 - 查询根文件夹时必须指定 folder_category - 查询子文件夹时 folder_category 自动继承 """ service = FolderService(session) return await service.get_folders( current_user.user_id, parent_id, folder_category, page, page_size ) @router.post("", response_model=FolderResponse) async def create_folder( folder_data: FolderCreate, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session) ): """ 创建文件夹 - 创建根文件夹时必须指定 folder_category - 创建子文件夹时 folder_category 自动继承父文件夹 """ service = FolderService(session) folder = await service.create_folder(current_user.user_id, folder_data) return { "id": folder.id, "name": folder.name, "folderCategory": folder.folder_category, "folderCategoryName": FolderCategory.get_display_name(folder.folder_category), # ... 其他字段 } ``` --- ## 前端实现 ### 1. 类型定义 ```typescript // client/src/types/folder.ts export enum FolderCategory { MY_PROJECTS = 1, COLLABORATIVE_PROJECTS = 2 } export const FOLDER_CATEGORY_NAMES = { [FolderCategory.MY_PROJECTS]: '我的项目', [FolderCategory.COLLABORATIVE_PROJECTS]: '协作项目' }; export interface Folder { id: string; name: string; description?: string; parentFolderId?: string; ownerId: string; path: string; level: number; folderCategory: FolderCategory; folderCategoryName: string; sortOrder: number; color?: string; icon?: string; isShared: boolean; createdAt: string; updatedAt: string; subfolderCount?: number; projectCount?: number; } export interface CreateFolderDto { name: string; description?: string; parentFolderId?: string; folderCategory?: FolderCategory; // 仅根文件夹需要 color?: string; icon?: string; } ``` ### 2. 虚拟根文件夹 ```typescript // client/src/constants/folder.ts export const VIRTUAL_ROOTS = { [FolderCategory.MY_PROJECTS]: { id: 'virtual-mine', name: '我的项目', category: FolderCategory.MY_PROJECTS, icon: 'folder', color: '#3B82F6' }, [FolderCategory.COLLABORATIVE_PROJECTS]: { id: 'virtual-collab', name: '协作项目', category: FolderCategory.COLLABORATIVE_PROJECTS, icon: 'users', color: '#10B981' } } as const; export const isVirtualRoot = (id: string): boolean => { return id === 'virtual-mine' || id === 'virtual-collab'; }; export const getVirtualRootCategory = (id: string): FolderCategory | null => { if (id === 'virtual-mine') return FolderCategory.MY_PROJECTS; if (id === 'virtual-collab') return FolderCategory.COLLABORATIVE_PROJECTS; return null; }; ``` ### 3. API 调用 ```typescript // client/src/services/api/folders.ts export const foldersApi = { // 获取文件夹列表 async getFolders(params: { parentId?: string | null; folderCategory?: FolderCategory; page?: number; pageSize?: number; }): Promise { // 如果是虚拟根文件夹,转换为实际查询参数 let actualParentId = params.parentId; let actualCategory = params.folderCategory; if (isVirtualRoot(params.parentId)) { actualParentId = null; // 查询根文件夹 actualCategory = getVirtualRootCategory(params.parentId); } const response = await apiClient.get('/folders', { params: { parent_id: actualParentId, folder_category: actualCategory, page: params.page || 1, page_size: params.pageSize || 20 } }); return transformKeys(response.data, toCamelCase); }, // 创建文件夹 async createFolder(data: CreateFolderDto): Promise { // 如果父文件夹是虚拟根,转换为实际参数 let actualData = { ...data }; if (isVirtualRoot(data.parentFolderId)) { actualData.parentFolderId = undefined; // 创建根文件夹 actualData.folderCategory = getVirtualRootCategory(data.parentFolderId); } const response = await apiClient.post('/folders', transformKeys(actualData, toSnakeCase)); return transformKeys(response.data, toCamelCase); } }; ``` ### 4. React Hooks ```typescript // client/src/hooks/api/useFolders.ts export const useFolders = (parentId?: string | null, category?: FolderCategory) => { return useQuery({ queryKey: ['folders', parentId, category], queryFn: () => foldersApi.getFolders({ parentId, folderCategory: category }), enabled: parentId !== undefined // 只有指定了 parentId 才查询 }); }; export const useCreateFolder = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: foldersApi.createFolder, onSuccess: (newFolder) => { // 使文件夹列表缓存失效 queryClient.invalidateQueries({ queryKey: ['folders'] }); // 如果是根文件夹,使对应分类的缓存失效 if (!newFolder.parentFolderId) { queryClient.invalidateQueries({ queryKey: ['folders', null, newFolder.folderCategory] }); } } }); }; ``` ### 5. 页面实现 ```typescript // client/src/pages/ProjectsPage.tsx export default function ProjectsPage() { const [activeCategory, setActiveCategory] = useState(FolderCategory.MY_PROJECTS); const [currentFolderId, setCurrentFolderId] = useState('virtual-mine'); // 获取文件夹列表 const { data: foldersData } = useFolders(currentFolderId, activeCategory); // 处理根视图切换 const handleRootChange = (category: FolderCategory) => { setActiveCategory(category); setCurrentFolderId(category === FolderCategory.MY_PROJECTS ? 'virtual-mine' : 'virtual-collab'); }; // 处理创建文件夹 const handleCreateFolder = () => { setCreateFolderModalOpen(true, { parentId: currentFolderId, // 可能是虚拟根 ID category: activeCategory }); }; // 面包屑处理 const breadcrumbs = useMemo(() => { const crumbs = []; // 添加虚拟根 const virtualRoot = VIRTUAL_ROOTS[activeCategory]; crumbs.push(virtualRoot); // 添加实际文件夹路径 if (currentFolderId && !isVirtualRoot(currentFolderId)) { // ... 构建实际路径 } return crumbs; }, [currentFolderId, activeCategory]); return (
{/* ... */}
); } ``` --- ## 优势 1. **节省存储**:不需要为每个用户创建2条文件夹记录 2. **逻辑清晰**:通过 `folder_category` 明确区分 3. **自动继承**:子文件夹自动继承父文件夹的分类 4. **易于扩展**:使用 SMALLINT 预留 3-255 用于未来扩展 5. **前端灵活**:虚拟根文件夹,用户体验不受影响 --- ## 注意事项 1. **查询根文件夹时必须指定 folder_category** 2. **创建根文件夹时必须指定 folder_category** 3. **子文件夹的 folder_category 由触发器自动继承,不能手动指定** 4. **前端需要处理虚拟根文件夹 ID 的转换** 5. **面包屑需要手动添加虚拟根层级** --- ## 变更记录 ### v1.0 (2025-01-21) - 初始设计 - 采用 SMALLINT + 代码枚举方案 - 实现自动继承触发器 - 提供完整的前后端实现示例