feat(dashboard): add reusable folder management component library

Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.

- Add base folder components (tree, breadcrumb, card, dialogs) with
  customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage
This commit is contained in:
RC-CHN
2026-01-12 15:17:25 +08:00
parent 36355ad391
commit 3fbb3db27c
22 changed files with 3585 additions and 1 deletions
@@ -0,0 +1,132 @@
<template>
<v-dialog v-model="showDialog" max-width="450px" persistent>
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-folder-plus</v-icon>
{{ labels.title }}
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="formValid">
<v-text-field v-model="formData.name" :label="mergedLabels.nameLabel"
:rules="[(v: any) => !!v || mergedLabels.nameRequired]" variant="outlined"
density="comfortable" autofocus class="mb-3" />
<v-textarea v-model="formData.description" :label="labels.descriptionLabel" variant="outlined"
rows="3" density="comfortable" hide-details />
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="closeDialog">
{{ labels.cancelButton }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitForm" :loading="loading" :disabled="!formValid">
{{ labels.createButton }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { CreateFolderData } from './types';
interface DefaultLabels {
title: string;
nameLabel: string;
descriptionLabel: string;
nameRequired: string;
cancelButton: string;
createButton: string;
}
const defaultLabels: DefaultLabels = {
title: '创建文件夹',
nameLabel: '名称',
descriptionLabel: '描述',
nameRequired: '请输入文件夹名称',
cancelButton: '取消',
createButton: '创建'
};
export default defineComponent({
name: 'BaseCreateFolderDialog',
props: {
modelValue: {
type: Boolean,
default: false
},
parentFolderId: {
type: String as PropType<string | null>,
default: null
},
labels: {
type: Object as PropType<Partial<DefaultLabels>>,
default: () => ({})
}
},
emits: ['update:modelValue', 'create'],
data() {
return {
formValid: false,
loading: false,
formData: {
name: '',
description: ''
}
};
},
computed: {
showDialog: {
get(): boolean {
return this.modelValue;
},
set(value: boolean) {
this.$emit('update:modelValue', value);
}
},
mergedLabels(): DefaultLabels {
return { ...defaultLabels, ...this.labels };
}
},
watch: {
modelValue(newValue: boolean) {
if (newValue) {
this.resetForm();
}
}
},
methods: {
resetForm() {
this.formData = {
name: '',
description: ''
};
if (this.$refs.form) {
(this.$refs.form as any).resetValidation();
}
},
closeDialog() {
this.showDialog = false;
},
async submitForm() {
if (!this.formValid) return;
const data: CreateFolderData = {
name: this.formData.name,
description: this.formData.description || undefined,
parent_id: this.parentFolderId
};
this.$emit('create', data);
},
setLoading(value: boolean) {
this.loading = value;
}
}
});
</script>
@@ -0,0 +1,84 @@
<template>
<v-breadcrumbs :items="computedItems" class="base-folder-breadcrumb pa-0">
<template v-slot:prepend>
<v-icon size="small" class="mr-1">mdi-folder-outline</v-icon>
</template>
<template v-slot:item="{ item }">
<v-breadcrumbs-item :disabled="(item as any).disabled" @click="!(item as any).disabled && handleClick((item as any).folderId)"
:class="{ 'breadcrumb-link': !(item as any).disabled }">
<v-icon v-if="(item as any).isRoot" size="small" class="mr-1">mdi-home</v-icon>
{{ (item as any).title }}
</v-breadcrumbs-item>
</template>
<template v-slot:divider>
<v-icon size="small">mdi-chevron-right</v-icon>
</template>
</v-breadcrumbs>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { BreadcrumbItem, FolderTreeNode } from './types';
export default defineComponent({
name: 'BaseFolderBreadcrumb',
props: {
breadcrumbPath: {
type: Array as PropType<FolderTreeNode[]>,
required: true
},
currentFolderId: {
type: String as PropType<string | null>,
default: null
},
rootFolderName: {
type: String,
default: '根目录'
}
},
emits: ['navigate'],
computed: {
computedItems(): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [
{
title: this.rootFolderName,
folderId: null,
disabled: this.currentFolderId === null,
isRoot: true
}
];
this.breadcrumbPath.forEach((folder, index) => {
items.push({
title: folder.name,
folderId: folder.folder_id,
disabled: index === this.breadcrumbPath.length - 1,
isRoot: false
});
});
return items;
}
},
methods: {
handleClick(folderId: string | null) {
this.$emit('navigate', folderId);
}
}
});
</script>
<style scoped>
.base-folder-breadcrumb {
font-size: 14px;
}
.breadcrumb-link {
cursor: pointer;
transition: color 0.2s;
}
.breadcrumb-link:hover {
color: rgb(var(--v-theme-primary));
}
</style>
@@ -0,0 +1,143 @@
<template>
<v-card class="base-folder-card" :class="{ 'drag-over': isDragOver }" rounded="lg" @click="$emit('click')" @contextmenu.prevent="$emit('contextmenu', $event)"
elevation="1" hover @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
<v-card-text class="d-flex align-center pa-3">
<v-icon size="40" color="amber-darken-2" class="mr-3">mdi-folder</v-icon>
<div class="folder-info flex-grow-1 overflow-hidden">
<div class="text-subtitle-1 font-weight-medium text-truncate">{{ folder.name }}</div>
<div v-if="folder.description" class="text-body-2 text-medium-emphasis text-truncate">
{{ folder.description }}
</div>
</div>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
</template>
<v-list density="compact">
<v-list-item @click.stop="$emit('open')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-open</v-icon>
</template>
<v-list-item-title>{{ labels.open }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('rename')">
<template v-slot:prepend>
<v-icon size="small">mdi-pencil</v-icon>
</template>
<v-list-item-title>{{ labels.rename }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('move')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
</template>
<v-list-item-title>{{ labels.moveTo }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click.stop="$emit('delete')" class="text-error">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>{{ labels.delete }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { Folder } from './types';
interface DefaultLabels {
open: string;
rename: string;
moveTo: string;
delete: string;
}
const defaultLabels: DefaultLabels = {
open: '打开',
rename: '重命名',
moveTo: '移动到...',
delete: '删除'
};
export default defineComponent({
name: 'BaseFolderCard',
props: {
folder: {
type: Object as PropType<Folder>,
required: true
},
acceptDropTypes: {
type: Array as PropType<string[]>,
default: () => []
},
labels: {
type: Object as PropType<Partial<DefaultLabels>>,
default: () => ({})
}
},
emits: ['click', 'contextmenu', 'open', 'rename', 'move', 'delete', 'item-dropped'],
data() {
return {
isDragOver: false
};
},
computed: {
mergedLabels(): DefaultLabels {
return { ...defaultLabels, ...this.labels };
}
},
methods: {
handleDragOver(event: DragEvent) {
if (!event.dataTransfer) return;
event.dataTransfer.dropEffect = 'move';
this.isDragOver = true;
},
handleDragLeave() {
this.isDragOver = false;
},
handleDrop(event: DragEvent) {
this.isDragOver = false;
if (!event.dataTransfer) return;
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'));
if (this.acceptDropTypes.length === 0 || this.acceptDropTypes.includes(data.type)) {
this.$emit('item-dropped', {
item_id: data.id || data.persona_id || data.item_id,
item_type: data.type,
target_folder_id: this.folder.folder_id,
source_data: data
});
}
} catch (e) {
console.error('Failed to parse drop data:', e);
}
}
}
});
</script>
<style scoped>
.base-folder-card {
cursor: pointer;
transition: all 0.2s ease;
}
.base-folder-card:hover {
transform: translateY(-2px);
}
.base-folder-card.drag-over {
background-color: rgba(var(--v-theme-primary), 0.15);
border: 2px dashed rgb(var(--v-theme-primary));
transform: scale(1.02);
}
.folder-info {
min-width: 0;
}
</style>
@@ -0,0 +1,272 @@
<template>
<div class="base-folder-tree">
<!-- 搜索框 -->
<v-text-field v-model="searchQuery" :placeholder="labels.searchPlaceholder" prepend-inner-icon="mdi-magnify"
variant="outlined" density="compact" hide-details clearable class="mb-3" />
<!-- 根目录节点 -->
<v-list density="compact" nav class="tree-list" bg-color="transparent">
<v-list-item :active="currentFolderId === null" @click="handleFolderClick(null)" rounded="lg"
:class="['root-item', { 'drag-over': isRootDragOver }]"
@dragover.prevent="handleRootDragOver" @dragleave="handleRootDragLeave" @drop.prevent="handleRootDrop">
<template v-slot:prepend>
<v-icon>mdi-home</v-icon>
</template>
<v-list-item-title>{{ labels.rootFolder }}</v-list-item-title>
</v-list-item>
<!-- 文件夹树 -->
<template v-if="!treeLoading">
<BaseFolderTreeNode v-for="folder in filteredFolderTree" :key="folder.folder_id" :folder="folder"
:depth="0" :current-folder-id="currentFolderId" :search-query="searchQuery"
:expanded-folder-ids="expandedFolderIds" :accept-drop-types="acceptDropTypes"
@folder-click="handleFolderClick" @folder-context-menu="handleContextMenu"
@item-dropped="$emit('item-dropped', $event)"
@toggle-expansion="$emit('toggle-expansion', $event)"
@set-expansion="$emit('set-expansion', $event)" />
</template>
<!-- 加载状态 -->
<div v-if="treeLoading" class="text-center pa-4">
<v-progress-circular indeterminate size="24" />
</div>
<!-- 空状态 -->
<div v-if="!treeLoading && folderTree.length === 0" class="text-center pa-4 text-medium-emphasis">
<v-icon size="32" class="mb-2">mdi-folder-outline</v-icon>
<div class="text-body-2">{{ labels.noFolders }}</div>
</div>
</v-list>
<!-- 右键菜单 -->
<v-menu v-model="contextMenu.show" :target="contextMenu.target as any" location="end" :close-on-content-click="true">
<v-list density="compact">
<v-list-item @click="openFolder">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-open</v-icon>
</template>
<v-list-item-title>{{ mergedLabels.contextMenu.open }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('rename-folder', contextMenu.folder)">
<template v-slot:prepend>
<v-icon size="small">mdi-pencil</v-icon>
</template>
<v-list-item-title>{{ mergedLabels.contextMenu.rename }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('move-folder', contextMenu.folder)">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
</template>
<v-list-item-title>{{ mergedLabels.contextMenu.moveTo }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click="$emit('delete-folder', contextMenu.folder)" class="text-error">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>{{ mergedLabels.contextMenu.delete }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { FolderTreeNode, ContextMenuEvent } from './types';
import BaseFolderTreeNode from './BaseFolderTreeNode.vue';
interface ContextMenuState {
show: boolean;
target: [number, number] | null;
folder: FolderTreeNode | null;
}
interface Folder {
folder_id: string;
name: string;
parent_id: string | null;
description?: string | null;
sort_order?: number;
created_at?: string;
updated_at?: string;
}
interface DefaultLabels {
searchPlaceholder: string;
rootFolder: string;
noFolders: string;
contextMenu: {
open: string;
rename: string;
moveTo: string;
delete: string;
};
}
const defaultLabels: DefaultLabels = {
searchPlaceholder: '搜索文件夹...',
rootFolder: '根目录',
noFolders: '暂无文件夹',
contextMenu: {
open: '打开',
rename: '重命名',
moveTo: '移动到...',
delete: '删除'
}
};
export default defineComponent({
name: 'BaseFolderTree',
components: {
BaseFolderTreeNode
},
props: {
folderTree: {
type: Array as PropType<FolderTreeNode[]>,
required: true
},
currentFolderId: {
type: String as PropType<string | null>,
default: null
},
expandedFolderIds: {
type: Array as PropType<string[]>,
default: () => []
},
treeLoading: {
type: Boolean,
default: false
},
acceptDropTypes: {
type: Array as PropType<string[]>,
default: () => []
},
labels: {
type: Object as PropType<Partial<DefaultLabels>>,
default: () => ({})
}
},
emits: [
'folder-click',
'rename-folder',
'move-folder',
'delete-folder',
'item-dropped',
'toggle-expansion',
'set-expansion'
],
data() {
return {
searchQuery: '',
isRootDragOver: false,
contextMenu: {
show: false,
target: null,
folder: null
} as ContextMenuState
};
},
computed: {
mergedLabels(): DefaultLabels {
return {
...defaultLabels,
...this.labels,
contextMenu: {
...defaultLabels.contextMenu,
...(this.labels?.contextMenu || {})
}
};
},
filteredFolderTree(): FolderTreeNode[] {
if (!this.searchQuery) {
return this.folderTree;
}
const query = this.searchQuery.toLowerCase();
return this.filterTreeBySearch(this.folderTree, query);
}
},
methods: {
filterTreeBySearch(nodes: FolderTreeNode[], query: string): FolderTreeNode[] {
return nodes.filter(node => {
const matches = node.name.toLowerCase().includes(query);
const childMatches = this.filterTreeBySearch(node.children || [], query);
return matches || childMatches.length > 0;
}).map(node => ({
...node,
children: this.filterTreeBySearch(node.children || [], query)
}));
},
handleFolderClick(folderId: string | null) {
this.$emit('folder-click', folderId);
},
handleRootDragOver(event: DragEvent) {
if (!event.dataTransfer) return;
event.dataTransfer.dropEffect = 'move';
this.isRootDragOver = true;
},
handleRootDragLeave() {
this.isRootDragOver = false;
},
handleRootDrop(event: DragEvent) {
this.isRootDragOver = false;
if (!event.dataTransfer) return;
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'));
if (this.acceptDropTypes.length === 0 || this.acceptDropTypes.includes(data.type)) {
this.$emit('item-dropped', {
item_id: data.id || data.persona_id || data.item_id,
item_type: data.type,
target_folder_id: null,
source_data: data
});
}
} catch (e) {
console.error('Failed to parse drop data:', e);
}
},
handleContextMenu(eventData: ContextMenuEvent) {
const { event, folder } = eventData;
this.contextMenu.target = [event.clientX, event.clientY];
this.contextMenu.folder = folder as FolderTreeNode;
this.contextMenu.show = true;
},
openFolder() {
if (this.contextMenu.folder) {
this.$emit('folder-click', this.contextMenu.folder.folder_id);
}
}
}
});
</script>
<style scoped>
.base-folder-tree {
height: 100%;
display: flex;
flex-direction: column;
}
.tree-list {
flex: 1;
overflow-y: auto;
}
.root-item {
margin-bottom: 4px;
transition: all 0.2s ease;
}
.root-item.drag-over {
background-color: rgba(var(--v-theme-primary), 0.15);
border: 2px dashed rgb(var(--v-theme-primary));
border-radius: 8px;
}
</style>
@@ -0,0 +1,154 @@
<template>
<div class="base-folder-tree-node">
<v-list-item :active="currentFolderId === folder.folder_id" @click.stop="$emit('folder-click', folder.folder_id)"
@contextmenu.prevent="handleContextMenu" rounded="lg" :style="{ paddingLeft: `${(depth + 1) * 16}px` }"
:class="['folder-item', { 'drag-over': isDragOver }]"
@dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
<template v-slot:prepend>
<v-btn v-if="hasChildren" icon variant="text" size="x-small" @click.stop="toggleExpand"
class="expand-btn">
<v-icon size="16">{{ isExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</v-btn>
<div v-else class="expand-placeholder"></div>
<v-icon :color="currentFolderId === folder.folder_id ? 'primary' : ''">
{{ isExpanded ? 'mdi-folder-open' : 'mdi-folder' }}
</v-icon>
</template>
<v-list-item-title class="text-truncate">{{ folder.name }}</v-list-item-title>
</v-list-item>
<!-- 子文件夹 -->
<v-expand-transition>
<div v-show="isExpanded && hasChildren">
<BaseFolderTreeNode v-for="child in folder.children" :key="child.folder_id" :folder="child" :depth="depth + 1"
:current-folder-id="currentFolderId" :search-query="searchQuery"
:expanded-folder-ids="expandedFolderIds" :accept-drop-types="acceptDropTypes"
@folder-click="$emit('folder-click', $event)"
@folder-context-menu="$emit('folder-context-menu', $event)"
@item-dropped="$emit('item-dropped', $event)"
@toggle-expansion="$emit('toggle-expansion', $event)"
@set-expansion="$emit('set-expansion', $event)" />
</div>
</v-expand-transition>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { FolderTreeNode } from './types';
export default defineComponent({
name: 'BaseFolderTreeNode',
props: {
folder: {
type: Object as PropType<FolderTreeNode>,
required: true
},
depth: {
type: Number,
default: 0
},
currentFolderId: {
type: String as PropType<string | null>,
default: null
},
searchQuery: {
type: String,
default: ''
},
expandedFolderIds: {
type: Array as PropType<string[]>,
default: () => []
},
acceptDropTypes: {
type: Array as PropType<string[]>,
default: () => []
}
},
emits: ['folder-click', 'folder-context-menu', 'item-dropped', 'toggle-expansion', 'set-expansion'],
data() {
return {
isDragOver: false
};
},
computed: {
hasChildren(): boolean {
return this.folder.children && this.folder.children.length > 0;
},
isExpanded(): boolean {
return this.expandedFolderIds.includes(this.folder.folder_id);
}
},
watch: {
searchQuery: {
immediate: true,
handler(newQuery: string) {
// 搜索时自动展开匹配的节点
if (newQuery && this.hasChildren) {
this.$emit('set-expansion', { folderId: this.folder.folder_id, expanded: true });
}
}
}
},
methods: {
toggleExpand() {
this.$emit('toggle-expansion', this.folder.folder_id);
},
handleContextMenu(event: MouseEvent) {
this.$emit('folder-context-menu', { event, folder: this.folder });
},
handleDragOver(event: DragEvent) {
if (!event.dataTransfer) return;
event.dataTransfer.dropEffect = 'move';
this.isDragOver = true;
},
handleDragLeave() {
this.isDragOver = false;
},
handleDrop(event: DragEvent) {
this.isDragOver = false;
if (!event.dataTransfer) return;
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'));
if (this.acceptDropTypes.length === 0 || this.acceptDropTypes.includes(data.type)) {
this.$emit('item-dropped', {
item_id: data.id || data.persona_id || data.item_id,
item_type: data.type,
target_folder_id: this.folder.folder_id,
source_data: data
});
}
} catch (e) {
console.error('Failed to parse drop data:', e);
}
}
}
});
</script>
<style scoped>
.base-folder-tree-node {
width: 100%;
}
.folder-item {
min-height: 36px;
transition: all 0.2s ease;
}
.folder-item.drag-over {
background-color: rgba(var(--v-theme-primary), 0.15);
border: 2px dashed rgb(var(--v-theme-primary));
border-radius: 8px;
}
.expand-btn {
margin-right: 4px;
}
.expand-placeholder {
width: 28px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,93 @@
<template>
<div class="base-move-target-node">
<v-list-item :active="selectedFolderId === folder.folder_id" :disabled="isDisabled"
@click.stop="!isDisabled && $emit('select', folder.folder_id)" rounded="lg"
:style="{ paddingLeft: `${(depth + 1) * 16}px` }" class="folder-item">
<template v-slot:prepend>
<v-btn v-if="hasChildren" icon variant="text" size="x-small" @click.stop="toggleExpand"
class="expand-btn" :disabled="isDisabled">
<v-icon size="16">{{ isExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</v-btn>
<div v-else class="expand-placeholder"></div>
<v-icon :color="isDisabled ? 'grey' : (selectedFolderId === folder.folder_id ? 'primary' : '')">
{{ isExpanded ? 'mdi-folder-open' : 'mdi-folder' }}
</v-icon>
</template>
<v-list-item-title class="text-truncate">{{ folder.name }}</v-list-item-title>
</v-list-item>
<!-- 子文件夹 -->
<v-expand-transition>
<div v-show="isExpanded && hasChildren">
<BaseMoveTargetNode v-for="child in folder.children" :key="child.folder_id" :folder="child" :depth="depth + 1"
:selected-folder-id="selectedFolderId" :disabled-folder-ids="disabledFolderIds"
@select="$emit('select', $event)" />
</div>
</v-expand-transition>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { FolderTreeNode } from './types';
export default defineComponent({
name: 'BaseMoveTargetNode',
props: {
folder: {
type: Object as PropType<FolderTreeNode>,
required: true
},
depth: {
type: Number,
default: 0
},
selectedFolderId: {
type: String as PropType<string | null>,
default: null
},
disabledFolderIds: {
type: Array as PropType<string[]>,
default: () => []
}
},
emits: ['select'],
data() {
return {
isExpanded: true
};
},
computed: {
hasChildren(): boolean {
return this.folder.children && this.folder.children.length > 0;
},
isDisabled(): boolean {
return this.disabledFolderIds.includes(this.folder.folder_id);
}
},
methods: {
toggleExpand() {
this.isExpanded = !this.isExpanded;
}
}
});
</script>
<style scoped>
.base-move-target-node {
width: 100%;
}
.folder-item {
min-height: 36px;
}
.expand-btn {
margin-right: 4px;
}
.expand-placeholder {
width: 28px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,178 @@
<template>
<v-dialog v-model="showDialog" max-width="500px" persistent>
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-folder-move</v-icon>
{{ labels.title }}
</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
{{ labels.description }}
</p>
<!-- 文件夹选择树 -->
<div class="folder-select-tree">
<v-list density="compact" nav class="tree-list">
<!-- 根目录选项 -->
<v-list-item :active="selectedFolderId === null" @click="selectFolder(null)" rounded="lg"
class="mb-1">
<template v-slot:prepend>
<v-icon>mdi-home</v-icon>
</template>
<v-list-item-title>{{ labels.rootFolder }}</v-list-item-title>
</v-list-item>
<!-- 文件夹树 -->
<template v-if="!treeLoading">
<BaseMoveTargetNode v-for="folder in folderTree" :key="folder.folder_id" :folder="folder"
:depth="0" :selected-folder-id="selectedFolderId" :disabled-folder-ids="disabledFolderIds"
@select="selectFolder" />
</template>
<!-- 加载状态 -->
<div v-if="treeLoading" class="text-center pa-4">
<v-progress-circular indeterminate size="24" />
</div>
</v-list>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="closeDialog">
{{ labels.cancelButton }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitMove" :loading="loading">
{{ labels.moveButton }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import type { FolderTreeNode } from './types';
import BaseMoveTargetNode from './BaseMoveTargetNode.vue';
import { collectFolderAndChildrenIds } from './useFolderManager';
interface DefaultLabels {
title: string;
description: string;
rootFolder: string;
cancelButton: string;
moveButton: string;
}
const defaultLabels: DefaultLabels = {
title: '移动到文件夹',
description: '选择目标文件夹',
rootFolder: '根目录',
cancelButton: '取消',
moveButton: '移动'
};
export default defineComponent({
name: 'BaseMoveToFolderDialog',
components: {
BaseMoveTargetNode
},
props: {
modelValue: {
type: Boolean,
default: false
},
folderTree: {
type: Array as PropType<FolderTreeNode[]>,
required: true
},
treeLoading: {
type: Boolean,
default: false
},
// 当移动的是文件夹时,需要传入当前文件夹 ID 以禁用自身和子文件夹
currentFolderId: {
type: String as PropType<string | null>,
default: null
},
// 项目当前所在的文件夹 ID(用于初始化选择)
itemCurrentFolderId: {
type: String as PropType<string | null>,
default: null
},
// 是否是移动文件夹(如果是,需要禁用自身和子文件夹)
isMovingFolder: {
type: Boolean,
default: false
},
labels: {
type: Object as PropType<Partial<DefaultLabels>>,
default: () => ({})
}
},
emits: ['update:modelValue', 'move'],
data() {
return {
selectedFolderId: null as string | null,
loading: false
};
},
computed: {
showDialog: {
get(): boolean {
return this.modelValue;
},
set(value: boolean) {
this.$emit('update:modelValue', value);
}
},
mergedLabels(): DefaultLabels {
return { ...defaultLabels, ...this.labels };
},
// 禁用的文件夹 ID(不能移动到自己或子文件夹)
disabledFolderIds(): string[] {
if (!this.isMovingFolder || !this.currentFolderId) return [];
return collectFolderAndChildrenIds(this.folderTree, this.currentFolderId);
}
},
watch: {
modelValue(newValue: boolean) {
if (newValue) {
// 初始化选中为当前所在文件夹
this.selectedFolderId = this.itemCurrentFolderId;
}
}
},
methods: {
selectFolder(folderId: string | null) {
// 检查是否禁用
if (folderId && this.disabledFolderIds.includes(folderId)) return;
this.selectedFolderId = folderId;
},
closeDialog() {
this.showDialog = false;
},
submitMove() {
this.$emit('move', this.selectedFolderId);
},
setLoading(value: boolean) {
this.loading = value;
}
}
});
</script>
<style scoped>
.folder-select-tree {
max-height: 400px;
overflow-y: auto;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
}
.tree-list {
padding: 8px;
}
</style>
+349
View File
@@ -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
<template>
<div class="folder-manager">
<!-- 侧边栏 -->
<div class="sidebar">
<BaseFolderTree
:folder-tree="folderTree"
:current-folder-id="currentFolderId"
:expanded-folder-ids="expandedFolderIds"
:tree-loading="treeLoading"
:accept-drop-types="['item']"
:labels="treeLabels"
@folder-click="navigateToFolder"
@rename-folder="handleRenameFolder"
@move-folder="handleMoveFolder"
@delete-folder="handleDeleteFolder"
@item-dropped="handleItemDropped"
@toggle-expansion="toggleFolderExpansion"
/>
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 面包屑 -->
<BaseFolderBreadcrumb
:breadcrumb-path="breadcrumbPath"
:current-folder-id="currentFolderId"
root-folder-name="根目录"
@navigate="navigateToFolder"
/>
<!-- 文件夹卡片 -->
<v-row>
<v-col v-for="folder in currentFolders" :key="folder.folder_id" cols="3">
<BaseFolderCard
:folder="folder"
:accept-drop-types="['item']"
:labels="cardLabels"
@click="navigateToFolder(folder.folder_id)"
@open="navigateToFolder(folder.folder_id)"
@rename="handleRenameFolder(folder)"
@move="handleMoveFolder(folder)"
@delete="handleDeleteFolder(folder)"
@item-dropped="handleItemDropped"
/>
</v-col>
</v-row>
</div>
<!-- 创建文件夹对话框 -->
<BaseCreateFolderDialog
v-model="showCreateDialog"
:parent-folder-id="currentFolderId"
:labels="createDialogLabels"
@create="handleCreateFolder"
/>
<!-- 移动对话框 -->
<BaseMoveToFolderDialog
v-model="showMoveDialog"
:folder-tree="folderTree"
:tree-loading="treeLoading"
:current-folder-id="movingFolder?.folder_id"
:item-current-folder-id="movingFolder?.parent_id"
:is-moving-folder="true"
:labels="moveDialogLabels"
@move="handleMove"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
BaseFolderTree,
BaseFolderCard,
BaseFolderBreadcrumb,
BaseCreateFolderDialog,
BaseMoveToFolderDialog,
useFolderManager,
} from '@/components/folder';
const folderManager = useFolderManager({
operations: {
// ... 实现你的 API 调用
},
});
const {
folderTree,
currentFolderId,
currentFolders,
breadcrumbPath,
expandedFolderIds,
treeLoading,
navigateToFolder,
toggleFolderExpansion,
createFolder,
} = folderManager;
const showCreateDialog = ref(false);
const showMoveDialog = ref(false);
const movingFolder = ref(null);
// 自定义标签
const treeLabels = {
searchPlaceholder: '搜索文件夹...',
rootFolder: '根目录',
noFolders: '暂无文件夹',
contextMenu: {
open: '打开',
rename: '重命名',
moveTo: '移动到...',
delete: '删除',
},
};
const cardLabels = {
open: '打开',
rename: '重命名',
moveTo: '移动到...',
delete: '删除',
};
const createDialogLabels = {
title: '创建文件夹',
nameLabel: '名称',
descriptionLabel: '描述',
nameRequired: '请输入名称',
cancelButton: '取消',
createButton: '创建',
};
// 处理函数
async function handleCreateFolder(data) {
await createFolder(data);
showCreateDialog.value = false;
}
function handleRenameFolder(folder) {
// 打开重命名对话框
}
function handleMoveFolder(folder) {
movingFolder.value = folder;
showMoveDialog.value = true;
}
function handleDeleteFolder(folder) {
// 确认并删除
}
function handleItemDropped({ item_id, item_type, target_folder_id }) {
// 处理拖放
}
async function handleMove(targetFolderId) {
// 执行移动
showMoveDialog.value = false;
}
</script>
```
## 类型定义
```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
<BaseFolderTree
:labels="{
searchPlaceholder: t('folder.search'),
rootFolder: t('folder.root'),
noFolders: t('folder.empty'),
contextMenu: {
open: t('folder.menu.open'),
rename: t('folder.menu.rename'),
moveTo: t('folder.menu.move'),
delete: t('folder.menu.delete'),
},
}"
/>
```
## 拖放支持
组件内置了拖放支持,可以通过 `acceptDropTypes` 指定接受的拖放类型:
```vue
<!-- 只接受 'persona' 类型的拖放 -->
<BaseFolderTree
:accept-drop-types="['persona']"
@item-dropped="handleDrop"
/>
<!-- 拖放事件处理 -->
<script setup>
function handleDrop({ item_id, item_type, target_folder_id, source_data }) {
if (item_type === 'persona') {
// 移动 persona 到目标文件夹
movePersonaToFolder(item_id, target_folder_id);
}
}
</script>
```
## 与 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() {
// ...
},
// ...
},
});
```
+46
View File
@@ -0,0 +1,46 @@
/**
* 通用文件夹管理组件库
*
* 提供可复用的文件夹管理 UI 组件,适用于各种需要文件夹组织功能的场景
* 如:persona 管理、模板管理、知识库管理等
*
* 使用示例:
* ```vue
* <script setup>
* import {
* BaseFolderTree,
* BaseFolderCard,
* BaseFolderBreadcrumb,
* BaseCreateFolderDialog,
* BaseMoveToFolderDialog,
* useFolderManager
* } from '@/components/folder';
*
* const folderManager = useFolderManager({
* operations: {
* loadFolderTree: async () => { ... },
* loadSubFolders: async (parentId) => { ... },
* createFolder: async (data) => { ... },
* updateFolder: async (data) => { ... },
* deleteFolder: async (folderId) => { ... },
* }
* });
* </script>
* ```
*/
// 类型导出
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';
+200
View File
@@ -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<FolderTreeNode[]>;
// 加载指定文件夹的子文件夹
loadSubFolders: (parentId: string | null) => Promise<Folder[]>;
// 创建文件夹
createFolder: (data: CreateFolderData) => Promise<Folder>;
// 更新文件夹
updateFolder: (data: UpdateFolderData) => Promise<void>;
// 删除文件夹
deleteFolder: (folderId: string) => Promise<void>;
// 移动文件夹
moveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
}
/**
* 创建文件夹数据
*/
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, any>) => string;
// i18n 键配置
i18nKeys?: FolderI18nKeys;
}
@@ -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<FolderTreeNode[]>;
currentFolderId: Ref<string | null>;
currentFolders: Ref<Folder[]>;
breadcrumbPath: Ref<FolderTreeNode[]>;
expandedFolderIds: Ref<string[]>;
loading: Ref<boolean>;
treeLoading: Ref<boolean>;
// 计算属性
currentFolderName: ComputedRef<string>;
breadcrumbItems: ComputedRef<BreadcrumbItem[]>;
// 方法
loadFolderTree: () => Promise<void>;
navigateToFolder: (folderId: string | null) => Promise<void>;
refreshCurrentFolder: () => Promise<void>;
createFolder: (data: CreateFolderData) => Promise<Folder>;
updateFolder: (data: UpdateFolderData) => Promise<void>;
deleteFolder: (folderId: string) => Promise<void>;
moveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
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<FolderTreeNode[]>([]);
const currentFolderId = ref<string | null>(null);
const currentFolders = ref<Folder[]>([]);
const breadcrumbPath = ref<FolderTreeNode[]>([]);
const expandedFolderIds = ref<string[]>([]);
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<void> => {
treeLoading.value = true;
try {
folderTree.value = await operations.loadFolderTree();
} finally {
treeLoading.value = false;
}
};
const navigateToFolder = async (folderId: string | null): Promise<void> => {
loading.value = true;
try {
currentFolderId.value = folderId;
currentFolders.value = await operations.loadSubFolders(folderId);
updateBreadcrumb(folderId);
} finally {
loading.value = false;
}
};
const refreshCurrentFolder = async (): Promise<void> => {
await navigateToFolder(currentFolderId.value);
};
const createFolder = async (data: CreateFolderData): Promise<Folder> => {
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<void> => {
await operations.updateFolder(data);
await Promise.all([refreshCurrentFolder(), loadFolderTree()]);
};
const deleteFolder = async (folderId: string): Promise<void> => {
await operations.deleteFolder(folderId);
await Promise.all([refreshCurrentFolder(), loadFolderTree()]);
};
const moveFolder = async (folderId: string, targetParentId: string | null): Promise<void> => {
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;
+1 -1
View File
@@ -21,7 +21,7 @@
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import PersonaManager from '@/components/persona/PersonaManager.vue';
import { PersonaManager } from '@/views/persona';
export default {
name: 'PersonaPage',
@@ -0,0 +1,77 @@
<template>
<BaseCreateFolderDialog v-model="showDialog" :parent-folder-id="parentFolderId" :labels="labels"
@create="handleCreate" ref="baseDialog" />
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapActions } from 'pinia';
import BaseCreateFolderDialog from '@/components/folder/BaseCreateFolderDialog.vue';
import type { CreateFolderData } from '@/components/folder/types';
export default defineComponent({
name: 'CreateFolderDialog',
components: {
BaseCreateFolderDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
parentFolderId: {
type: String as PropType<string | null>,
default: null
}
},
emits: ['update:modelValue', 'created', 'error'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
computed: {
showDialog: {
get(): boolean {
return this.modelValue;
},
set(value: boolean) {
this.$emit('update:modelValue', value);
}
},
labels() {
return {
title: this.tm('folder.createDialog.title'),
nameLabel: this.tm('folder.form.name'),
descriptionLabel: this.tm('folder.form.description'),
nameRequired: this.tm('folder.validation.nameRequired'),
cancelButton: this.tm('buttons.cancel'),
createButton: this.tm('buttons.create')
};
}
},
methods: {
...mapActions(usePersonaStore, ['createFolder']),
async handleCreate(data: CreateFolderData) {
const baseDialog = this.$refs.baseDialog as InstanceType<typeof BaseCreateFolderDialog>;
baseDialog.setLoading(true);
try {
await this.createFolder({
name: data.name,
description: data.description,
parent_id: data.parent_id
});
this.$emit('created', this.tm('folder.messages.createSuccess'));
this.showDialog = false;
} catch (error: any) {
this.$emit('error', error.message || this.tm('folder.messages.createError'));
} finally {
baseDialog.setLoading(false);
}
}
}
});
</script>
@@ -0,0 +1,87 @@
<template>
<v-breadcrumbs :items="breadcrumbItems" class="folder-breadcrumb pa-0">
<template v-slot:prepend>
<v-icon size="small" class="mr-1">mdi-folder-outline</v-icon>
</template>
<template v-slot:item="{ item }">
<v-breadcrumbs-item :disabled="item.disabled" @click="!item.disabled && handleClick((item as any).folderId)"
:class="{ 'breadcrumb-link': !item.disabled }">
<v-icon v-if="(item as any).isRoot" size="small" class="mr-1">mdi-home</v-icon>
{{ item.title }}
</v-breadcrumbs-item>
</template>
<template v-slot:divider>
<v-icon size="small">mdi-chevron-right</v-icon>
</template>
</v-breadcrumbs>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import type { FolderTreeNode } from '@/components/folder/types';
interface BreadcrumbItem {
title: string;
folderId: string | null;
disabled: boolean;
isRoot: boolean;
}
export default defineComponent({
name: 'FolderBreadcrumb',
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
computed: {
...mapState(usePersonaStore, ['breadcrumbPath', 'currentFolderId']),
breadcrumbItems(): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [
{
title: this.tm('folder.rootFolder'),
folderId: null,
disabled: this.currentFolderId === null,
isRoot: true
}
];
(this.breadcrumbPath as FolderTreeNode[]).forEach((folder, index) => {
items.push({
title: folder.name,
folderId: folder.folder_id,
disabled: index === (this.breadcrumbPath as FolderTreeNode[]).length - 1,
isRoot: false
});
});
return items;
}
},
methods: {
...mapActions(usePersonaStore, ['navigateToFolder']),
handleClick(folderId: string | null) {
this.navigateToFolder(folderId);
}
}
});
</script>
<style scoped>
.folder-breadcrumb {
font-size: 14px;
}
.breadcrumb-link {
cursor: pointer;
transition: color 0.2s;
}
.breadcrumb-link:hover {
color: rgb(var(--v-theme-primary));
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<v-card class="folder-card" :class="{ 'drag-over': isDragOver }" rounded="lg" @click="$emit('click')" @contextmenu.prevent="$emit('contextmenu', $event)"
elevation="1" hover @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
<v-card-text class="d-flex align-center pa-3">
<v-icon size="40" color="amber-darken-2" class="mr-3">mdi-folder</v-icon>
<div class="folder-info flex-grow-1 overflow-hidden">
<div class="text-subtitle-1 font-weight-medium text-truncate">{{ folder.name }}</div>
<div v-if="folder.description" class="text-body-2 text-medium-emphasis text-truncate">
{{ folder.description }}
</div>
</div>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
</template>
<v-list density="compact">
<v-list-item @click.stop="$emit('open')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-open</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.open') }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('rename')">
<template v-slot:prepend>
<v-icon size="small">mdi-pencil</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.rename') }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('move')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.moveTo') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click.stop="$emit('delete')" class="text-error">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { Folder } from '@/components/folder/types';
export default defineComponent({
name: 'FolderCard',
props: {
folder: {
type: Object as PropType<Folder>,
required: true
}
},
emits: ['click', 'contextmenu', 'open', 'rename', 'move', 'delete', 'persona-dropped'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
isDragOver: false
};
},
methods: {
handleDragOver(event: DragEvent) {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
this.isDragOver = true;
},
handleDragLeave() {
this.isDragOver = false;
},
handleDrop(event: DragEvent) {
this.isDragOver = false;
if (!event.dataTransfer) return;
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'));
if (data.type === 'persona') {
this.$emit('persona-dropped', {
persona_id: data.persona_id,
target_folder_id: this.folder.folder_id
});
}
} catch (e) {
console.error('Failed to parse drop data:', e);
}
}
}
});
</script>
<style scoped>
.folder-card {
cursor: pointer;
transition: all 0.2s ease;
}
.folder-card:hover {
transform: translateY(-2px);
}
.folder-card.drag-over {
background-color: rgba(var(--v-theme-primary), 0.15);
border: 2px dashed rgb(var(--v-theme-primary));
transform: scale(1.02);
}
.folder-info {
min-width: 0;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<template>
<div class="folder-tree">
<!-- 搜索框 -->
<v-text-field v-model="searchQuery" :placeholder="tm('folder.searchPlaceholder')" prepend-inner-icon="mdi-magnify"
variant="outlined" density="compact" hide-details clearable class="mb-3" />
<!-- 根目录节点 -->
<v-list density="compact" nav class="tree-list" bg-color="transparent">
<v-list-item :active="currentFolderId === null" @click="handleFolderClick(null)" rounded="lg"
:class="['root-item', { 'drag-over': isRootDragOver }]"
@dragover.prevent="handleRootDragOver" @dragleave="handleRootDragLeave" @drop.prevent="handleRootDrop">
<template v-slot:prepend>
<v-icon>mdi-home</v-icon>
</template>
<v-list-item-title>{{ tm('folder.rootFolder') }}</v-list-item-title>
</v-list-item>
<!-- 文件夹树 -->
<template v-if="!treeLoading">
<FolderTreeNode v-for="folder in filteredFolderTree" :key="folder.folder_id" :folder="folder"
:depth="0" :current-folder-id="currentFolderId" :search-query="searchQuery"
@folder-click="handleFolderClick" @folder-context-menu="handleContextMenu"
@persona-dropped="$emit('persona-dropped', $event)" />
</template>
<!-- 加载状态 -->
<div v-if="treeLoading" class="text-center pa-4">
<v-progress-circular indeterminate size="24" />
</div>
<!-- 空状态 -->
<div v-if="!treeLoading && folderTree.length === 0" class="text-center pa-4 text-medium-emphasis">
<v-icon size="32" class="mb-2">mdi-folder-outline</v-icon>
<div class="text-body-2">{{ tm('folder.noFolders') }}</div>
</div>
</v-list>
<!-- 右键菜单 -->
<v-menu v-model="contextMenu.show" :target="contextMenu.target as any" location="end" :close-on-content-click="true">
<v-list density="compact">
<v-list-item @click="openFolder">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-open</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.open') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="renameFolder">
<template v-slot:prepend>
<v-icon size="small">mdi-pencil</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.rename') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('move-folder', contextMenu.folder)">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.moveTo') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click="confirmDeleteFolder" class="text-error">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>{{ tm('folder.contextMenu.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- 重命名对话框 -->
<v-dialog v-model="renameDialog.show" max-width="400px" persistent>
<v-card>
<v-card-title>{{ tm('folder.renameDialog.title') }}</v-card-title>
<v-card-text>
<v-text-field v-model="renameDialog.name" :label="tm('folder.form.name')"
:rules="[v => !!v || tm('folder.validation.nameRequired')]" variant="outlined"
density="comfortable" autofocus @keyup.enter="submitRename" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="renameDialog.show = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitRename" :loading="renameDialog.loading"
:disabled="!renameDialog.name">
{{ tm('buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteDialog.show" max-width="450px">
<v-card>
<v-card-title class="text-error">
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
{{ tm('folder.deleteDialog.title') }}
</v-card-title>
<v-card-text>
<p>{{ tm('folder.deleteDialog.message', { name: deleteDialog.folder?.name ?? '' }) }}</p>
<p class="text-warning mt-2">
<v-icon size="small" class="mr-1">mdi-information</v-icon>
{{ tm('folder.deleteDialog.warning') }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="error" variant="flat" @click="submitDelete" :loading="deleteDialog.loading">
{{ tm('buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import FolderTreeNode from './FolderTreeNode.vue';
import type { FolderTreeNode as FolderTreeNodeType } from '@/components/folder/types';
interface ContextMenuState {
show: boolean;
target: [number, number] | null;
folder: FolderTreeNodeType | null;
}
interface RenameDialogState {
show: boolean;
folder: FolderTreeNodeType | null;
name: string;
loading: boolean;
}
interface DeleteDialogState {
show: boolean;
folder: FolderTreeNodeType | null;
loading: boolean;
}
export default defineComponent({
name: 'FolderTree',
components: {
FolderTreeNode
},
emits: ['move-folder', 'error', 'success', 'persona-dropped'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
searchQuery: '',
isRootDragOver: false,
contextMenu: {
show: false,
target: null,
folder: null
} as ContextMenuState,
renameDialog: {
show: false,
folder: null,
name: '',
loading: false
} as RenameDialogState,
deleteDialog: {
show: false,
folder: null,
loading: false
} as DeleteDialogState
};
},
computed: {
...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'treeLoading']),
filteredFolderTree(): FolderTreeNodeType[] {
if (!this.searchQuery) {
return this.folderTree as FolderTreeNodeType[];
}
const query = this.searchQuery.toLowerCase();
return this.filterTreeBySearch(this.folderTree as FolderTreeNodeType[], query);
}
},
methods: {
...mapActions(usePersonaStore, ['navigateToFolder', 'updateFolder', 'deleteFolder']),
filterTreeBySearch(nodes: FolderTreeNodeType[], query: string): FolderTreeNodeType[] {
return nodes.filter(node => {
const matches = node.name.toLowerCase().includes(query);
const childMatches = this.filterTreeBySearch(node.children || [], query);
return matches || childMatches.length > 0;
}).map(node => ({
...node,
children: this.filterTreeBySearch(node.children || [], query)
}));
},
handleFolderClick(folderId: string | null) {
this.navigateToFolder(folderId);
},
handleRootDragOver(event: DragEvent) {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
this.isRootDragOver = true;
},
handleRootDragLeave() {
this.isRootDragOver = false;
},
handleRootDrop(event: DragEvent) {
this.isRootDragOver = false;
if (!event.dataTransfer) return;
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'));
if (data.type === 'persona') {
this.$emit('persona-dropped', {
persona_id: data.persona_id,
target_folder_id: null
});
}
} catch (e) {
console.error('Failed to parse drop data:', e);
}
},
handleContextMenu(eventData: { event: MouseEvent; folder: FolderTreeNodeType }) {
this.contextMenu.target = [eventData.event.clientX, eventData.event.clientY];
this.contextMenu.folder = eventData.folder;
this.contextMenu.show = true;
},
openFolder() {
if (this.contextMenu.folder) {
this.navigateToFolder(this.contextMenu.folder.folder_id);
}
},
renameFolder() {
if (this.contextMenu.folder) {
this.renameDialog.folder = this.contextMenu.folder;
this.renameDialog.name = this.contextMenu.folder.name;
this.renameDialog.show = true;
}
},
async submitRename() {
if (!this.renameDialog.name || !this.renameDialog.folder) return;
this.renameDialog.loading = true;
try {
await this.updateFolder({
folder_id: this.renameDialog.folder.folder_id,
name: this.renameDialog.name
});
this.$emit('success', this.tm('folder.messages.renameSuccess'));
this.renameDialog.show = false;
} catch (error: any) {
this.$emit('error', error.message || this.tm('folder.messages.renameError'));
} finally {
this.renameDialog.loading = false;
}
},
confirmDeleteFolder() {
if (this.contextMenu.folder) {
this.deleteDialog.folder = this.contextMenu.folder;
this.deleteDialog.show = true;
}
},
async submitDelete() {
if (!this.deleteDialog.folder) return;
this.deleteDialog.loading = true;
try {
await this.deleteFolder(this.deleteDialog.folder.folder_id);
this.$emit('success', this.tm('folder.messages.deleteSuccess'));
this.deleteDialog.show = false;
} catch (error: any) {
this.$emit('error', error.message || this.tm('folder.messages.deleteError'));
} finally {
this.deleteDialog.loading = false;
}
}
}
});
</script>
<style scoped>
.folder-tree {
height: 100%;
display: flex;
flex-direction: column;
}
.tree-list {
flex: 1;
overflow-y: auto;
}
.root-item {
margin-bottom: 4px;
transition: all 0.2s ease;
}
.root-item.drag-over {
background-color: rgba(var(--v-theme-primary), 0.15);
border: 2px dashed rgb(var(--v-theme-primary));
border-radius: 8px;
}
</style>
@@ -0,0 +1,66 @@
<template>
<BaseFolderTreeNode :folder="folder" :depth="depth" :current-folder-id="currentFolderId"
:search-query="searchQuery" :expanded-folder-ids="expandedFolderIds" :accept-drop-types="['persona']"
@folder-click="$emit('folder-click', $event)"
@folder-context-menu="handleContextMenu"
@item-dropped="handleItemDropped"
@toggle-expansion="toggleFolderExpansion"
@set-expansion="handleSetExpansion" />
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import BaseFolderTreeNode from '@/components/folder/BaseFolderTreeNode.vue';
import type { FolderTreeNode as FolderTreeNodeType } from '@/components/folder/types';
export default defineComponent({
name: 'FolderTreeNode',
components: {
BaseFolderTreeNode
},
props: {
folder: {
type: Object as PropType<FolderTreeNodeType>,
required: true
},
depth: {
type: Number,
default: 0
},
currentFolderId: {
type: String as PropType<string | null>,
default: null
},
searchQuery: {
type: String,
default: ''
}
},
emits: ['folder-click', 'folder-context-menu', 'persona-dropped'],
computed: {
...mapState(usePersonaStore, ['expandedFolderIds'])
},
methods: {
...mapActions(usePersonaStore, ['toggleFolderExpansion', 'setFolderExpansion']),
handleContextMenu(event: { event: MouseEvent; folder: FolderTreeNodeType }) {
this.$emit('folder-context-menu', event);
},
handleItemDropped(data: { item_id: string; item_type: string; target_folder_id: string | null; source_data: any }) {
if (data.item_type === 'persona') {
this.$emit('persona-dropped', {
persona_id: data.item_id,
target_folder_id: data.target_folder_id
});
}
},
handleSetExpansion(data: { folderId: string; expanded: boolean }) {
this.setFolderExpansion(data.folderId, data.expanded);
}
}
});
</script>
@@ -0,0 +1,36 @@
<template>
<BaseMoveTargetNode :folder="folder" :depth="depth" :selected-folder-id="selectedFolderId"
:disabled-folder-ids="disabledFolderIds" @select="$emit('select', $event)" />
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import BaseMoveTargetNode from '@/components/folder/BaseMoveTargetNode.vue';
import type { FolderTreeNode } from '@/components/folder/types';
export default defineComponent({
name: 'MoveTargetNode',
components: {
BaseMoveTargetNode
},
props: {
folder: {
type: Object as PropType<FolderTreeNode>,
required: true
},
depth: {
type: Number,
default: 0
},
selectedFolderId: {
type: String as PropType<string | null>,
default: null
},
disabledFolderIds: {
type: Array as PropType<string[]>,
default: () => []
}
},
emits: ['select']
});
</script>
@@ -0,0 +1,201 @@
<template>
<v-dialog v-model="showDialog" max-width="500px" persistent>
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-folder-move</v-icon>
{{ tm('moveDialog.title') }}
</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
{{ tm('moveDialog.description', { name: itemName }) }}
</p>
<!-- 文件夹选择树 -->
<div class="folder-select-tree">
<v-list density="compact" nav class="tree-list">
<!-- 根目录选项 -->
<v-list-item :active="selectedFolderId === null" @click="selectFolder(null)" rounded="lg"
class="mb-1">
<template v-slot:prepend>
<v-icon>mdi-home</v-icon>
</template>
<v-list-item-title>{{ tm('folder.rootFolder') }}</v-list-item-title>
</v-list-item>
<!-- 文件夹树 -->
<template v-if="!treeLoading">
<MoveTargetNode v-for="folder in availableFolders" :key="folder.folder_id" :folder="folder"
:depth="0" :selected-folder-id="selectedFolderId" :disabled-folder-ids="disabledFolderIds"
@select="selectFolder" />
</template>
<!-- 加载状态 -->
<div v-if="treeLoading" class="text-center pa-4">
<v-progress-circular indeterminate size="24" />
</div>
</v-list>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="closeDialog">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitMove" :loading="loading">
{{ tm('buttons.move') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import MoveTargetNode from './MoveTargetNode.vue';
import { collectFolderAndChildrenIds } from '@/components/folder/useFolderManager';
import type { FolderTreeNode } from '@/components/folder/types';
interface PersonaItem {
persona_id: string;
folder_id?: string | null;
[key: string]: any;
}
interface FolderItem {
folder_id: string;
name: string;
parent_id?: string | null;
[key: string]: any;
}
export default defineComponent({
name: 'MoveToFolderDialog',
components: {
MoveTargetNode
},
props: {
modelValue: {
type: Boolean,
default: false
},
itemType: {
type: String as PropType<'persona' | 'folder'>,
required: true
},
item: {
type: Object as PropType<PersonaItem | FolderItem | null>,
default: null
}
},
emits: ['update:modelValue', 'moved', 'error'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
selectedFolderId: null as string | null,
loading: false
};
},
computed: {
...mapState(usePersonaStore, ['folderTree', 'treeLoading']),
showDialog: {
get(): boolean {
return this.modelValue;
},
set(value: boolean) {
this.$emit('update:modelValue', value);
}
},
itemName(): string {
if (!this.item) return '';
return this.itemType === 'persona'
? (this.item as PersonaItem).persona_id
: (this.item as FolderItem).name;
},
// 禁用的文件夹 ID(不能移动到自己或子文件夹)
disabledFolderIds(): string[] {
if (this.itemType !== 'folder' || !this.item) return [];
return collectFolderAndChildrenIds(
this.folderTree as FolderTreeNode[],
(this.item as FolderItem).folder_id
);
},
// 过滤掉禁用的文件夹
availableFolders(): FolderTreeNode[] {
return this.folderTree as FolderTreeNode[];
}
},
watch: {
modelValue(newValue: boolean) {
if (newValue) {
// 初始化选中为当前所在文件夹
if (this.item) {
this.selectedFolderId = this.itemType === 'persona'
? (this.item as PersonaItem).folder_id ?? null
: (this.item as FolderItem).parent_id ?? null;
}
}
}
},
methods: {
...mapActions(usePersonaStore, ['movePersonaToFolder', 'moveFolderToFolder']),
selectFolder(folderId: string | null) {
// 检查是否禁用
if (folderId && this.disabledFolderIds.includes(folderId)) return;
this.selectedFolderId = folderId;
},
closeDialog() {
this.showDialog = false;
},
async submitMove() {
if (!this.item) return;
this.loading = true;
try {
if (this.itemType === 'persona') {
await this.movePersonaToFolder(
(this.item as PersonaItem).persona_id,
this.selectedFolderId
);
} else {
await this.moveFolderToFolder(
(this.item as FolderItem).folder_id,
this.selectedFolderId
);
}
this.$emit('moved', this.tm('moveDialog.success'));
this.closeDialog();
} catch (error: any) {
this.$emit('error', error.message || this.tm('moveDialog.error'));
} finally {
this.loading = false;
}
}
}
});
</script>
<style scoped>
.folder-select-tree {
max-height: 400px;
overflow-y: auto;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
}
.tree-list {
padding: 8px;
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<v-card class="persona-card" :class="{ 'dragging': isDragging }" rounded="lg" @click="$emit('view')" elevation="1" hover
draggable="true" @dragstart="handleDragStart" @dragend="handleDragEnd">
<v-card-title class="d-flex justify-space-between align-center">
<div class="text-truncate ml-2">{{ persona.persona_id }}</div>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
</template>
<v-list density="compact">
<v-list-item @click.stop="$emit('edit')">
<template v-slot:prepend>
<v-icon size="small">mdi-pencil</v-icon>
</template>
<v-list-item-title>{{ tm('buttons.edit') }}</v-list-item-title>
</v-list-item>
<v-list-item @click.stop="$emit('move')">
<template v-slot:prepend>
<v-icon size="small">mdi-folder-move</v-icon>
</template>
<v-list-item-title>{{ tm('persona.contextMenu.moveTo') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item @click.stop="$emit('delete')" class="text-error">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>{{ tm('buttons.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
<v-card-text>
<div class="system-prompt-preview">
{{ truncateText(persona.system_prompt, 100) }}
</div>
<div class="mt-3 d-flex flex-wrap ga-1">
<v-chip v-if="persona.begin_dialogs && persona.begin_dialogs.length > 0" size="small" color="secondary"
variant="tonal" prepend-icon="mdi-chat">
{{ tm('labels.presetDialogs', { count: persona.begin_dialogs.length / 2 }) }}
</v-chip>
<v-chip v-if="persona.tools === null" size="small" color="success" variant="tonal"
prepend-icon="mdi-tools">
{{ tm('form.allToolsAvailable') }}
</v-chip>
<v-chip v-else-if="persona.tools && persona.tools.length > 0" size="small" color="primary" variant="tonal"
prepend-icon="mdi-tools">
{{ persona.tools.length }} {{ tm('persona.toolsCount') }}
</v-chip>
</div>
<div class="mt-3 text-caption text-medium-emphasis">
{{ tm('labels.createdAt') }}: {{ formatDate(persona.created_at) }}
</div>
</v-card-text>
</v-card>
<!-- Custom Drag Preview -->
<div ref="dragPreview" class="drag-preview">
<v-icon size="small" class="mr-2">mdi-account</v-icon>
<span class="text-subtitle-2">{{ persona.persona_id }}</span>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
interface Persona {
persona_id: string;
system_prompt: string;
begin_dialogs?: string[] | null;
tools?: string[] | null;
created_at?: string;
updated_at?: string;
folder_id?: string | null;
[key: string]: any;
}
export default defineComponent({
name: 'PersonaCard',
props: {
persona: {
type: Object as PropType<Persona>,
required: true
}
},
emits: ['view', 'edit', 'move', 'delete'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
isDragging: false
};
},
methods: {
handleDragStart(event: DragEvent) {
this.isDragging = true;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('application/json', JSON.stringify({
type: 'persona',
persona_id: this.persona.persona_id,
persona: this.persona
}));
// Set custom drag image
const dragPreview = this.$refs.dragPreview as HTMLElement;
if (dragPreview) {
event.dataTransfer.setDragImage(dragPreview, 15, 15);
}
}
},
handleDragEnd() {
this.isDragging = false;
},
truncateText(text: string | undefined | null, maxLength: number): string {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
},
formatDate(dateString: string | undefined | null): string {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
}
}
});
</script>
<style scoped>
.persona-card {
height: 100%;
cursor: grab;
transition: all 0.2s ease;
}
.persona-card:active {
cursor: grabbing;
}
.persona-card.dragging {
opacity: 0.5;
transform: scale(0.95);
}
.persona-card:hover {
transform: translateY(-2px);
}
.system-prompt-preview {
font-size: 14px;
line-height: 1.4;
color: rgba(var(--v-theme-on-surface), 0.7);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
}
.drag-preview {
position: fixed;
top: -1000px;
left: -1000px;
background: rgb(var(--v-theme-surface));
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 9999;
pointer-events: none;
}
</style>
@@ -0,0 +1,501 @@
<template>
<div class="persona-manager">
<!-- 移动端顶部导航 -->
<div class="mobile-nav d-md-none mb-4">
<FolderBreadcrumb />
</div>
<div class="manager-layout">
<!-- 左侧边栏 - 仅桌面端显示 -->
<div class="sidebar d-none d-md-block">
<div class="sidebar-header d-flex justify-space-between align-center mb-3">
<h3 class="text-h6">{{ tm('folder.sidebarTitle') }}</h3>
<v-btn icon="mdi-folder-plus" variant="text" size="small" @click="showCreateFolderDialog = true"
:title="tm('folder.createButton')" />
</div>
<FolderTree @move-folder="openMoveFolderDialog" @success="showSuccess" @error="showError"
@persona-dropped="handlePersonaDropped" />
</div>
<!-- 主内容区 -->
<div class="main-content">
<!-- 顶部工具栏 -->
<div class="toolbar d-flex flex-wrap justify-space-between align-center mb-4 ga-2">
<!-- 面包屑 - 仅桌面端显示 -->
<div class="d-none d-md-block">
<FolderBreadcrumb />
</div>
<!-- 操作按钮组 -->
<div class="d-flex ga-2">
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="openCreatePersonaDialog"
rounded="lg">
{{ tm('buttons.create') }}
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-folder-plus" @click="showCreateFolderDialog = true"
rounded="lg">
{{ tm('folder.createButton') }}
</v-btn>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<v-row>
<v-col v-for="n in 6" :key="n" cols="12" sm="6" lg="4" xl="3">
<v-skeleton-loader type="card" rounded="lg" />
</v-col>
</v-row>
</div>
<!-- 内容区域 -->
<div v-else>
<!-- 子文件夹区域 -->
<div v-if="currentFolders.length > 0" class="folders-section mb-6">
<h3 class="text-subtitle-1 font-weight-medium mb-3">
<v-icon size="small" class="mr-1">mdi-folder</v-icon>
{{ tm('folder.foldersTitle') }} ({{ currentFolders.length }})
</h3>
<v-row>
<v-col v-for="folder in currentFolders" :key="folder.folder_id" cols="12" sm="6" lg="4"
xl="3">
<FolderCard :folder="folder" @click="navigateToFolder(folder.folder_id)"
@open="navigateToFolder(folder.folder_id)" @rename="openRenameFolderDialog(folder)"
@move="openMoveFolderDialog(folder)" @delete="confirmDeleteFolder(folder)"
@persona-dropped="handlePersonaDropped" />
</v-col>
</v-row>
</div>
<!-- Persona 区域 -->
<div v-if="currentPersonas.length > 0" class="personas-section">
<h3 class="text-subtitle-1 font-weight-medium mb-3">
<v-icon size="small" class="mr-1">mdi-account-heart</v-icon>
{{ tm('persona.personasTitle') }} ({{ currentPersonas.length }})
</h3>
<v-row>
<v-col v-for="persona in currentPersonas" :key="persona.persona_id" cols="12" sm="6" lg="4"
xl="3">
<PersonaCard :persona="persona" @view="viewPersona(persona)"
@edit="editPersona(persona)" @move="openMovePersonaDialog(persona)"
@delete="confirmDeletePersona(persona)" />
</v-col>
</v-row>
</div>
<!-- 空状态 -->
<div v-if="currentFolders.length === 0 && currentPersonas.length === 0" class="empty-state">
<v-card class="text-center pa-8" elevation="0">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-folder-open-outline</v-icon>
<h3 class="text-h5 mb-2">{{ tm('empty.folderEmpty') }}</h3>
<p class="text-body-1 text-medium-emphasis mb-4">{{ tm('empty.folderEmptyDescription') }}</p>
<div class="d-flex justify-center ga-2">
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus"
@click="openCreatePersonaDialog">
{{ tm('buttons.create') }}
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-folder-plus"
@click="showCreateFolderDialog = true">
{{ tm('folder.createButton') }}
</v-btn>
</div>
</v-card>
</div>
</div>
</div>
</div>
<!-- 创建/编辑 Persona 对话框 -->
<PersonaForm v-model="showPersonaDialog" :editing-persona="editingPersona ?? undefined"
:current-folder-id="currentFolderId ?? undefined" @saved="handlePersonaSaved" @error="showError" />
<!-- 查看 Persona 详情对话框 -->
<v-dialog v-model="showViewDialog" max-width="700px">
<v-card v-if="viewingPersona">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ viewingPersona.persona_id }}</span>
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
</v-card-title>
<v-card-text>
<div class="mb-4">
<h4 class="text-h6 mb-2">{{ tm('form.systemPrompt') }}</h4>
<pre class="system-prompt-content">{{ viewingPersona.system_prompt }}</pre>
</div>
<div v-if="viewingPersona.begin_dialogs && viewingPersona.begin_dialogs.length > 0" class="mb-4">
<h4 class="text-h6 mb-2">{{ tm('form.presetDialogs') }}</h4>
<div v-for="(dialog, index) in viewingPersona.begin_dialogs" :key="index" class="mb-2">
<v-chip :color="index % 2 === 0 ? 'primary' : 'secondary'" variant="tonal" size="small"
class="mb-1">
{{ index % 2 === 0 ? tm('form.userMessage') : tm('form.assistantMessage') }}
</v-chip>
<div class="dialog-content ml-2">{{ dialog }}</div>
</div>
</div>
<div class="mb-4">
<h4 class="text-h6 mb-2">{{ tm('form.tools') }}</h4>
<div v-if="viewingPersona.tools === null" class="text-body-2 text-medium-emphasis">
<v-chip size="small" color="success" variant="tonal" prepend-icon="mdi-check-all">
{{ tm('form.allToolsAvailable') }}
</v-chip>
</div>
<div v-else-if="viewingPersona.tools && viewingPersona.tools.length > 0"
class="d-flex flex-wrap ga-1">
<v-chip v-for="toolName in viewingPersona.tools" :key="toolName" size="small"
color="primary" variant="tonal">
{{ toolName }}
</v-chip>
</div>
<div v-else class="text-body-2 text-medium-emphasis">
{{ tm('form.noToolsSelected') }}
</div>
</div>
<div class="text-caption text-medium-emphasis">
<div>{{ tm('labels.createdAt') }}: {{ formatDate(viewingPersona.created_at) }}</div>
<div v-if="viewingPersona.updated_at">{{ tm('labels.updatedAt') }}:
{{ formatDate(viewingPersona.updated_at) }}</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- 创建文件夹对话框 -->
<CreateFolderDialog v-model="showCreateFolderDialog" :parent-folder-id="currentFolderId"
@created="showSuccess" @error="showError" />
<!-- 重命名文件夹对话框 -->
<v-dialog v-model="showRenameFolderDialog" max-width="400px" persistent>
<v-card>
<v-card-title>{{ tm('folder.renameDialog.title') }}</v-card-title>
<v-card-text>
<v-text-field v-model="renameFolderData.name" :label="tm('folder.form.name')"
:rules="[v => !!v || tm('folder.validation.nameRequired')]" variant="outlined"
density="comfortable" autofocus @keyup.enter="submitRenameFolder" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRenameFolderDialog = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitRenameFolder" :loading="renameLoading"
:disabled="!renameFolderData.name">
{{ tm('buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 移动对话框 -->
<MoveToFolderDialog v-model="showMoveDialog" :item-type="moveDialogType" :item="moveDialogItem"
@moved="showSuccess" @error="showError" />
<!-- 删除文件夹确认对话框 -->
<v-dialog v-model="showDeleteFolderDialog" max-width="450px">
<v-card>
<v-card-title class="text-error">
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
{{ tm('folder.deleteDialog.title') }}
</v-card-title>
<v-card-text>
<p>{{ tm('folder.deleteDialog.message', { name: deleteFolderData?.name ?? '' }) }}</p>
<p class="text-warning mt-2">
<v-icon size="small" class="mr-1">mdi-information</v-icon>
{{ tm('folder.deleteDialog.warning') }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteFolderDialog = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="error" variant="flat" @click="submitDeleteFolder" :loading="deleteLoading">
{{ tm('buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
{{ message }}
</v-snackbar>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import FolderTree from './FolderTree.vue';
import FolderBreadcrumb from './FolderBreadcrumb.vue';
import FolderCard from './FolderCard.vue';
import PersonaCard from './PersonaCard.vue';
import PersonaForm from '@/components/shared/PersonaForm.vue';
import CreateFolderDialog from './CreateFolderDialog.vue';
import MoveToFolderDialog from './MoveToFolderDialog.vue';
import type { Folder, FolderTreeNode } from '@/components/folder/types';
interface Persona {
persona_id: string;
system_prompt: string;
begin_dialogs?: string[] | null;
tools?: string[] | null;
created_at?: string;
updated_at?: string;
folder_id?: string | null;
[key: string]: any;
}
interface RenameFolderData {
folder: Folder | null;
name: string;
}
export default defineComponent({
name: 'PersonaManager',
components: {
FolderTree,
FolderBreadcrumb,
FolderCard,
PersonaCard,
PersonaForm,
CreateFolderDialog,
MoveToFolderDialog
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/persona');
return { t, tm };
},
data() {
return {
// Persona 相关
showPersonaDialog: false,
showViewDialog: false,
editingPersona: null as Persona | null,
viewingPersona: null as Persona | null,
// 文件夹相关
showCreateFolderDialog: false,
showRenameFolderDialog: false,
showDeleteFolderDialog: false,
renameFolderData: { folder: null, name: '' } as RenameFolderData,
deleteFolderData: null as Folder | null,
renameLoading: false,
deleteLoading: false,
// 移动对话框
showMoveDialog: false,
moveDialogType: 'persona' as 'persona' | 'folder',
moveDialogItem: null as Persona | Folder | null,
// 消息提示
showMessage: false,
message: '',
messageType: 'success' as 'success' | 'error'
};
},
computed: {
...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'currentFolders', 'currentPersonas', 'loading'])
},
async mounted() {
await this.initialize();
},
methods: {
...mapActions(usePersonaStore, ['loadFolderTree', 'navigateToFolder', 'updateFolder', 'deleteFolder', 'deletePersona', 'refreshCurrentFolder', 'movePersonaToFolder']),
async initialize() {
await Promise.all([
this.loadFolderTree(),
this.navigateToFolder(null)
]);
},
// Persona 操作
openCreatePersonaDialog() {
this.editingPersona = null;
this.showPersonaDialog = true;
},
editPersona(persona: Persona) {
this.editingPersona = persona;
this.showPersonaDialog = true;
},
viewPersona(persona: Persona) {
this.viewingPersona = persona;
this.showViewDialog = true;
},
handlePersonaSaved(message: string) {
this.showSuccess(message);
this.refreshCurrentFolder();
},
async confirmDeletePersona(persona: Persona) {
if (!confirm(this.tm('messages.deleteConfirm', { id: persona.persona_id }))) {
return;
}
try {
await this.deletePersona(persona.persona_id);
this.showSuccess(this.tm('messages.deleteSuccess'));
} catch (error: any) {
this.showError(error.message || this.tm('messages.deleteError'));
}
},
openMovePersonaDialog(persona: Persona) {
this.moveDialogType = 'persona';
this.moveDialogItem = persona;
this.showMoveDialog = true;
},
async handlePersonaDropped({ persona_id, target_folder_id }: { persona_id: string; target_folder_id: string | null }) {
try {
await this.movePersonaToFolder(persona_id, target_folder_id);
this.showSuccess(this.tm('persona.messages.moveSuccess'));
// Navigate to the target folder
await this.navigateToFolder(target_folder_id);
} catch (error: any) {
this.showError(error.message || this.tm('persona.messages.moveError'));
}
},
// 文件夹操作
openRenameFolderDialog(folder: Folder) {
this.renameFolderData = { folder, name: folder.name };
this.showRenameFolderDialog = true;
},
async submitRenameFolder() {
if (!this.renameFolderData.name || !this.renameFolderData.folder) return;
this.renameLoading = true;
try {
await this.updateFolder({
folder_id: this.renameFolderData.folder.folder_id,
name: this.renameFolderData.name
});
this.showSuccess(this.tm('folder.messages.renameSuccess'));
this.showRenameFolderDialog = false;
} catch (error: any) {
this.showError(error.message || this.tm('folder.messages.renameError'));
} finally {
this.renameLoading = false;
}
},
openMoveFolderDialog(folder: Folder) {
this.moveDialogType = 'folder';
this.moveDialogItem = folder;
this.showMoveDialog = true;
},
confirmDeleteFolder(folder: Folder) {
this.deleteFolderData = folder;
this.showDeleteFolderDialog = true;
},
async submitDeleteFolder() {
if (!this.deleteFolderData) return;
this.deleteLoading = true;
try {
await this.deleteFolder(this.deleteFolderData.folder_id);
this.showSuccess(this.tm('folder.messages.deleteSuccess'));
this.showDeleteFolderDialog = false;
} catch (error: any) {
this.showError(error.message || this.tm('folder.messages.deleteError'));
} finally {
this.deleteLoading = false;
}
},
// 辅助方法
formatDate(dateString: string | undefined | null): string {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
},
showSuccess(message: string) {
this.message = message;
this.messageType = 'success';
this.showMessage = true;
},
showError(message: string) {
this.message = message;
this.messageType = 'error';
this.showMessage = true;
}
}
});
</script>
<style scoped>
.persona-manager {
height: 100%;
}
.manager-layout {
display: flex;
gap: 24px;
height: 100%;
}
.sidebar {
width: 280px;
flex-shrink: 0;
padding-right: 16px;
height: fit-content;
max-height: calc(100vh - 200px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
min-width: 0;
}
.system-prompt-content {
max-height: 400px;
overflow: auto;
padding: 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
background: rgba(var(--v-theme-surface-variant), 0.3);
}
.dialog-content {
background-color: rgba(var(--v-theme-surface-variant), 0.3);
padding: 8px 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
margin-bottom: 8px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 960px) {
.manager-layout {
flex-direction: column;
}
.sidebar {
display: none;
}
}
</style>
+23
View File
@@ -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';