Initial TrueGrowth source import
This commit is contained in:
616
docs/RENAME_VALIDATION_ANALYSIS.md
Normal file
616
docs/RENAME_VALIDATION_ANALYSIS.md
Normal file
@@ -0,0 +1,616 @@
|
||||
# 项目管理重命名功能优化方案
|
||||
|
||||
> 分析时间:2026-01-27
|
||||
> 问题:项目管理中的文件夹和画板重命名时允许重复名称
|
||||
|
||||
---
|
||||
|
||||
## 🔍 当前问题分析
|
||||
|
||||
### 现状
|
||||
|
||||
```typescript
|
||||
// workspace-service.ts (Line 153-163)
|
||||
async renameFolder(id: string, name: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
// 在输入时实时提示
|
||||
<Input
|
||||
value={editingName}
|
||||
status={isDuplicate ? 'error' : undefined}
|
||||
tips={isDuplicate ? '已存在同名项目' : undefined}
|
||||
onChange={(value) => {
|
||||
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 提示
|
||||
<Message theme="warning">
|
||||
名称 "{name}" 已存在,建议使用 "{suggestedName}"
|
||||
</Message>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 对比总结
|
||||
|
||||
| 方案 | 用户体验 | 实现复杂度 | 维护成本 | 推荐指数 |
|
||||
|------|---------|----------|---------|---------|
|
||||
| **方案 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`
|
||||
Reference in New Issue
Block a user