# 项目管理重命名功能优化方案 > 分析时间:2026-01-27 > 问题:项目管理中的文件夹和画板重命名时允许重复名称 --- ## 🔍 当前问题分析 ### 现状 ```typescript // workspace-service.ts (Line 153-163) async renameFolder(id: string, name: string): Promise { const folder = this.folders.get(id); if (!folder) throw new Error(`Folder ${id} not found`); folder.name = name; // ❌ 没有任何验证 folder.updatedAt = Date.now(); this.folders.set(id, folder); await workspaceStorageService.saveFolder(folder); this.emit('folderUpdated', folder); } // renameBoard 方法也是一样的问题 ``` **问题**: 1. ❌ 没有名称验证 2. ❌ 允许完全重复的名称 3. ❌ 没有空名称检查 4. ❌ 没有长度限制 5. ⚠️ 用户体验混乱(多个同名项目无法区分) --- ## 📊 主流应用的做法分析 ### 1️⃣ **Figma / FigJam** **策略**:允许同名,但提供时间戳区分 ``` 文件结构: ├── 我的项目 (2024-01-15) ├── 我的项目 (2024-01-20) └── 我的项目 (2024-01-27) ``` **优点**: - ✅ 用户可以自由命名 - ✅ 通过时间戳避免混淆 - ✅ 不影响工作流 **缺点**: - ⚠️ 需要额外的 UI 显示时间 - ⚠️ 列表变长 --- ### 2️⃣ **Notion** **策略**:允许同名,通过路径和图标区分 ``` 工作区/ ├── 📁 项目管理 │ └── 📄 会议记录 └── 📁 个人笔记 └── 📄 会议记录 ← 同名但在不同文件夹 ``` **优点**: - ✅ 灵活性高 - ✅ 用户体验自然 - ✅ 通过层级结构区分 **缺点**: - ⚠️ 搜索时可能混淆 - ⚠️ 需要完整路径展示 --- ### 3️⃣ **VS Code / Sublime Text** **策略**:**同一文件夹内禁止重名**,不同文件夹可以同名 ``` 项目/ ├── 文件夹A/ │ └── index.tsx ✅ └── 文件夹B/ └── index.tsx ✅ (可以同名) 但同一文件夹内: 文件夹A/ ├── index.tsx └── index.tsx ❌ 不允许 ``` **优点**: - ✅ 避免用户混淆 - ✅ 符合文件系统语义 - ✅ 实现简单 **缺点**: - ⚠️ 需要提示用户 --- ### 4️⃣ **Trello** **策略**:允许同名,但建议添加后缀 ``` 看板列表: - 项目计划 - 项目计划 (复制) - 项目计划 2 ``` **优点**: - ✅ 灵活但有引导 - ✅ 自动添加 "(副本)" 后缀 **缺点**: - ⚠️ 用户仍可能创建同名 --- ### 5️⃣ **Apple Finder / Windows 资源管理器** **策略**:**同一文件夹内严格禁止重名** ``` 尝试重命名时: "已存在名为'新建文件夹'的项目,请选择其他名称。" ``` **优点**: - ✅ 完全避免混淆 - ✅ 用户理解成本低 - ✅ 符合文件系统习惯 **缺点**: - ⚠️ 稍微不够灵活 --- ## 🎯 推荐方案(综合最佳实践) ### 方案 A:**同级禁止重名**(推荐 ⭐⭐⭐⭐⭐) **规则**: 1. **同一文件夹内**的画板和文件夹不能重名 2. **不同文件夹内**可以有同名项目 3. 文件夹和画板可以同名(不同类型) **示例**: ``` 根目录/ ├── 📁 项目A │ ├── 📄 设计稿 ✅ │ └── 📄 设计稿 ❌ 禁止(同级重名) ├── 📁 项目B │ └── 📄 设计稿 ✅(不同文件夹,允许) └── 📄 项目A ✅(文件夹和画板可以同名) ``` **验证逻辑**: ```typescript // 重命名画板时 function validateBoardName( boardId: string, newName: string, folderId: string | null ): { valid: boolean; error?: string } { // 1. 空名称检查 if (!newName || newName.trim().length === 0) { return { valid: false, error: '名称不能为空' }; } // 2. 长度检查 if (newName.length > 100) { return { valid: false, error: '名称不能超过100个字符' }; } // 3. 同级重名检查(只检查同一文件夹内的画板) const siblings = Array.from(boards.values()) .filter(b => b.folderId === folderId && b.id !== boardId); const isDuplicate = siblings.some(b => b.name === newName); if (isDuplicate) { return { valid: false, error: '此文件夹中已存在同名画板,请使用其他名称' }; } return { valid: true }; } ``` **优点**: - ✅ 避免用户混淆 - ✅ 符合文件系统习惯 - ✅ 实现简单 - ✅ 性能好(只需检查同级) **缺点**: - ⚠️ 需要提示用户 - ⚠️ 稍微限制灵活性 --- ### 方案 B:允许重名 + 自动编号(备选) **规则**: 1. 允许同名 2. 自动添加编号避免完全重复 3. 类似 macOS 复制文件的行为 **示例**: ``` 用户输入 "设计稿": - 如果已存在 "设计稿" → 自动改为 "设计稿 2" - 如果已存在 "设计稿 2" → 自动改为 "设计稿 3" ``` **实现**: ```typescript function generateUniqueName( baseName: string, folderId: string | null, excludeId: string ): string { const siblings = Array.from(boards.values()) .filter(b => b.folderId === folderId && b.id !== excludeId) .map(b => b.name); if (!siblings.includes(baseName)) { return baseName; } let counter = 2; let newName = `${baseName} ${counter}`; while (siblings.includes(newName)) { counter++; newName = `${baseName} ${counter}`; } return newName; } ``` **优点**: - ✅ 不打断用户流程 - ✅ 自动解决冲突 - ✅ 用户体验流畅 **缺点**: - ⚠️ 用户可能不知道名称被改了 - ⚠️ 需要明确提示 --- ### 方案 C:混合方案(最佳 ⭐⭐⭐⭐⭐) **策略**: 1. **重命名时**:禁止同名,提示用户修改 2. **复制时**:自动添加 "副本" + 编号 3. **创建时**:允许默认名称重复,但自动编号 **规则**: | 场景 | 行为 | 示例 | |------|------|------| | **手动重命名** | 禁止同名 | "设计稿" → 已存在,提示错误 | | **复制画板** | 自动添加 "副本" | "设计稿" → "设计稿 副本" | | **新建画板** | 自动编号 | "未命名画板" → "未命名画板 2" | **优点**: - ✅ 用户主动重命名时给予控制权 - ✅ 自动操作时智能避免冲突 - ✅ 符合用户心理预期 --- ## 💡 技术实现方案 ### 1. 更新 WorkspaceService ```typescript // workspace-service.ts /** * Validate board name */ private validateBoardName( boardId: string, name: string, folderId: string | null ): { valid: boolean; error?: string } { // 1. 空名称检查 const trimmedName = name.trim(); if (trimmedName.length === 0) { return { valid: false, error: '画板名称不能为空' }; } // 2. 长度检查 if (trimmedName.length > 100) { return { valid: false, error: '画板名称不能超过100个字符' }; } // 3. 特殊字符检查(可选) // const invalidChars = /[<>:"/\\|?*]/g; // if (invalidChars.test(trimmedName)) { // return { valid: false, error: '名称不能包含特殊字符' }; // } // 4. 同级重名检查 const siblings = Array.from(this.boards.values()) .filter(b => b.folderId === folderId && b.id !== boardId); const isDuplicate = siblings.some(b => b.name === trimmedName); if (isDuplicate) { return { valid: false, error: '此文件夹中已存在同名画板,请使用其他名称' }; } return { valid: true }; } /** * Validate folder name */ private validateFolderName( folderId: string, name: string, parentId: string | null ): { valid: boolean; error?: string } { // 1. 空名称检查 const trimmedName = name.trim(); if (trimmedName.length === 0) { return { valid: false, error: '文件夹名称不能为空' }; } // 2. 长度检查 if (trimmedName.length > 100) { return { valid: false, error: '文件夹名称不能超过100个字符' }; } // 3. 同级重名检查 const siblings = this.getFolderChildren(parentId) .filter(f => f.id !== folderId); const isDuplicate = siblings.some(f => f.name === trimmedName); if (isDuplicate) { return { valid: false, error: '此文件夹中已存在同名文件夹,请使用其他名称' }; } return { valid: true }; } /** * Rename board with validation */ async renameBoard(id: string, name: string): Promise { const board = this.boards.get(id); if (!board) throw new Error(`Board ${id} not found`); // 验证名称 const validation = this.validateBoardName(id, name, board.folderId); if (!validation.valid) { throw new ValidationError(validation.error!); } const trimmedName = name.trim(); board.name = trimmedName; board.updatedAt = Date.now(); this.boards.set(id, board); await workspaceStorageService.saveBoard(board); this.emit('boardUpdated', board); } /** * Rename folder with validation */ async renameFolder(id: string, name: string): Promise { const folder = this.folders.get(id); if (!folder) throw new Error(`Folder ${id} not found`); // 验证名称 const validation = this.validateFolderName(id, name, folder.parentId); if (!validation.valid) { throw new ValidationError(validation.error!); } const trimmedName = name.trim(); folder.name = trimmedName; folder.updatedAt = Date.now(); this.folders.set(id, folder); await workspaceStorageService.saveFolder(folder); this.emit('folderUpdated', folder); } ``` ### 2. 添加错误类型 ```typescript // types/workspace.types.ts export class ValidationError extends Error { constructor(message: string) { super(message); this.name = 'ValidationError'; } } ``` ### 3. 更新 UI 错误处理 ```typescript // ProjectDrawer.tsx const handleRename = useCallback(async ( type: 'folder' | 'board', id: string, name: string ) => { try { if (type === 'folder') { await renameFolder(id, name); MessagePlugin.success('重命名成功'); } else { await renameBoard(id, name); MessagePlugin.success('重命名成功'); } } catch (error: any) { if (error.name === 'ValidationError') { MessagePlugin.warning({ content: error.message, duration: 3000, }); // 保持编辑状态,让用户修改 return false; // 不关闭编辑状态 } else { MessagePlugin.error({ content: '重命名失败', duration: 3000, }); } throw error; } }, [renameFolder, renameBoard]); ``` --- ## 🎨 UI 优化建议 ### 1. 实时验证 ```typescript // 在输入时实时提示 { setEditingName(value); // 实时检查重名 checkDuplicate(value); }} /> ``` ### 2. 视觉反馈 ```scss .project-drawer-node__label { // 正常状态 &--editing-error { border-color: var(--td-error-color); background: var(--td-error-color-1); } } ``` ### 3. 智能建议 ```typescript // 如果用户输入重复名称,自动建议替代名称 function suggestAlternativeName(baseName: string, siblings: string[]): string { if (!siblings.includes(baseName)) return baseName; let counter = 2; while (siblings.includes(`${baseName} ${counter}`)) { counter++; } return `${baseName} ${counter}`; } // UI 提示 名称 "{name}" 已存在,建议使用 "{suggestedName}" ``` --- ## 📊 对比总结 | 方案 | 用户体验 | 实现复杂度 | 维护成本 | 推荐指数 | |------|---------|----------|---------|---------| | **方案 A(同级禁止重名)** | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | | **方案 B(自动编号)** | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | | **方案 C(混合方案)** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | --- ## 🚀 实施建议 ### 阶段 1:核心验证(必须) 1. ✅ 添加空名称检查 2. ✅ 添加长度限制 3. ✅ 添加同级重名检查 4. ✅ 更新错误处理 ### 阶段 2:体验优化(推荐) 1. ✅ 实时验证提示 2. ✅ 智能名称建议 3. ✅ 保持编辑状态直到成功 ### 阶段 3:高级功能(可选) 1. ⭐ 全局搜索时显示完整路径 2. ⭐ 重复名称警告(跨文件夹) 3. ⭐ 批量重命名工具 --- ## 📝 测试用例 ```typescript describe('Rename Validation', () => { it('应该拒绝空名称', async () => { await expect(renameBoard(id, '')).rejects.toThrow('名称不能为空'); }); it('应该拒绝过长名称', async () => { const longName = 'a'.repeat(101); await expect(renameBoard(id, longName)).rejects.toThrow('不能超过100个字符'); }); it('应该拒绝同级重名', async () => { await createBoard({ name: '设计稿', folderId: null }); await createBoard({ name: '其他', folderId: null }); await expect( renameBoard('其他的ID', '设计稿') ).rejects.toThrow('已存在同名画板'); }); it('应该允许不同文件夹同名', async () => { await createBoard({ name: '设计稿', folderId: 'folder1' }); await createBoard({ name: '设计稿', folderId: 'folder2' }); // ✅ 应该成功 }); it('应该自动 trim 空格', async () => { await renameBoard(id, ' 设计稿 '); const board = getBoard(id); expect(board.name).toBe('设计稿'); }); }); ``` --- ## 🎯 最终推荐 **采用方案 C(混合方案)**: 1. **手动重命名**:同级禁止重名,给出清晰错误提示 2. **自动操作**:智能编号避免冲突 3. **即时验证**:输入时实时检查,提供建议 这个方案: - ✅ 兼顾用户体验和系统稳定性 - ✅ 符合主流应用的最佳实践 - ✅ 实现难度适中 - ✅ 易于维护和扩展 --- **文档版本**:v1.0 **最后更新**:2026-01-27 **相关文件**: - `packages/drawnix/src/services/workspace-service.ts` - `packages/drawnix/src/components/project-drawer/ProjectDrawer.tsx` - `packages/drawnix/src/types/workspace.types.ts`