Files
TrueGrowth/docs/RENAME_VALIDATION_ANALYSIS.md

14 KiB
Raw Blame History

项目管理重命名功能优化方案

分析时间2026-01-27
问题:项目管理中的文件夹和画板重命名时允许重复名称


🔍 当前问题分析

现状

// 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  ✅(文件夹和画板可以同名)

验证逻辑

// 重命名画板时
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"

实现

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

// 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. 添加错误类型

// types/workspace.types.ts

export class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

3. 更新 UI 错误处理

// 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. 实时验证

// 在输入时实时提示
<Input
  value={editingName}
  status={isDuplicate ? 'error' : undefined}
  tips={isDuplicate ? '已存在同名项目' : undefined}
  onChange={(value) => {
    setEditingName(value);
    // 实时检查重名
    checkDuplicate(value);
  }}
/>

2. 视觉反馈

.project-drawer-node__label {
  // 正常状态
  
  &--editing-error {
    border-color: var(--td-error-color);
    background: var(--td-error-color-1);
  }
}

3. 智能建议

// 如果用户输入重复名称,自动建议替代名称
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. 批量重命名工具

📝 测试用例

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