# 项目 ID 匹配问题修复 ## 问题描述 项目面板里的项目 ID 和资源面板顶部的项目选择里的项目 ID 不一致,导致: 1. 从项目面板选择项目后,左侧搜索框显示空白 2. 项目面板的选中状态无法正确高亮 3. 项目选择器无法正确匹配当前选中的项目 4. **关键问题**:素材面板下拉选择项目后,路由变为 `/editor/project-1` 而不是 `/editor/1` 5. **新问题**:左边项目面板选择了项目,素材面板顶部的项目选择下拉列表中没有高亮显示当前选中的项目 ## 问题分析 ### 数据源 **Mock 数据**:`client/src/mocks/projects.ts` 和 `client/src/mocks/folders.ts` - 项目的 `id` 字段:数字类型(例如:`1`, `2`, `3`) - 所有组件都应该使用相同的 mock 数据 ### 树形结构构建 **文件**:`client/src/utils/treeBuilder.ts` 在构建树形节点时(第 32-38 行): ```typescript children.push({ id: `project-${project.id}`, // ❌ 字符串:"project-1" name: `${project.name}.kd`, type: 'project', projectId: project.id, // ✅ 数字:1 color: 'purple', }); ``` **问题**: - `TreeNode.id` 使用了字符串前缀格式:`"project-1"` - `TreeNode.projectId` 保持原始数字:`1` - 这导致不同组件使用不同的字段进行匹配 ### 组件层面的问题 #### 1. ProjectSmartSearchInput(修复前) **文件**:`client/src/components/features/project/ProjectSmartSearchInput.tsx` ```typescript function findProjectName(nodes: TreeNode[]): string { for (const node of nodes) { if (node.id === selectedProjectId) { // ❌ 错误匹配 return node.name; } // ... } } ``` **问题**: - `selectedProjectId` 是路由参数(字符串 `"1"`) - `node.id` 是 `"project-1"` 格式 - `"1" !== "project-1"` → 匹配失败 #### 2. ProjectTreeNode(修复前) **文件**:`client/src/components/features/project/ProjectTreeNode.tsx` ```typescript const { selectedProjectId } = useUIStore(); const isSelected = isProject && node.projectId && selectedProjectId === node.projectId.toString(); // ✅ 这个是对的 ``` **问题**: - 使用了 `useUIStore` 的 `selectedProjectId` - 但现在应该使用路由的 `projectId` ## 解决方案 ### 修复 1:ProjectSmartSearchInput - 显示项目名称 **文件**:`client/src/components/features/project/ProjectSmartSearchInput.tsx` **问题 1**:选中项目后,搜索框无法显示项目名称 **修改内容**:使用 `node.projectId` 而不是 `node.id` 进行匹配 ```typescript // 获取选中项目的显示名称 const selectedProjectName = useMemo(() => { if (!selectedProjectId) return ''; // 将 selectedProjectId 转换为数字进行匹配 const projectIdNum = parseInt(selectedProjectId, 10); if (isNaN(projectIdNum)) return ''; function findProjectName(nodes: TreeNode[]): string { for (const node of nodes) { // ✅ 使用 projectId 而不是 id 来匹配 if (node.projectId === projectIdNum) { return node.name; } if (node.children) { const childResult = findProjectName(node.children); if (childResult) return childResult; } } return ''; } return findProjectName(projectTree); }, [selectedProjectId, projectTree]); ``` **关键变更**: 1. 将 `selectedProjectId` 转换为数字 2. 使用 `node.projectId` 进行匹配(数字类型) 3. 添加了 `isNaN` 检查以防止无效输入 **问题 2**:点击项目时传递的是错误的 ID **原代码**(错误): ```typescript const handleClick = () => { if (isFolder && hasChildren) { onToggleExpand(node.id); } else if (isProject) { onSelect(node.id, node.name); // ❌ 传递 "project-1" } }; ``` **修复后**: ```typescript const handleClick = () => { if (isFolder && hasChildren) { onToggleExpand(node.id); } else if (isProject && node.projectId) { // ✅ 传递 projectId 而不是 node.id onSelect(node.projectId.toString(), node.name); // 传递 "1" } }; ``` **导致的问题**: - 原代码传递 `node.id` = `"project-1"` - 路由导航到 `/editor/project-1`(错误) - 应该传递 `node.projectId.toString()` = `"1"` - 正确路由应该是 `/editor/1` ### 修复 2:ProjectSmartSearchInput - 下拉列表选中状态 **文件**:`client/src/components/features/project/ProjectSmartSearchInput.tsx` **问题**:下拉列表中没有高亮显示当前选中的项目 **原因**:`ProjectTreeItem` 组件没有接收 `selectedProjectId` 参数,无法判断是否为选中状态 **修复步骤**: 1. **添加 `selectedProjectId` 参数到接口**: ```typescript interface ProjectTreeItemProps { node: TreeNode; level: number; searchQuery: string; expandedNodes: Set; selectedProjectId?: string; // ✅ 新增 onToggleExpand: (nodeId: string) => void; onSelect: (projectId: string, projectName: string) => void; highlightText: (text: string, query: string) => React.ReactNode; } ``` 2. **添加选中状态判断逻辑**: ```typescript function ProjectTreeItem({ node, level, searchQuery, expandedNodes, selectedProjectId, // ✅ 接收参数 onToggleExpand, onSelect, highlightText, }: ProjectTreeItemProps) { // ... // ✅ 判断是否为选中状态 const isSelected = isProject && node.projectId && selectedProjectId && selectedProjectId === node.projectId.toString(); return (
``` 3. **传递 `selectedProjectId` 给子组件**: ```typescript // 顶层渲染 filteredNodes.map((node) => ( )) // 递归渲染子节点 node.children.map((child) => ( )) ``` **效果**: - ✅ 当前选中的项目在下拉列表中以浅蓝色背景高亮显示 - ✅ 文字颜色变为主题色 - ✅ 与项目面板的选中状态保持一致 ### 修复 3:ProjectTreeNode **文件**:`client/src/components/features/project/ProjectTreeNode.tsx` **修改内容**:使用路由的 `projectId` 而不是 `useUIStore` 的 `selectedProjectId` ```typescript export function ProjectTreeNode({ node, level, expandedNodes, onToggleExpand, onCreateProject, }: ProjectTreeNodeProps) { const { setCreateFolderModalOpen, selectProject } = useUIStore(); const { projectId, setProjectId } = useProjectRoute(); // ✅ 使用路由 // ... // ✅ 判断是否为选中状态 - 使用路由的 projectId const isSelected = isProject && node.projectId && projectId && projectId === node.projectId.toString(); } ``` **关键变更**: 1. 移除 `selectedProjectId` 从 `useUIStore` 2. 使用 `useProjectRoute` 获取路由的 `projectId` 3. 使用路由的 `projectId` 进行选中状态判断 ## ID 类型说明 ### 不同场景下的 ID 格式 | 场景 | ID 格式 | 类型 | 示例 | |------|---------|------|------| | Mock 数据 `project.id` | 数字 | `number` | `1` | | 路由参数 `/:projectId` | 字符串 | `string` | `"1"` | | TreeNode.id | 带前缀字符串 | `string` | `"project-1"` | | TreeNode.projectId | 数字 | `number` | `1` | | API 调用 | 字符串 | `string` | `"1"` | ### 匹配规则 ```typescript // ✅ 正确的匹配方式 const routeProjectId = "1"; // 来自路由 const node = { projectId: 1 }; // 来自树节点 const isMatch = routeProjectId === node.projectId.toString(); // ❌ 错误的匹配方式 const routeProjectId = "1"; const node = { id: "project-1" }; const isMatch = routeProjectId === node.id; // false ``` ## 数据流验证 ### 正确的数据流 ``` Mock 数据 (project.id: 1) ↓ TreeNode (projectId: 1, id: "project-1") ↓ 用户点击项目 ↓ selectProject("1", ...) ↓ 路由导航 /editor/1 ↓ useParams() → projectId: "1" ↓ ProjectTreeNode: projectId === "1" → isSelected = true ↓ ProjectSmartSearchInput: projectId(1) === node.projectId(1) → 显示项目名称 ``` ## 测试验证 ### 测试用例 1. **项目面板选择测试**: - [x] 点击项目后,项目高亮显示 - [x] 左侧搜索框显示正确的项目名称 - [x] URL 变化为 `/editor/:projectId` 2. **左侧搜索框选择测试**: - [x] 选择项目后,项目面板(如果打开)高亮对应项目 - [x] 搜索框显示正确的项目名称 - [x] 素材面板加载对应项目的素材 3. **路由直接访问测试**: - [x] 直接访问 `/editor/1` - [x] 项目面板高亮项目 1 - [x] 左侧搜索框显示项目 1 的名称 4. **ID 类型转换测试**: - [x] 字符串 projectId 正确转换为数字 - [x] 无效的 projectId 不会导致错误 ## 经验教训 ### 1. ID 类型一致性 在整个应用中保持 ID 类型的一致性非常重要: - 路由参数总是字符串 - Mock 数据的 ID 是数字 - 需要在组件层面进行类型转换 ### 2. 数据结构设计 TreeNode 的设计中: - `id`:用于 React key 和树节点唯一标识(可以使用前缀) - `projectId`:用于业务逻辑匹配(应该保持原始类型) 这样的设计避免了冲突,但需要明确文档说明。 ### 3. 状态来源统一 在路由驱动架构中: - 所有组件应该从路由读取 projectId - 避免使用多个状态源(如 uiStore.selectedProjectId) - 保持单一数据源原则 ### 4. 类型安全 TypeScript 的类型系统可以帮助发现这类问题: ```typescript // 明确 ID 的类型 type ProjectId = number; // 数据库 ID type RouteProjectId = string; // 路由参数 type TreeNodeId = string; // 树节点 ID(带前缀) interface TreeNode { id: TreeNodeId; // "project-1" projectId?: ProjectId; // 1 } ``` ## 后续优化建议 ### 1. 统一 ID 处理工具 创建 ID 转换工具: ```typescript // utils/idConverter.ts export const idConverter = { toTreeNodeId: (projectId: number) => `project-${projectId}`, fromTreeNodeId: (treeNodeId: string) => parseInt(treeNodeId.replace('project-', ''), 10), toRouteId: (projectId: number) => projectId.toString(), fromRouteId: (routeId: string) => parseInt(routeId, 10), }; ``` ### 2. 类型定义优化 使用 branded types 确保类型安全: ```typescript type ProjectId = number & { __brand: 'ProjectId' }; type RouteProjectId = string & { __brand: 'RouteProjectId' }; type TreeNodeId = string & { __brand: 'TreeNodeId' }; ``` ### 3. 文档完善 在 TreeNode 类型定义中添加注释: ```typescript interface TreeNode { /** 树节点唯一标识,格式:`project-${projectId}` 或 `folder-${folderId}` */ id: string; /** 项目的数据库 ID,用于 API 调用和业务逻辑匹配 */ projectId?: number; /** 文件夹的数据库 ID */ folderId?: number; } ``` --- **修复日期**:2026-01-14 **修复状态**:✅ 已完成 **影响范围**:ProjectSmartSearchInput, ProjectTreeNode