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>
|
<script>
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
import PersonaManager from '@/components/persona/PersonaManager.vue';
|
import { PersonaManager } from '@/views/persona';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PersonaPage',
|
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