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:
@@ -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": "移动失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user