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

文件夹选择器实现方案

概述

为创建项目弹窗添加文件夹选择功能,允许用户在创建项目时选择目标文件夹。实现了可复用的文件夹树组件,支持多种使用场景。

实现日期

2026-01-14

需求背景

  • 用户在创建项目时需要选择保存位置
  • 默认保存在"我的项目"文件夹
  • 支持选择"我的项目"下的任意子文件夹
  • 交互方式参考素材库的智能搜索框(Popover + 树形结构)
  • 只显示文件夹,不显示项目

技术方案

架构设计

采用组件化设计,将功能拆分为三个层次:

  1. FolderTree(基础层):通用的文件夹树组件
  2. FolderSelector(业务层):文件夹选择器组件
  3. 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 集成

修改内容

  1. 表单 Schema 更新

    const createProjectSchema = z.object({
      name: z.string().min(1, '请输入项目名称'),
      folderId: z.number().min(1, '请选择项目文件夹'), // 新增
      projectType: z.enum([...]),
      // ... 其他字段
    });
    
  2. 默认值设置

    defaultValues: {
      name: '',
      folderId: 1, // 默认选择"我的项目"
      // ... 其他字段
    }
    
  3. 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
  • 支持键盘导航和点击交互
  • 选中状态高亮显示

文件清单

新增文件

  1. client/src/components/features/project/FolderTree.tsx

    • 通用文件夹树组件
    • 约 180 行代码
  2. client/src/components/features/project/FolderSelector.tsx

    • 文件夹选择器组件
    • 约 130 行代码
  3. docs/方案/folder-selector-implementation.md

    • 实现方案文档

修改文件

  1. 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 效果
  • 验证键盘导航

总结

本次实现完成了以下目标:

  1. 创建了可复用的 FolderTree 组件
  2. 实现了 FolderSelector 文件夹选择器
  3. CreateProjectModal 中集成文件夹选择功能
  4. 支持多种过滤模式(我的项目/协作项目/全部)
  5. 提供了良好的用户体验和扩展性

组件设计遵循了单一职责原则和开闭原则,具有良好的可维护性和可扩展性。

更新记录

2026-01-14 - 交互优化

优化内容:实现箭头和内容区域分离点击

问题

  • 原实现中点击整行都会触发相应动作
  • 无法精确控制展开和选择的交互

解决方案

  1. 添加 selectionMode 参数,支持三种选择模式
  2. 箭头区域独立处理点击事件(handleArrowClick
  3. 内容区域独立处理点击事件(handleContentClick
  4. 使用 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}
/>

交互细节

  1. 箭头区域

    • 只在有子节点时显示
    • 独立的 hover 效果(hover:bg-accent
    • 点击时阻止事件冒泡
    • 只负责展开/折叠
  2. 内容区域(图标 + 文字):

    • 根据 selectionMode 决定是否可点击
    • 可点击时显示 hover 效果
    • 点击时触发选择或展开逻辑
    • 使用 -mx-1px-1 扩大点击区域
  3. 样式优化

    • 箭头和内容区域独立圆角
    • 选中状态整行高亮
    • hover 效果只在可交互区域显示

用户体验提升

  • 交互更精确,不会误触
  • 视觉反馈更清晰
  • 符合用户直觉(箭头=展开,文字=选择)
  • 支持多种使用场景