You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
12 KiB
12 KiB
文件夹选择器实现方案
概述
为创建项目弹窗添加文件夹选择功能,允许用户在创建项目时选择目标文件夹。实现了可复用的文件夹树组件,支持多种使用场景。
实现日期
2026-01-14
需求背景
- 用户在创建项目时需要选择保存位置
- 默认保存在"我的项目"文件夹
- 支持选择"我的项目"下的任意子文件夹
- 交互方式参考素材库的智能搜索框(Popover + 树形结构)
- 只显示文件夹,不显示项目
技术方案
架构设计
采用组件化设计,将功能拆分为三个层次:
- FolderTree(基础层):通用的文件夹树组件
- FolderSelector(业务层):文件夹选择器组件
- CreateProjectModal(应用层):集成文件夹选择器
组件结构
FolderTree.tsx # 通用文件夹树组件(可复用)
├── FolderTreeNode # 树节点子组件
└── highlightText # 搜索高亮函数
FolderSelector.tsx # 文件夹选择器(业务组件)
├── Popover # 弹出层容器
├── Button # 触发按钮
└── FolderTree # 文件夹树
CreateProjectModal.tsx # 创建项目弹窗(应用组件)
└── FolderSelector # 文件夹选择器
核心功能
1. FolderTree 组件
文件路径:client/src/components/features/project/FolderTree.tsx
功能特性:
- ✅ 支持树形展示文件夹和项目
- ✅ 可配置是否显示项目节点(
showProjects参数) - ✅ 支持搜索关键词高亮(
searchQuery参数) - ✅ 支持展开/收起状态管理
- ✅ 支持文件夹和项目的选择回调
- ✅ 支持选中状态高亮显示
- ✅ 完全可复用,适用于多种场景
Props 接口:
interface FolderTreeProps {
nodes: TreeNode[]; // 树形数据
showProjects?: boolean; // 是否显示项目节点
searchQuery?: string; // 搜索关键词
expandedNodes: Set<string>; // 展开的节点集合
onToggleExpand: (nodeId: string) => void;
onFolderSelect?: (
folderId: number,
folderName: string,
folderPath: string
) => void;
onProjectSelect?: (projectId: string, projectName: string) => void;
selectedFolderId?: number; // 当前选中的文件夹
selectedProjectId?: string; // 当前选中的项目
}
2. FolderSelector 组件
文件路径:client/src/components/features/project/FolderSelector.tsx
功能特性:
- ✅ Popover 弹出层交互
- ✅ 支持三种过滤模式:
mine:仅显示"我的项目"及其子文件夹collab:仅显示"协作项目"及其子文件夹all:显示全部文件夹
- ✅ 只显示文件夹,不显示项目
- ✅ 显示选中文件夹的完整路径
- ✅ 默认展开"我的项目"根文件夹
Props 接口:
interface FolderSelectorProps {
value?: number; // 当前选中的文件夹 ID
onChange?: (folderId: number, folderName: string, folderPath: string) => void;
filterMode?: "all" | "mine" | "collab";
disabled?: boolean;
className?: string;
placeholder?: string;
}
3. CreateProjectModal 集成
修改内容:
-
表单 Schema 更新:
const createProjectSchema = z.object({ name: z.string().min(1, '请输入项目名称'), folderId: z.number().min(1, '请选择项目文件夹'), // 新增 projectType: z.enum([...]), // ... 其他字段 }); -
默认值设置:
defaultValues: { name: '', folderId: 1, // 默认选择"我的项目" // ... 其他字段 } -
UI 布局:
- 在"项目名称"字段下方添加"保存位置"字段
- 使用
FolderSelector组件 - 设置
filterMode="mine"只显示"我的项目"
技术亮点
1. 组件复用性设计
通过参数化配置,FolderTree 可以适用于多种场景:
// 场景 1:文件夹选择器(箭头展开,文字选择)
<FolderTree
nodes={treeData}
showProjects={false}
selectionMode="folder-selectable"
onFolderSelect={handleSelect}
/>
// 场景 2:项目浏览器(文件夹整行展开,项目可选)
<FolderTree
nodes={treeData}
showProjects={true}
selectionMode="project-only"
onProjectSelect={handleProjectClick}
/>
// 场景 3:通用选择器(箭头展开,文字选择文件夹或项目)
<FolderTree
nodes={treeData}
showProjects={true}
selectionMode="both-selectable"
onFolderSelect={handleFolderClick}
onProjectSelect={handleProjectClick}
/>
// 场景 4:搜索结果(带高亮)
<FolderTree
nodes={filteredData}
searchQuery="关键词"
selectionMode="project-only"
showProjects={true}
/>
2. 精确的交互控制
箭头和内容区域分离点击:
// 箭头点击 - 只负责展开/折叠
const handleArrowClick = (e: React.MouseEvent) => {
e.stopPropagation(); // 阻止事件冒泡
if (hasChildren) {
onToggleExpand(node.id);
}
};
// 内容点击 - 根据选择模式决定行为
const handleContentClick = () => {
if (selectionMode === "folder-selectable" && isFolder) {
// 选择文件夹
onFolderSelect?.(node.folderId, node.name, folderPath);
} else if (selectionMode === "project-only") {
// 文件夹展开,项目选择
if (isFolder && hasChildren) {
onToggleExpand(node.id);
} else if (isProject) {
onProjectSelect?.(node.id, node.name);
}
}
// ... 其他模式
};
样式控制:
// 箭头区域:有子节点时显示 hover 效果
<span
className={cn(
'mr-1 flex h-4 w-4 shrink-0 items-center justify-center',
hasChildren && 'cursor-pointer hover:bg-accent rounded'
)}
onClick={hasChildren ? handleArrowClick : undefined}
>
// 内容区域:可点击时显示 hover 效果
<div
className={cn(
'flex items-center flex-1 min-w-0',
isContentClickable && 'cursor-pointer hover:bg-accent hover:text-accent-foreground rounded px-1 -mx-1'
)}
onClick={isContentClickable ? handleContentClick : undefined}
>
2. 数据过滤策略
在 FolderSelector 中实现了灵活的过滤逻辑:
const treeData = useMemo(() => {
const fullTree = buildProjectTree(mockFolders, mockProjects);
if (filterMode === "mine") {
return fullTree.filter((node) => node.folderId === 1);
} else if (filterMode === "collab") {
return fullTree.filter((node) => node.folderId === 2);
}
return fullTree;
}, [filterMode]);
3. 数据过滤策略
- 使用
useWatch监听表单字段变化 - 使用
Set管理展开状态,性能优异 - 通过回调函数实现父子组件通信
4. 状态管理
- 默认展开"我的项目"根文件夹
- 显示选中文件夹的完整路径
- 选择后自动关闭 Popover
- 支持键盘导航和点击交互
- 选中状态高亮显示
文件清单
新增文件
-
client/src/components/features/project/FolderTree.tsx- 通用文件夹树组件
- 约 180 行代码
-
client/src/components/features/project/FolderSelector.tsx- 文件夹选择器组件
- 约 130 行代码
-
docs/方案/folder-selector-implementation.md- 实现方案文档
修改文件
client/src/components/features/project/CreateProjectModal.tsx- 添加
folderId字段到表单 schema - 添加
FolderSelector组件 - 设置默认值为 1(我的项目)
- 添加
使用示例
基础使用
import { FolderSelector } from "@/components/features/project/FolderSelector";
function MyComponent() {
const [folderId, setFolderId] = useState(1);
return (
<FolderSelector
value={folderId}
onChange={(id, name, path) => {
console.log("选中文件夹:", { id, name, path });
setFolderId(id);
}}
filterMode="mine"
placeholder="选择项目文件夹"
/>
);
}
在表单中使用
import { useForm } from "react-hook-form";
import { FolderSelector } from "@/components/features/project/FolderSelector";
function CreateForm() {
const form = useForm({
defaultValues: {
folderId: 1,
},
});
return (
<FormItem label="保存位置" required>
<FolderSelector
value={form.watch("folderId")}
onChange={(id) => form.setValue("folderId", id)}
filterMode="mine"
/>
</FormItem>
);
}
后续扩展
1. 支持协作项目
修改 filterMode 为 'collab' 即可:
<FolderSelector
value={folderId}
onChange={handleChange}
filterMode="collab" // 显示协作项目文件夹
/>
2. 支持搜索功能
在 FolderSelector 中添加搜索输入框:
const [searchQuery, setSearchQuery] = useState('');
// 在 Popover 中添加搜索框
<Input
placeholder="搜索文件夹..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
// 传递给 FolderTree
<FolderTree
searchQuery={searchQuery}
// ... 其他 props
/>
3. 重构 SmartSearchInput
可以使用 FolderTree 替代 SmartSearchInput 中的树形实现:
import { FolderTree } from "@/components/features/project/FolderTree";
// 在 SmartSearchInput 中
<FolderTree
nodes={filteredNodes}
showProjects={true}
searchQuery={value}
expandedNodes={allExpandedNodes}
onToggleExpand={toggleExpand}
onProjectSelect={handleProjectSelect}
/>;
测试建议
功能测试
- 打开创建项目弹窗,验证"保存位置"字段显示
- 点击文件夹选择器,验证 Popover 弹出
- 验证只显示"我的项目"及其子文件夹
- 验证不显示项目节点
- 点击文件夹,验证选择功能
- 验证选中后 Popover 关闭
- 验证选中文件夹路径显示
边界测试
- 验证默认值为"我的项目"(folderId: 1)
- 验证表单验证(必填项)
- 验证禁用状态
- 验证空数据状态
交互测试
- 验证展开/收起功能
- 验证选中状态高亮
- 验证 hover 效果
- 验证键盘导航
总结
本次实现完成了以下目标:
- ✅ 创建了可复用的
FolderTree组件 - ✅ 实现了
FolderSelector文件夹选择器 - ✅ 在
CreateProjectModal中集成文件夹选择功能 - ✅ 支持多种过滤模式(我的项目/协作项目/全部)
- ✅ 提供了良好的用户体验和扩展性
组件设计遵循了单一职责原则和开闭原则,具有良好的可维护性和可扩展性。
更新记录
2026-01-14 - 交互优化
优化内容:实现箭头和内容区域分离点击
问题:
- 原实现中点击整行都会触发相应动作
- 无法精确控制展开和选择的交互
解决方案:
- 添加
selectionMode参数,支持三种选择模式 - 箭头区域独立处理点击事件(
handleArrowClick) - 内容区域独立处理点击事件(
handleContentClick) - 使用
e.stopPropagation()阻止事件冒泡
选择模式说明:
| 模式 | 箭头点击 | 文件夹内容点击 | 项目内容点击 | 使用场景 |
|---|---|---|---|---|
folder-selectable |
展开/折叠 | 选择文件夹 | - | FolderSelector |
project-only |
展开/折叠 | 展开/折叠 | 选择项目 | SmartSearchInput |
both-selectable |
展开/折叠 | 选择文件夹 | 选择项目 | 通用场景 |
代码示例:
// FolderSelector 使用 folder-selectable 模式
<FolderTree
nodes={treeData}
showProjects={false}
selectionMode="folder-selectable" // 箭头展开,文字选择
onFolderSelect={handleFolderSelect}
/>
// SmartSearchInput 使用 project-only 模式
<FolderTree
nodes={treeData}
showProjects={true}
selectionMode="project-only" // 文件夹整行展开,项目可选
onProjectSelect={handleProjectSelect}
/>
交互细节:
-
箭头区域:
- 只在有子节点时显示
- 独立的 hover 效果(
hover:bg-accent) - 点击时阻止事件冒泡
- 只负责展开/折叠
-
内容区域(图标 + 文字):
- 根据
selectionMode决定是否可点击 - 可点击时显示 hover 效果
- 点击时触发选择或展开逻辑
- 使用
-mx-1和px-1扩大点击区域
- 根据
-
样式优化:
- 箭头和内容区域独立圆角
- 选中状态整行高亮
- hover 效果只在可交互区域显示
用户体验提升:
- ✅ 交互更精确,不会误触
- ✅ 视觉反馈更清晰
- ✅ 符合用户直觉(箭头=展开,文字=选择)
- ✅ 支持多种使用场景