12 KiB
项目 ID 匹配问题修复
问题描述
项目面板里的项目 ID 和资源面板顶部的项目选择里的项目 ID 不一致,导致:
- 从项目面板选择项目后,左侧搜索框显示空白
- 项目面板的选中状态无法正确高亮
- 项目选择器无法正确匹配当前选中的项目
- 关键问题:素材面板下拉选择项目后,路由变为
/editor/project-1而不是/editor/1 - 新问题:左边项目面板选择了项目,素材面板顶部的项目选择下拉列表中没有高亮显示当前选中的项目
问题分析
数据源
Mock 数据:client/src/mocks/projects.ts 和 client/src/mocks/folders.ts
- 项目的
id字段:数字类型(例如:1,2,3) - 所有组件都应该使用相同的 mock 数据
树形结构构建
文件:client/src/utils/treeBuilder.ts
在构建树形节点时(第 32-38 行):
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
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
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 进行匹配
// 获取选中项目的显示名称
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]);
关键变更:
- 将
selectedProjectId转换为数字 - 使用
node.projectId进行匹配(数字类型) - 添加了
isNaN检查以防止无效输入
问题 2:点击项目时传递的是错误的 ID
原代码(错误):
const handleClick = () => {
if (isFolder && hasChildren) {
onToggleExpand(node.id);
} else if (isProject) {
onSelect(node.id, node.name); // ❌ 传递 "project-1"
}
};
修复后:
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 参数,无法判断是否为选中状态
修复步骤:
- 添加
selectedProjectId参数到接口:
interface ProjectTreeItemProps {
node: TreeNode;
level: number;
searchQuery: string;
expandedNodes: Set<string>;
selectedProjectId?: string; // ✅ 新增
onToggleExpand: (nodeId: string) => void;
onSelect: (projectId: string, projectName: string) => void;
highlightText: (text: string, query: string) => React.ReactNode;
}
- 添加选中状态判断逻辑:
function ProjectTreeItem({
node,
level,
searchQuery,
expandedNodes,
selectedProjectId, // ✅ 接收参数
onToggleExpand,
onSelect,
highlightText,
}: ProjectTreeItemProps) {
// ...
// ✅ 判断是否为选中状态
const isSelected = isProject && node.projectId && selectedProjectId &&
selectedProjectId === node.projectId.toString();
return (
<div className="w-full">
<div
className={cn(
'flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground',
isProject && 'cursor-pointer',
isSelected && 'bg-primary/10 text-primary' // ✅ 选中状态样式
)}
// ...
>
- 传递
selectedProjectId给子组件:
// 顶层渲染
filteredNodes.map((node) => (
<ProjectTreeItem
key={node.id}
node={node}
level={0}
searchQuery={value}
expandedNodes={allExpandedNodes}
selectedProjectId={selectedProjectId} // ✅ 传递参数
onToggleExpand={toggleExpand}
onSelect={handleProjectSelect}
highlightText={highlightText}
/>
))
// 递归渲染子节点
node.children.map((child) => (
<ProjectTreeItem
key={child.id}
node={child}
level={level + 1}
searchQuery={searchQuery}
expandedNodes={expandedNodes}
selectedProjectId={selectedProjectId} // ✅ 传递参数
onToggleExpand={onToggleExpand}
onSelect={onSelect}
highlightText={highlightText}
/>
))
效果:
- ✅ 当前选中的项目在下拉列表中以浅蓝色背景高亮显示
- ✅ 文字颜色变为主题色
- ✅ 与项目面板的选中状态保持一致
修复 3:ProjectTreeNode
文件:client/src/components/features/project/ProjectTreeNode.tsx
修改内容:使用路由的 projectId 而不是 useUIStore 的 selectedProjectId
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();
}
关键变更:
- 移除
selectedProjectId从useUIStore - 使用
useProjectRoute获取路由的projectId - 使用路由的
projectId进行选中状态判断
ID 类型说明
不同场景下的 ID 格式
| 场景 | ID 格式 | 类型 | 示例 |
|---|---|---|---|
Mock 数据 project.id |
数字 | number |
1 |
路由参数 /:projectId |
字符串 | string |
"1" |
| TreeNode.id | 带前缀字符串 | string |
"project-1" |
| TreeNode.projectId | 数字 | number |
1 |
| API 调用 | 字符串 | string |
"1" |
匹配规则
// ✅ 正确的匹配方式
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) → 显示项目名称
测试验证
测试用例
-
项目面板选择测试:
- 点击项目后,项目高亮显示
- 左侧搜索框显示正确的项目名称
- URL 变化为
/editor/:projectId
-
左侧搜索框选择测试:
- 选择项目后,项目面板(如果打开)高亮对应项目
- 搜索框显示正确的项目名称
- 素材面板加载对应项目的素材
-
路由直接访问测试:
- 直接访问
/editor/1 - 项目面板高亮项目 1
- 左侧搜索框显示项目 1 的名称
- 直接访问
-
ID 类型转换测试:
- 字符串 projectId 正确转换为数字
- 无效的 projectId 不会导致错误
经验教训
1. ID 类型一致性
在整个应用中保持 ID 类型的一致性非常重要:
- 路由参数总是字符串
- Mock 数据的 ID 是数字
- 需要在组件层面进行类型转换
2. 数据结构设计
TreeNode 的设计中:
id:用于 React key 和树节点唯一标识(可以使用前缀)projectId:用于业务逻辑匹配(应该保持原始类型)
这样的设计避免了冲突,但需要明确文档说明。
3. 状态来源统一
在路由驱动架构中:
- 所有组件应该从路由读取 projectId
- 避免使用多个状态源(如 uiStore.selectedProjectId)
- 保持单一数据源原则
4. 类型安全
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 转换工具:
// 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 确保类型安全:
type ProjectId = number & { __brand: 'ProjectId' };
type RouteProjectId = string & { __brand: 'RouteProjectId' };
type TreeNodeId = string & { __brand: 'TreeNodeId' };
3. 文档完善
在 TreeNode 类型定义中添加注释:
interface TreeNode {
/** 树节点唯一标识,格式:`project-${projectId}` 或 `folder-${folderId}` */
id: string;
/** 项目的数据库 ID,用于 API 调用和业务逻辑匹配 */
projectId?: number;
/** 文件夹的数据库 ID */
folderId?: number;
}
修复日期:2026-01-14
修复状态:✅ 已完成
影响范围:ProjectSmartSearchInput, ProjectTreeNode