mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-20 02:55:08 +08:00
feat: implement persona folder for advanced persona management (#4443)
* feat(db): add persona folder management for hierarchical organization
Implement hierarchical folder structure for organizing personas:
- Add PersonaFolder model with recursive parent-child relationships
- Add folder_id and sort_order fields to Persona model
- Implement CRUD operations for persona folders in database layer
- Add migration support for existing databases
- Extend PersonaManager with folder management methods
- Add dashboard API routes for folder operations
* feat(persona): add batch sort order update endpoint for personas and folders
Add new API endpoint POST /persona/reorder to batch update sort_order
for both personas and folders. This enables drag-and-drop reordering
in the dashboard UI.
Changes:
- Add abstract batch_update_sort_order method to BaseDatabase
- Implement batch_update_sort_order in SQLiteDatabase
- Add batch_update_sort_order to PersonaManager with cache refresh
- Add reorder_items route handler with input validation
* feat(persona): add folder_id and sort_order params to persona creation
Extend persona creation flow to support folder placement and ordering:
- Add folder_id and sort_order parameters to insert_persona in db layer
- Update PersonaManager.create_persona to accept and pass folder params
- Add get_folder_detail API endpoint for retrieving folder information
- Include folder_id and sort_order in persona creation response
- Add session flush/refresh to return complete persona object
* 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
* feat(dashboard): centralize folder expansion state in persona store
Move folder expansion logic from local component state to global Pinia
store to persist expansion state.
- Add `expandedFolderIds` state and toggle actions to `personaStore`
- Update `FolderTreeNode` to use store state instead of local data
- Automatically navigate to target folder after moving a persona
* feat(dashboard): add reusable folder management component library
Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.
- Add base folder components (tree, breadcrumb, card, dialogs) with
customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage
* refactor(dashboard): remove legacy persona folder management components
Remove deprecated persona folder management Vue components that have been
superseded by the new reusable folder management component library.
Deleted components:
- CreateFolderDialog.vue
- FolderBreadcrumb.vue
- FolderCard.vue
- FolderTree.vue
- FolderTreeNode.vue
- MoveTargetNode.vue
- MoveToFolderDialog.vue
- PersonaCard.vue
- PersonaManager.vue
These components are replaced by the centralized folder management
implementation introduced in commit 3fbb3db2.
* fix(dashboard): add delayed skeleton loading to prevent UI flicker
Implement a 150ms delay before showing the skeleton loader in
PersonaManager to prevent visual flicker during fast loading operations.
- Add showSkeleton state with timer-based delay mechanism
- Use v-fade-transition for smooth skeleton visibility transitions
- Clean up timer on component unmount to prevent memory leaks
- Only display skeleton when loading exceeds threshold duration
* feat(dashboard): add generic folder item selector component for persona selection
Introduce BaseFolderItemSelector.vue as a reusable component for selecting
items within folder hierarchies. Refactor PersonaSelector to use this new
base component instead of its previous flat list implementation.
Changes:
- Add BaseFolderItemSelector with folder tree navigation and item selection
- Extend folder types with SelectableItem and FolderItemSelectorLabels
- Refactor PersonaSelector to leverage the new base component
- Add i18n translations for rootFolder and emptyFolder labels
* feat(persona): add tree-view display for persona list command
Add hierarchical folder tree output for the persona list command,
showing personas organized by folders with visual tree connectors.
- Add _build_tree_output method for recursive tree structure rendering
- Display folders with 📁 icon and personas with 👤 icon
- Show root-level personas separately from folder contents
- Include total persona count in output
* refactor(persona): simplify tree-view output with shorter indentation lines
Replace complex tree connector logic with simpler depth-based indentation
using "│ " prefix. Remove unnecessary parameters (prefix, is_last) and
computed variables (has_content, total_items, item_idx) in favor of a
cleaner depth-based approach.
* feat(dashboard): add duplicate persona ID validation in create form
Add frontend validation to prevent creating personas with duplicate IDs.
Load existing persona IDs when opening the create form and validate
against them in real-time.
- Add existingPersonaIds array and loadExistingPersonaIds method
- Add validation rule to check for duplicate persona IDs
- Add i18n messages for duplicate ID error (en-US and zh-CN)
- Fix minLength validation to require at least 1 character
* i18n(persona): add createButton translation key for folder dialog
Move create button label to folder-specific translation path
instead of using generic buttons.create key.
* feat(persona): show target folder name in persona creation dialog
Add visual feedback showing which folder a new persona will be created in.
- Add info alert in PersonaForm displaying the target folder name
- Pass currentFolderName prop from PersonaManager and PersonaSelector
- Add recursive findFolderName helper to resolve folder ID to name
- Add i18n translations for createInFolder and rootFolder labels
* style:format code
* fix: remove 'persistent' attribute from dialog components
---------
Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
132
dashboard/src/components/folder/BaseCreateFolderDialog.vue
Normal file
132
dashboard/src/components/folder/BaseCreateFolderDialog.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="450px">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-folder-plus</v-icon>
|
||||
{{ labels.title }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form ref="form" v-model="formValid">
|
||||
<v-text-field v-model="formData.name" :label="mergedLabels.nameLabel"
|
||||
:rules="[(v: any) => !!v || mergedLabels.nameRequired]" variant="outlined"
|
||||
density="comfortable" autofocus class="mb-3" />
|
||||
|
||||
<v-textarea v-model="formData.description" :label="labels.descriptionLabel" variant="outlined"
|
||||
rows="3" density="comfortable" hide-details />
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeDialog">
|
||||
{{ labels.cancelButton }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="submitForm" :loading="loading" :disabled="!formValid">
|
||||
{{ labels.createButton }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { CreateFolderData } from './types';
|
||||
|
||||
interface DefaultLabels {
|
||||
title: string;
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
nameRequired: string;
|
||||
cancelButton: string;
|
||||
createButton: string;
|
||||
}
|
||||
|
||||
const defaultLabels: DefaultLabels = {
|
||||
title: '创建文件夹',
|
||||
nameLabel: '名称',
|
||||
descriptionLabel: '描述',
|
||||
nameRequired: '请输入文件夹名称',
|
||||
cancelButton: '取消',
|
||||
createButton: '创建'
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseCreateFolderDialog',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
parentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
labels: {
|
||||
type: Object as PropType<Partial<DefaultLabels>>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'create'],
|
||||
data() {
|
||||
return {
|
||||
formValid: false,
|
||||
loading: false,
|
||||
formData: {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showDialog: {
|
||||
get(): boolean {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value: boolean) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
},
|
||||
mergedLabels(): DefaultLabels {
|
||||
return { ...defaultLabels, ...this.labels };
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue: boolean) {
|
||||
if (newValue) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetForm() {
|
||||
this.formData = {
|
||||
name: '',
|
||||
description: ''
|
||||
};
|
||||
if (this.$refs.form) {
|
||||
(this.$refs.form as any).resetValidation();
|
||||
}
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
async submitForm() {
|
||||
if (!this.formValid) return;
|
||||
|
||||
const data: CreateFolderData = {
|
||||
name: this.formData.name,
|
||||
description: this.formData.description || undefined,
|
||||
parent_id: this.parentFolderId
|
||||
};
|
||||
|
||||
this.$emit('create', data);
|
||||
},
|
||||
|
||||
setLoading(value: boolean) {
|
||||
this.loading = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
84
dashboard/src/components/folder/BaseFolderBreadcrumb.vue
Normal file
84
dashboard/src/components/folder/BaseFolderBreadcrumb.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<v-breadcrumbs :items="computedItems" class="base-folder-breadcrumb pa-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" class="mr-1">mdi-folder-outline</v-icon>
|
||||
</template>
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item :disabled="(item as any).disabled" @click="!(item as any).disabled && handleClick((item as any).folderId)"
|
||||
:class="{ 'breadcrumb-link': !(item as any).disabled }">
|
||||
<v-icon v-if="(item as any).isRoot" size="small" class="mr-1">mdi-home</v-icon>
|
||||
{{ (item as any).title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
<template v-slot:divider>
|
||||
<v-icon size="small">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { BreadcrumbItem, FolderTreeNode } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseFolderBreadcrumb',
|
||||
props: {
|
||||
breadcrumbPath: {
|
||||
type: Array as PropType<FolderTreeNode[]>,
|
||||
required: true
|
||||
},
|
||||
currentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
rootFolderName: {
|
||||
type: String,
|
||||
default: '根目录'
|
||||
}
|
||||
},
|
||||
emits: ['navigate'],
|
||||
computed: {
|
||||
computedItems(): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [
|
||||
{
|
||||
title: this.rootFolderName,
|
||||
folderId: null,
|
||||
disabled: this.currentFolderId === null,
|
||||
isRoot: true
|
||||
}
|
||||
];
|
||||
|
||||
this.breadcrumbPath.forEach((folder, index) => {
|
||||
items.push({
|
||||
title: folder.name,
|
||||
folderId: folder.folder_id,
|
||||
disabled: index === this.breadcrumbPath.length - 1,
|
||||
isRoot: false
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClick(folderId: string | null) {
|
||||
this.$emit('navigate', folderId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-folder-breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
</style>
|
||||
143
dashboard/src/components/folder/BaseFolderCard.vue
Normal file
143
dashboard/src/components/folder/BaseFolderCard.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<v-card class="base-folder-card" :class="{ 'drag-over': isDragOver }" rounded="lg" @click="$emit('click')" @contextmenu.prevent="$emit('contextmenu', $event)"
|
||||
elevation="1" hover @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<v-card-text class="d-flex align-center pa-3">
|
||||
<v-icon size="40" color="amber-darken-2" class="mr-3">mdi-folder</v-icon>
|
||||
<div class="folder-info flex-grow-1 overflow-hidden">
|
||||
<div class="text-subtitle-1 font-weight-medium text-truncate">{{ folder.name }}</div>
|
||||
<div v-if="folder.description" class="text-body-2 text-medium-emphasis text-truncate">
|
||||
{{ folder.description }}
|
||||
</div>
|
||||
</div>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click.stop="$emit('open')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-open</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ labels.open }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('rename')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ labels.rename }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('move')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-move</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ labels.moveTo }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item @click.stop="$emit('delete')" class="text-error">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="error">mdi-delete</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ labels.delete }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { Folder } from './types';
|
||||
|
||||
interface DefaultLabels {
|
||||
open: string;
|
||||
rename: string;
|
||||
moveTo: string;
|
||||
delete: string;
|
||||
}
|
||||
|
||||
const defaultLabels: DefaultLabels = {
|
||||
open: '打开',
|
||||
rename: '重命名',
|
||||
moveTo: '移动到...',
|
||||
delete: '删除'
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseFolderCard',
|
||||
props: {
|
||||
folder: {
|
||||
type: Object as PropType<Folder>,
|
||||
required: true
|
||||
},
|
||||
acceptDropTypes: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
},
|
||||
labels: {
|
||||
type: Object as PropType<Partial<DefaultLabels>>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['click', 'contextmenu', 'open', 'rename', 'move', 'delete', 'item-dropped'],
|
||||
data() {
|
||||
return {
|
||||
isDragOver: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
mergedLabels(): DefaultLabels {
|
||||
return { ...defaultLabels, ...this.labels };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleDragOver(event: DragEvent) {
|
||||
if (!event.dataTransfer) return;
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
this.isDragOver = true;
|
||||
},
|
||||
handleDragLeave() {
|
||||
this.isDragOver = false;
|
||||
},
|
||||
handleDrop(event: DragEvent) {
|
||||
this.isDragOver = false;
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
if (this.acceptDropTypes.length === 0 || this.acceptDropTypes.includes(data.type)) {
|
||||
this.$emit('item-dropped', {
|
||||
item_id: data.id || data.persona_id || data.item_id,
|
||||
item_type: data.type,
|
||||
target_folder_id: this.folder.folder_id,
|
||||
source_data: data
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drop data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-folder-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.base-folder-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.base-folder-card.drag-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
border: 2px dashed rgb(var(--v-theme-primary));
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.folder-info {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
513
dashboard/src/components/folder/BaseFolderItemSelector.vue
Normal file
513
dashboard/src/components/folder/BaseFolderItemSelector.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="folder-item-selector">
|
||||
<!-- 触发按钮区域 -->
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
|
||||
{{ labels.notSelected || '未选择' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ labels.buttonText || '选择...' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 选择对话框 -->
|
||||
<v-dialog v-model="dialog" max-width="1000px" min-width="800px">
|
||||
<v-card class="selector-dialog-card">
|
||||
<v-card-title class="dialog-title d-flex align-center py-4 px-5">
|
||||
<v-icon class="mr-3" color="primary">mdi-account-circle</v-icon>
|
||||
<span>{{ labels.dialogTitle || '选择项目' }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0" style="height: 600px; max-height: 80vh; overflow: hidden;">
|
||||
<div class="selector-layout">
|
||||
<!-- 左侧文件夹树 -->
|
||||
<div class="folder-sidebar">
|
||||
<div class="sidebar-header pa-3 pb-2">
|
||||
<span class="text-caption text-medium-emphasis font-weight-medium">
|
||||
<v-icon size="small" class="mr-1">mdi-folder-multiple</v-icon>
|
||||
文件夹
|
||||
</span>
|
||||
</div>
|
||||
<v-list density="compact" nav class="tree-list pa-2" bg-color="transparent">
|
||||
<!-- 根目录 -->
|
||||
<v-list-item :active="currentFolderId === null" @click="navigateToFolder(null)"
|
||||
rounded="lg" class="mb-1 root-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="20" :color="currentFolderId === null ? 'primary' : ''">mdi-home</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">{{ labels.rootFolder || '根目录' }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<template v-if="!treeLoading">
|
||||
<BaseMoveTargetNode v-for="folder in folderTree" :key="folder.folder_id"
|
||||
:folder="folder" :depth="0" :selected-folder-id="currentFolderId"
|
||||
:disabled-folder-ids="[]" @select="navigateToFolder" />
|
||||
</template>
|
||||
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate size="20" color="primary" />
|
||||
</div>
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<!-- 右侧项目列表 -->
|
||||
<div class="items-panel">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="breadcrumb-bar px-4 py-3">
|
||||
<v-breadcrumbs :items="breadcrumbItems" density="compact" class="pa-0">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item :disabled="(item as any).disabled"
|
||||
@click="!(item as any).disabled && navigateToFolder((item as any).folderId)"
|
||||
:class="{ 'breadcrumb-link': !(item as any).disabled }">
|
||||
<v-icon v-if="(item as any).isRoot" size="small"
|
||||
class="mr-1">mdi-home</v-icon>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
<template v-slot:divider>
|
||||
<v-icon size="small" color="grey">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div class="items-list">
|
||||
<v-progress-linear v-if="itemsLoading" indeterminate
|
||||
color="primary" height="2"></v-progress-linear>
|
||||
|
||||
<!-- 子文件夹 -->
|
||||
<v-list v-if="!itemsLoading" lines="two" class="pa-3 items-content">
|
||||
<template v-if="currentSubFolders.length > 0">
|
||||
<div class="section-label text-caption text-medium-emphasis mb-2 px-2">子文件夹</div>
|
||||
<v-list-item v-for="folder in currentSubFolders" :key="'folder-' + folder.folder_id"
|
||||
@click="navigateToFolder(folder.folder_id)" rounded="lg" class="mb-1 folder-item">
|
||||
<template v-slot:prepend>
|
||||
<v-avatar size="36" color="amber-lighten-4" class="mr-3">
|
||||
<v-icon color="amber-darken-2" size="20">mdi-folder</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-medium">{{ folder.name }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<template v-if="currentItems.length > 0">
|
||||
<div class="section-label text-caption text-medium-emphasis mb-2 px-2" :class="{ 'mt-4': currentSubFolders.length > 0 }">可选项目</div>
|
||||
<v-list-item v-for="item in currentItems" :key="'item-' + getItemId(item)"
|
||||
:value="getItemId(item)" @click="selectItem(item)"
|
||||
:active="selectedItemId === getItemId(item)" rounded="lg" class="mb-1 persona-item"
|
||||
:class="{ 'selected-item': selectedItemId === getItemId(item) }">
|
||||
<template v-slot:prepend>
|
||||
<v-avatar size="36" :color="selectedItemId === getItemId(item) ? 'primary-lighten-4' : 'grey-lighten-3'" class="mr-3">
|
||||
<v-icon :color="selectedItemId === getItemId(item) ? 'primary' : 'grey-darken-1'" size="20">mdi-account</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-medium">{{ getItemName(item) }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="getItemDescription(item)" class="text-truncate">
|
||||
{{ truncateText(getItemDescription(item), 80) }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon v-if="selectedItemId === getItemId(item)"
|
||||
color="primary" size="22">mdi-check-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="currentSubFolders.length === 0 && currentItems.length === 0"
|
||||
class="empty-state text-center py-12">
|
||||
<v-icon size="64" color="grey-lighten-2">mdi-folder-open-outline</v-icon>
|
||||
<p class="text-grey mt-4 text-body-2">{{ labels.emptyFolder || labels.noItems || '此文件夹为空' }}</p>
|
||||
</div>
|
||||
</v-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn v-if="showCreateButton" variant="text" color="primary" prepend-icon="mdi-plus"
|
||||
@click="$emit('create')">
|
||||
{{ labels.createButton || '新建' }}
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="cancelSelection">{{ labels.cancelButton || '取消' }}</v-btn>
|
||||
<v-btn color="primary" @click="confirmSelection" :disabled="!selectedItemId">
|
||||
{{ labels.confirmButton || '确认' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import BaseMoveTargetNode from './BaseMoveTargetNode.vue';
|
||||
import type { FolderTreeNode, FolderItemSelectorLabels, SelectableItem } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseFolderItemSelector',
|
||||
components: {
|
||||
BaseMoveTargetNode
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 文件夹树数据
|
||||
folderTree: {
|
||||
type: Array as PropType<FolderTreeNode[]>,
|
||||
default: () => []
|
||||
},
|
||||
// 当前项目列表
|
||||
items: {
|
||||
type: Array as PropType<SelectableItem[]>,
|
||||
default: () => []
|
||||
},
|
||||
// 加载状态
|
||||
treeLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
itemsLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 标签配置
|
||||
labels: {
|
||||
type: Object as PropType<Partial<FolderItemSelectorLabels>>,
|
||||
default: () => ({})
|
||||
},
|
||||
// 是否显示创建按钮
|
||||
showCreateButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 默认项(如 "默认人格")
|
||||
defaultItem: {
|
||||
type: Object as PropType<SelectableItem | null>,
|
||||
default: null
|
||||
},
|
||||
// 项目字段映射
|
||||
itemIdField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
itemNameField: {
|
||||
type: String,
|
||||
default: 'name'
|
||||
},
|
||||
itemDescriptionField: {
|
||||
type: String,
|
||||
default: 'description'
|
||||
},
|
||||
// 显示值的格式化函数(用于显示选中项的名称)
|
||||
displayValueFormatter: {
|
||||
type: Function as unknown as PropType<((value: string) => string) | null>,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'navigate', 'create'],
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
selectedItemId: '' as string,
|
||||
currentFolderId: null as string | null,
|
||||
breadcrumbPath: [] as FolderTreeNode[]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayValue(): string {
|
||||
if (this.displayValueFormatter) {
|
||||
return this.displayValueFormatter(this.modelValue);
|
||||
}
|
||||
// 如果是默认项
|
||||
if (this.defaultItem && this.modelValue === this.getItemId(this.defaultItem)) {
|
||||
return this.labels.defaultItem || this.getItemName(this.defaultItem);
|
||||
}
|
||||
return this.modelValue;
|
||||
},
|
||||
|
||||
currentItems(): SelectableItem[] {
|
||||
const items: SelectableItem[] = [];
|
||||
|
||||
// 如果在根目录且有默认项,添加到列表开头
|
||||
if (this.currentFolderId === null && this.defaultItem) {
|
||||
items.push(this.defaultItem);
|
||||
}
|
||||
|
||||
// 添加当前文件夹的项目
|
||||
items.push(...this.items);
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
currentSubFolders(): FolderTreeNode[] {
|
||||
if (this.currentFolderId === null) {
|
||||
return this.folderTree;
|
||||
}
|
||||
const folder = this.findFolderInTree(this.currentFolderId);
|
||||
return folder?.children || [];
|
||||
},
|
||||
|
||||
breadcrumbItems(): any[] {
|
||||
const items: any[] = [
|
||||
{
|
||||
title: this.labels.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: {
|
||||
getItemId(item: SelectableItem): string {
|
||||
return String(item[this.itemIdField] || item.id || '');
|
||||
},
|
||||
|
||||
getItemName(item: SelectableItem): string {
|
||||
return String(item[this.itemNameField] || item.name || '');
|
||||
},
|
||||
|
||||
getItemDescription(item: SelectableItem): string {
|
||||
return String(item[this.itemDescriptionField] || item.description || '');
|
||||
},
|
||||
|
||||
truncateText(text: string, maxLength: number): string {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
},
|
||||
|
||||
openDialog() {
|
||||
this.selectedItemId = this.modelValue || '';
|
||||
this.currentFolderId = null;
|
||||
this.breadcrumbPath = [];
|
||||
this.dialog = true;
|
||||
this.$emit('navigate', null);
|
||||
},
|
||||
|
||||
navigateToFolder(folderId: string | null) {
|
||||
this.currentFolderId = folderId;
|
||||
this.updateBreadcrumb(folderId);
|
||||
this.$emit('navigate', folderId);
|
||||
},
|
||||
|
||||
findFolderInTree(folderId: string): FolderTreeNode | null {
|
||||
const findNode = (nodes: FolderTreeNode[]): FolderTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === folderId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNode(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findNode(this.folderTree);
|
||||
},
|
||||
|
||||
findPathToFolder(folderId: string): FolderTreeNode[] {
|
||||
const findPath = (nodes: FolderTreeNode[], path: FolderTreeNode[]): FolderTreeNode[] | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === folderId) {
|
||||
return [...path, node];
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const result = findPath(node.children, [...path, node]);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findPath(this.folderTree, []) || [];
|
||||
},
|
||||
|
||||
updateBreadcrumb(folderId: string | null) {
|
||||
if (folderId === null) {
|
||||
this.breadcrumbPath = [];
|
||||
} else {
|
||||
this.breadcrumbPath = this.findPathToFolder(folderId);
|
||||
}
|
||||
},
|
||||
|
||||
selectItem(item: SelectableItem) {
|
||||
this.selectedItemId = this.getItemId(item);
|
||||
},
|
||||
|
||||
confirmSelection() {
|
||||
this.$emit('update:modelValue', this.selectedItemId);
|
||||
this.dialog = false;
|
||||
},
|
||||
|
||||
cancelSelection() {
|
||||
this.selectedItemId = this.modelValue || '';
|
||||
this.dialog = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selector-dialog-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selector-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.folder-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), 0.5);
|
||||
}
|
||||
|
||||
.items-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.breadcrumb-bar {
|
||||
background-color: transparent;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.items-content {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.root-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.folder-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.persona-item {
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.persona-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
|
||||
.persona-item.selected-item {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.v-list-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
|
||||
.v-list-item.v-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.selector-layout {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.folder-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
dashboard/src/components/folder/BaseFolderTree.vue
Normal file
272
dashboard/src/components/folder/BaseFolderTree.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="base-folder-tree">
|
||||
<!-- 搜索框 -->
|
||||
<v-text-field v-model="searchQuery" :placeholder="labels.searchPlaceholder" prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined" density="compact" hide-details clearable class="mb-3" />
|
||||
|
||||
<!-- 根目录节点 -->
|
||||
<v-list density="compact" nav class="tree-list" bg-color="transparent">
|
||||
<v-list-item :active="currentFolderId === null" @click="handleFolderClick(null)" rounded="lg"
|
||||
:class="['root-item', { 'drag-over': isRootDragOver }]"
|
||||
@dragover.prevent="handleRootDragOver" @dragleave="handleRootDragLeave" @drop.prevent="handleRootDrop">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-home</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ labels.rootFolder }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<template v-if="!treeLoading">
|
||||
<BaseFolderTreeNode v-for="folder in filteredFolderTree" :key="folder.folder_id" :folder="folder"
|
||||
:depth="0" :current-folder-id="currentFolderId" :search-query="searchQuery"
|
||||
:expanded-folder-ids="expandedFolderIds" :accept-drop-types="acceptDropTypes"
|
||||
@folder-click="handleFolderClick" @folder-context-menu="handleContextMenu"
|
||||
@item-dropped="$emit('item-dropped', $event)"
|
||||
@toggle-expansion="$emit('toggle-expansion', $event)"
|
||||
@set-expansion="$emit('set-expansion', $event)" />
|
||||
</template>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate size="24" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!treeLoading && folderTree.length === 0" class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="32" class="mb-2">mdi-folder-outline</v-icon>
|
||||
<div class="text-body-2">{{ labels.noFolders }}</div>
|
||||
</div>
|
||||
</v-list>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<v-menu v-model="contextMenu.show" :target="contextMenu.target as any" location="end" :close-on-content-click="true">
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="openFolder">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-open</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.contextMenu.open }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('rename-folder', contextMenu.folder)">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.contextMenu.rename }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('move-folder', contextMenu.folder)">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-move</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.contextMenu.moveTo }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item @click="$emit('delete-folder', contextMenu.folder)" class="text-error">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="error">mdi-delete</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ mergedLabels.contextMenu.delete }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { FolderTreeNode, ContextMenuEvent } from './types';
|
||||
import BaseFolderTreeNode from './BaseFolderTreeNode.vue';
|
||||
|
||||
interface ContextMenuState {
|
||||
show: boolean;
|
||||
target: [number, number] | null;
|
||||
folder: FolderTreeNode | null;
|
||||
}
|
||||
|
||||
interface Folder {
|
||||
folder_id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
description?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface DefaultLabels {
|
||||
searchPlaceholder: string;
|
||||
rootFolder: string;
|
||||
noFolders: string;
|
||||
contextMenu: {
|
||||
open: string;
|
||||
rename: string;
|
||||
moveTo: string;
|
||||
delete: string;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultLabels: DefaultLabels = {
|
||||
searchPlaceholder: '搜索文件夹...',
|
||||
rootFolder: '根目录',
|
||||
noFolders: '暂无文件夹',
|
||||
contextMenu: {
|
||||
open: '打开',
|
||||
rename: '重命名',
|
||||
moveTo: '移动到...',
|
||||
delete: '删除'
|
||||
}
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseFolderTree',
|
||||
components: {
|
||||
BaseFolderTreeNode
|
||||
},
|
||||
props: {
|
||||
folderTree: {
|
||||
type: Array as PropType<FolderTreeNode[]>,
|
||||
required: true
|
||||
},
|
||||
currentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
expandedFolderIds: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
},
|
||||
treeLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
acceptDropTypes: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
},
|
||||
labels: {
|
||||
type: Object as PropType<Partial<DefaultLabels>>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'folder-click',
|
||||
'rename-folder',
|
||||
'move-folder',
|
||||
'delete-folder',
|
||||
'item-dropped',
|
||||
'toggle-expansion',
|
||||
'set-expansion'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
isRootDragOver: false,
|
||||
contextMenu: {
|
||||
show: false,
|
||||
target: null,
|
||||
folder: null
|
||||
} as ContextMenuState
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
mergedLabels(): DefaultLabels {
|
||||
return {
|
||||
...defaultLabels,
|
||||
...this.labels,
|
||||
contextMenu: {
|
||||
...defaultLabels.contextMenu,
|
||||
...(this.labels?.contextMenu || {})
|
||||
}
|
||||
};
|
||||
},
|
||||
filteredFolderTree(): FolderTreeNode[] {
|
||||
if (!this.searchQuery) {
|
||||
return this.folderTree;
|
||||
}
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.filterTreeBySearch(this.folderTree, query);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterTreeBySearch(nodes: FolderTreeNode[], query: string): FolderTreeNode[] {
|
||||
return nodes.filter(node => {
|
||||
const matches = node.name.toLowerCase().includes(query);
|
||||
const childMatches = this.filterTreeBySearch(node.children || [], query);
|
||||
return matches || childMatches.length > 0;
|
||||
}).map(node => ({
|
||||
...node,
|
||||
children: this.filterTreeBySearch(node.children || [], query)
|
||||
}));
|
||||
},
|
||||
|
||||
handleFolderClick(folderId: string | null) {
|
||||
this.$emit('folder-click', folderId);
|
||||
},
|
||||
|
||||
handleRootDragOver(event: DragEvent) {
|
||||
if (!event.dataTransfer) return;
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
this.isRootDragOver = true;
|
||||
},
|
||||
|
||||
handleRootDragLeave() {
|
||||
this.isRootDragOver = false;
|
||||
},
|
||||
|
||||
handleRootDrop(event: DragEvent) {
|
||||
this.isRootDragOver = false;
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
if (this.acceptDropTypes.length === 0 || this.acceptDropTypes.includes(data.type)) {
|
||||
this.$emit('item-dropped', {
|
||||
item_id: data.id || data.persona_id || data.item_id,
|
||||
item_type: data.type,
|
||||
target_folder_id: null,
|
||||
source_data: data
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drop data:', e);
|
||||
}
|
||||
},
|
||||
|
||||
handleContextMenu(eventData: ContextMenuEvent) {
|
||||
const { event, folder } = eventData;
|
||||
this.contextMenu.target = [event.clientX, event.clientY];
|
||||
this.contextMenu.folder = folder as FolderTreeNode;
|
||||
this.contextMenu.show = true;
|
||||
},
|
||||
|
||||
openFolder() {
|
||||
if (this.contextMenu.folder) {
|
||||
this.$emit('folder-click', this.contextMenu.folder.folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-folder-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.root-item.drag-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
border: 2px dashed rgb(var(--v-theme-primary));
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
154
dashboard/src/components/folder/BaseFolderTreeNode.vue
Normal file
154
dashboard/src/components/folder/BaseFolderTreeNode.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="base-folder-tree-node">
|
||||
<v-list-item :active="currentFolderId === folder.folder_id" @click.stop="$emit('folder-click', folder.folder_id)"
|
||||
@contextmenu.prevent="handleContextMenu" rounded="lg" :style="{ paddingLeft: `${(depth + 1) * 16}px` }"
|
||||
:class="['folder-item', { 'drag-over': isDragOver }]"
|
||||
@dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<template v-slot:prepend>
|
||||
<v-btn v-if="hasChildren" icon variant="text" size="x-small" @click.stop="toggleExpand"
|
||||
class="expand-btn">
|
||||
<v-icon size="16">{{ isExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
|
||||
</v-btn>
|
||||
<div v-else class="expand-placeholder"></div>
|
||||
<v-icon :color="currentFolderId === folder.folder_id ? 'primary' : ''">
|
||||
{{ isExpanded ? 'mdi-folder-open' : 'mdi-folder' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-truncate">{{ folder.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 子文件夹 -->
|
||||
<v-expand-transition>
|
||||
<div v-show="isExpanded && hasChildren">
|
||||
<BaseFolderTreeNode v-for="child in folder.children" :key="child.folder_id" :folder="child" :depth="depth + 1"
|
||||
:current-folder-id="currentFolderId" :search-query="searchQuery"
|
||||
:expanded-folder-ids="expandedFolderIds" :accept-drop-types="acceptDropTypes"
|
||||
@folder-click="$emit('folder-click', $event)"
|
||||
@folder-context-menu="$emit('folder-context-menu', $event)"
|
||||
@item-dropped="$emit('item-dropped', $event)"
|
||||
@toggle-expansion="$emit('toggle-expansion', $event)"
|
||||
@set-expansion="$emit('set-expansion', $event)" />
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { FolderTreeNode } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseFolderTreeNode',
|
||||
props: {
|
||||
folder: {
|
||||
type: Object as PropType<FolderTreeNode>,
|
||||
required: true
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
expandedFolderIds: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
},
|
||||
acceptDropTypes: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['folder-click', 'folder-context-menu', 'item-dropped', 'toggle-expansion', 'set-expansion'],
|
||||
data() {
|
||||
return {
|
||||
isDragOver: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasChildren(): boolean {
|
||||
return this.folder.children && this.folder.children.length > 0;
|
||||
},
|
||||
isExpanded(): boolean {
|
||||
return this.expandedFolderIds.includes(this.folder.folder_id);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchQuery: {
|
||||
immediate: true,
|
||||
handler(newQuery: string) {
|
||||
// 搜索时自动展开匹配的节点
|
||||
if (newQuery && this.hasChildren) {
|
||||
this.$emit('set-expansion', { folderId: this.folder.folder_id, expanded: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleExpand() {
|
||||
this.$emit('toggle-expansion', this.folder.folder_id);
|
||||
},
|
||||
handleContextMenu(event: MouseEvent) {
|
||||
this.$emit('folder-context-menu', { event, folder: this.folder });
|
||||
},
|
||||
handleDragOver(event: DragEvent) {
|
||||
if (!event.dataTransfer) return;
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
this.isDragOver = true;
|
||||
},
|
||||
handleDragLeave() {
|
||||
this.isDragOver = false;
|
||||
},
|
||||
handleDrop(event: DragEvent) {
|
||||
this.isDragOver = false;
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
if (this.acceptDropTypes.length === 0 || this.acceptDropTypes.includes(data.type)) {
|
||||
this.$emit('item-dropped', {
|
||||
item_id: data.id || data.persona_id || data.item_id,
|
||||
item_type: data.type,
|
||||
target_folder_id: this.folder.folder_id,
|
||||
source_data: data
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drop data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-folder-tree-node {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
min-height: 36px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-item.drag-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
border: 2px dashed rgb(var(--v-theme-primary));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
93
dashboard/src/components/folder/BaseMoveTargetNode.vue
Normal file
93
dashboard/src/components/folder/BaseMoveTargetNode.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="base-move-target-node">
|
||||
<v-list-item :active="selectedFolderId === folder.folder_id" :disabled="isDisabled"
|
||||
@click.stop="!isDisabled && $emit('select', folder.folder_id)" rounded="lg"
|
||||
:style="{ paddingLeft: `${(depth + 1) * 16}px` }" class="folder-item">
|
||||
<template v-slot:prepend>
|
||||
<v-btn v-if="hasChildren" icon variant="text" size="x-small" @click.stop="toggleExpand"
|
||||
class="expand-btn" :disabled="isDisabled">
|
||||
<v-icon size="16">{{ isExpanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
|
||||
</v-btn>
|
||||
<div v-else class="expand-placeholder"></div>
|
||||
<v-icon :color="isDisabled ? 'grey' : (selectedFolderId === folder.folder_id ? 'primary' : '')">
|
||||
{{ isExpanded ? 'mdi-folder-open' : 'mdi-folder' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-truncate">{{ folder.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 子文件夹 -->
|
||||
<v-expand-transition>
|
||||
<div v-show="isExpanded && hasChildren">
|
||||
<BaseMoveTargetNode v-for="child in folder.children" :key="child.folder_id" :folder="child" :depth="depth + 1"
|
||||
:selected-folder-id="selectedFolderId" :disabled-folder-ids="disabledFolderIds"
|
||||
@select="$emit('select', $event)" />
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { FolderTreeNode } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseMoveTargetNode',
|
||||
props: {
|
||||
folder: {
|
||||
type: Object as PropType<FolderTreeNode>,
|
||||
required: true
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
selectedFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
disabledFolderIds: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['select'],
|
||||
data() {
|
||||
return {
|
||||
isExpanded: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasChildren(): boolean {
|
||||
return this.folder.children && this.folder.children.length > 0;
|
||||
},
|
||||
isDisabled(): boolean {
|
||||
return this.disabledFolderIds.includes(this.folder.folder_id);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleExpand() {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-move-target-node {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
178
dashboard/src/components/folder/BaseMoveToFolderDialog.vue
Normal file
178
dashboard/src/components/folder/BaseMoveToFolderDialog.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="500px" persistent>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-folder-move</v-icon>
|
||||
{{ labels.title }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
{{ labels.description }}
|
||||
</p>
|
||||
|
||||
<!-- 文件夹选择树 -->
|
||||
<div class="folder-select-tree">
|
||||
<v-list density="compact" nav class="tree-list">
|
||||
<!-- 根目录选项 -->
|
||||
<v-list-item :active="selectedFolderId === null" @click="selectFolder(null)" rounded="lg"
|
||||
class="mb-1">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-home</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ labels.rootFolder }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<template v-if="!treeLoading">
|
||||
<BaseMoveTargetNode v-for="folder in folderTree" :key="folder.folder_id" :folder="folder"
|
||||
:depth="0" :selected-folder-id="selectedFolderId" :disabled-folder-ids="disabledFolderIds"
|
||||
@select="selectFolder" />
|
||||
</template>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate size="24" />
|
||||
</div>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeDialog">
|
||||
{{ labels.cancelButton }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="submitMove" :loading="loading">
|
||||
{{ labels.moveButton }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import type { FolderTreeNode } from './types';
|
||||
import BaseMoveTargetNode from './BaseMoveTargetNode.vue';
|
||||
import { collectFolderAndChildrenIds } from './useFolderManager';
|
||||
|
||||
interface DefaultLabels {
|
||||
title: string;
|
||||
description: string;
|
||||
rootFolder: string;
|
||||
cancelButton: string;
|
||||
moveButton: string;
|
||||
}
|
||||
|
||||
const defaultLabels: DefaultLabels = {
|
||||
title: '移动到文件夹',
|
||||
description: '选择目标文件夹',
|
||||
rootFolder: '根目录',
|
||||
cancelButton: '取消',
|
||||
moveButton: '移动'
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseMoveToFolderDialog',
|
||||
components: {
|
||||
BaseMoveTargetNode
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
folderTree: {
|
||||
type: Array as PropType<FolderTreeNode[]>,
|
||||
required: true
|
||||
},
|
||||
treeLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 当移动的是文件夹时,需要传入当前文件夹 ID 以禁用自身和子文件夹
|
||||
currentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
// 项目当前所在的文件夹 ID(用于初始化选择)
|
||||
itemCurrentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
// 是否是移动文件夹(如果是,需要禁用自身和子文件夹)
|
||||
isMovingFolder: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
labels: {
|
||||
type: Object as PropType<Partial<DefaultLabels>>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'move'],
|
||||
data() {
|
||||
return {
|
||||
selectedFolderId: null as string | null,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showDialog: {
|
||||
get(): boolean {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value: boolean) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
},
|
||||
mergedLabels(): DefaultLabels {
|
||||
return { ...defaultLabels, ...this.labels };
|
||||
},
|
||||
// 禁用的文件夹 ID(不能移动到自己或子文件夹)
|
||||
disabledFolderIds(): string[] {
|
||||
if (!this.isMovingFolder || !this.currentFolderId) return [];
|
||||
return collectFolderAndChildrenIds(this.folderTree, this.currentFolderId);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue: boolean) {
|
||||
if (newValue) {
|
||||
// 初始化选中为当前所在文件夹
|
||||
this.selectedFolderId = this.itemCurrentFolderId;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectFolder(folderId: string | null) {
|
||||
// 检查是否禁用
|
||||
if (folderId && this.disabledFolderIds.includes(folderId)) return;
|
||||
this.selectedFolderId = folderId;
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
submitMove() {
|
||||
this.$emit('move', this.selectedFolderId);
|
||||
},
|
||||
|
||||
setLoading(value: boolean) {
|
||||
this.loading = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-select-tree {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
349
dashboard/src/components/folder/README.md
Normal file
349
dashboard/src/components/folder/README.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# 通用文件夹管理组件库
|
||||
|
||||
这是一个可复用的文件夹管理 UI 组件库,提供了完整的文件夹树、面包屑导航、拖放操作等功能。可用于管理各种类型的项目,如 Persona、模板、知识库等。
|
||||
|
||||
## 组件列表
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| `BaseFolderTree` | 文件夹树组件,支持搜索、展开/折叠、右键菜单、拖放 |
|
||||
| `BaseFolderTreeNode` | 文件夹树节点组件(内部使用) |
|
||||
| `BaseFolderCard` | 文件夹卡片组件,用于网格布局展示 |
|
||||
| `BaseFolderBreadcrumb` | 面包屑导航组件 |
|
||||
| `BaseCreateFolderDialog` | 创建文件夹对话框 |
|
||||
| `BaseMoveToFolderDialog` | 移动项目到文件夹对话框 |
|
||||
| `BaseMoveTargetNode` | 移动对话框中的目标文件夹节点(内部使用) |
|
||||
|
||||
## Composable
|
||||
|
||||
### `useFolderManager`
|
||||
|
||||
提供文件夹管理的核心逻辑,包括状态管理、导航、CRUD 操作等。
|
||||
|
||||
```typescript
|
||||
import { useFolderManager } from '@/components/folder';
|
||||
|
||||
const {
|
||||
// 状态
|
||||
folderTree,
|
||||
currentFolderId,
|
||||
currentFolders,
|
||||
breadcrumbPath,
|
||||
expandedFolderIds,
|
||||
loading,
|
||||
treeLoading,
|
||||
|
||||
// 计算属性
|
||||
currentFolderName,
|
||||
breadcrumbItems,
|
||||
|
||||
// 方法
|
||||
loadFolderTree,
|
||||
navigateToFolder,
|
||||
refreshCurrentFolder,
|
||||
createFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
moveFolder,
|
||||
toggleFolderExpansion,
|
||||
setFolderExpansion,
|
||||
findFolderInTree,
|
||||
findPathToFolder,
|
||||
filterTreeBySearch,
|
||||
} = useFolderManager({
|
||||
operations: {
|
||||
loadFolderTree: async () => {
|
||||
const response = await axios.get('/api/your-module/folder/tree');
|
||||
return response.data.data;
|
||||
},
|
||||
loadSubFolders: async (parentId) => {
|
||||
const response = await axios.get('/api/your-module/folder/list', {
|
||||
params: { parent_id: parentId ?? '' }
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
createFolder: async (data) => {
|
||||
const response = await axios.post('/api/your-module/folder/create', data);
|
||||
return response.data.data.folder;
|
||||
},
|
||||
updateFolder: async (data) => {
|
||||
await axios.post('/api/your-module/folder/update', data);
|
||||
},
|
||||
deleteFolder: async (folderId) => {
|
||||
await axios.post('/api/your-module/folder/delete', { folder_id: folderId });
|
||||
},
|
||||
},
|
||||
rootFolderName: '根目录',
|
||||
autoLoad: true,
|
||||
});
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="folder-manager">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<BaseFolderTree
|
||||
:folder-tree="folderTree"
|
||||
:current-folder-id="currentFolderId"
|
||||
:expanded-folder-ids="expandedFolderIds"
|
||||
:tree-loading="treeLoading"
|
||||
:accept-drop-types="['item']"
|
||||
:labels="treeLabels"
|
||||
@folder-click="navigateToFolder"
|
||||
@rename-folder="handleRenameFolder"
|
||||
@move-folder="handleMoveFolder"
|
||||
@delete-folder="handleDeleteFolder"
|
||||
@item-dropped="handleItemDropped"
|
||||
@toggle-expansion="toggleFolderExpansion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<!-- 面包屑 -->
|
||||
<BaseFolderBreadcrumb
|
||||
:breadcrumb-path="breadcrumbPath"
|
||||
:current-folder-id="currentFolderId"
|
||||
root-folder-name="根目录"
|
||||
@navigate="navigateToFolder"
|
||||
/>
|
||||
|
||||
<!-- 文件夹卡片 -->
|
||||
<v-row>
|
||||
<v-col v-for="folder in currentFolders" :key="folder.folder_id" cols="3">
|
||||
<BaseFolderCard
|
||||
:folder="folder"
|
||||
:accept-drop-types="['item']"
|
||||
:labels="cardLabels"
|
||||
@click="navigateToFolder(folder.folder_id)"
|
||||
@open="navigateToFolder(folder.folder_id)"
|
||||
@rename="handleRenameFolder(folder)"
|
||||
@move="handleMoveFolder(folder)"
|
||||
@delete="handleDeleteFolder(folder)"
|
||||
@item-dropped="handleItemDropped"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 创建文件夹对话框 -->
|
||||
<BaseCreateFolderDialog
|
||||
v-model="showCreateDialog"
|
||||
:parent-folder-id="currentFolderId"
|
||||
:labels="createDialogLabels"
|
||||
@create="handleCreateFolder"
|
||||
/>
|
||||
|
||||
<!-- 移动对话框 -->
|
||||
<BaseMoveToFolderDialog
|
||||
v-model="showMoveDialog"
|
||||
:folder-tree="folderTree"
|
||||
:tree-loading="treeLoading"
|
||||
:current-folder-id="movingFolder?.folder_id"
|
||||
:item-current-folder-id="movingFolder?.parent_id"
|
||||
:is-moving-folder="true"
|
||||
:labels="moveDialogLabels"
|
||||
@move="handleMove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
BaseFolderTree,
|
||||
BaseFolderCard,
|
||||
BaseFolderBreadcrumb,
|
||||
BaseCreateFolderDialog,
|
||||
BaseMoveToFolderDialog,
|
||||
useFolderManager,
|
||||
} from '@/components/folder';
|
||||
|
||||
const folderManager = useFolderManager({
|
||||
operations: {
|
||||
// ... 实现你的 API 调用
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
folderTree,
|
||||
currentFolderId,
|
||||
currentFolders,
|
||||
breadcrumbPath,
|
||||
expandedFolderIds,
|
||||
treeLoading,
|
||||
navigateToFolder,
|
||||
toggleFolderExpansion,
|
||||
createFolder,
|
||||
} = folderManager;
|
||||
|
||||
const showCreateDialog = ref(false);
|
||||
const showMoveDialog = ref(false);
|
||||
const movingFolder = ref(null);
|
||||
|
||||
// 自定义标签
|
||||
const treeLabels = {
|
||||
searchPlaceholder: '搜索文件夹...',
|
||||
rootFolder: '根目录',
|
||||
noFolders: '暂无文件夹',
|
||||
contextMenu: {
|
||||
open: '打开',
|
||||
rename: '重命名',
|
||||
moveTo: '移动到...',
|
||||
delete: '删除',
|
||||
},
|
||||
};
|
||||
|
||||
const cardLabels = {
|
||||
open: '打开',
|
||||
rename: '重命名',
|
||||
moveTo: '移动到...',
|
||||
delete: '删除',
|
||||
};
|
||||
|
||||
const createDialogLabels = {
|
||||
title: '创建文件夹',
|
||||
nameLabel: '名称',
|
||||
descriptionLabel: '描述',
|
||||
nameRequired: '请输入名称',
|
||||
cancelButton: '取消',
|
||||
createButton: '创建',
|
||||
};
|
||||
|
||||
// 处理函数
|
||||
async function handleCreateFolder(data) {
|
||||
await createFolder(data);
|
||||
showCreateDialog.value = false;
|
||||
}
|
||||
|
||||
function handleRenameFolder(folder) {
|
||||
// 打开重命名对话框
|
||||
}
|
||||
|
||||
function handleMoveFolder(folder) {
|
||||
movingFolder.value = folder;
|
||||
showMoveDialog.value = true;
|
||||
}
|
||||
|
||||
function handleDeleteFolder(folder) {
|
||||
// 确认并删除
|
||||
}
|
||||
|
||||
function handleItemDropped({ item_id, item_type, target_folder_id }) {
|
||||
// 处理拖放
|
||||
}
|
||||
|
||||
async function handleMove(targetFolderId) {
|
||||
// 执行移动
|
||||
showMoveDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
```typescript
|
||||
// 文件夹基础接口
|
||||
interface Folder {
|
||||
folder_id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
description?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 文件夹树节点接口
|
||||
interface FolderTreeNode extends Folder {
|
||||
children: FolderTreeNode[];
|
||||
}
|
||||
|
||||
// 拖放事件数据
|
||||
interface DropEventData {
|
||||
item_id: string;
|
||||
item_type: string;
|
||||
target_folder_id: string | null;
|
||||
source_data?: any;
|
||||
}
|
||||
|
||||
// 创建文件夹数据
|
||||
interface CreateFolderData {
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 国际化支持
|
||||
|
||||
所有组件都支持通过 `labels` prop 自定义文本,方便集成到不同的国际化方案中:
|
||||
|
||||
```vue
|
||||
<BaseFolderTree
|
||||
:labels="{
|
||||
searchPlaceholder: t('folder.search'),
|
||||
rootFolder: t('folder.root'),
|
||||
noFolders: t('folder.empty'),
|
||||
contextMenu: {
|
||||
open: t('folder.menu.open'),
|
||||
rename: t('folder.menu.rename'),
|
||||
moveTo: t('folder.menu.move'),
|
||||
delete: t('folder.menu.delete'),
|
||||
},
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
## 拖放支持
|
||||
|
||||
组件内置了拖放支持,可以通过 `acceptDropTypes` 指定接受的拖放类型:
|
||||
|
||||
```vue
|
||||
<!-- 只接受 'persona' 类型的拖放 -->
|
||||
<BaseFolderTree
|
||||
:accept-drop-types="['persona']"
|
||||
@item-dropped="handleDrop"
|
||||
/>
|
||||
|
||||
<!-- 拖放事件处理 -->
|
||||
<script setup>
|
||||
function handleDrop({ item_id, item_type, target_folder_id, source_data }) {
|
||||
if (item_type === 'persona') {
|
||||
// 移动 persona 到目标文件夹
|
||||
movePersonaToFolder(item_id, target_folder_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 与 Pinia Store 集成
|
||||
|
||||
如果你更喜欢使用 Pinia Store 管理状态,可以参考现有的 `personaStore.ts` 实现:
|
||||
|
||||
```typescript
|
||||
// stores/myFolderStore.ts
|
||||
import { defineStore } from 'pinia';
|
||||
import type { FolderTreeNode, Folder } from '@/components/folder';
|
||||
|
||||
export const useMyFolderStore = defineStore('myFolder', {
|
||||
state: () => ({
|
||||
folderTree: [] as FolderTreeNode[],
|
||||
currentFolderId: null as string | null,
|
||||
currentFolders: [] as Folder[],
|
||||
// ...
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadFolderTree() {
|
||||
// ...
|
||||
},
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
46
dashboard/src/components/folder/index.ts
Normal file
46
dashboard/src/components/folder/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 通用文件夹管理组件库
|
||||
*
|
||||
* 提供可复用的文件夹管理 UI 组件,适用于各种需要文件夹组织功能的场景
|
||||
* 如:persona 管理、模板管理、知识库管理等
|
||||
*
|
||||
* 使用示例:
|
||||
* ```vue
|
||||
* <script setup>
|
||||
* import {
|
||||
* BaseFolderTree,
|
||||
* BaseFolderCard,
|
||||
* BaseFolderBreadcrumb,
|
||||
* BaseCreateFolderDialog,
|
||||
* BaseMoveToFolderDialog,
|
||||
* useFolderManager
|
||||
* } from '@/components/folder';
|
||||
*
|
||||
* const folderManager = useFolderManager({
|
||||
* operations: {
|
||||
* loadFolderTree: async () => { ... },
|
||||
* loadSubFolders: async (parentId) => { ... },
|
||||
* createFolder: async (data) => { ... },
|
||||
* updateFolder: async (data) => { ... },
|
||||
* deleteFolder: async (folderId) => { ... },
|
||||
* }
|
||||
* });
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export * from './types';
|
||||
|
||||
// Composable 导出
|
||||
export { useFolderManager, collectFolderAndChildrenIds } from './useFolderManager';
|
||||
export type { UseFolderManagerOptions, UseFolderManagerReturn } from './useFolderManager';
|
||||
|
||||
// 组件导出
|
||||
export { default as BaseFolderTree } from './BaseFolderTree.vue';
|
||||
export { default as BaseFolderTreeNode } from './BaseFolderTreeNode.vue';
|
||||
export { default as BaseFolderCard } from './BaseFolderCard.vue';
|
||||
export { default as BaseFolderBreadcrumb } from './BaseFolderBreadcrumb.vue';
|
||||
export { default as BaseCreateFolderDialog } from './BaseCreateFolderDialog.vue';
|
||||
export { default as BaseMoveToFolderDialog } from './BaseMoveToFolderDialog.vue';
|
||||
export { default as BaseMoveTargetNode } from './BaseMoveTargetNode.vue';
|
||||
249
dashboard/src/components/folder/types.ts
Normal file
249
dashboard/src/components/folder/types.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 通用文件夹管理组件类型定义
|
||||
*
|
||||
* 这是一个可复用的文件夹管理系统,可用于管理各种类型的项目(如 persona、模板、知识库等)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件夹基础接口
|
||||
*/
|
||||
export interface Folder {
|
||||
folder_id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
description?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹树节点接口
|
||||
*/
|
||||
export interface FolderTreeNode extends Folder {
|
||||
children: FolderTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽的项目接口(可以是文件夹或其他项目)
|
||||
*/
|
||||
export interface DraggableItem {
|
||||
id: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽放置事件数据
|
||||
*/
|
||||
export interface DropEventData {
|
||||
item_id: string;
|
||||
item_type: string;
|
||||
target_folder_id: string | null;
|
||||
source_data?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹操作接口 - 由使用方提供具体实现
|
||||
*/
|
||||
export interface FolderOperations {
|
||||
// 加载文件夹树
|
||||
loadFolderTree: () => Promise<FolderTreeNode[]>;
|
||||
|
||||
// 加载指定文件夹的子文件夹
|
||||
loadSubFolders: (parentId: string | null) => Promise<Folder[]>;
|
||||
|
||||
// 创建文件夹
|
||||
createFolder: (data: CreateFolderData) => Promise<Folder>;
|
||||
|
||||
// 更新文件夹
|
||||
updateFolder: (data: UpdateFolderData) => Promise<void>;
|
||||
|
||||
// 删除文件夹
|
||||
deleteFolder: (folderId: string) => Promise<void>;
|
||||
|
||||
// 移动文件夹
|
||||
moveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹数据
|
||||
*/
|
||||
export interface CreateFolderData {
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件夹数据
|
||||
*/
|
||||
export interface UpdateFolderData {
|
||||
folder_id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parent_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹管理器状态
|
||||
*/
|
||||
export interface FolderManagerState {
|
||||
folderTree: FolderTreeNode[];
|
||||
currentFolderId: string | null;
|
||||
currentFolders: Folder[];
|
||||
breadcrumbPath: FolderTreeNode[];
|
||||
expandedFolderIds: string[];
|
||||
loading: boolean;
|
||||
treeLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面包屑项接口
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
title: string;
|
||||
folderId: string | null;
|
||||
disabled: boolean;
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文菜单事件
|
||||
*/
|
||||
export interface ContextMenuEvent {
|
||||
event: MouseEvent;
|
||||
folder: Folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹组件 i18n 键配置
|
||||
* 允许使用方自定义翻译键
|
||||
*/
|
||||
export interface FolderI18nKeys {
|
||||
// 搜索框
|
||||
searchPlaceholder?: string;
|
||||
|
||||
// 根目录
|
||||
rootFolder?: string;
|
||||
|
||||
// 侧边栏标题
|
||||
sidebarTitle?: string;
|
||||
|
||||
// 空状态
|
||||
noFolders?: string;
|
||||
|
||||
// 文件夹标题
|
||||
foldersTitle?: string;
|
||||
|
||||
// 按钮
|
||||
buttons?: {
|
||||
create?: string;
|
||||
cancel?: string;
|
||||
save?: string;
|
||||
delete?: string;
|
||||
move?: string;
|
||||
};
|
||||
|
||||
// 表单
|
||||
form?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
// 验证
|
||||
validation?: {
|
||||
nameRequired?: string;
|
||||
};
|
||||
|
||||
// 右键菜单
|
||||
contextMenu?: {
|
||||
open?: string;
|
||||
rename?: string;
|
||||
moveTo?: string;
|
||||
delete?: string;
|
||||
};
|
||||
|
||||
// 对话框
|
||||
dialogs?: {
|
||||
createTitle?: string;
|
||||
renameTitle?: string;
|
||||
deleteTitle?: string;
|
||||
deleteMessage?: string;
|
||||
deleteWarning?: string;
|
||||
moveTitle?: string;
|
||||
moveDescription?: string;
|
||||
};
|
||||
|
||||
// 消息
|
||||
messages?: {
|
||||
createSuccess?: string;
|
||||
createError?: string;
|
||||
renameSuccess?: string;
|
||||
renameError?: string;
|
||||
deleteSuccess?: string;
|
||||
deleteError?: string;
|
||||
moveSuccess?: string;
|
||||
moveError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用文件夹组件 Props
|
||||
*/
|
||||
export interface BaseFolderProps {
|
||||
// i18n 翻译函数
|
||||
t?: (key: string, params?: Record<string, any>) => string;
|
||||
|
||||
// i18n 键配置
|
||||
i18nKeys?: FolderI18nKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选择的项目基础接口
|
||||
*/
|
||||
export interface SelectableItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
folder_id?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹项目选择器操作接口
|
||||
*/
|
||||
export interface FolderItemSelectorOperations<T extends SelectableItem> {
|
||||
// 加载文件夹树
|
||||
loadFolderTree: () => Promise<FolderTreeNode[]>;
|
||||
|
||||
// 加载指定文件夹下的项目
|
||||
loadItemsInFolder: (folderId: string | null) => Promise<T[]>;
|
||||
|
||||
// 创建项目(可选)
|
||||
createItem?: (data: any) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹项目选择器标签配置
|
||||
*/
|
||||
export interface FolderItemSelectorLabels {
|
||||
// 对话框
|
||||
dialogTitle?: string;
|
||||
notSelected?: string;
|
||||
buttonText?: string;
|
||||
|
||||
// 项目列表
|
||||
noItems?: string;
|
||||
defaultItem?: string;
|
||||
noDescription?: string;
|
||||
emptyFolder?: string;
|
||||
|
||||
// 按钮
|
||||
createButton?: string;
|
||||
confirmButton?: string;
|
||||
cancelButton?: string;
|
||||
|
||||
// 文件夹
|
||||
rootFolder?: string;
|
||||
}
|
||||
324
dashboard/src/components/folder/useFolderManager.ts
Normal file
324
dashboard/src/components/folder/useFolderManager.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 通用文件夹管理 Composable
|
||||
*
|
||||
* 提供文件夹管理的核心逻辑,可以被不同的业务模块复用
|
||||
*/
|
||||
import { ref, computed, reactive, type Ref, type ComputedRef } from 'vue';
|
||||
import type {
|
||||
Folder,
|
||||
FolderTreeNode,
|
||||
FolderOperations,
|
||||
CreateFolderData,
|
||||
UpdateFolderData,
|
||||
BreadcrumbItem,
|
||||
} from './types';
|
||||
|
||||
export interface UseFolderManagerOptions {
|
||||
// 文件夹操作实现
|
||||
operations: FolderOperations;
|
||||
|
||||
// 根目录显示名称
|
||||
rootFolderName?: string;
|
||||
|
||||
// 是否自动加载
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
export interface UseFolderManagerReturn {
|
||||
// 状态
|
||||
folderTree: Ref<FolderTreeNode[]>;
|
||||
currentFolderId: Ref<string | null>;
|
||||
currentFolders: Ref<Folder[]>;
|
||||
breadcrumbPath: Ref<FolderTreeNode[]>;
|
||||
expandedFolderIds: Ref<string[]>;
|
||||
loading: Ref<boolean>;
|
||||
treeLoading: Ref<boolean>;
|
||||
|
||||
// 计算属性
|
||||
currentFolderName: ComputedRef<string>;
|
||||
breadcrumbItems: ComputedRef<BreadcrumbItem[]>;
|
||||
|
||||
// 方法
|
||||
loadFolderTree: () => Promise<void>;
|
||||
navigateToFolder: (folderId: string | null) => Promise<void>;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
|
||||
createFolder: (data: CreateFolderData) => Promise<Folder>;
|
||||
updateFolder: (data: UpdateFolderData) => Promise<void>;
|
||||
deleteFolder: (folderId: string) => Promise<void>;
|
||||
moveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
|
||||
|
||||
toggleFolderExpansion: (folderId: string) => void;
|
||||
setFolderExpansion: (folderId: string, expanded: boolean) => void;
|
||||
|
||||
findFolderInTree: (folderId: string) => FolderTreeNode | null;
|
||||
findPathToFolder: (folderId: string) => FolderTreeNode[];
|
||||
|
||||
filterTreeBySearch: (query: string) => FolderTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹管理 composable
|
||||
*/
|
||||
export function useFolderManager(options: UseFolderManagerOptions): UseFolderManagerReturn {
|
||||
const { operations, rootFolderName = '根目录', autoLoad = false } = options;
|
||||
|
||||
// 状态
|
||||
const folderTree = ref<FolderTreeNode[]>([]);
|
||||
const currentFolderId = ref<string | null>(null);
|
||||
const currentFolders = ref<Folder[]>([]);
|
||||
const breadcrumbPath = ref<FolderTreeNode[]>([]);
|
||||
const expandedFolderIds = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
const treeLoading = ref(false);
|
||||
|
||||
// 计算属性
|
||||
const currentFolderName = computed(() => {
|
||||
if (breadcrumbPath.value.length === 0) {
|
||||
return rootFolderName;
|
||||
}
|
||||
return breadcrumbPath.value[breadcrumbPath.value.length - 1]?.name || rootFolderName;
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed((): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [
|
||||
{
|
||||
title: rootFolderName,
|
||||
folderId: null,
|
||||
disabled: currentFolderId.value === null,
|
||||
isRoot: true,
|
||||
},
|
||||
];
|
||||
|
||||
breadcrumbPath.value.forEach((folder, index) => {
|
||||
items.push({
|
||||
title: folder.name,
|
||||
folderId: folder.folder_id,
|
||||
disabled: index === breadcrumbPath.value.length - 1,
|
||||
isRoot: false,
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// 内部方法
|
||||
const findPathToFolderInternal = (
|
||||
nodes: FolderTreeNode[],
|
||||
targetId: string,
|
||||
path: FolderTreeNode[] = []
|
||||
): FolderTreeNode[] | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === targetId) {
|
||||
return [...path, node];
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const result = findPathToFolderInternal(node.children, targetId, [...path, node]);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateBreadcrumb = (folderId: string | null): void => {
|
||||
if (folderId === null) {
|
||||
breadcrumbPath.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const path = findPathToFolderInternal(folderTree.value, folderId);
|
||||
breadcrumbPath.value = path || [];
|
||||
};
|
||||
|
||||
// 公开方法
|
||||
const loadFolderTree = async (): Promise<void> => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
folderTree.value = await operations.loadFolderTree();
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToFolder = async (folderId: string | null): Promise<void> => {
|
||||
loading.value = true;
|
||||
try {
|
||||
currentFolderId.value = folderId;
|
||||
currentFolders.value = await operations.loadSubFolders(folderId);
|
||||
updateBreadcrumb(folderId);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCurrentFolder = async (): Promise<void> => {
|
||||
await navigateToFolder(currentFolderId.value);
|
||||
};
|
||||
|
||||
const createFolder = async (data: CreateFolderData): Promise<Folder> => {
|
||||
const folder = await operations.createFolder({
|
||||
...data,
|
||||
parent_id: data.parent_id ?? currentFolderId.value,
|
||||
});
|
||||
|
||||
await Promise.all([refreshCurrentFolder(), loadFolderTree()]);
|
||||
|
||||
return folder;
|
||||
};
|
||||
|
||||
const updateFolder = async (data: UpdateFolderData): Promise<void> => {
|
||||
await operations.updateFolder(data);
|
||||
await Promise.all([refreshCurrentFolder(), loadFolderTree()]);
|
||||
};
|
||||
|
||||
const deleteFolder = async (folderId: string): Promise<void> => {
|
||||
await operations.deleteFolder(folderId);
|
||||
await Promise.all([refreshCurrentFolder(), loadFolderTree()]);
|
||||
};
|
||||
|
||||
const moveFolder = async (folderId: string, targetParentId: string | null): Promise<void> => {
|
||||
if (operations.moveFolder) {
|
||||
await operations.moveFolder(folderId, targetParentId);
|
||||
} else {
|
||||
// 如果没有专门的移动方法,使用更新方法
|
||||
await operations.updateFolder({
|
||||
folder_id: folderId,
|
||||
parent_id: targetParentId,
|
||||
});
|
||||
}
|
||||
await Promise.all([refreshCurrentFolder(), loadFolderTree()]);
|
||||
};
|
||||
|
||||
const toggleFolderExpansion = (folderId: string): void => {
|
||||
const index = expandedFolderIds.value.indexOf(folderId);
|
||||
if (index === -1) {
|
||||
expandedFolderIds.value.push(folderId);
|
||||
} else {
|
||||
expandedFolderIds.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const setFolderExpansion = (folderId: string, expanded: boolean): void => {
|
||||
const index = expandedFolderIds.value.indexOf(folderId);
|
||||
if (expanded && index === -1) {
|
||||
expandedFolderIds.value.push(folderId);
|
||||
} else if (!expanded && index !== -1) {
|
||||
expandedFolderIds.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const findFolderInTree = (folderId: string): FolderTreeNode | null => {
|
||||
const findNode = (nodes: FolderTreeNode[]): FolderTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === folderId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNode(node.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findNode(folderTree.value);
|
||||
};
|
||||
|
||||
const findPathToFolder = (folderId: string): FolderTreeNode[] => {
|
||||
return findPathToFolderInternal(folderTree.value, folderId) || [];
|
||||
};
|
||||
|
||||
const filterTreeBySearch = (query: string): FolderTreeNode[] => {
|
||||
if (!query) return folderTree.value;
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const filterNodes = (nodes: FolderTreeNode[]): FolderTreeNode[] => {
|
||||
return nodes
|
||||
.filter((node) => {
|
||||
const matches = node.name.toLowerCase().includes(lowerQuery);
|
||||
const childMatches = filterNodes(node.children || []);
|
||||
return matches || childMatches.length > 0;
|
||||
})
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: filterNodes(node.children || []),
|
||||
}));
|
||||
};
|
||||
|
||||
return filterNodes(folderTree.value);
|
||||
};
|
||||
|
||||
// 自动加载
|
||||
if (autoLoad) {
|
||||
loadFolderTree();
|
||||
navigateToFolder(null);
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
folderTree,
|
||||
currentFolderId,
|
||||
currentFolders,
|
||||
breadcrumbPath,
|
||||
expandedFolderIds,
|
||||
loading,
|
||||
treeLoading,
|
||||
|
||||
// 计算属性
|
||||
currentFolderName,
|
||||
breadcrumbItems,
|
||||
|
||||
// 方法
|
||||
loadFolderTree,
|
||||
navigateToFolder,
|
||||
refreshCurrentFolder,
|
||||
createFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
moveFolder,
|
||||
toggleFolderExpansion,
|
||||
setFolderExpansion,
|
||||
findFolderInTree,
|
||||
findPathToFolder,
|
||||
filterTreeBySearch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集文件夹及其所有子文件夹的 ID
|
||||
* 用于禁用移动对话框中不能选择的目标
|
||||
*/
|
||||
export function collectFolderAndChildrenIds(
|
||||
folderTree: FolderTreeNode[],
|
||||
folderId: string
|
||||
): string[] {
|
||||
const ids: string[] = [folderId];
|
||||
|
||||
const collectChildIds = (nodes: FolderTreeNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === folderId) {
|
||||
const collectAllChildren = (children: FolderTreeNode[]) => {
|
||||
for (const child of children) {
|
||||
ids.push(child.folder_id);
|
||||
if (child.children) {
|
||||
collectAllChildren(child.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (node.children) {
|
||||
collectAllChildren(node.children);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (node.children && collectChildIds(node.children)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
collectChildIds(folderTree);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export default useFolderManager;
|
||||
@@ -1,11 +1,23 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="500px" persistent>
|
||||
<v-dialog v-model="showDialog" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h2">
|
||||
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<!-- 创建位置提示 -->
|
||||
<v-alert
|
||||
v-if="!editingPersona"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
icon="mdi-folder-outline"
|
||||
>
|
||||
{{ tm('form.createInFolder', { folder: folderDisplayName }) }}
|
||||
</v-alert>
|
||||
|
||||
<v-form ref="personaForm" v-model="formValid">
|
||||
<v-text-field v-model="personaForm.persona_id" :label="tm('form.personaId')"
|
||||
:rules="personaIdRules" :disabled="editingPersona" variant="outlined" density="comfortable"
|
||||
@@ -209,6 +221,14 @@ export default {
|
||||
editingPersona: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
currentFolderId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
currentFolderName: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'saved', 'error'],
|
||||
@@ -225,15 +245,18 @@ export default {
|
||||
mcpServers: [],
|
||||
availableTools: [],
|
||||
loadingTools: false,
|
||||
existingPersonaIds: [], // 已存在的人格ID列表
|
||||
personaForm: {
|
||||
persona_id: '',
|
||||
system_prompt: '',
|
||||
begin_dialogs: [],
|
||||
tools: []
|
||||
tools: [],
|
||||
folder_id: null
|
||||
},
|
||||
personaIdRules: [
|
||||
v => !!v || this.tm('validation.required'),
|
||||
v => (v && v.length >= 0) || this.tm('validation.minLength', { min: 2 }),
|
||||
v => (v && v.length >= 1) || this.tm('validation.minLength', { min: 1 }),
|
||||
v => !this.existingPersonaIds.includes(v) || this.tm('validation.personaIdExists'),
|
||||
],
|
||||
systemPromptRules: [
|
||||
v => !!v || this.tm('validation.required'),
|
||||
@@ -262,6 +285,18 @@ export default {
|
||||
(tool.description && tool.description.toLowerCase().includes(search)) ||
|
||||
(tool.mcp_server_name && tool.mcp_server_name.toLowerCase().includes(search))
|
||||
);
|
||||
},
|
||||
folderDisplayName() {
|
||||
// 优先使用传入的文件夹名称
|
||||
if (this.currentFolderName) {
|
||||
return this.currentFolderName;
|
||||
}
|
||||
// 如果没有文件夹 ID,显示根目录
|
||||
if (!this.currentFolderId) {
|
||||
return this.tm('form.rootFolder');
|
||||
}
|
||||
// 否则显示文件夹 ID(作为备用)
|
||||
return this.currentFolderId;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -273,6 +308,8 @@ export default {
|
||||
this.initFormWithPersona(this.editingPersona);
|
||||
} else {
|
||||
this.initForm();
|
||||
// 只在创建新人格时加载已存在的人格列表
|
||||
this.loadExistingPersonaIds();
|
||||
}
|
||||
this.loadMcpServers();
|
||||
this.loadTools();
|
||||
@@ -310,7 +347,8 @@ export default {
|
||||
persona_id: '',
|
||||
system_prompt: '',
|
||||
begin_dialogs: [],
|
||||
tools: []
|
||||
tools: [],
|
||||
folder_id: this.currentFolderId
|
||||
};
|
||||
this.toolSelectValue = '0';
|
||||
this.expandedPanels = [];
|
||||
@@ -321,7 +359,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';
|
||||
@@ -363,6 +402,18 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async loadExistingPersonaIds() {
|
||||
try {
|
||||
const response = await axios.get('/api/persona/list');
|
||||
if (response.data.status === 'ok') {
|
||||
this.existingPersonaIds = (response.data.data || []).map(p => p.persona_id);
|
||||
}
|
||||
} catch (error) {
|
||||
// 加载失败不影响表单使用,只是无法进行前端重名校验
|
||||
this.existingPersonaIds = [];
|
||||
}
|
||||
},
|
||||
|
||||
async savePersona() {
|
||||
if (!this.formValid) return;
|
||||
|
||||
|
||||
@@ -1,84 +1,46 @@
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
|
||||
{{ tm('personaSelector.notSelected') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ modelValue === 'default' ? tm('personaSelector.defaultPersona') : modelValue }}
|
||||
</span>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ buttonText || tm('personaSelector.buttonText') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Persona Selection Dialog -->
|
||||
<v-dialog v-model="dialog" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
|
||||
{{ tm('personaSelector.dialogTitle') }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-2" style="max-height: 400px; overflow-y: auto;">
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
|
||||
|
||||
<v-list v-if="!loading && personaList.length > 0" density="compact">
|
||||
<v-list-item
|
||||
v-for="persona in personaList"
|
||||
:key="persona.persona_id"
|
||||
:value="persona.persona_id"
|
||||
@click="selectPersona(persona)"
|
||||
:active="selectedPersona === persona.persona_id"
|
||||
rounded="md"
|
||||
class="ma-1">
|
||||
<v-list-item-title>{{ persona.persona_id === 'default' ? tm('personaSelector.defaultPersona') : persona.persona_id }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ persona.system_prompt ? persona.system_prompt.substring(0, 50) + '...' : tm('personaSelector.noDescription') }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon v-if="selectedPersona === persona.persona_id" color="primary">mdi-check-circle</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-else-if="!loading && personaList.length === 0" class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-account-off</v-icon>
|
||||
<p class="text-grey mt-4">{{ tm('personaSelector.noPersonas') }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn variant="text" color="primary" prepend-icon="mdi-plus" @click="openCreatePersona">
|
||||
{{ tm('personaSelector.createPersona') }}
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="cancelSelection">{{ t('core.common.cancel') }}</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="confirmSelection"
|
||||
:disabled="!selectedPersona">
|
||||
{{ t('core.common.confirm') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<BaseFolderItemSelector
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleUpdate"
|
||||
:folder-tree="folderTree"
|
||||
:items="currentPersonas as any"
|
||||
:tree-loading="treeLoading"
|
||||
:items-loading="itemsLoading"
|
||||
:labels="labels"
|
||||
:show-create-button="true"
|
||||
:default-item="defaultPersona"
|
||||
item-id-field="persona_id"
|
||||
item-name-field="persona_id"
|
||||
item-description-field="system_prompt"
|
||||
:display-value-formatter="formatDisplayValue"
|
||||
@navigate="handleNavigate"
|
||||
@create="openCreatePersona"
|
||||
/>
|
||||
|
||||
<!-- 创建人格对话框 -->
|
||||
<PersonaForm
|
||||
<PersonaForm
|
||||
v-model="showCreateDialog"
|
||||
:editing-persona="null"
|
||||
:mcp-servers="mcpServers"
|
||||
:available-tools="availableTools"
|
||||
:loading-tools="loadingTools"
|
||||
:editing-persona="undefined"
|
||||
:current-folder-id="currentFolderId ?? undefined"
|
||||
:current-folder-name="currentFolderName ?? undefined"
|
||||
@saved="handlePersonaCreated"
|
||||
@error="handleError" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import BaseFolderItemSelector from '@/components/folder/BaseFolderItemSelector.vue'
|
||||
import PersonaForm from './PersonaForm.vue'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||
import type { FolderTreeNode, SelectableItem } from '@/components/folder/types'
|
||||
|
||||
interface Persona {
|
||||
persona_id: string
|
||||
system_prompt: string
|
||||
folder_id?: string | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -95,91 +57,142 @@ const emit = defineEmits(['update:modelValue'])
|
||||
const { t } = useI18n()
|
||||
const { tm } = useModuleI18n('core.shared')
|
||||
|
||||
const dialog = ref(false)
|
||||
const personaList = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedPersona = ref('')
|
||||
// 状态
|
||||
const folderTree = ref<FolderTreeNode[]>([])
|
||||
const currentPersonas = ref<Persona[]>([])
|
||||
const treeLoading = ref(false)
|
||||
const itemsLoading = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
const currentFolderId = ref<string | null>(null)
|
||||
|
||||
// 监听 modelValue 变化,同步到 selectedPersona
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectedPersona.value = newValue || ''
|
||||
}, { immediate: true })
|
||||
|
||||
async function openDialog() {
|
||||
selectedPersona.value = props.modelValue || ''
|
||||
dialog.value = true
|
||||
await loadPersonas()
|
||||
// 默认人格
|
||||
const defaultPersona: SelectableItem = {
|
||||
id: 'default',
|
||||
persona_id: 'default',
|
||||
name: tm('personaSelector.defaultPersona'),
|
||||
system_prompt: 'You are a helpful and friendly assistant.'
|
||||
}
|
||||
|
||||
async function loadPersonas() {
|
||||
loading.value = true
|
||||
// 递归查找文件夹名称
|
||||
function findFolderName(nodes: FolderTreeNode[], folderId: string): string | null {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === folderId) {
|
||||
return node.name
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findFolderName(node.children, folderId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 当前文件夹名称
|
||||
const currentFolderName = computed(() => {
|
||||
if (!currentFolderId.value) {
|
||||
return null // 根目录,PersonaForm 会使用 tm('form.rootFolder')
|
||||
}
|
||||
return findFolderName(folderTree.value, currentFolderId.value)
|
||||
})
|
||||
|
||||
// 标签配置
|
||||
const labels = computed(() => ({
|
||||
dialogTitle: tm('personaSelector.dialogTitle'),
|
||||
notSelected: tm('personaSelector.notSelected'),
|
||||
buttonText: props.buttonText || tm('personaSelector.buttonText'),
|
||||
noItems: tm('personaSelector.noPersonas'),
|
||||
defaultItem: tm('personaSelector.defaultPersona'),
|
||||
noDescription: tm('personaSelector.noDescription'),
|
||||
createButton: tm('personaSelector.createPersona'),
|
||||
confirmButton: t('core.common.confirm'),
|
||||
cancelButton: t('core.common.cancel'),
|
||||
rootFolder: tm('personaSelector.rootFolder') || '全部人格',
|
||||
emptyFolder: tm('personaSelector.emptyFolder') || '此文件夹为空'
|
||||
}))
|
||||
|
||||
// 格式化显示值
|
||||
function formatDisplayValue(value: string): string {
|
||||
if (value === 'default') {
|
||||
return tm('personaSelector.defaultPersona')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// 处理值更新
|
||||
function handleUpdate(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 加载文件夹树
|
||||
async function loadFolderTree() {
|
||||
treeLoading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/persona/list')
|
||||
const response = await axios.get('/api/persona/folder/tree')
|
||||
if (response.data.status === 'ok') {
|
||||
const personas = response.data.data || []
|
||||
// 添加默认人格选项
|
||||
personaList.value = [
|
||||
{
|
||||
persona_id: 'default',
|
||||
system_prompt: 'You are a helpful and friendly assistant.'
|
||||
},
|
||||
...personas
|
||||
]
|
||||
folderTree.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载人格列表失败:', error)
|
||||
personaList.value = [
|
||||
{
|
||||
persona_id: 'default',
|
||||
system_prompt: 'You are a helpful and friendly assistant.'
|
||||
}
|
||||
]
|
||||
console.error('加载文件夹树失败:', error)
|
||||
folderTree.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
treeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectPersona(persona) {
|
||||
selectedPersona.value = persona.persona_id
|
||||
// 加载指定文件夹的人格
|
||||
async function loadPersonasInFolder(folderId: string | null) {
|
||||
itemsLoading.value = true
|
||||
try {
|
||||
// 使用 /api/persona/list 端点,通过 folder_id 参数筛选
|
||||
const params = new URLSearchParams()
|
||||
if (folderId !== null) {
|
||||
params.set('folder_id', folderId)
|
||||
} else {
|
||||
// 根目录:folder_id 为空字符串表示获取根目录下的人格
|
||||
params.set('folder_id', '')
|
||||
}
|
||||
const response = await axios.get(`/api/persona/list?${params.toString()}`)
|
||||
if (response.data.status === 'ok') {
|
||||
currentPersonas.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载人格列表失败:', error)
|
||||
currentPersonas.value = []
|
||||
} finally {
|
||||
itemsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
emit('update:modelValue', selectedPersona.value)
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function cancelSelection() {
|
||||
selectedPersona.value = props.modelValue || ''
|
||||
dialog.value = false
|
||||
// 处理文件夹导航
|
||||
async function handleNavigate(folderId: string | null) {
|
||||
currentFolderId.value = folderId
|
||||
await loadPersonasInFolder(folderId)
|
||||
}
|
||||
|
||||
// 打开创建人格对话框
|
||||
function openCreatePersona() {
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
async function handlePersonaCreated(message) {
|
||||
// 人格创建成功
|
||||
async function handlePersonaCreated(message: string) {
|
||||
console.log('人格创建成功:', message)
|
||||
showCreateDialog.value = false
|
||||
// 刷新人格列表
|
||||
await loadPersonas()
|
||||
// 刷新当前文件夹的人格列表
|
||||
await loadPersonasInFolder(currentFolderId.value)
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
// 错误处理
|
||||
function handleError(error: string) {
|
||||
console.error('创建人格失败:', error)
|
||||
}
|
||||
|
||||
// 初始化加载文件夹树
|
||||
onMounted(() => {
|
||||
loadFolderTree()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-list-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
|
||||
.v-list-item.v-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
/* 样式继承自 BaseFolderItemSelector */
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
"createPersona": "Create New Persona",
|
||||
"cancelSelection": "Cancel",
|
||||
"confirmSelection": "Confirm Selection",
|
||||
"selectPersonaPool": "Select Persona Pool..."
|
||||
"selectPersonaPool": "Select Persona Pool...",
|
||||
"rootFolder": "All Personas",
|
||||
"emptyFolder": "This folder is empty"
|
||||
},
|
||||
"t2iTemplateEditor": {
|
||||
"buttonText": "Customize T2I Template",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"move": "Move",
|
||||
"addDialogPair": "Add Dialog Pair"
|
||||
},
|
||||
"labels": {
|
||||
@@ -36,7 +37,9 @@
|
||||
"noToolsFound": "No matching tools found",
|
||||
"loadingTools": "Loading tools...",
|
||||
"allToolsAvailable": "Use all available tools",
|
||||
"noToolsSelected": "No tools selected"
|
||||
"noToolsSelected": "No tools selected",
|
||||
"createInFolder": "Will be created in \"{folder}\"",
|
||||
"rootFolder": "All Personas"
|
||||
},
|
||||
"dialog": {
|
||||
"create": {
|
||||
@@ -48,13 +51,16 @@
|
||||
},
|
||||
"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",
|
||||
"minLength": "Minimum {min} characters required",
|
||||
"alphanumeric": "Only letters, numbers, underscores and hyphens are allowed",
|
||||
"dialogRequired": "{type} cannot be empty"
|
||||
"dialogRequired": "{type} cannot be empty",
|
||||
"personaIdExists": "This persona name already exists"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Failed to load persona list",
|
||||
@@ -63,5 +69,63 @@
|
||||
"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",
|
||||
"createButton": "Create"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
"createPersona": "创建新人格",
|
||||
"cancelSelection": "取消",
|
||||
"confirmSelection": "确认选择",
|
||||
"selectPersonaPool": "选择人格池..."
|
||||
"selectPersonaPool": "选择人格池...",
|
||||
"rootFolder": "全部人格",
|
||||
"emptyFolder": "此文件夹为空"
|
||||
},
|
||||
"t2iTemplateEditor": {
|
||||
"buttonText": "自定义 T2I 模板",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"move": "移动",
|
||||
"addDialogPair": "添加对话对"
|
||||
},
|
||||
"labels": {
|
||||
@@ -36,7 +37,9 @@
|
||||
"noToolsFound": "未找到匹配的工具",
|
||||
"loadingTools": "正在加载工具...",
|
||||
"allToolsAvailable": "使用所有可用工具",
|
||||
"noToolsSelected": "未选择任何工具"
|
||||
"noToolsSelected": "未选择任何工具",
|
||||
"createInFolder": "将在「{folder}」中创建",
|
||||
"rootFolder": "全部人格"
|
||||
},
|
||||
"dialog": {
|
||||
"create": {
|
||||
@@ -48,13 +51,16 @@
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无人格配置",
|
||||
"description": "来创建一个吧!"
|
||||
"description": "来创建一个吧!",
|
||||
"folderEmpty": "此文件夹为空",
|
||||
"folderEmptyDescription": "创建新的人格或文件夹开始使用"
|
||||
},
|
||||
"validation": {
|
||||
"required": "此字段为必填项",
|
||||
"minLength": "最少需要 {min} 个字符",
|
||||
"alphanumeric": "只能包含字母、数字、下划线和连字符",
|
||||
"dialogRequired": "{type}不能为空"
|
||||
"dialogRequired": "{type}不能为空",
|
||||
"personaIdExists": "该人格名称已存在"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "加载人格列表失败",
|
||||
@@ -63,5 +69,63 @@
|
||||
"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": "创建新文件夹",
|
||||
"createButton": "创建"
|
||||
},
|
||||
"renameDialog": {
|
||||
"title": "重命名文件夹"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除文件夹",
|
||||
"message": "确定要删除文件夹 \"{name}\" 吗?",
|
||||
"warning": "文件夹内的所有人格将被移动到根目录。"
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "文件夹创建成功",
|
||||
"createError": "创建文件夹失败",
|
||||
"renameSuccess": "文件夹重命名成功",
|
||||
"renameError": "重命名文件夹失败",
|
||||
"deleteSuccess": "文件夹删除成功",
|
||||
"deleteError": "删除文件夹失败"
|
||||
}
|
||||
},
|
||||
"moveDialog": {
|
||||
"title": "移动到文件夹",
|
||||
"description": "为 \"{name}\" 选择目标文件夹",
|
||||
"success": "移动成功",
|
||||
"error": "移动失败"
|
||||
}
|
||||
}
|
||||
|
||||
333
dashboard/src/stores/personaStore.ts
Normal file
333
dashboard/src/stores/personaStore.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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[],
|
||||
expandedFolderIds: [] as string[], // Store expanded folder IDs
|
||||
loading: false,
|
||||
treeLoading: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 当前文件夹名称
|
||||
currentFolderName(): string {
|
||||
if (this.breadcrumbPath.length === 0) {
|
||||
return '根目录';
|
||||
}
|
||||
return this.breadcrumbPath[this.breadcrumbPath.length - 1]?.name || '根目录';
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Toggle folder expansion state
|
||||
*/
|
||||
toggleFolderExpansion(folderId: string) {
|
||||
const index = this.expandedFolderIds.indexOf(folderId);
|
||||
if (index === -1) {
|
||||
this.expandedFolderIds.push(folderId);
|
||||
} else {
|
||||
this.expandedFolderIds.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set folder expansion state
|
||||
*/
|
||||
setFolderExpansion(folderId: string, expanded: boolean) {
|
||||
const index = this.expandedFolderIds.indexOf(folderId);
|
||||
if (expanded && index === -1) {
|
||||
this.expandedFolderIds.push(folderId);
|
||||
} else if (!expanded && index !== -1) {
|
||||
this.expandedFolderIds.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载文件夹树形结构
|
||||
*/
|
||||
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 '@/views/persona';
|
||||
|
||||
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>
|
||||
|
||||
77
dashboard/src/views/persona/CreateFolderDialog.vue
Normal file
77
dashboard/src/views/persona/CreateFolderDialog.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<BaseCreateFolderDialog v-model="showDialog" :parent-folder-id="parentFolderId" :labels="labels"
|
||||
@create="handleCreate" ref="baseDialog" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { usePersonaStore } from '@/stores/personaStore';
|
||||
import { mapActions } from 'pinia';
|
||||
import BaseCreateFolderDialog from '@/components/folder/BaseCreateFolderDialog.vue';
|
||||
import type { CreateFolderData } from '@/components/folder/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CreateFolderDialog',
|
||||
components: {
|
||||
BaseCreateFolderDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
parentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'created', 'error'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
},
|
||||
computed: {
|
||||
showDialog: {
|
||||
get(): boolean {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value: boolean) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
},
|
||||
labels() {
|
||||
return {
|
||||
title: this.tm('folder.createDialog.title'),
|
||||
nameLabel: this.tm('folder.form.name'),
|
||||
descriptionLabel: this.tm('folder.form.description'),
|
||||
nameRequired: this.tm('folder.validation.nameRequired'),
|
||||
cancelButton: this.tm('buttons.cancel'),
|
||||
createButton: this.tm('folder.createDialog.createButton')
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePersonaStore, ['createFolder']),
|
||||
|
||||
async handleCreate(data: CreateFolderData) {
|
||||
const baseDialog = this.$refs.baseDialog as InstanceType<typeof BaseCreateFolderDialog>;
|
||||
baseDialog.setLoading(true);
|
||||
|
||||
try {
|
||||
await this.createFolder({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
parent_id: data.parent_id
|
||||
});
|
||||
this.$emit('created', this.tm('folder.messages.createSuccess'));
|
||||
this.showDialog = false;
|
||||
} catch (error: any) {
|
||||
this.$emit('error', error.message || this.tm('folder.messages.createError'));
|
||||
} finally {
|
||||
baseDialog.setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
87
dashboard/src/views/persona/FolderBreadcrumb.vue
Normal file
87
dashboard/src/views/persona/FolderBreadcrumb.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<v-breadcrumbs :items="breadcrumbItems" class="folder-breadcrumb pa-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" class="mr-1">mdi-folder-outline</v-icon>
|
||||
</template>
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item :disabled="item.disabled" @click="!item.disabled && handleClick((item as any).folderId)"
|
||||
:class="{ 'breadcrumb-link': !item.disabled }">
|
||||
<v-icon v-if="(item as any).isRoot" size="small" class="mr-1">mdi-home</v-icon>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
<template v-slot:divider>
|
||||
<v-icon size="small">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { usePersonaStore } from '@/stores/personaStore';
|
||||
import { mapState, mapActions } from 'pinia';
|
||||
import type { FolderTreeNode } from '@/components/folder/types';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
title: string;
|
||||
folderId: string | null;
|
||||
disabled: boolean;
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FolderBreadcrumb',
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePersonaStore, ['breadcrumbPath', 'currentFolderId']),
|
||||
|
||||
breadcrumbItems(): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [
|
||||
{
|
||||
title: this.tm('folder.rootFolder'),
|
||||
folderId: null,
|
||||
disabled: this.currentFolderId === null,
|
||||
isRoot: true
|
||||
}
|
||||
];
|
||||
|
||||
(this.breadcrumbPath as FolderTreeNode[]).forEach((folder, index) => {
|
||||
items.push({
|
||||
title: folder.name,
|
||||
folderId: folder.folder_id,
|
||||
disabled: index === (this.breadcrumbPath as FolderTreeNode[]).length - 1,
|
||||
isRoot: false
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePersonaStore, ['navigateToFolder']),
|
||||
|
||||
handleClick(folderId: string | null) {
|
||||
this.navigateToFolder(folderId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
</style>
|
||||
120
dashboard/src/views/persona/FolderCard.vue
Normal file
120
dashboard/src/views/persona/FolderCard.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<v-card class="folder-card" :class="{ 'drag-over': isDragOver }" rounded="lg" @click="$emit('click')" @contextmenu.prevent="$emit('contextmenu', $event)"
|
||||
elevation="1" hover @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<v-card-text class="d-flex align-center pa-3">
|
||||
<v-icon size="40" color="amber-darken-2" class="mr-3">mdi-folder</v-icon>
|
||||
<div class="folder-info flex-grow-1 overflow-hidden">
|
||||
<div class="text-subtitle-1 font-weight-medium text-truncate">{{ folder.name }}</div>
|
||||
<div v-if="folder.description" class="text-body-2 text-medium-emphasis text-truncate">
|
||||
{{ folder.description }}
|
||||
</div>
|
||||
</div>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click.stop="$emit('open')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-open</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.open') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('rename')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.rename') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('move')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-move</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.moveTo') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item @click.stop="$emit('delete')" class="text-error">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="error">mdi-delete</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.delete') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { Folder } from '@/components/folder/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FolderCard',
|
||||
props: {
|
||||
folder: {
|
||||
type: Object as PropType<Folder>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['click', 'contextmenu', 'open', 'rename', 'move', 'delete', 'persona-dropped'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDragOver: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
this.isDragOver = true;
|
||||
},
|
||||
handleDragLeave() {
|
||||
this.isDragOver = false;
|
||||
},
|
||||
handleDrop(event: DragEvent) {
|
||||
this.isDragOver = false;
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'persona') {
|
||||
this.$emit('persona-dropped', {
|
||||
persona_id: data.persona_id,
|
||||
target_folder_id: this.folder.folder_id
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drop data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.folder-card.drag-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
border: 2px dashed rgb(var(--v-theme-primary));
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.folder-info {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
320
dashboard/src/views/persona/FolderTree.vue
Normal file
320
dashboard/src/views/persona/FolderTree.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<!-- 搜索框 -->
|
||||
<v-text-field v-model="searchQuery" :placeholder="tm('folder.searchPlaceholder')" prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined" density="compact" hide-details clearable class="mb-3" />
|
||||
|
||||
<!-- 根目录节点 -->
|
||||
<v-list density="compact" nav class="tree-list" bg-color="transparent">
|
||||
<v-list-item :active="currentFolderId === null" @click="handleFolderClick(null)" rounded="lg"
|
||||
:class="['root-item', { 'drag-over': isRootDragOver }]"
|
||||
@dragover.prevent="handleRootDragOver" @dragleave="handleRootDragLeave" @drop.prevent="handleRootDrop">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-home</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.rootFolder') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<template v-if="!treeLoading">
|
||||
<FolderTreeNode v-for="folder in filteredFolderTree" :key="folder.folder_id" :folder="folder"
|
||||
:depth="0" :current-folder-id="currentFolderId" :search-query="searchQuery"
|
||||
@folder-click="handleFolderClick" @folder-context-menu="handleContextMenu"
|
||||
@persona-dropped="$emit('persona-dropped', $event)" />
|
||||
</template>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate size="24" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!treeLoading && folderTree.length === 0" class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="32" class="mb-2">mdi-folder-outline</v-icon>
|
||||
<div class="text-body-2">{{ tm('folder.noFolders') }}</div>
|
||||
</div>
|
||||
</v-list>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<v-menu v-model="contextMenu.show" :target="contextMenu.target as any" location="end" :close-on-content-click="true">
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="openFolder">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-open</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.open') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="renameFolder">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.rename') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('move-folder', contextMenu.folder)">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-move</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.moveTo') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item @click="confirmDeleteFolder" class="text-error">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="error">mdi-delete</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.contextMenu.delete') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<v-dialog v-model="renameDialog.show" max-width="400px" persistent>
|
||||
<v-card>
|
||||
<v-card-title>{{ tm('folder.renameDialog.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="renameDialog.name" :label="tm('folder.form.name')"
|
||||
:rules="[v => !!v || tm('folder.validation.nameRequired')]" variant="outlined"
|
||||
density="comfortable" autofocus @keyup.enter="submitRename" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="renameDialog.show = false">
|
||||
{{ tm('buttons.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="submitRename" :loading="renameDialog.loading"
|
||||
:disabled="!renameDialog.name">
|
||||
{{ tm('buttons.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<v-dialog v-model="deleteDialog.show" max-width="450px">
|
||||
<v-card>
|
||||
<v-card-title class="text-error">
|
||||
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
|
||||
{{ tm('folder.deleteDialog.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>{{ tm('folder.deleteDialog.message', { name: deleteDialog.folder?.name ?? '' }) }}</p>
|
||||
<p class="text-warning mt-2">
|
||||
<v-icon size="small" class="mr-1">mdi-information</v-icon>
|
||||
{{ tm('folder.deleteDialog.warning') }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog.show = false">
|
||||
{{ tm('buttons.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="submitDelete" :loading="deleteDialog.loading">
|
||||
{{ tm('buttons.delete') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { usePersonaStore } from '@/stores/personaStore';
|
||||
import { mapState, mapActions } from 'pinia';
|
||||
import FolderTreeNode from './FolderTreeNode.vue';
|
||||
import type { FolderTreeNode as FolderTreeNodeType } from '@/components/folder/types';
|
||||
|
||||
interface ContextMenuState {
|
||||
show: boolean;
|
||||
target: [number, number] | null;
|
||||
folder: FolderTreeNodeType | null;
|
||||
}
|
||||
|
||||
interface RenameDialogState {
|
||||
show: boolean;
|
||||
folder: FolderTreeNodeType | null;
|
||||
name: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface DeleteDialogState {
|
||||
show: boolean;
|
||||
folder: FolderTreeNodeType | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FolderTree',
|
||||
components: {
|
||||
FolderTreeNode
|
||||
},
|
||||
emits: ['move-folder', 'error', 'success', 'persona-dropped'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
isRootDragOver: false,
|
||||
contextMenu: {
|
||||
show: false,
|
||||
target: null,
|
||||
folder: null
|
||||
} as ContextMenuState,
|
||||
renameDialog: {
|
||||
show: false,
|
||||
folder: null,
|
||||
name: '',
|
||||
loading: false
|
||||
} as RenameDialogState,
|
||||
deleteDialog: {
|
||||
show: false,
|
||||
folder: null,
|
||||
loading: false
|
||||
} as DeleteDialogState
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'treeLoading']),
|
||||
|
||||
filteredFolderTree(): FolderTreeNodeType[] {
|
||||
if (!this.searchQuery) {
|
||||
return this.folderTree as FolderTreeNodeType[];
|
||||
}
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.filterTreeBySearch(this.folderTree as FolderTreeNodeType[], query);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePersonaStore, ['navigateToFolder', 'updateFolder', 'deleteFolder']),
|
||||
|
||||
filterTreeBySearch(nodes: FolderTreeNodeType[], query: string): FolderTreeNodeType[] {
|
||||
return nodes.filter(node => {
|
||||
const matches = node.name.toLowerCase().includes(query);
|
||||
const childMatches = this.filterTreeBySearch(node.children || [], query);
|
||||
return matches || childMatches.length > 0;
|
||||
}).map(node => ({
|
||||
...node,
|
||||
children: this.filterTreeBySearch(node.children || [], query)
|
||||
}));
|
||||
},
|
||||
|
||||
handleFolderClick(folderId: string | null) {
|
||||
this.navigateToFolder(folderId);
|
||||
},
|
||||
|
||||
handleRootDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
this.isRootDragOver = true;
|
||||
},
|
||||
|
||||
handleRootDragLeave() {
|
||||
this.isRootDragOver = false;
|
||||
},
|
||||
|
||||
handleRootDrop(event: DragEvent) {
|
||||
this.isRootDragOver = false;
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
if (data.type === 'persona') {
|
||||
this.$emit('persona-dropped', {
|
||||
persona_id: data.persona_id,
|
||||
target_folder_id: null
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse drop data:', e);
|
||||
}
|
||||
},
|
||||
|
||||
handleContextMenu(eventData: { event: MouseEvent; folder: FolderTreeNodeType }) {
|
||||
this.contextMenu.target = [eventData.event.clientX, eventData.event.clientY];
|
||||
this.contextMenu.folder = eventData.folder;
|
||||
this.contextMenu.show = true;
|
||||
},
|
||||
|
||||
openFolder() {
|
||||
if (this.contextMenu.folder) {
|
||||
this.navigateToFolder(this.contextMenu.folder.folder_id);
|
||||
}
|
||||
},
|
||||
|
||||
renameFolder() {
|
||||
if (this.contextMenu.folder) {
|
||||
this.renameDialog.folder = this.contextMenu.folder;
|
||||
this.renameDialog.name = this.contextMenu.folder.name;
|
||||
this.renameDialog.show = true;
|
||||
}
|
||||
},
|
||||
|
||||
async submitRename() {
|
||||
if (!this.renameDialog.name || !this.renameDialog.folder) return;
|
||||
|
||||
this.renameDialog.loading = true;
|
||||
try {
|
||||
await this.updateFolder({
|
||||
folder_id: this.renameDialog.folder.folder_id,
|
||||
name: this.renameDialog.name
|
||||
});
|
||||
this.$emit('success', this.tm('folder.messages.renameSuccess'));
|
||||
this.renameDialog.show = false;
|
||||
} catch (error: any) {
|
||||
this.$emit('error', error.message || this.tm('folder.messages.renameError'));
|
||||
} finally {
|
||||
this.renameDialog.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteFolder() {
|
||||
if (this.contextMenu.folder) {
|
||||
this.deleteDialog.folder = this.contextMenu.folder;
|
||||
this.deleteDialog.show = true;
|
||||
}
|
||||
},
|
||||
|
||||
async submitDelete() {
|
||||
if (!this.deleteDialog.folder) return;
|
||||
|
||||
this.deleteDialog.loading = true;
|
||||
try {
|
||||
await this.deleteFolder(this.deleteDialog.folder.folder_id);
|
||||
this.$emit('success', this.tm('folder.messages.deleteSuccess'));
|
||||
this.deleteDialog.show = false;
|
||||
} catch (error: any) {
|
||||
this.$emit('error', error.message || this.tm('folder.messages.deleteError'));
|
||||
} finally {
|
||||
this.deleteDialog.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.root-item.drag-over {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
border: 2px dashed rgb(var(--v-theme-primary));
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
66
dashboard/src/views/persona/FolderTreeNode.vue
Normal file
66
dashboard/src/views/persona/FolderTreeNode.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<BaseFolderTreeNode :folder="folder" :depth="depth" :current-folder-id="currentFolderId"
|
||||
:search-query="searchQuery" :expanded-folder-ids="expandedFolderIds" :accept-drop-types="['persona']"
|
||||
@folder-click="$emit('folder-click', $event)"
|
||||
@folder-context-menu="handleContextMenu"
|
||||
@item-dropped="handleItemDropped"
|
||||
@toggle-expansion="toggleFolderExpansion"
|
||||
@set-expansion="handleSetExpansion" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import { usePersonaStore } from '@/stores/personaStore';
|
||||
import { mapState, mapActions } from 'pinia';
|
||||
import BaseFolderTreeNode from '@/components/folder/BaseFolderTreeNode.vue';
|
||||
import type { FolderTreeNode as FolderTreeNodeType } from '@/components/folder/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FolderTreeNode',
|
||||
components: {
|
||||
BaseFolderTreeNode
|
||||
},
|
||||
props: {
|
||||
folder: {
|
||||
type: Object as PropType<FolderTreeNodeType>,
|
||||
required: true
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currentFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['folder-click', 'folder-context-menu', 'persona-dropped'],
|
||||
computed: {
|
||||
...mapState(usePersonaStore, ['expandedFolderIds'])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePersonaStore, ['toggleFolderExpansion', 'setFolderExpansion']),
|
||||
|
||||
handleContextMenu(event: { event: MouseEvent; folder: FolderTreeNodeType }) {
|
||||
this.$emit('folder-context-menu', event);
|
||||
},
|
||||
|
||||
handleItemDropped(data: { item_id: string; item_type: string; target_folder_id: string | null; source_data: any }) {
|
||||
if (data.item_type === 'persona') {
|
||||
this.$emit('persona-dropped', {
|
||||
persona_id: data.item_id,
|
||||
target_folder_id: data.target_folder_id
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleSetExpansion(data: { folderId: string; expanded: boolean }) {
|
||||
this.setFolderExpansion(data.folderId, data.expanded);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
36
dashboard/src/views/persona/MoveTargetNode.vue
Normal file
36
dashboard/src/views/persona/MoveTargetNode.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<BaseMoveTargetNode :folder="folder" :depth="depth" :selected-folder-id="selectedFolderId"
|
||||
:disabled-folder-ids="disabledFolderIds" @select="$emit('select', $event)" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import BaseMoveTargetNode from '@/components/folder/BaseMoveTargetNode.vue';
|
||||
import type { FolderTreeNode } from '@/components/folder/types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MoveTargetNode',
|
||||
components: {
|
||||
BaseMoveTargetNode
|
||||
},
|
||||
props: {
|
||||
folder: {
|
||||
type: Object as PropType<FolderTreeNode>,
|
||||
required: true
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
selectedFolderId: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
},
|
||||
disabledFolderIds: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['select']
|
||||
});
|
||||
</script>
|
||||
201
dashboard/src/views/persona/MoveToFolderDialog.vue
Normal file
201
dashboard/src/views/persona/MoveToFolderDialog.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="500px" persistent>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-folder-move</v-icon>
|
||||
{{ tm('moveDialog.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
{{ tm('moveDialog.description', { name: itemName }) }}
|
||||
</p>
|
||||
|
||||
<!-- 文件夹选择树 -->
|
||||
<div class="folder-select-tree">
|
||||
<v-list density="compact" nav class="tree-list">
|
||||
<!-- 根目录选项 -->
|
||||
<v-list-item :active="selectedFolderId === null" @click="selectFolder(null)" rounded="lg"
|
||||
class="mb-1">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-home</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('folder.rootFolder') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<template v-if="!treeLoading">
|
||||
<MoveTargetNode v-for="folder in availableFolders" :key="folder.folder_id" :folder="folder"
|
||||
:depth="0" :selected-folder-id="selectedFolderId" :disabled-folder-ids="disabledFolderIds"
|
||||
@select="selectFolder" />
|
||||
</template>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="treeLoading" class="text-center pa-4">
|
||||
<v-progress-circular indeterminate size="24" />
|
||||
</div>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeDialog">
|
||||
{{ tm('buttons.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="submitMove" :loading="loading">
|
||||
{{ tm('buttons.move') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { usePersonaStore } from '@/stores/personaStore';
|
||||
import { mapState, mapActions } from 'pinia';
|
||||
import MoveTargetNode from './MoveTargetNode.vue';
|
||||
import { collectFolderAndChildrenIds } from '@/components/folder/useFolderManager';
|
||||
import type { FolderTreeNode } from '@/components/folder/types';
|
||||
|
||||
interface PersonaItem {
|
||||
persona_id: string;
|
||||
folder_id?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface FolderItem {
|
||||
folder_id: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MoveToFolderDialog',
|
||||
components: {
|
||||
MoveTargetNode
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
itemType: {
|
||||
type: String as PropType<'persona' | 'folder'>,
|
||||
required: true
|
||||
},
|
||||
item: {
|
||||
type: Object as PropType<PersonaItem | FolderItem | null>,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'moved', 'error'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedFolderId: null as string | null,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePersonaStore, ['folderTree', 'treeLoading']),
|
||||
|
||||
showDialog: {
|
||||
get(): boolean {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value: boolean) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
},
|
||||
|
||||
itemName(): string {
|
||||
if (!this.item) return '';
|
||||
return this.itemType === 'persona'
|
||||
? (this.item as PersonaItem).persona_id
|
||||
: (this.item as FolderItem).name;
|
||||
},
|
||||
|
||||
// 禁用的文件夹 ID(不能移动到自己或子文件夹)
|
||||
disabledFolderIds(): string[] {
|
||||
if (this.itemType !== 'folder' || !this.item) return [];
|
||||
return collectFolderAndChildrenIds(
|
||||
this.folderTree as FolderTreeNode[],
|
||||
(this.item as FolderItem).folder_id
|
||||
);
|
||||
},
|
||||
|
||||
// 过滤掉禁用的文件夹
|
||||
availableFolders(): FolderTreeNode[] {
|
||||
return this.folderTree as FolderTreeNode[];
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue: boolean) {
|
||||
if (newValue) {
|
||||
// 初始化选中为当前所在文件夹
|
||||
if (this.item) {
|
||||
this.selectedFolderId = this.itemType === 'persona'
|
||||
? (this.item as PersonaItem).folder_id ?? null
|
||||
: (this.item as FolderItem).parent_id ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePersonaStore, ['movePersonaToFolder', 'moveFolderToFolder']),
|
||||
|
||||
selectFolder(folderId: string | null) {
|
||||
// 检查是否禁用
|
||||
if (folderId && this.disabledFolderIds.includes(folderId)) return;
|
||||
this.selectedFolderId = folderId;
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
async submitMove() {
|
||||
if (!this.item) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
if (this.itemType === 'persona') {
|
||||
await this.movePersonaToFolder(
|
||||
(this.item as PersonaItem).persona_id,
|
||||
this.selectedFolderId
|
||||
);
|
||||
} else {
|
||||
await this.moveFolderToFolder(
|
||||
(this.item as FolderItem).folder_id,
|
||||
this.selectedFolderId
|
||||
);
|
||||
}
|
||||
this.$emit('moved', this.tm('moveDialog.success'));
|
||||
this.closeDialog();
|
||||
} catch (error: any) {
|
||||
this.$emit('error', error.message || this.tm('moveDialog.error'));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-select-tree {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
178
dashboard/src/views/persona/PersonaCard.vue
Normal file
178
dashboard/src/views/persona/PersonaCard.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<v-card class="persona-card" :class="{ 'dragging': isDragging }" rounded="lg" @click="$emit('view')" elevation="1" hover
|
||||
draggable="true" @dragstart="handleDragStart" @dragend="handleDragEnd">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<div class="text-truncate ml-2">{{ persona.persona_id }}</div>
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop />
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click.stop="$emit('edit')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('buttons.edit') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click.stop="$emit('move')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-folder-move</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('persona.contextMenu.moveTo') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider class="my-1" />
|
||||
<v-list-item @click.stop="$emit('delete')" class="text-error">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="error">mdi-delete</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('buttons.delete') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="system-prompt-preview">
|
||||
{{ truncateText(persona.system_prompt, 100) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex flex-wrap ga-1">
|
||||
<v-chip v-if="persona.begin_dialogs && persona.begin_dialogs.length > 0" size="small" color="secondary"
|
||||
variant="tonal" prepend-icon="mdi-chat">
|
||||
{{ tm('labels.presetDialogs', { count: persona.begin_dialogs.length / 2 }) }}
|
||||
</v-chip>
|
||||
<v-chip v-if="persona.tools === null" size="small" color="success" variant="tonal"
|
||||
prepend-icon="mdi-tools">
|
||||
{{ tm('form.allToolsAvailable') }}
|
||||
</v-chip>
|
||||
<v-chip v-else-if="persona.tools && persona.tools.length > 0" size="small" color="primary" variant="tonal"
|
||||
prepend-icon="mdi-tools">
|
||||
{{ persona.tools.length }} {{ tm('persona.toolsCount') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-caption text-medium-emphasis">
|
||||
{{ tm('labels.createdAt') }}: {{ formatDate(persona.created_at) }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Custom Drag Preview -->
|
||||
<div ref="dragPreview" class="drag-preview">
|
||||
<v-icon size="small" class="mr-2">mdi-account</v-icon>
|
||||
<span class="text-subtitle-2">{{ persona.persona_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
interface Persona {
|
||||
persona_id: string;
|
||||
system_prompt: string;
|
||||
begin_dialogs?: string[] | null;
|
||||
tools?: string[] | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
folder_id?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PersonaCard',
|
||||
props: {
|
||||
persona: {
|
||||
type: Object as PropType<Persona>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['view', 'edit', 'move', 'delete'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDragging: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleDragStart(event: DragEvent) {
|
||||
this.isDragging = true;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'persona',
|
||||
persona_id: this.persona.persona_id,
|
||||
persona: this.persona
|
||||
}));
|
||||
|
||||
// Set custom drag image
|
||||
const dragPreview = this.$refs.dragPreview as HTMLElement;
|
||||
if (dragPreview) {
|
||||
event.dataTransfer.setDragImage(dragPreview, 15, 15);
|
||||
}
|
||||
}
|
||||
},
|
||||
handleDragEnd() {
|
||||
this.isDragging = false;
|
||||
},
|
||||
truncateText(text: string | undefined | null, maxLength: number): string {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
},
|
||||
formatDate(dateString: string | undefined | null): string {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.persona-card {
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.persona-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.persona-card.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.persona-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.system-prompt-preview {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.drag-preview {
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
557
dashboard/src/views/persona/PersonaManager.vue
Normal file
557
dashboard/src/views/persona/PersonaManager.vue
Normal file
@@ -0,0 +1,557 @@
|
||||
<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>
|
||||
|
||||
<!-- 加载状态 - 只有加载超过阈值才显示骨架屏 -->
|
||||
<v-fade-transition>
|
||||
<div v-if="showSkeleton" 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>
|
||||
</v-fade-transition>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-if="!loading">
|
||||
<!-- 子文件夹区域 -->
|
||||
<div v-if="currentFolders.length > 0" class="folders-section mb-6">
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
<v-icon size="small" class="mr-1">mdi-folder</v-icon>
|
||||
{{ tm('folder.foldersTitle') }} ({{ currentFolders.length }})
|
||||
</h3>
|
||||
<v-row>
|
||||
<v-col v-for="folder in currentFolders" :key="folder.folder_id" cols="12" sm="6" lg="4"
|
||||
xl="3">
|
||||
<FolderCard :folder="folder" @click="navigateToFolder(folder.folder_id)"
|
||||
@open="navigateToFolder(folder.folder_id)" @rename="openRenameFolderDialog(folder)"
|
||||
@move="openMoveFolderDialog(folder)" @delete="confirmDeleteFolder(folder)"
|
||||
@persona-dropped="handlePersonaDropped" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Persona 区域 -->
|
||||
<div v-if="currentPersonas.length > 0" class="personas-section">
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
<v-icon size="small" class="mr-1">mdi-account-heart</v-icon>
|
||||
{{ tm('persona.personasTitle') }} ({{ currentPersonas.length }})
|
||||
</h3>
|
||||
<v-row>
|
||||
<v-col v-for="persona in currentPersonas" :key="persona.persona_id" cols="12" sm="6" lg="4"
|
||||
xl="3">
|
||||
<PersonaCard :persona="persona" @view="viewPersona(persona)"
|
||||
@edit="editPersona(persona)" @move="openMovePersonaDialog(persona)"
|
||||
@delete="confirmDeletePersona(persona)" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="currentFolders.length === 0 && currentPersonas.length === 0" class="empty-state">
|
||||
<v-card class="text-center pa-8" elevation="0">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-folder-open-outline</v-icon>
|
||||
<h3 class="text-h5 mb-2">{{ tm('empty.folderEmpty') }}</h3>
|
||||
<p class="text-body-1 text-medium-emphasis mb-4">{{ tm('empty.folderEmptyDescription') }}</p>
|
||||
<div class="d-flex justify-center ga-2">
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus"
|
||||
@click="openCreatePersonaDialog">
|
||||
{{ tm('buttons.create') }}
|
||||
</v-btn>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-folder-plus"
|
||||
@click="showCreateFolderDialog = true">
|
||||
{{ tm('folder.createButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑 Persona 对话框 -->
|
||||
<PersonaForm v-model="showPersonaDialog" :editing-persona="editingPersona ?? undefined"
|
||||
:current-folder-id="currentFolderId ?? undefined" :current-folder-name="currentFolderName ?? undefined"
|
||||
@saved="handlePersonaSaved" @error="showError" />
|
||||
|
||||
<!-- 查看 Persona 详情对话框 -->
|
||||
<v-dialog v-model="showViewDialog" max-width="700px">
|
||||
<v-card v-if="viewingPersona">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h5">{{ viewingPersona.persona_id }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-h6 mb-2">{{ tm('form.systemPrompt') }}</h4>
|
||||
<pre class="system-prompt-content">{{ viewingPersona.system_prompt }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="viewingPersona.begin_dialogs && viewingPersona.begin_dialogs.length > 0" class="mb-4">
|
||||
<h4 class="text-h6 mb-2">{{ tm('form.presetDialogs') }}</h4>
|
||||
<div v-for="(dialog, index) in viewingPersona.begin_dialogs" :key="index" class="mb-2">
|
||||
<v-chip :color="index % 2 === 0 ? 'primary' : 'secondary'" variant="tonal" size="small"
|
||||
class="mb-1">
|
||||
{{ index % 2 === 0 ? tm('form.userMessage') : tm('form.assistantMessage') }}
|
||||
</v-chip>
|
||||
<div class="dialog-content ml-2">{{ dialog }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-h6 mb-2">{{ tm('form.tools') }}</h4>
|
||||
<div v-if="viewingPersona.tools === null" class="text-body-2 text-medium-emphasis">
|
||||
<v-chip size="small" color="success" variant="tonal" prepend-icon="mdi-check-all">
|
||||
{{ tm('form.allToolsAvailable') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-else-if="viewingPersona.tools && viewingPersona.tools.length > 0"
|
||||
class="d-flex flex-wrap ga-1">
|
||||
<v-chip v-for="toolName in viewingPersona.tools" :key="toolName" size="small"
|
||||
color="primary" variant="tonal">
|
||||
{{ toolName }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-else class="text-body-2 text-medium-emphasis">
|
||||
{{ tm('form.noToolsSelected') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
<div>{{ tm('labels.createdAt') }}: {{ formatDate(viewingPersona.created_at) }}</div>
|
||||
<div v-if="viewingPersona.updated_at">{{ tm('labels.updatedAt') }}:
|
||||
{{ formatDate(viewingPersona.updated_at) }}</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 创建文件夹对话框 -->
|
||||
<CreateFolderDialog v-model="showCreateFolderDialog" :parent-folder-id="currentFolderId"
|
||||
@created="showSuccess" @error="showError" />
|
||||
|
||||
<!-- 重命名文件夹对话框 -->
|
||||
<v-dialog v-model="showRenameFolderDialog" max-width="400px">
|
||||
<v-card>
|
||||
<v-card-title>{{ tm('folder.renameDialog.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="renameFolderData.name" :label="tm('folder.form.name')"
|
||||
:rules="[v => !!v || tm('folder.validation.nameRequired')]" variant="outlined"
|
||||
density="comfortable" autofocus @keyup.enter="submitRenameFolder" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRenameFolderDialog = false">
|
||||
{{ tm('buttons.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="submitRenameFolder" :loading="renameLoading"
|
||||
:disabled="!renameFolderData.name">
|
||||
{{ tm('buttons.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 移动对话框 -->
|
||||
<MoveToFolderDialog v-model="showMoveDialog" :item-type="moveDialogType" :item="moveDialogItem"
|
||||
@moved="showSuccess" @error="showError" />
|
||||
|
||||
<!-- 删除文件夹确认对话框 -->
|
||||
<v-dialog v-model="showDeleteFolderDialog" max-width="450px">
|
||||
<v-card>
|
||||
<v-card-title class="text-error">
|
||||
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
|
||||
{{ tm('folder.deleteDialog.title') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p>{{ tm('folder.deleteDialog.message', { name: deleteFolderData?.name ?? '' }) }}</p>
|
||||
<p class="text-warning mt-2">
|
||||
<v-icon size="small" class="mr-1">mdi-information</v-icon>
|
||||
{{ tm('folder.deleteDialog.warning') }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showDeleteFolderDialog = false">
|
||||
{{ tm('buttons.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="submitDeleteFolder" :loading="deleteLoading">
|
||||
{{ tm('buttons.delete') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
|
||||
{{ message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { usePersonaStore } from '@/stores/personaStore';
|
||||
import { mapState, mapActions } from 'pinia';
|
||||
|
||||
import FolderTree from './FolderTree.vue';
|
||||
import FolderBreadcrumb from './FolderBreadcrumb.vue';
|
||||
import FolderCard from './FolderCard.vue';
|
||||
import PersonaCard from './PersonaCard.vue';
|
||||
import PersonaForm from '@/components/shared/PersonaForm.vue';
|
||||
import CreateFolderDialog from './CreateFolderDialog.vue';
|
||||
import MoveToFolderDialog from './MoveToFolderDialog.vue';
|
||||
|
||||
import type { Folder, FolderTreeNode } from '@/components/folder/types';
|
||||
|
||||
interface Persona {
|
||||
persona_id: string;
|
||||
system_prompt: string;
|
||||
begin_dialogs?: string[] | null;
|
||||
tools?: string[] | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
folder_id?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface RenameFolderData {
|
||||
folder: Folder | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PersonaManager',
|
||||
components: {
|
||||
FolderTree,
|
||||
FolderBreadcrumb,
|
||||
FolderCard,
|
||||
PersonaCard,
|
||||
PersonaForm,
|
||||
CreateFolderDialog,
|
||||
MoveToFolderDialog
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/persona');
|
||||
return { t, tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Persona 相关
|
||||
showPersonaDialog: false,
|
||||
showViewDialog: false,
|
||||
editingPersona: null as Persona | null,
|
||||
viewingPersona: null as Persona | null,
|
||||
|
||||
// 文件夹相关
|
||||
showCreateFolderDialog: false,
|
||||
showRenameFolderDialog: false,
|
||||
showDeleteFolderDialog: false,
|
||||
renameFolderData: { folder: null, name: '' } as RenameFolderData,
|
||||
deleteFolderData: null as Folder | null,
|
||||
renameLoading: false,
|
||||
deleteLoading: false,
|
||||
|
||||
// 移动对话框
|
||||
showMoveDialog: false,
|
||||
moveDialogType: 'persona' as 'persona' | 'folder',
|
||||
moveDialogItem: null as Persona | Folder | null,
|
||||
|
||||
// 消息提示
|
||||
showMessage: false,
|
||||
message: '',
|
||||
messageType: 'success' as 'success' | 'error',
|
||||
|
||||
// 骨架屏延迟显示控制
|
||||
showSkeleton: false,
|
||||
skeletonTimer: null as ReturnType<typeof setTimeout> | null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(usePersonaStore, ['folderTree', 'currentFolderId', 'currentFolders', 'currentPersonas', 'loading']),
|
||||
currentFolderName(): string | null {
|
||||
if (!this.currentFolderId) {
|
||||
return null; // 根目录,PersonaForm 会使用 tm('form.rootFolder')
|
||||
}
|
||||
// 递归查找文件夹名称
|
||||
const findName = (nodes: FolderTreeNode[], id: string): string | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.folder_id === id) {
|
||||
return node.name;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findName(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findName(this.folderTree, this.currentFolderId);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 监听 loading 状态变化,实现延迟显示骨架屏
|
||||
loading: {
|
||||
handler(newVal: boolean) {
|
||||
if (newVal) {
|
||||
// 加载开始时,延迟 150ms 后才显示骨架屏
|
||||
// 如果加载在 150ms 内完成,则不显示骨架屏,避免闪烁
|
||||
this.skeletonTimer = setTimeout(() => {
|
||||
if (this.loading) {
|
||||
this.showSkeleton = true;
|
||||
}
|
||||
}, 150);
|
||||
} else {
|
||||
// 加载结束,立即隐藏骨架屏并清除定时器
|
||||
if (this.skeletonTimer) {
|
||||
clearTimeout(this.skeletonTimer);
|
||||
this.skeletonTimer = null;
|
||||
}
|
||||
this.showSkeleton = false;
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
// 组件卸载时清除定时器
|
||||
if (this.skeletonTimer) {
|
||||
clearTimeout(this.skeletonTimer);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.initialize();
|
||||
},
|
||||
methods: {
|
||||
...mapActions(usePersonaStore, ['loadFolderTree', 'navigateToFolder', 'updateFolder', 'deleteFolder', 'deletePersona', 'refreshCurrentFolder', 'movePersonaToFolder']),
|
||||
|
||||
async initialize() {
|
||||
await Promise.all([
|
||||
this.loadFolderTree(),
|
||||
this.navigateToFolder(null)
|
||||
]);
|
||||
},
|
||||
|
||||
// Persona 操作
|
||||
openCreatePersonaDialog() {
|
||||
this.editingPersona = null;
|
||||
this.showPersonaDialog = true;
|
||||
},
|
||||
|
||||
editPersona(persona: Persona) {
|
||||
this.editingPersona = persona;
|
||||
this.showPersonaDialog = true;
|
||||
},
|
||||
|
||||
viewPersona(persona: Persona) {
|
||||
this.viewingPersona = persona;
|
||||
this.showViewDialog = true;
|
||||
},
|
||||
|
||||
handlePersonaSaved(message: string) {
|
||||
this.showSuccess(message);
|
||||
this.refreshCurrentFolder();
|
||||
},
|
||||
|
||||
async confirmDeletePersona(persona: Persona) {
|
||||
if (!confirm(this.tm('messages.deleteConfirm', { id: persona.persona_id }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deletePersona(persona.persona_id);
|
||||
this.showSuccess(this.tm('messages.deleteSuccess'));
|
||||
} catch (error: any) {
|
||||
this.showError(error.message || this.tm('messages.deleteError'));
|
||||
}
|
||||
},
|
||||
|
||||
openMovePersonaDialog(persona: Persona) {
|
||||
this.moveDialogType = 'persona';
|
||||
this.moveDialogItem = persona;
|
||||
this.showMoveDialog = true;
|
||||
},
|
||||
|
||||
async handlePersonaDropped({ persona_id, target_folder_id }: { persona_id: string; target_folder_id: string | null }) {
|
||||
try {
|
||||
await this.movePersonaToFolder(persona_id, target_folder_id);
|
||||
this.showSuccess(this.tm('persona.messages.moveSuccess'));
|
||||
// Navigate to the target folder
|
||||
await this.navigateToFolder(target_folder_id);
|
||||
} catch (error: any) {
|
||||
this.showError(error.message || this.tm('persona.messages.moveError'));
|
||||
}
|
||||
},
|
||||
|
||||
// 文件夹操作
|
||||
openRenameFolderDialog(folder: Folder) {
|
||||
this.renameFolderData = { folder, name: folder.name };
|
||||
this.showRenameFolderDialog = true;
|
||||
},
|
||||
|
||||
async submitRenameFolder() {
|
||||
if (!this.renameFolderData.name || !this.renameFolderData.folder) return;
|
||||
|
||||
this.renameLoading = true;
|
||||
try {
|
||||
await this.updateFolder({
|
||||
folder_id: this.renameFolderData.folder.folder_id,
|
||||
name: this.renameFolderData.name
|
||||
});
|
||||
this.showSuccess(this.tm('folder.messages.renameSuccess'));
|
||||
this.showRenameFolderDialog = false;
|
||||
} catch (error: any) {
|
||||
this.showError(error.message || this.tm('folder.messages.renameError'));
|
||||
} finally {
|
||||
this.renameLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openMoveFolderDialog(folder: Folder) {
|
||||
this.moveDialogType = 'folder';
|
||||
this.moveDialogItem = folder;
|
||||
this.showMoveDialog = true;
|
||||
},
|
||||
|
||||
confirmDeleteFolder(folder: Folder) {
|
||||
this.deleteFolderData = folder;
|
||||
this.showDeleteFolderDialog = true;
|
||||
},
|
||||
|
||||
async submitDeleteFolder() {
|
||||
if (!this.deleteFolderData) return;
|
||||
|
||||
this.deleteLoading = true;
|
||||
try {
|
||||
await this.deleteFolder(this.deleteFolderData.folder_id);
|
||||
this.showSuccess(this.tm('folder.messages.deleteSuccess'));
|
||||
this.showDeleteFolderDialog = false;
|
||||
} catch (error: any) {
|
||||
this.showError(error.message || this.tm('folder.messages.deleteError'));
|
||||
} finally {
|
||||
this.deleteLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 辅助方法
|
||||
formatDate(dateString: string | undefined | null): string {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString();
|
||||
},
|
||||
|
||||
showSuccess(message: string) {
|
||||
this.message = message;
|
||||
this.messageType = 'success';
|
||||
this.showMessage = true;
|
||||
},
|
||||
|
||||
showError(message: string) {
|
||||
this.message = message;
|
||||
this.messageType = 'error';
|
||||
this.showMessage = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.persona-manager {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.manager-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
padding-right: 16px;
|
||||
height: fit-content;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system-prompt-content {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.manager-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
dashboard/src/views/persona/index.ts
Normal file
23
dashboard/src/views/persona/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Persona 管理相关组件
|
||||
*
|
||||
* 这些组件使用了 dashboard/src/components/folder 下的通用文件夹组件
|
||||
* 通过包装器模式将 personaStore 的状态和方法连接到通用组件
|
||||
*/
|
||||
|
||||
// 主组件
|
||||
export { default as PersonaManager } from './PersonaManager.vue';
|
||||
|
||||
// 文件夹相关组件
|
||||
export { default as FolderTree } from './FolderTree.vue';
|
||||
export { default as FolderTreeNode } from './FolderTreeNode.vue';
|
||||
export { default as FolderBreadcrumb } from './FolderBreadcrumb.vue';
|
||||
export { default as FolderCard } from './FolderCard.vue';
|
||||
|
||||
// 对话框组件
|
||||
export { default as CreateFolderDialog } from './CreateFolderDialog.vue';
|
||||
export { default as MoveToFolderDialog } from './MoveToFolderDialog.vue';
|
||||
export { default as MoveTargetNode } from './MoveTargetNode.vue';
|
||||
|
||||
// Persona 相关组件
|
||||
export { default as PersonaCard } from './PersonaCard.vue';
|
||||
Reference in New Issue
Block a user