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:
@@ -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>
|
||||
@@ -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() {
|
||||
// ...
|
||||
},
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user