feat(dashboard): implement persona folder management UI

- Add folder management system with tree view and breadcrumbs
- Implement create, rename, delete, and move operations for folders
- Add drag-and-drop support for organizing personas and folders
- Create new PersonaManager component and Pinia store for state management
- Refactor PersonaPage to support hierarchical structure
- Update locale files with folder-related translations
- Handle empty parent_id correctly in backend route
This commit is contained in:
RC-CHN
2026-01-12 14:33:38 +08:00
parent 27e1a72a9b
commit fd5dc3c54b
15 changed files with 2118 additions and 290 deletions
+3
View File
@@ -254,6 +254,9 @@ class PersonaRoute(Route):
"""获取文件夹列表"""
try:
parent_id = request.args.get("parent_id")
# 空字符串视为 None(根目录)
if parent_id == "":
parent_id = None
folders = await self.persona_mgr.get_folders(parent_id)
return (
Response()
@@ -0,0 +1,117 @@
<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>
{{ tm('folder.createDialog.title') }}
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="formValid">
<v-text-field v-model="formData.name" :label="tm('folder.form.name')"
:rules="[v => !!v || tm('folder.validation.nameRequired')]" variant="outlined"
density="comfortable" autofocus class="mb-3" />
<v-textarea v-model="formData.description" :label="tm('folder.form.description')" variant="outlined"
rows="3" density="comfortable" hide-details />
</v-form>
</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="submitForm" :loading="loading" :disabled="!formValid">
{{ tm('buttons.create') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapActions } from 'pinia';
export default {
name: 'CreateFolderDialog',
props: {
modelValue: {
type: Boolean,
default: false
},
parentFolderId: {
type: String,
default: null
}
},
emits: ['update:modelValue', 'created', 'error'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
formValid: false,
loading: false,
formData: {
name: '',
description: ''
}
};
},
computed: {
showDialog: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
}
},
watch: {
modelValue(newValue) {
if (newValue) {
this.resetForm();
}
}
},
methods: {
...mapActions(usePersonaStore, ['createFolder']),
resetForm() {
this.formData = {
name: '',
description: ''
};
if (this.$refs.form) {
this.$refs.form.resetValidation();
}
},
closeDialog() {
this.showDialog = false;
},
async submitForm() {
if (!this.formValid) return;
this.loading = true;
try {
await this.createFolder({
name: this.formData.name,
description: this.formData.description || undefined,
parent_id: this.parentFolderId
});
this.$emit('created', this.tm('folder.messages.createSuccess'));
this.closeDialog();
} catch (error) {
this.$emit('error', error.message || this.tm('folder.messages.createError'));
} finally {
this.loading = false;
}
}
}
};
</script>
@@ -0,0 +1,78 @@
<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.folderId)"
:class="{ 'breadcrumb-link': !item.disabled }">
<v-icon v-if="item.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>
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
export default {
name: 'FolderBreadcrumb',
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
computed: {
...mapState(usePersonaStore, ['breadcrumbPath', 'currentFolderId']),
breadcrumbItems() {
const items = [
{
title: this.tm('folder.rootFolder'),
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: {
...mapActions(usePersonaStore, ['navigateToFolder']),
handleClick(folderId) {
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,114 @@
<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>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'FolderCard',
props: {
folder: {
type: Object,
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) {
event.dataTransfer.dropEffect = 'move';
this.isDragOver = true;
},
handleDragLeave() {
this.isDragOver = false;
},
handleDrop(event) {
this.isDragOver = false;
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,295 @@
<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" 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>
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import FolderTreeNode from './FolderTreeNode.vue';
export default {
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
},
renameDialog: {
show: false,
folder: null,
name: '',
loading: false
},
deleteDialog: {
show: false,
folder: null,
loading: false
}
};
},
computed: {
...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'treeLoading']),
filteredFolderTree() {
if (!this.searchQuery) {
return this.folderTree;
}
const query = this.searchQuery.toLowerCase();
return this.filterTreeBySearch(this.folderTree, query);
}
},
methods: {
...mapActions(usePersonaStore, ['navigateToFolder', 'updateFolder', 'deleteFolder']),
filterTreeBySearch(nodes, query) {
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) {
this.navigateToFolder(folderId);
},
handleRootDragOver(event) {
event.dataTransfer.dropEffect = 'move';
this.isRootDragOver = true;
},
handleRootDragLeave() {
this.isRootDragOver = false;
},
handleRootDrop(event) {
this.isRootDragOver = false;
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(event, folder) {
this.contextMenu.target = [event.clientX, event.clientY];
this.contextMenu.folder = 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) {
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) {
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,133 @@
<template>
<div class="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">
<FolderTreeNode v-for="child in folder.children" :key="child.folder_id" :folder="child" :depth="depth + 1"
:current-folder-id="currentFolderId" :search-query="searchQuery"
@folder-click="$emit('folder-click', $event)"
@folder-context-menu="$emit('folder-context-menu', $event.event, $event.folder)"
@persona-dropped="$emit('persona-dropped', $event)" />
</div>
</v-expand-transition>
</div>
</template>
<script>
export default {
name: 'FolderTreeNode',
props: {
folder: {
type: Object,
required: true
},
depth: {
type: Number,
default: 0
},
currentFolderId: {
type: String,
default: null
},
searchQuery: {
type: String,
default: ''
}
},
emits: ['folder-click', 'folder-context-menu', 'persona-dropped'],
data() {
return {
isExpanded: false,
isDragOver: false
};
},
computed: {
hasChildren() {
return this.folder.children && this.folder.children.length > 0;
}
},
watch: {
searchQuery: {
immediate: true,
handler(newQuery) {
// 搜索时自动展开匹配的节点
if (newQuery && this.hasChildren) {
this.isExpanded = true;
}
}
}
},
methods: {
toggleExpand() {
this.isExpanded = !this.isExpanded;
},
handleContextMenu(event) {
this.$emit('folder-context-menu', { event, folder: this.folder });
},
handleDragOver(event) {
event.dataTransfer.dropEffect = 'move';
this.isDragOver = true;
},
handleDragLeave() {
this.isDragOver = false;
},
handleDrop(event) {
this.isDragOver = false;
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-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,90 @@
<template>
<div class="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">
<MoveTargetNode 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>
export default {
name: 'MoveTargetNode',
props: {
folder: {
type: Object,
required: true
},
depth: {
type: Number,
default: 0
},
selectedFolderId: {
type: String,
default: null
},
disabledFolderIds: {
type: Array,
default: () => []
}
},
emits: ['select'],
data() {
return {
isExpanded: true
};
},
computed: {
hasChildren() {
return this.folder.children && this.folder.children.length > 0;
},
isDisabled() {
return this.disabledFolderIds.includes(this.folder.folder_id);
}
},
methods: {
toggleExpand() {
this.isExpanded = !this.isExpanded;
}
}
};
</script>
<style scoped>
.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,198 @@
<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>
import { useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
import MoveTargetNode from './MoveTargetNode.vue';
export default {
name: 'MoveToFolderDialog',
components: {
MoveTargetNode
},
props: {
modelValue: {
type: Boolean,
default: false
},
itemType: {
type: String, // 'persona' or 'folder'
required: true
},
item: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'moved', 'error'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
selectedFolderId: null,
loading: false
};
},
computed: {
...mapState(usePersonaStore, ['folderTree', 'treeLoading']),
showDialog: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
},
itemName() {
if (!this.item) return '';
return this.itemType === 'persona' ? this.item.persona_id : this.item.name;
},
// 禁用的文件夹 ID(不能移动到自己或子文件夹)
disabledFolderIds() {
if (this.itemType !== 'folder' || !this.item) return [];
const ids = [this.item.folder_id];
// 递归收集所有子文件夹 ID
const collectChildIds = (nodes) => {
for (const node of nodes) {
if (node.folder_id === this.item.folder_id) {
const collectAllChildren = (children) => {
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(this.folderTree);
return ids;
},
// 过滤掉禁用的文件夹
availableFolders() {
return this.folderTree;
}
},
watch: {
modelValue(newValue) {
if (newValue) {
// 初始化选中为当前所在文件夹
if (this.item) {
this.selectedFolderId = this.itemType === 'persona' ? this.item.folder_id : this.item.parent_id;
}
}
}
},
methods: {
...mapActions(usePersonaStore, ['movePersonaToFolder', 'moveFolderToFolder']),
selectFolder(folderId) {
// 检查是否禁用
if (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.persona_id, this.selectedFolderId);
} else {
await this.moveFolderToFolder(this.item.folder_id, this.selectedFolderId);
}
this.$emit('moved', this.tm('moveDialog.success'));
this.closeDialog();
} catch (error) {
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,163 @@
<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>
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'PersonaCard',
props: {
persona: {
type: Object,
required: true
}
},
emits: ['view', 'edit', 'move', 'delete'],
setup() {
const { tm } = useModuleI18n('features/persona');
return { tm };
},
data() {
return {
isDragging: false
};
},
methods: {
handleDragStart(event) {
this.isDragging = true;
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
if (this.$refs.dragPreview) {
event.dataTransfer.setDragImage(this.$refs.dragPreview, 15, 15);
}
},
handleDragEnd() {
this.isDragging = false;
},
truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
},
formatDate(dateString) {
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,480 @@
<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"
:current-folder-id="currentFolderId" @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>
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';
export default {
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,
viewingPersona: null,
// 文件夹相关
showCreateFolderDialog: false,
showRenameFolderDialog: false,
showDeleteFolderDialog: false,
renameFolderData: { folder: null, name: '' },
deleteFolderData: null,
renameLoading: false,
deleteLoading: false,
// 移动对话框
showMoveDialog: false,
moveDialogType: 'persona',
moveDialogItem: null,
// 消息提示
showMessage: false,
message: '',
messageType: 'success'
};
},
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) {
this.editingPersona = persona;
this.showPersonaDialog = true;
},
viewPersona(persona) {
this.viewingPersona = persona;
this.showViewDialog = true;
},
handlePersonaSaved(message) {
this.showSuccess(message);
this.refreshCurrentFolder();
},
async confirmDeletePersona(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) {
this.showError(error.message || this.tm('messages.deleteError'));
}
},
openMovePersonaDialog(persona) {
this.moveDialogType = 'persona';
this.moveDialogItem = persona;
this.showMoveDialog = true;
},
async handlePersonaDropped({ persona_id, target_folder_id }) {
try {
await this.movePersonaToFolder(persona_id, target_folder_id);
this.showSuccess(this.tm('persona.messages.moveSuccess'));
} catch (error) {
this.showError(error.message || this.tm('persona.messages.moveError'));
}
},
// 文件夹操作
openRenameFolderDialog(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) {
this.showError(error.message || this.tm('folder.messages.renameError'));
} finally {
this.renameLoading = false;
}
},
openMoveFolderDialog(folder) {
this.moveDialogType = 'folder';
this.moveDialogItem = folder;
this.showMoveDialog = true;
},
confirmDeleteFolder(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) {
this.showError(error.message || this.tm('folder.messages.deleteError'));
} finally {
this.deleteLoading = false;
}
},
// 辅助方法
formatDate(dateString) {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
},
showSuccess(message) {
this.message = message;
this.messageType = 'success';
this.showMessage = true;
},
showError(message) {
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>
@@ -209,6 +209,10 @@ export default {
editingPersona: {
type: Object,
default: null
},
currentFolderId: {
type: String,
default: null
}
},
emits: ['update:modelValue', 'saved', 'error'],
@@ -229,7 +233,8 @@ export default {
persona_id: '',
system_prompt: '',
begin_dialogs: [],
tools: []
tools: [],
folder_id: null
},
personaIdRules: [
v => !!v || this.tm('validation.required'),
@@ -310,7 +315,8 @@ export default {
persona_id: '',
system_prompt: '',
begin_dialogs: [],
tools: []
tools: [],
folder_id: this.currentFolderId
};
this.toolSelectValue = '0';
this.expandedPanels = [];
@@ -321,7 +327,8 @@ export default {
persona_id: persona.persona_id,
system_prompt: persona.system_prompt,
begin_dialogs: [...(persona.begin_dialogs || [])],
tools: persona.tools === null ? null : [...(persona.tools || [])]
tools: persona.tools === null ? null : [...(persona.tools || [])],
folder_id: persona.folder_id
};
// 根据 tools 的值设置 toolSelectValue
this.toolSelectValue = persona.tools === null ? '0' : '1';
@@ -9,6 +9,7 @@
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"move": "Move",
"addDialogPair": "Add Dialog Pair"
},
"labels": {
@@ -48,7 +49,9 @@
},
"empty": {
"title": "No Persona Configured",
"description": "Create your first persona to start using personalized chatbots"
"description": "Create your first persona to start using personalized chatbots",
"folderEmpty": "This folder is empty",
"folderEmptyDescription": "Create a new persona or folder to get started"
},
"validation": {
"required": "This field is required",
@@ -63,5 +66,62 @@
"deleteConfirm": "Are you sure you want to delete persona \"{id}\"? This action cannot be undone.",
"deleteSuccess": "Deleted successfully",
"deleteError": "Delete failed"
},
"persona": {
"personasTitle": "Personas",
"toolsCount": "tools",
"contextMenu": {
"moveTo": "Move to..."
},
"messages": {
"moveSuccess": "Persona moved successfully",
"moveError": "Failed to move persona"
}
},
"folder": {
"sidebarTitle": "Folders",
"rootFolder": "Root",
"foldersTitle": "Folders",
"noFolders": "No folders yet",
"createButton": "New Folder",
"searchPlaceholder": "Search folders...",
"form": {
"name": "Folder Name",
"description": "Description (optional)"
},
"validation": {
"nameRequired": "Folder name is required"
},
"contextMenu": {
"open": "Open",
"rename": "Rename",
"moveTo": "Move to...",
"delete": "Delete"
},
"createDialog": {
"title": "Create New Folder"
},
"renameDialog": {
"title": "Rename Folder"
},
"deleteDialog": {
"title": "Delete Folder",
"message": "Are you sure you want to delete folder \"{name}\"?",
"warning": "All personas inside will be moved to root folder."
},
"messages": {
"createSuccess": "Folder created successfully",
"createError": "Failed to create folder",
"renameSuccess": "Folder renamed successfully",
"renameError": "Failed to rename folder",
"deleteSuccess": "Folder deleted successfully",
"deleteError": "Failed to delete folder"
}
},
"moveDialog": {
"title": "Move to Folder",
"description": "Select a destination folder for \"{name}\"",
"success": "Moved successfully",
"error": "Failed to move"
}
}
@@ -9,6 +9,7 @@
"delete": "删除",
"cancel": "取消",
"save": "保存",
"move": "移动",
"addDialogPair": "添加对话对"
},
"labels": {
@@ -48,7 +49,9 @@
},
"empty": {
"title": "暂无人格配置",
"description": "来创建一个吧!"
"description": "来创建一个吧!",
"folderEmpty": "此文件夹为空",
"folderEmptyDescription": "创建新的人格或文件夹开始使用"
},
"validation": {
"required": "此字段为必填项",
@@ -63,5 +66,62 @@
"deleteConfirm": "确定要删除人格 \"{id}\" 吗?此操作不可撤销。",
"deleteSuccess": "删除成功",
"deleteError": "删除失败"
},
"persona": {
"personasTitle": "人格",
"toolsCount": "个工具",
"contextMenu": {
"moveTo": "移动到..."
},
"messages": {
"moveSuccess": "人格移动成功",
"moveError": "移动人格失败"
}
},
"folder": {
"sidebarTitle": "文件夹",
"rootFolder": "根目录",
"foldersTitle": "文件夹",
"noFolders": "暂无文件夹",
"createButton": "新建文件夹",
"searchPlaceholder": "搜索文件夹...",
"form": {
"name": "文件夹名称",
"description": "描述(可选)"
},
"validation": {
"nameRequired": "文件夹名称不能为空"
},
"contextMenu": {
"open": "打开",
"rename": "重命名",
"moveTo": "移动到...",
"delete": "删除"
},
"createDialog": {
"title": "创建新文件夹"
},
"renameDialog": {
"title": "重命名文件夹"
},
"deleteDialog": {
"title": "删除文件夹",
"message": "确定要删除文件夹 \"{name}\" 吗?",
"warning": "文件夹内的所有人格将被移动到根目录。"
},
"messages": {
"createSuccess": "文件夹创建成功",
"createError": "创建文件夹失败",
"renameSuccess": "文件夹重命名成功",
"renameError": "重命名文件夹失败",
"deleteSuccess": "文件夹删除成功",
"deleteError": "删除文件夹失败"
}
},
"moveDialog": {
"title": "移动到文件夹",
"description": "为 \"{name}\" 选择目标文件夹",
"success": "移动成功",
"error": "移动失败"
}
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Persona 文件夹管理 Store
*/
import { defineStore } from 'pinia';
import axios from 'axios';
// 类型定义
export interface PersonaFolder {
folder_id: string;
name: string;
parent_id: string | null;
description: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface Persona {
persona_id: string;
system_prompt: string;
begin_dialogs: string[];
tools: string[] | null;
folder_id: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface FolderTreeNode {
folder_id: string;
name: string;
parent_id: string | null;
description: string | null;
sort_order: number;
children: FolderTreeNode[];
}
export interface ReorderItem {
id: string;
type: 'persona' | 'folder';
sort_order: number;
}
export const usePersonaStore = defineStore({
id: 'persona',
state: () => ({
folderTree: [] as FolderTreeNode[],
currentFolderId: null as string | null,
currentFolders: [] as PersonaFolder[],
currentPersonas: [] as Persona[],
breadcrumbPath: [] as FolderTreeNode[],
loading: false,
treeLoading: false,
}),
getters: {
// 当前文件夹名称
currentFolderName(): string {
if (this.breadcrumbPath.length === 0) {
return '根目录';
}
return this.breadcrumbPath[this.breadcrumbPath.length - 1]?.name || '根目录';
},
},
actions: {
/**
* 加载文件夹树形结构
*/
async loadFolderTree(): Promise<void> {
this.treeLoading = true;
try {
const response = await axios.get('/api/persona/folder/tree');
if (response.data.status === 'ok') {
this.folderTree = response.data.data || [];
} else {
throw new Error(response.data.message || '获取文件夹树失败');
}
} finally {
this.treeLoading = false;
}
},
/**
* 导航到指定文件夹
*/
async navigateToFolder(folderId: string | null): Promise<void> {
this.loading = true;
try {
this.currentFolderId = folderId;
// 并行加载子文件夹和 Persona
const [foldersRes, personasRes] = await Promise.all([
axios.get('/api/persona/folder/list', {
params: { parent_id: folderId ?? '' }
}),
axios.get('/api/persona/list', {
params: { folder_id: folderId ?? '' }
}),
]);
if (foldersRes.data.status === 'ok') {
this.currentFolders = foldersRes.data.data || [];
}
if (personasRes.data.status === 'ok') {
this.currentPersonas = personasRes.data.data || [];
}
// 更新面包屑
this.updateBreadcrumb(folderId);
} finally {
this.loading = false;
}
},
/**
* 更新面包屑路径
*/
updateBreadcrumb(folderId: string | null): void {
if (folderId === null) {
this.breadcrumbPath = [];
return;
}
// 从树中查找路径
const path: FolderTreeNode[] = [];
const findPath = (nodes: FolderTreeNode[], targetId: string): boolean => {
for (const node of nodes) {
if (node.folder_id === targetId) {
path.push(node);
return true;
}
if (node.children.length > 0 && findPath(node.children, targetId)) {
path.unshift(node);
return true;
}
}
return false;
};
findPath(this.folderTree, folderId);
this.breadcrumbPath = path;
},
/**
* 刷新当前文件夹内容
*/
async refreshCurrentFolder(): Promise<void> {
await this.navigateToFolder(this.currentFolderId);
},
/**
* 移动 Persona 到文件夹
*/
async movePersonaToFolder(personaId: string, targetFolderId: string | null): Promise<void> {
const response = await axios.post('/api/persona/move', {
persona_id: personaId,
folder_id: targetFolderId
});
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '移动人格失败');
}
// 刷新当前文件夹内容和文件夹树
await Promise.all([
this.refreshCurrentFolder(),
this.loadFolderTree(),
]);
},
/**
* 移动文件夹到另一个文件夹
*/
async moveFolderToFolder(folderId: string, targetParentId: string | null): Promise<void> {
const response = await axios.post('/api/persona/folder/update', {
folder_id: folderId,
parent_id: targetParentId
});
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '移动文件夹失败');
}
// 刷新当前文件夹内容和文件夹树
await Promise.all([
this.refreshCurrentFolder(),
this.loadFolderTree(),
]);
},
/**
* 创建文件夹
*/
async createFolder(data: {
name: string;
parent_id?: string | null;
description?: string;
}): Promise<PersonaFolder> {
const response = await axios.post('/api/persona/folder/create', {
...data,
parent_id: data.parent_id ?? this.currentFolderId,
});
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '创建文件夹失败');
}
// 刷新当前文件夹内容和文件夹树
await Promise.all([
this.refreshCurrentFolder(),
this.loadFolderTree(),
]);
return response.data.data.folder;
},
/**
* 更新文件夹
*/
async updateFolder(data: {
folder_id: string;
name?: string;
description?: string;
}): Promise<void> {
const response = await axios.post('/api/persona/folder/update', data);
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '更新文件夹失败');
}
// 刷新当前文件夹内容和文件夹树
await Promise.all([
this.refreshCurrentFolder(),
this.loadFolderTree(),
]);
},
/**
* 删除文件夹
*/
async deleteFolder(folderId: string): Promise<void> {
const response = await axios.post('/api/persona/folder/delete', {
folder_id: folderId
});
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '删除文件夹失败');
}
// 刷新当前文件夹内容和文件夹树
await Promise.all([
this.refreshCurrentFolder(),
this.loadFolderTree(),
]);
},
/**
* 删除 Persona
*/
async deletePersona(personaId: string): Promise<void> {
const response = await axios.post('/api/persona/delete', {
persona_id: personaId
});
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '删除人格失败');
}
// 刷新当前文件夹内容
await this.refreshCurrentFolder();
},
/**
* 批量更新排序
*/
async reorderItems(items: ReorderItem[]): Promise<void> {
const response = await axios.post('/api/persona/reorder', { items });
if (response.data.status !== 'ok') {
throw new Error(response.data.message || '更新排序失败');
}
// 刷新当前文件夹内容
await this.refreshCurrentFolder();
},
/**
* 根据文件夹 ID 查找树节点
*/
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.length > 0) {
const found = findNode(node.children);
if (found) return found;
}
}
return null;
};
return findNode(this.folderTree);
},
}
});
+7 -285
View File
@@ -2,277 +2,38 @@
<div class="persona-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-6">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-heart</v-icon>{{ t('core.navigation.persona') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
<p class="text-subtitle-1 text-medium-emphasis mb-0">
{{ tm('page.description') }}
</p>
</div>
<div>
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="openCreateDialog"
rounded="xl" size="x-large">
{{ tm('buttons.create') }}
</v-btn>
</div>
</v-row>
<!-- 人格卡片网格 -->
<v-row>
<v-col v-for="persona in personas" :key="persona.persona_id" cols="12" md="6" lg="4" xl="3">
<v-card class="persona-card" rounded="md" @click="viewPersona(persona)">
<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="editPersona(persona)">
<v-list-item-title>
<v-icon class="mr-2" size="small">mdi-pencil</v-icon>
{{ tm('buttons.edit') }}
</v-list-item-title>
</v-list-item>
<v-list-item @click="deletePersona(persona)" class="text-error">
<v-list-item-title>
<v-icon class="mr-2" size="small">mdi-delete</v-icon>
{{ 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" v-if="persona.begin_dialogs && persona.begin_dialogs.length > 0">
<v-chip size="small" color="secondary" variant="tonal" prepend-icon="mdi-chat">
{{ tm('labels.presetDialogs', { count: persona.begin_dialogs.length / 2 }) }}
</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>
</v-col>
<!-- 空状态 -->
<v-col v-if="personas.length === 0 && !loading" cols="12">
<v-card class="text-center pa-8" elevation="0">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-account-group</v-icon>
<h3 class="text-h5 mb-2">{{ tm('empty.title') }}</h3>
<p class="text-body-1 text-medium-emphasis mb-4">{{ tm('empty.description') }}</p>
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" @click="openCreateDialog">
{{ tm('buttons.createFirst') }}
</v-btn>
</v-card>
</v-col>
</v-row>
<!-- 加载状态 -->
<v-row v-if="loading">
<v-col v-for="n in 6" :key="n" cols="12" md="6" lg="4" xl="3">
<v-skeleton-loader type="card" rounded="lg"></v-skeleton-loader>
</v-col>
</v-row>
<!-- 主容器组件 -->
<PersonaManager />
</v-container>
<!-- 创建/编辑人格对话框 -->
<PersonaForm
v-model="showPersonaDialog"
:editing-persona="editingPersona"
@saved="handlePersonaSaved"
@error="showError" />
<!-- 查看人格详情对话框 -->
<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>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
{{ message }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import PersonaForm from '@/components/shared/PersonaForm.vue';
import PersonaManager from '@/components/persona/PersonaManager.vue';
export default {
name: 'PersonaPage',
components: {
PersonaForm
PersonaManager
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/persona');
return { t, tm };
},
data() {
return {
personas: [],
loading: false,
showPersonaDialog: false,
showViewDialog: false,
editingPersona: null,
viewingPersona: null,
showMessage: false,
message: '',
messageType: 'success'
}
},
mounted() {
this.loadPersonas();
},
methods: {
async loadPersonas() {
this.loading = true;
try {
const response = await axios.get('/api/persona/list');
if (response.data.status === 'ok') {
this.personas = response.data.data;
} else {
this.showError(response.data.message || this.tm('messages.loadError'));
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.loadError'));
}
this.loading = false;
},
openCreateDialog() {
this.editingPersona = null;
this.showPersonaDialog = true;
},
editPersona(persona) {
this.editingPersona = persona;
this.showPersonaDialog = true;
},
viewPersona(persona) {
this.viewingPersona = persona;
this.showViewDialog = true;
},
handlePersonaSaved(message) {
this.showSuccess(message);
this.loadPersonas();
},
async deletePersona(persona) {
if (!confirm(this.tm('messages.deleteConfirm', { id: persona.persona_id }))) {
return;
}
try {
const response = await axios.post('/api/persona/delete', {
persona_id: persona.persona_id
});
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
await this.loadPersonas();
} else {
this.showError(response.data.message || this.tm('messages.deleteError'));
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.deleteError'));
}
},
truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
},
formatDate(dateString) {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
},
showSuccess(message) {
this.message = message;
this.messageType = 'success';
this.showMessage = true;
},
showError(message) {
this.message = message;
this.messageType = 'error';
this.showMessage = true;
}
}
}
};
</script>
<style scoped>
@@ -280,43 +41,4 @@ export default {
padding: 20px;
padding-top: 8px;
}
.persona-card {
transition: all 0.3s ease;
height: 100%;
cursor: pointer;
}
.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;
}
.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;
}
.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;
}
</style>