diff --git a/dashboard/src/components/folder/BaseCreateFolderDialog.vue b/dashboard/src/components/folder/BaseCreateFolderDialog.vue new file mode 100644 index 000000000..7adab5bee --- /dev/null +++ b/dashboard/src/components/folder/BaseCreateFolderDialog.vue @@ -0,0 +1,132 @@ + + + diff --git a/dashboard/src/components/folder/BaseFolderBreadcrumb.vue b/dashboard/src/components/folder/BaseFolderBreadcrumb.vue new file mode 100644 index 000000000..037d0ff2f --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderBreadcrumb.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderCard.vue b/dashboard/src/components/folder/BaseFolderCard.vue new file mode 100644 index 000000000..eddda9b62 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderCard.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderTree.vue b/dashboard/src/components/folder/BaseFolderTree.vue new file mode 100644 index 000000000..1fe924153 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderTree.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderTreeNode.vue b/dashboard/src/components/folder/BaseFolderTreeNode.vue new file mode 100644 index 000000000..b02cd3c2c --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderTreeNode.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseMoveTargetNode.vue b/dashboard/src/components/folder/BaseMoveTargetNode.vue new file mode 100644 index 000000000..330947be0 --- /dev/null +++ b/dashboard/src/components/folder/BaseMoveTargetNode.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseMoveToFolderDialog.vue b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue new file mode 100644 index 000000000..de2686798 --- /dev/null +++ b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/dashboard/src/components/folder/README.md b/dashboard/src/components/folder/README.md new file mode 100644 index 000000000..cacf874c7 --- /dev/null +++ b/dashboard/src/components/folder/README.md @@ -0,0 +1,349 @@ +# 通用文件夹管理组件库 + +这是一个可复用的文件夹管理 UI 组件库,提供了完整的文件夹树、面包屑导航、拖放操作等功能。可用于管理各种类型的项目,如 Persona、模板、知识库等。 + +## 组件列表 + +| 组件 | 说明 | +|------|------| +| `BaseFolderTree` | 文件夹树组件,支持搜索、展开/折叠、右键菜单、拖放 | +| `BaseFolderTreeNode` | 文件夹树节点组件(内部使用) | +| `BaseFolderCard` | 文件夹卡片组件,用于网格布局展示 | +| `BaseFolderBreadcrumb` | 面包屑导航组件 | +| `BaseCreateFolderDialog` | 创建文件夹对话框 | +| `BaseMoveToFolderDialog` | 移动项目到文件夹对话框 | +| `BaseMoveTargetNode` | 移动对话框中的目标文件夹节点(内部使用) | + +## Composable + +### `useFolderManager` + +提供文件夹管理的核心逻辑,包括状态管理、导航、CRUD 操作等。 + +```typescript +import { useFolderManager } from '@/components/folder'; + +const { + // 状态 + folderTree, + currentFolderId, + currentFolders, + breadcrumbPath, + expandedFolderIds, + loading, + treeLoading, + + // 计算属性 + currentFolderName, + breadcrumbItems, + + // 方法 + loadFolderTree, + navigateToFolder, + refreshCurrentFolder, + createFolder, + updateFolder, + deleteFolder, + moveFolder, + toggleFolderExpansion, + setFolderExpansion, + findFolderInTree, + findPathToFolder, + filterTreeBySearch, +} = useFolderManager({ + operations: { + loadFolderTree: async () => { + const response = await axios.get('/api/your-module/folder/tree'); + return response.data.data; + }, + loadSubFolders: async (parentId) => { + const response = await axios.get('/api/your-module/folder/list', { + params: { parent_id: parentId ?? '' } + }); + return response.data.data; + }, + createFolder: async (data) => { + const response = await axios.post('/api/your-module/folder/create', data); + return response.data.data.folder; + }, + updateFolder: async (data) => { + await axios.post('/api/your-module/folder/update', data); + }, + deleteFolder: async (folderId) => { + await axios.post('/api/your-module/folder/delete', { folder_id: folderId }); + }, + }, + rootFolderName: '根目录', + autoLoad: true, +}); +``` + +## 使用示例 + +### 基础用法 + +```vue + + + +``` + +## 类型定义 + +```typescript +// 文件夹基础接口 +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + description?: string | null; + sort_order?: number; + created_at?: string; + updated_at?: string; +} + +// 文件夹树节点接口 +interface FolderTreeNode extends Folder { + children: FolderTreeNode[]; +} + +// 拖放事件数据 +interface DropEventData { + item_id: string; + item_type: string; + target_folder_id: string | null; + source_data?: any; +} + +// 创建文件夹数据 +interface CreateFolderData { + name: string; + parent_id?: string | null; + description?: string; +} +``` + +## 国际化支持 + +所有组件都支持通过 `labels` prop 自定义文本,方便集成到不同的国际化方案中: + +```vue + +``` + +## 拖放支持 + +组件内置了拖放支持,可以通过 `acceptDropTypes` 指定接受的拖放类型: + +```vue + + + + + +``` + +## 与 Pinia Store 集成 + +如果你更喜欢使用 Pinia Store 管理状态,可以参考现有的 `personaStore.ts` 实现: + +```typescript +// stores/myFolderStore.ts +import { defineStore } from 'pinia'; +import type { FolderTreeNode, Folder } from '@/components/folder'; + +export const useMyFolderStore = defineStore('myFolder', { + state: () => ({ + folderTree: [] as FolderTreeNode[], + currentFolderId: null as string | null, + currentFolders: [] as Folder[], + // ... + }), + + actions: { + async loadFolderTree() { + // ... + }, + // ... + }, +}); +``` diff --git a/dashboard/src/components/folder/index.ts b/dashboard/src/components/folder/index.ts new file mode 100644 index 000000000..07fde8313 --- /dev/null +++ b/dashboard/src/components/folder/index.ts @@ -0,0 +1,46 @@ +/** + * 通用文件夹管理组件库 + * + * 提供可复用的文件夹管理 UI 组件,适用于各种需要文件夹组织功能的场景 + * 如:persona 管理、模板管理、知识库管理等 + * + * 使用示例: + * ```vue + * + * ``` + */ + +// 类型导出 +export * from './types'; + +// Composable 导出 +export { useFolderManager, collectFolderAndChildrenIds } from './useFolderManager'; +export type { UseFolderManagerOptions, UseFolderManagerReturn } from './useFolderManager'; + +// 组件导出 +export { default as BaseFolderTree } from './BaseFolderTree.vue'; +export { default as BaseFolderTreeNode } from './BaseFolderTreeNode.vue'; +export { default as BaseFolderCard } from './BaseFolderCard.vue'; +export { default as BaseFolderBreadcrumb } from './BaseFolderBreadcrumb.vue'; +export { default as BaseCreateFolderDialog } from './BaseCreateFolderDialog.vue'; +export { default as BaseMoveToFolderDialog } from './BaseMoveToFolderDialog.vue'; +export { default as BaseMoveTargetNode } from './BaseMoveTargetNode.vue'; diff --git a/dashboard/src/components/folder/types.ts b/dashboard/src/components/folder/types.ts new file mode 100644 index 000000000..20a8b5e14 --- /dev/null +++ b/dashboard/src/components/folder/types.ts @@ -0,0 +1,200 @@ +/** + * 通用文件夹管理组件类型定义 + * + * 这是一个可复用的文件夹管理系统,可用于管理各种类型的项目(如 persona、模板、知识库等) + */ + +/** + * 文件夹基础接口 + */ +export interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + description?: string | null; + sort_order?: number; + created_at?: string; + updated_at?: string; +} + +/** + * 文件夹树节点接口 + */ +export interface FolderTreeNode extends Folder { + children: FolderTreeNode[]; +} + +/** + * 可拖拽的项目接口(可以是文件夹或其他项目) + */ +export interface DraggableItem { + id: string; + type: string; + [key: string]: any; +} + +/** + * 拖拽放置事件数据 + */ +export interface DropEventData { + item_id: string; + item_type: string; + target_folder_id: string | null; + source_data?: any; +} + +/** + * 文件夹操作接口 - 由使用方提供具体实现 + */ +export interface FolderOperations { + // 加载文件夹树 + loadFolderTree: () => Promise; + + // 加载指定文件夹的子文件夹 + loadSubFolders: (parentId: string | null) => Promise; + + // 创建文件夹 + createFolder: (data: CreateFolderData) => Promise; + + // 更新文件夹 + updateFolder: (data: UpdateFolderData) => Promise; + + // 删除文件夹 + deleteFolder: (folderId: string) => Promise; + + // 移动文件夹 + moveFolder?: (folderId: string, targetParentId: string | null) => Promise; +} + +/** + * 创建文件夹数据 + */ +export interface CreateFolderData { + name: string; + parent_id?: string | null; + description?: string; +} + +/** + * 更新文件夹数据 + */ +export interface UpdateFolderData { + folder_id: string; + name?: string; + description?: string; + parent_id?: string | null; +} + +/** + * 文件夹管理器状态 + */ +export interface FolderManagerState { + folderTree: FolderTreeNode[]; + currentFolderId: string | null; + currentFolders: Folder[]; + breadcrumbPath: FolderTreeNode[]; + expandedFolderIds: string[]; + loading: boolean; + treeLoading: boolean; +} + +/** + * 面包屑项接口 + */ +export interface BreadcrumbItem { + title: string; + folderId: string | null; + disabled: boolean; + isRoot: boolean; +} + +/** + * 上下文菜单事件 + */ +export interface ContextMenuEvent { + event: MouseEvent; + folder: Folder; +} + +/** + * 文件夹组件 i18n 键配置 + * 允许使用方自定义翻译键 + */ +export interface FolderI18nKeys { + // 搜索框 + searchPlaceholder?: string; + + // 根目录 + rootFolder?: string; + + // 侧边栏标题 + sidebarTitle?: string; + + // 空状态 + noFolders?: string; + + // 文件夹标题 + foldersTitle?: string; + + // 按钮 + buttons?: { + create?: string; + cancel?: string; + save?: string; + delete?: string; + move?: string; + }; + + // 表单 + form?: { + name?: string; + description?: string; + }; + + // 验证 + validation?: { + nameRequired?: string; + }; + + // 右键菜单 + contextMenu?: { + open?: string; + rename?: string; + moveTo?: string; + delete?: string; + }; + + // 对话框 + dialogs?: { + createTitle?: string; + renameTitle?: string; + deleteTitle?: string; + deleteMessage?: string; + deleteWarning?: string; + moveTitle?: string; + moveDescription?: string; + }; + + // 消息 + messages?: { + createSuccess?: string; + createError?: string; + renameSuccess?: string; + renameError?: string; + deleteSuccess?: string; + deleteError?: string; + moveSuccess?: string; + moveError?: string; + }; +} + +/** + * 通用文件夹组件 Props + */ +export interface BaseFolderProps { + // i18n 翻译函数 + t?: (key: string, params?: Record) => string; + + // i18n 键配置 + i18nKeys?: FolderI18nKeys; +} diff --git a/dashboard/src/components/folder/useFolderManager.ts b/dashboard/src/components/folder/useFolderManager.ts new file mode 100644 index 000000000..a6c1e4b22 --- /dev/null +++ b/dashboard/src/components/folder/useFolderManager.ts @@ -0,0 +1,324 @@ +/** + * 通用文件夹管理 Composable + * + * 提供文件夹管理的核心逻辑,可以被不同的业务模块复用 + */ +import { ref, computed, reactive, type Ref, type ComputedRef } from 'vue'; +import type { + Folder, + FolderTreeNode, + FolderOperations, + CreateFolderData, + UpdateFolderData, + BreadcrumbItem, +} from './types'; + +export interface UseFolderManagerOptions { + // 文件夹操作实现 + operations: FolderOperations; + + // 根目录显示名称 + rootFolderName?: string; + + // 是否自动加载 + autoLoad?: boolean; +} + +export interface UseFolderManagerReturn { + // 状态 + folderTree: Ref; + currentFolderId: Ref; + currentFolders: Ref; + breadcrumbPath: Ref; + expandedFolderIds: Ref; + loading: Ref; + treeLoading: Ref; + + // 计算属性 + currentFolderName: ComputedRef; + breadcrumbItems: ComputedRef; + + // 方法 + loadFolderTree: () => Promise; + navigateToFolder: (folderId: string | null) => Promise; + refreshCurrentFolder: () => Promise; + + createFolder: (data: CreateFolderData) => Promise; + updateFolder: (data: UpdateFolderData) => Promise; + deleteFolder: (folderId: string) => Promise; + moveFolder: (folderId: string, targetParentId: string | null) => Promise; + + toggleFolderExpansion: (folderId: string) => void; + setFolderExpansion: (folderId: string, expanded: boolean) => void; + + findFolderInTree: (folderId: string) => FolderTreeNode | null; + findPathToFolder: (folderId: string) => FolderTreeNode[]; + + filterTreeBySearch: (query: string) => FolderTreeNode[]; +} + +/** + * 创建文件夹管理 composable + */ +export function useFolderManager(options: UseFolderManagerOptions): UseFolderManagerReturn { + const { operations, rootFolderName = '根目录', autoLoad = false } = options; + + // 状态 + const folderTree = ref([]); + const currentFolderId = ref(null); + const currentFolders = ref([]); + const breadcrumbPath = ref([]); + const expandedFolderIds = ref([]); + const loading = ref(false); + const treeLoading = ref(false); + + // 计算属性 + const currentFolderName = computed(() => { + if (breadcrumbPath.value.length === 0) { + return rootFolderName; + } + return breadcrumbPath.value[breadcrumbPath.value.length - 1]?.name || rootFolderName; + }); + + const breadcrumbItems = computed((): BreadcrumbItem[] => { + const items: BreadcrumbItem[] = [ + { + title: rootFolderName, + folderId: null, + disabled: currentFolderId.value === null, + isRoot: true, + }, + ]; + + breadcrumbPath.value.forEach((folder, index) => { + items.push({ + title: folder.name, + folderId: folder.folder_id, + disabled: index === breadcrumbPath.value.length - 1, + isRoot: false, + }); + }); + + return items; + }); + + // 内部方法 + const findPathToFolderInternal = ( + nodes: FolderTreeNode[], + targetId: string, + path: FolderTreeNode[] = [] + ): FolderTreeNode[] | null => { + for (const node of nodes) { + if (node.folder_id === targetId) { + return [...path, node]; + } + if (node.children && node.children.length > 0) { + const result = findPathToFolderInternal(node.children, targetId, [...path, node]); + if (result) return result; + } + } + return null; + }; + + const updateBreadcrumb = (folderId: string | null): void => { + if (folderId === null) { + breadcrumbPath.value = []; + return; + } + + const path = findPathToFolderInternal(folderTree.value, folderId); + breadcrumbPath.value = path || []; + }; + + // 公开方法 + const loadFolderTree = async (): Promise => { + treeLoading.value = true; + try { + folderTree.value = await operations.loadFolderTree(); + } finally { + treeLoading.value = false; + } + }; + + const navigateToFolder = async (folderId: string | null): Promise => { + loading.value = true; + try { + currentFolderId.value = folderId; + currentFolders.value = await operations.loadSubFolders(folderId); + updateBreadcrumb(folderId); + } finally { + loading.value = false; + } + }; + + const refreshCurrentFolder = async (): Promise => { + await navigateToFolder(currentFolderId.value); + }; + + const createFolder = async (data: CreateFolderData): Promise => { + const folder = await operations.createFolder({ + ...data, + parent_id: data.parent_id ?? currentFolderId.value, + }); + + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + + return folder; + }; + + const updateFolder = async (data: UpdateFolderData): Promise => { + await operations.updateFolder(data); + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const deleteFolder = async (folderId: string): Promise => { + await operations.deleteFolder(folderId); + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const moveFolder = async (folderId: string, targetParentId: string | null): Promise => { + if (operations.moveFolder) { + await operations.moveFolder(folderId, targetParentId); + } else { + // 如果没有专门的移动方法,使用更新方法 + await operations.updateFolder({ + folder_id: folderId, + parent_id: targetParentId, + }); + } + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const toggleFolderExpansion = (folderId: string): void => { + const index = expandedFolderIds.value.indexOf(folderId); + if (index === -1) { + expandedFolderIds.value.push(folderId); + } else { + expandedFolderIds.value.splice(index, 1); + } + }; + + const setFolderExpansion = (folderId: string, expanded: boolean): void => { + const index = expandedFolderIds.value.indexOf(folderId); + if (expanded && index === -1) { + expandedFolderIds.value.push(folderId); + } else if (!expanded && index !== -1) { + expandedFolderIds.value.splice(index, 1); + } + }; + + const findFolderInTree = (folderId: string): FolderTreeNode | null => { + const findNode = (nodes: FolderTreeNode[]): FolderTreeNode | null => { + for (const node of nodes) { + if (node.folder_id === folderId) { + return node; + } + if (node.children && node.children.length > 0) { + const found = findNode(node.children); + if (found) return found; + } + } + return null; + }; + return findNode(folderTree.value); + }; + + const findPathToFolder = (folderId: string): FolderTreeNode[] => { + return findPathToFolderInternal(folderTree.value, folderId) || []; + }; + + const filterTreeBySearch = (query: string): FolderTreeNode[] => { + if (!query) return folderTree.value; + + const lowerQuery = query.toLowerCase(); + + const filterNodes = (nodes: FolderTreeNode[]): FolderTreeNode[] => { + return nodes + .filter((node) => { + const matches = node.name.toLowerCase().includes(lowerQuery); + const childMatches = filterNodes(node.children || []); + return matches || childMatches.length > 0; + }) + .map((node) => ({ + ...node, + children: filterNodes(node.children || []), + })); + }; + + return filterNodes(folderTree.value); + }; + + // 自动加载 + if (autoLoad) { + loadFolderTree(); + navigateToFolder(null); + } + + return { + // 状态 + folderTree, + currentFolderId, + currentFolders, + breadcrumbPath, + expandedFolderIds, + loading, + treeLoading, + + // 计算属性 + currentFolderName, + breadcrumbItems, + + // 方法 + loadFolderTree, + navigateToFolder, + refreshCurrentFolder, + createFolder, + updateFolder, + deleteFolder, + moveFolder, + toggleFolderExpansion, + setFolderExpansion, + findFolderInTree, + findPathToFolder, + filterTreeBySearch, + }; +} + +/** + * 收集文件夹及其所有子文件夹的 ID + * 用于禁用移动对话框中不能选择的目标 + */ +export function collectFolderAndChildrenIds( + folderTree: FolderTreeNode[], + folderId: string +): string[] { + const ids: string[] = [folderId]; + + const collectChildIds = (nodes: FolderTreeNode[]): boolean => { + for (const node of nodes) { + if (node.folder_id === folderId) { + const collectAllChildren = (children: FolderTreeNode[]) => { + for (const child of children) { + ids.push(child.folder_id); + if (child.children) { + collectAllChildren(child.children); + } + } + }; + if (node.children) { + collectAllChildren(node.children); + } + return true; + } + if (node.children && collectChildIds(node.children)) { + return true; + } + } + return false; + }; + + collectChildIds(folderTree); + return ids; +} + +export default useFolderManager; diff --git a/dashboard/src/views/PersonaPage.vue b/dashboard/src/views/PersonaPage.vue index f985c6851..102028c6d 100644 --- a/dashboard/src/views/PersonaPage.vue +++ b/dashboard/src/views/PersonaPage.vue @@ -21,7 +21,7 @@ diff --git a/dashboard/src/views/persona/FolderBreadcrumb.vue b/dashboard/src/views/persona/FolderBreadcrumb.vue new file mode 100644 index 000000000..9e4c57b60 --- /dev/null +++ b/dashboard/src/views/persona/FolderBreadcrumb.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderCard.vue b/dashboard/src/views/persona/FolderCard.vue new file mode 100644 index 000000000..5ee4a14a0 --- /dev/null +++ b/dashboard/src/views/persona/FolderCard.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderTree.vue b/dashboard/src/views/persona/FolderTree.vue new file mode 100644 index 000000000..13c596990 --- /dev/null +++ b/dashboard/src/views/persona/FolderTree.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderTreeNode.vue b/dashboard/src/views/persona/FolderTreeNode.vue new file mode 100644 index 000000000..c6a511fda --- /dev/null +++ b/dashboard/src/views/persona/FolderTreeNode.vue @@ -0,0 +1,66 @@ + + + diff --git a/dashboard/src/views/persona/MoveTargetNode.vue b/dashboard/src/views/persona/MoveTargetNode.vue new file mode 100644 index 000000000..90e1113f8 --- /dev/null +++ b/dashboard/src/views/persona/MoveTargetNode.vue @@ -0,0 +1,36 @@ + + + diff --git a/dashboard/src/views/persona/MoveToFolderDialog.vue b/dashboard/src/views/persona/MoveToFolderDialog.vue new file mode 100644 index 000000000..aeae03d3a --- /dev/null +++ b/dashboard/src/views/persona/MoveToFolderDialog.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/dashboard/src/views/persona/PersonaCard.vue b/dashboard/src/views/persona/PersonaCard.vue new file mode 100644 index 000000000..1468cda83 --- /dev/null +++ b/dashboard/src/views/persona/PersonaCard.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue new file mode 100644 index 000000000..985bad4c5 --- /dev/null +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/dashboard/src/views/persona/index.ts b/dashboard/src/views/persona/index.ts new file mode 100644 index 000000000..322155b93 --- /dev/null +++ b/dashboard/src/views/persona/index.ts @@ -0,0 +1,23 @@ +/** + * Persona 管理相关组件 + * + * 这些组件使用了 dashboard/src/components/folder 下的通用文件夹组件 + * 通过包装器模式将 personaStore 的状态和方法连接到通用组件 + */ + +// 主组件 +export { default as PersonaManager } from './PersonaManager.vue'; + +// 文件夹相关组件 +export { default as FolderTree } from './FolderTree.vue'; +export { default as FolderTreeNode } from './FolderTreeNode.vue'; +export { default as FolderBreadcrumb } from './FolderBreadcrumb.vue'; +export { default as FolderCard } from './FolderCard.vue'; + +// 对话框组件 +export { default as CreateFolderDialog } from './CreateFolderDialog.vue'; +export { default as MoveToFolderDialog } from './MoveToFolderDialog.vue'; +export { default as MoveTargetNode } from './MoveTargetNode.vue'; + +// Persona 相关组件 +export { default as PersonaCard } from './PersonaCard.vue';