diff --git a/astrbot/builtin_stars/builtin_commands/commands/persona.py b/astrbot/builtin_stars/builtin_commands/commands/persona.py index 13a57f07f..169c9e2b6 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/persona.py +++ b/astrbot/builtin_stars/builtin_commands/commands/persona.py @@ -1,13 +1,55 @@ import builtins +from typing import TYPE_CHECKING from astrbot.api import sp, star from astrbot.api.event import AstrMessageEvent, MessageEventResult +if TYPE_CHECKING: + from astrbot.core.db.po import Persona + class PersonaCommands: def __init__(self, context: star.Context): self.context = context + def _build_tree_output( + self, + folder_tree: list[dict], + all_personas: list["Persona"], + depth: int = 0, + ) -> list[str]: + """递归构建树状输出,使用短线条表示层级""" + lines: list[str] = [] + # 使用短线条作为缩进前缀,每层只用 "│" 加一个空格 + prefix = "│ " * depth + + for folder in folder_tree: + # 输出文件夹 + lines.append(f"{prefix}├ 📁 {folder['name']}/") + + # 获取该文件夹下的人格 + folder_personas = [ + p for p in all_personas if p.folder_id == folder["folder_id"] + ] + child_prefix = "│ " * (depth + 1) + + # 输出该文件夹下的人格 + for persona in folder_personas: + lines.append(f"{child_prefix}├ 👤 {persona.persona_id}") + + # 递归处理子文件夹 + children = folder.get("children", []) + if children: + lines.extend( + self._build_tree_output( + children, + all_personas, + depth + 1, + ) + ) + + return lines + async def persona(self, message: AstrMessageEvent): l = message.message_str.split(" ") # noqa: E741 umo = message.unified_msg_origin @@ -69,12 +111,32 @@ async def persona(self, message: AstrMessageEvent): .use_t2i(False), ) elif l[1] == "list": - parts = ["人格列表:\n"] - for persona in self.context.provider_manager.personas: - parts.append(f"- {persona['name']}\n") - parts.append("\n\n*输入 `/persona view 人格名` 查看人格详细信息") - msg = "".join(parts) - message.set_result(MessageEventResult().message(msg)) + # 获取文件夹树和所有人格 + folder_tree = await self.context.persona_manager.get_folder_tree() + all_personas = self.context.persona_manager.personas + + lines = ["📂 人格列表:\n"] + + # 构建树状输出 + tree_lines = self._build_tree_output(folder_tree, all_personas) + lines.extend(tree_lines) + + # 输出根目录下的人格(没有文件夹的) + root_personas = [p for p in all_personas if p.folder_id is None] + if root_personas: + if tree_lines: # 如果有文件夹内容,加个空行 + lines.append("") + for persona in root_personas: + lines.append(f"👤 {persona.persona_id}") + + # 统计信息 + total_count = len(all_personas) + lines.append(f"\n共 {total_count} 个人格") + lines.append("\n*使用 `/persona <人格名>` 设置人格") + lines.append("*使用 `/persona view <人格名>` 查看详细信息") + + msg = "\n".join(lines) + message.set_result(MessageEventResult().message(msg).use_t2i(False)) elif l[1] == "view": if len(l) == 2: message.set_result(MessageEventResult().message("请输入人格情景名")) diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 3a79e41c2..8e2da52ef 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -13,6 +13,7 @@ CommandConflict, ConversationV2, Persona, + PersonaFolder, PlatformMessageHistory, PlatformSession, PlatformStat, @@ -251,8 +252,19 @@ async def insert_persona( system_prompt: str, begin_dialogs: list[str] | None = None, tools: list[str] | None = None, + folder_id: str | None = None, + sort_order: int = 0, ) -> Persona: - """Insert a new persona record.""" + """Insert a new persona record. + + Args: + persona_id: Unique identifier for the persona + system_prompt: System prompt for the persona + begin_dialogs: Optional list of initial dialog strings + tools: Optional list of tool names (None means all tools, [] means no tools) + folder_id: Optional folder ID to place the persona in (None means root) + sort_order: Sort order within the folder (default 0) + """ ... @abc.abstractmethod @@ -281,6 +293,84 @@ async def delete_persona(self, persona_id: str) -> None: """Delete a persona by its ID.""" ... + # ==== + # Persona Folder Management + # ==== + + @abc.abstractmethod + async def insert_persona_folder( + self, + name: str, + parent_id: str | None = None, + description: str | None = None, + sort_order: int = 0, + ) -> PersonaFolder: + """Insert a new persona folder.""" + ... + + @abc.abstractmethod + async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None: + """Get a persona folder by its folder_id.""" + ... + + @abc.abstractmethod + async def get_persona_folders( + self, parent_id: str | None = None + ) -> list[PersonaFolder]: + """Get all persona folders, optionally filtered by parent_id.""" + ... + + @abc.abstractmethod + async def get_all_persona_folders(self) -> list[PersonaFolder]: + """Get all persona folders.""" + ... + + @abc.abstractmethod + async def update_persona_folder( + self, + folder_id: str, + name: str | None = None, + parent_id: T.Any = None, + description: T.Any = None, + sort_order: int | None = None, + ) -> PersonaFolder | None: + """Update a persona folder.""" + ... + + @abc.abstractmethod + async def delete_persona_folder(self, folder_id: str) -> None: + """Delete a persona folder by its folder_id.""" + ... + + @abc.abstractmethod + async def move_persona_to_folder( + self, persona_id: str, folder_id: str | None + ) -> Persona | None: + """Move a persona to a folder (or root if folder_id is None).""" + ... + + @abc.abstractmethod + async def get_personas_by_folder( + self, folder_id: str | None = None + ) -> list[Persona]: + """Get all personas in a specific folder.""" + ... + + @abc.abstractmethod + async def batch_update_sort_order( + self, + items: list[dict], + ) -> None: + """Batch update sort_order for personas and/or folders. + + Args: + items: List of dicts with keys: + - id: The persona_id or folder_id + - type: Either "persona" or "folder" + - sort_order: The new sort_order value + """ + ... + @abc.abstractmethod async def insert_preference_or_update( self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index fdbf4aff3..fe30c61e9 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -68,6 +68,44 @@ class ConversationV2(SQLModel, table=True): ) +class PersonaFolder(SQLModel, table=True): + """Persona 文件夹,支持递归层级结构。 + + 用于组织和管理多个 Persona,类似于文件系统的目录结构。 + """ + + __tablename__: str = "persona_folders" + + id: int | None = Field( + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + default=None, + ) + folder_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + name: str = Field(max_length=255, nullable=False) + parent_id: str | None = Field(default=None, max_length=36) + """父文件夹ID,NULL表示根目录""" + description: str | None = Field(default=None, sa_type=Text) + sort_order: int = Field(default=0) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + sa_column_kwargs={"onupdate": datetime.now(timezone.utc)}, + ) + + __table_args__ = ( + UniqueConstraint( + "folder_id", + name="uix_persona_folder_id", + ), + ) + + class Persona(SQLModel, table=True): """Persona is a set of instructions for LLMs to follow. @@ -87,6 +125,10 @@ class Persona(SQLModel, table=True): """a list of strings, each representing a dialog to start with""" tools: list | None = Field(default=None, sa_type=JSON) """None means use ALL tools for default, empty list means no tools, otherwise a list of tool names.""" + folder_id: str | None = Field(default=None, max_length=36) + """所属文件夹ID,NULL 表示在根目录""" + sort_order: int = Field(default=0) + """排序顺序""" created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 7422a5cc2..f3a6816e5 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -15,6 +15,7 @@ CommandConflict, ConversationV2, Persona, + PersonaFolder, PlatformMessageHistory, PlatformSession, PlatformStat, @@ -49,8 +50,30 @@ async def initialize(self) -> None: await conn.execute(text("PRAGMA temp_store=MEMORY")) await conn.execute(text("PRAGMA mmap_size=134217728")) await conn.execute(text("PRAGMA optimize")) + # 确保 personas 表有 folder_id 和 sort_order 列(前向兼容) + await self._ensure_persona_folder_columns(conn) await conn.commit() + async def _ensure_persona_folder_columns(self, conn) -> None: + """确保 personas 表有 folder_id 和 sort_order 列。 + + 这是为了支持旧版数据库的平滑升级。新版数据库通过 SQLModel + 的 metadata.create_all 自动创建这些列。 + """ + result = await conn.execute(text("PRAGMA table_info(personas)")) + columns = {row[1] for row in result.fetchall()} + + if "folder_id" not in columns: + await conn.execute( + text( + "ALTER TABLE personas ADD COLUMN folder_id VARCHAR(36) DEFAULT NULL" + ) + ) + if "sort_order" not in columns: + await conn.execute( + text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0") + ) + # ==== # Platform Statistics # ==== @@ -539,6 +562,8 @@ async def insert_persona( system_prompt, begin_dialogs=None, tools=None, + folder_id=None, + sort_order=0, ): """Insert a new persona record.""" async with self.get_db() as session: @@ -549,8 +574,12 @@ async def insert_persona( system_prompt=system_prompt, begin_dialogs=begin_dialogs or [], tools=tools, + folder_id=folder_id, + sort_order=sort_order, ) session.add(new_persona) + await session.flush() + await session.refresh(new_persona) return new_persona async def get_persona_by_id(self, persona_id): @@ -603,6 +632,207 @@ async def delete_persona(self, persona_id): delete(Persona).where(col(Persona.persona_id) == persona_id), ) + # ==== + # Persona Folder Management + # ==== + + async def insert_persona_folder( + self, + name: str, + parent_id: str | None = None, + description: str | None = None, + sort_order: int = 0, + ) -> PersonaFolder: + """Insert a new persona folder.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + new_folder = PersonaFolder( + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + session.add(new_folder) + await session.flush() + await session.refresh(new_folder) + return new_folder + + async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None: + """Get a persona folder by its folder_id.""" + async with self.get_db() as session: + session: AsyncSession + query = select(PersonaFolder).where(PersonaFolder.folder_id == folder_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def get_persona_folders( + self, parent_id: str | None = None + ) -> list[PersonaFolder]: + """Get all persona folders, optionally filtered by parent_id. + + Args: + parent_id: If None, returns root folders only. If specified, returns + children of that folder. + """ + async with self.get_db() as session: + session: AsyncSession + if parent_id is None: + # Get root folders (parent_id is NULL) + query = ( + select(PersonaFolder) + .where(col(PersonaFolder.parent_id).is_(None)) + .order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + ) + else: + query = ( + select(PersonaFolder) + .where(PersonaFolder.parent_id == parent_id) + .order_by(col(PersonaFolder.sort_order), col(PersonaFolder.name)) + ) + result = await session.execute(query) + return list(result.scalars().all()) + + async def get_all_persona_folders(self) -> list[PersonaFolder]: + """Get all persona folders.""" + async with self.get_db() as session: + session: AsyncSession + query = select(PersonaFolder).order_by( + col(PersonaFolder.sort_order), col(PersonaFolder.name) + ) + result = await session.execute(query) + return list(result.scalars().all()) + + async def update_persona_folder( + self, + folder_id: str, + name: str | None = None, + parent_id: T.Any = NOT_GIVEN, + description: T.Any = NOT_GIVEN, + sort_order: int | None = None, + ) -> PersonaFolder | None: + """Update a persona folder.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = update(PersonaFolder).where( + col(PersonaFolder.folder_id) == folder_id + ) + values: dict[str, T.Any] = {} + if name is not None: + values["name"] = name + if parent_id is not NOT_GIVEN: + values["parent_id"] = parent_id + if description is not NOT_GIVEN: + values["description"] = description + if sort_order is not None: + values["sort_order"] = sort_order + if not values: + return None + query = query.values(**values) + await session.execute(query) + return await self.get_persona_folder_by_id(folder_id) + + async def delete_persona_folder(self, folder_id: str) -> None: + """Delete a persona folder by its folder_id. + + Note: This will also set folder_id to NULL for all personas in this folder, + moving them to the root directory. + """ + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + # Move personas to root directory + await session.execute( + update(Persona) + .where(col(Persona.folder_id) == folder_id) + .values(folder_id=None) + ) + # Delete the folder + await session.execute( + delete(PersonaFolder).where( + col(PersonaFolder.folder_id) == folder_id + ), + ) + + async def move_persona_to_folder( + self, persona_id: str, folder_id: str | None + ) -> Persona | None: + """Move a persona to a folder (or root if folder_id is None).""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + update(Persona) + .where(col(Persona.persona_id) == persona_id) + .values(folder_id=folder_id) + ) + return await self.get_persona_by_id(persona_id) + + async def get_personas_by_folder( + self, folder_id: str | None = None + ) -> list[Persona]: + """Get all personas in a specific folder. + + Args: + folder_id: If None, returns personas in root directory. + """ + async with self.get_db() as session: + session: AsyncSession + if folder_id is None: + query = ( + select(Persona) + .where(col(Persona.folder_id).is_(None)) + .order_by(col(Persona.sort_order), col(Persona.persona_id)) + ) + else: + query = ( + select(Persona) + .where(Persona.folder_id == folder_id) + .order_by(col(Persona.sort_order), col(Persona.persona_id)) + ) + result = await session.execute(query) + return list(result.scalars().all()) + + async def batch_update_sort_order( + self, + items: list[dict], + ) -> None: + """Batch update sort_order for personas and/or folders. + + Args: + items: List of dicts with keys: + - id: The persona_id or folder_id + - type: Either "persona" or "folder" + - sort_order: The new sort_order value + """ + if not items: + return + + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + for item in items: + item_id = item.get("id") + item_type = item.get("type") + sort_order = item.get("sort_order") + + if item_id is None or item_type is None or sort_order is None: + continue + + if item_type == "persona": + await session.execute( + update(Persona) + .where(col(Persona.persona_id) == item_id) + .values(sort_order=sort_order) + ) + elif item_type == "folder": + await session.execute( + update(PersonaFolder) + .where(col(PersonaFolder.folder_id) == item_id) + .values(sort_order=sort_order) + ) + async def insert_preference_or_update(self, scope, scope_id, key, value): """Insert a new preference record or update if it exists.""" async with self.get_db() as session: diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index b2d2c6be1..3bc6ab4a9 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -1,7 +1,7 @@ from astrbot import logger from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.db import BaseDatabase -from astrbot.core.db.po import Persona, Personality +from astrbot.core.db.po import Persona, PersonaFolder, Personality from astrbot.core.platform.message_session import MessageSession DEFAULT_PERSONALITY = Personality( @@ -94,14 +94,164 @@ async def get_all_personas(self) -> list[Persona]: """获取所有 personas""" return await self.db.get_personas() + async def get_personas_by_folder( + self, folder_id: str | None = None + ) -> list[Persona]: + """获取指定文件夹中的 personas + + Args: + folder_id: 文件夹 ID,None 表示根目录 + """ + return await self.db.get_personas_by_folder(folder_id) + + async def move_persona_to_folder( + self, persona_id: str, folder_id: str | None + ) -> Persona | None: + """移动 persona 到指定文件夹 + + Args: + persona_id: Persona ID + folder_id: 目标文件夹 ID,None 表示移动到根目录 + """ + persona = await self.db.move_persona_to_folder(persona_id, folder_id) + if persona: + for i, p in enumerate(self.personas): + if p.persona_id == persona_id: + self.personas[i] = persona + break + return persona + + # ==== + # Persona Folder Management + # ==== + + async def create_folder( + self, + name: str, + parent_id: str | None = None, + description: str | None = None, + sort_order: int = 0, + ) -> PersonaFolder: + """创建新的文件夹""" + return await self.db.insert_persona_folder( + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + async def get_folder(self, folder_id: str) -> PersonaFolder | None: + """获取指定文件夹""" + return await self.db.get_persona_folder_by_id(folder_id) + + async def get_folders(self, parent_id: str | None = None) -> list[PersonaFolder]: + """获取文件夹列表 + + Args: + parent_id: 父文件夹 ID,None 表示获取根目录下的文件夹 + """ + return await self.db.get_persona_folders(parent_id) + + async def get_all_folders(self) -> list[PersonaFolder]: + """获取所有文件夹""" + return await self.db.get_all_persona_folders() + + async def update_folder( + self, + folder_id: str, + name: str | None = None, + parent_id: str | None = None, + description: str | None = None, + sort_order: int | None = None, + ) -> PersonaFolder | None: + """更新文件夹信息""" + return await self.db.update_persona_folder( + folder_id=folder_id, + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + async def delete_folder(self, folder_id: str) -> None: + """删除文件夹 + + Note: 文件夹内的 personas 会被移动到根目录 + """ + await self.db.delete_persona_folder(folder_id) + + async def batch_update_sort_order(self, items: list[dict]) -> None: + """批量更新 personas 和/或 folders 的排序顺序 + + Args: + items: 包含以下键的字典列表: + - id: persona_id 或 folder_id + - type: "persona" 或 "folder" + - sort_order: 新的排序顺序值 + """ + await self.db.batch_update_sort_order(items) + # 刷新缓存 + self.personas = await self.get_all_personas() + self.get_v3_persona_data() + + async def get_folder_tree(self) -> list[dict]: + """获取文件夹树形结构 + + Returns: + 树形结构的文件夹列表,每个文件夹包含 children 子列表 + """ + all_folders = await self.get_all_folders() + folder_map: dict[str, dict] = {} + + # 创建文件夹字典 + for folder in all_folders: + folder_map[folder.folder_id] = { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "children": [], + } + + # 构建树形结构 + root_folders = [] + for folder_id, folder_data in folder_map.items(): + parent_id = folder_data["parent_id"] + if parent_id is None: + root_folders.append(folder_data) + elif parent_id in folder_map: + folder_map[parent_id]["children"].append(folder_data) + + # 递归排序 + def sort_folders(folders: list[dict]) -> list[dict]: + folders.sort(key=lambda f: (f["sort_order"], f["name"])) + for folder in folders: + if folder["children"]: + folder["children"] = sort_folders(folder["children"]) + return folders + + return sort_folders(root_folders) + async def create_persona( self, persona_id: str, system_prompt: str, begin_dialogs: list[str] | None = None, tools: list[str] | None = None, + folder_id: str | None = None, + sort_order: int = 0, ) -> Persona: - """创建新的 persona。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具""" + """创建新的 persona。 + + Args: + persona_id: Persona 唯一标识 + system_prompt: 系统提示词 + begin_dialogs: 预设对话列表 + tools: 工具列表,None 表示使用所有工具,空列表表示不使用任何工具 + folder_id: 所属文件夹 ID,None 表示根目录 + sort_order: 排序顺序 + """ if await self.db.get_persona_by_id(persona_id): raise ValueError(f"Persona with ID {persona_id} already exists.") new_persona = await self.db.insert_persona( @@ -109,6 +259,8 @@ async def create_persona( system_prompt, begin_dialogs, tools=tools, + folder_id=folder_id, + sort_order=sort_order, ) self.personas.append(new_persona) self.get_v3_persona_data() diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py index 7ddb75f17..07a959396 100644 --- a/astrbot/dashboard/routes/persona.py +++ b/astrbot/dashboard/routes/persona.py @@ -23,6 +23,15 @@ def __init__( "/persona/create": ("POST", self.create_persona), "/persona/update": ("POST", self.update_persona), "/persona/delete": ("POST", self.delete_persona), + "/persona/move": ("POST", self.move_persona), + "/persona/reorder": ("POST", self.reorder_items), + # Folder routes + "/persona/folder/list": ("GET", self.list_folders), + "/persona/folder/tree": ("GET", self.get_folder_tree), + "/persona/folder/detail": ("POST", self.get_folder_detail), + "/persona/folder/create": ("POST", self.create_folder), + "/persona/folder/update": ("POST", self.update_folder), + "/persona/folder/delete": ("POST", self.delete_folder), } self.db_helper = db_helper self.persona_mgr = core_lifecycle.persona_mgr @@ -31,7 +40,14 @@ def __init__( async def list_personas(self): """获取所有人格列表""" try: - personas = await self.persona_mgr.get_all_personas() + # 支持按文件夹筛选 + folder_id = request.args.get("folder_id") + if folder_id is not None: + personas = await self.persona_mgr.get_personas_by_folder( + folder_id if folder_id else None + ) + else: + personas = await self.persona_mgr.get_all_personas() return ( Response() .ok( @@ -41,6 +57,8 @@ async def list_personas(self): "system_prompt": persona.system_prompt, "begin_dialogs": persona.begin_dialogs or [], "tools": persona.tools, + "folder_id": persona.folder_id, + "sort_order": persona.sort_order, "created_at": persona.created_at.isoformat() if persona.created_at else None, @@ -78,6 +96,8 @@ async def get_persona_detail(self): "system_prompt": persona.system_prompt, "begin_dialogs": persona.begin_dialogs or [], "tools": persona.tools, + "folder_id": persona.folder_id, + "sort_order": persona.sort_order, "created_at": persona.created_at.isoformat() if persona.created_at else None, @@ -100,6 +120,8 @@ async def create_persona(self): system_prompt = data.get("system_prompt", "").strip() begin_dialogs = data.get("begin_dialogs", []) tools = data.get("tools") + folder_id = data.get("folder_id") # None 表示根目录 + sort_order = data.get("sort_order", 0) if not persona_id: return Response().error("人格ID不能为空").__dict__ @@ -120,6 +142,8 @@ async def create_persona(self): system_prompt=system_prompt, begin_dialogs=begin_dialogs if begin_dialogs else None, tools=tools if tools else None, + folder_id=folder_id, + sort_order=sort_order, ) return ( @@ -132,6 +156,8 @@ async def create_persona(self): "system_prompt": persona.system_prompt, "begin_dialogs": persona.begin_dialogs or [], "tools": persona.tools or [], + "folder_id": persona.folder_id, + "sort_order": persona.sort_order, "created_at": persona.created_at.isoformat() if persona.created_at else None, @@ -200,3 +226,234 @@ async def delete_persona(self): except Exception as e: logger.error(f"删除人格失败: {e!s}\n{traceback.format_exc()}") return Response().error(f"删除人格失败: {e!s}").__dict__ + + async def move_persona(self): + """移动人格到指定文件夹""" + try: + data = await request.get_json() + persona_id = data.get("persona_id") + folder_id = data.get("folder_id") # None 表示移动到根目录 + + if not persona_id: + return Response().error("缺少必要参数: persona_id").__dict__ + + await self.persona_mgr.move_persona_to_folder(persona_id, folder_id) + + return Response().ok({"message": "人格移动成功"}).__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"移动人格失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"移动人格失败: {e!s}").__dict__ + + # ==== + # Folder Routes + # ==== + + async def list_folders(self): + """获取文件夹列表""" + try: + parent_id = request.args.get("parent_id") + # 空字符串视为 None(根目录) + if parent_id == "": + parent_id = None + folders = await self.persona_mgr.get_folders(parent_id) + return ( + Response() + .ok( + [ + { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "created_at": folder.created_at.isoformat() + if folder.created_at + else None, + "updated_at": folder.updated_at.isoformat() + if folder.updated_at + else None, + } + for folder in folders + ], + ) + .__dict__ + ) + except Exception as e: + logger.error(f"获取文件夹列表失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"获取文件夹列表失败: {e!s}").__dict__ + + async def get_folder_tree(self): + """获取文件夹树形结构""" + try: + tree = await self.persona_mgr.get_folder_tree() + return Response().ok(tree).__dict__ + except Exception as e: + logger.error(f"获取文件夹树失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"获取文件夹树失败: {e!s}").__dict__ + + async def get_folder_detail(self): + """获取指定文件夹的详细信息""" + try: + data = await request.get_json() + folder_id = data.get("folder_id") + + if not folder_id: + return Response().error("缺少必要参数: folder_id").__dict__ + + folder = await self.persona_mgr.get_folder(folder_id) + if not folder: + return Response().error("文件夹不存在").__dict__ + + return ( + Response() + .ok( + { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "created_at": folder.created_at.isoformat() + if folder.created_at + else None, + "updated_at": folder.updated_at.isoformat() + if folder.updated_at + else None, + }, + ) + .__dict__ + ) + except Exception as e: + logger.error(f"获取文件夹详情失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"获取文件夹详情失败: {e!s}").__dict__ + + async def create_folder(self): + """创建文件夹""" + try: + data = await request.get_json() + name = data.get("name", "").strip() + parent_id = data.get("parent_id") + description = data.get("description") + sort_order = data.get("sort_order", 0) + + if not name: + return Response().error("文件夹名称不能为空").__dict__ + + folder = await self.persona_mgr.create_folder( + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + return ( + Response() + .ok( + { + "message": "文件夹创建成功", + "folder": { + "folder_id": folder.folder_id, + "name": folder.name, + "parent_id": folder.parent_id, + "description": folder.description, + "sort_order": folder.sort_order, + "created_at": folder.created_at.isoformat() + if folder.created_at + else None, + "updated_at": folder.updated_at.isoformat() + if folder.updated_at + else None, + }, + }, + ) + .__dict__ + ) + except Exception as e: + logger.error(f"创建文件夹失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"创建文件夹失败: {e!s}").__dict__ + + async def update_folder(self): + """更新文件夹信息""" + try: + data = await request.get_json() + folder_id = data.get("folder_id") + name = data.get("name") + parent_id = data.get("parent_id") + description = data.get("description") + sort_order = data.get("sort_order") + + if not folder_id: + return Response().error("缺少必要参数: folder_id").__dict__ + + await self.persona_mgr.update_folder( + folder_id=folder_id, + name=name, + parent_id=parent_id, + description=description, + sort_order=sort_order, + ) + + return Response().ok({"message": "文件夹更新成功"}).__dict__ + except Exception as e: + logger.error(f"更新文件夹失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"更新文件夹失败: {e!s}").__dict__ + + async def delete_folder(self): + """删除文件夹""" + try: + data = await request.get_json() + folder_id = data.get("folder_id") + + if not folder_id: + return Response().error("缺少必要参数: folder_id").__dict__ + + await self.persona_mgr.delete_folder(folder_id) + + return Response().ok({"message": "文件夹删除成功"}).__dict__ + except Exception as e: + logger.error(f"删除文件夹失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"删除文件夹失败: {e!s}").__dict__ + + async def reorder_items(self): + """批量更新排序顺序 + + 请求体格式: + { + "items": [ + {"id": "persona_id_1", "type": "persona", "sort_order": 0}, + {"id": "persona_id_2", "type": "persona", "sort_order": 1}, + {"id": "folder_id_1", "type": "folder", "sort_order": 0}, + ... + ] + } + """ + try: + data = await request.get_json() + items = data.get("items", []) + + if not items: + return Response().error("items 不能为空").__dict__ + + # 验证每个 item 的格式 + for item in items: + if not all(k in item for k in ("id", "type", "sort_order")): + return ( + Response() + .error("每个 item 必须包含 id, type, sort_order 字段") + .__dict__ + ) + if item["type"] not in ("persona", "folder"): + return ( + Response() + .error("type 字段必须是 'persona' 或 'folder'") + .__dict__ + ) + + await self.persona_mgr.batch_update_sort_order(items) + + return Response().ok({"message": "排序更新成功"}).__dict__ + except Exception as e: + logger.error(f"更新排序失败: {e!s}\n{traceback.format_exc()}") + return Response().error(f"更新排序失败: {e!s}").__dict__ diff --git a/dashboard/src/components/folder/BaseCreateFolderDialog.vue b/dashboard/src/components/folder/BaseCreateFolderDialog.vue new file mode 100644 index 000000000..7adab5bee --- /dev/null +++ b/dashboard/src/components/folder/BaseCreateFolderDialog.vue @@ -0,0 +1,132 @@ + + + diff --git a/dashboard/src/components/folder/BaseFolderBreadcrumb.vue b/dashboard/src/components/folder/BaseFolderBreadcrumb.vue new file mode 100644 index 000000000..037d0ff2f --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderBreadcrumb.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderCard.vue b/dashboard/src/components/folder/BaseFolderCard.vue new file mode 100644 index 000000000..eddda9b62 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderCard.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderItemSelector.vue b/dashboard/src/components/folder/BaseFolderItemSelector.vue new file mode 100644 index 000000000..8ba37b789 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderItemSelector.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderTree.vue b/dashboard/src/components/folder/BaseFolderTree.vue new file mode 100644 index 000000000..1fe924153 --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderTree.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseFolderTreeNode.vue b/dashboard/src/components/folder/BaseFolderTreeNode.vue new file mode 100644 index 000000000..b02cd3c2c --- /dev/null +++ b/dashboard/src/components/folder/BaseFolderTreeNode.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseMoveTargetNode.vue b/dashboard/src/components/folder/BaseMoveTargetNode.vue new file mode 100644 index 000000000..330947be0 --- /dev/null +++ b/dashboard/src/components/folder/BaseMoveTargetNode.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/dashboard/src/components/folder/BaseMoveToFolderDialog.vue b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue new file mode 100644 index 000000000..de2686798 --- /dev/null +++ b/dashboard/src/components/folder/BaseMoveToFolderDialog.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/dashboard/src/components/folder/README.md b/dashboard/src/components/folder/README.md new file mode 100644 index 000000000..cacf874c7 --- /dev/null +++ b/dashboard/src/components/folder/README.md @@ -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 + + + +``` + +## 类型定义 + +```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 + +``` + +## 拖放支持 + +组件内置了拖放支持,可以通过 `acceptDropTypes` 指定接受的拖放类型: + +```vue + + + + + +``` + +## 与 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() { + // ... + }, + // ... + }, +}); +``` diff --git a/dashboard/src/components/folder/index.ts b/dashboard/src/components/folder/index.ts new file mode 100644 index 000000000..07fde8313 --- /dev/null +++ b/dashboard/src/components/folder/index.ts @@ -0,0 +1,46 @@ +/** + * 通用文件夹管理组件库 + * + * 提供可复用的文件夹管理 UI 组件,适用于各种需要文件夹组织功能的场景 + * 如:persona 管理、模板管理、知识库管理等 + * + * 使用示例: + * ```vue + * + * ``` + */ + +// 类型导出 +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'; diff --git a/dashboard/src/components/folder/types.ts b/dashboard/src/components/folder/types.ts new file mode 100644 index 000000000..6fbeb39c7 --- /dev/null +++ b/dashboard/src/components/folder/types.ts @@ -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; + + // 加载指定文件夹的子文件夹 + loadSubFolders: (parentId: string | null) => Promise; + + // 创建文件夹 + createFolder: (data: CreateFolderData) => Promise; + + // 更新文件夹 + updateFolder: (data: UpdateFolderData) => Promise; + + // 删除文件夹 + deleteFolder: (folderId: string) => Promise; + + // 移动文件夹 + moveFolder?: (folderId: string, targetParentId: string | null) => Promise; +} + +/** + * 创建文件夹数据 + */ +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; + + // i18n 键配置 + i18nKeys?: FolderI18nKeys; +} + +/** + * 可选择的项目基础接口 + */ +export interface SelectableItem { + id: string; + name: string; + description?: string | null; + folder_id?: string | null; + [key: string]: any; +} + +/** + * 文件夹项目选择器操作接口 + */ +export interface FolderItemSelectorOperations { + // 加载文件夹树 + loadFolderTree: () => Promise; + + // 加载指定文件夹下的项目 + loadItemsInFolder: (folderId: string | null) => Promise; + + // 创建项目(可选) + createItem?: (data: any) => Promise; +} + +/** + * 文件夹项目选择器标签配置 + */ +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; +} diff --git a/dashboard/src/components/folder/useFolderManager.ts b/dashboard/src/components/folder/useFolderManager.ts new file mode 100644 index 000000000..a6c1e4b22 --- /dev/null +++ b/dashboard/src/components/folder/useFolderManager.ts @@ -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; + currentFolderId: Ref; + currentFolders: Ref; + breadcrumbPath: Ref; + expandedFolderIds: Ref; + loading: Ref; + treeLoading: Ref; + + // 计算属性 + currentFolderName: ComputedRef; + breadcrumbItems: ComputedRef; + + // 方法 + loadFolderTree: () => Promise; + navigateToFolder: (folderId: string | null) => Promise; + refreshCurrentFolder: () => Promise; + + createFolder: (data: CreateFolderData) => Promise; + updateFolder: (data: UpdateFolderData) => Promise; + deleteFolder: (folderId: string) => Promise; + moveFolder: (folderId: string, targetParentId: string | null) => Promise; + + 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([]); + const currentFolderId = ref(null); + const currentFolders = ref([]); + const breadcrumbPath = ref([]); + const expandedFolderIds = ref([]); + 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 => { + treeLoading.value = true; + try { + folderTree.value = await operations.loadFolderTree(); + } finally { + treeLoading.value = false; + } + }; + + const navigateToFolder = async (folderId: string | null): Promise => { + loading.value = true; + try { + currentFolderId.value = folderId; + currentFolders.value = await operations.loadSubFolders(folderId); + updateBreadcrumb(folderId); + } finally { + loading.value = false; + } + }; + + const refreshCurrentFolder = async (): Promise => { + await navigateToFolder(currentFolderId.value); + }; + + const createFolder = async (data: CreateFolderData): Promise => { + 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 => { + await operations.updateFolder(data); + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const deleteFolder = async (folderId: string): Promise => { + await operations.deleteFolder(folderId); + await Promise.all([refreshCurrentFolder(), loadFolderTree()]); + }; + + const moveFolder = async (folderId: string, targetParentId: string | null): Promise => { + 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; diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index 48f1a0d0e..4b0afa677 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -6,6 +6,18 @@ + + + {{ tm('form.createInFolder', { folder: folderDisplayName }) }} + + !!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; diff --git a/dashboard/src/components/shared/PersonaSelector.vue b/dashboard/src/components/shared/PersonaSelector.vue index 2d4c84fa0..393704f4c 100644 --- a/dashboard/src/components/shared/PersonaSelector.vue +++ b/dashboard/src/components/shared/PersonaSelector.vue @@ -1,84 +1,46 @@ - diff --git a/dashboard/src/i18n/locales/en-US/core/shared.json b/dashboard/src/i18n/locales/en-US/core/shared.json index 772e53112..ed4fefc55 100644 --- a/dashboard/src/i18n/locales/en-US/core/shared.json +++ b/dashboard/src/i18n/locales/en-US/core/shared.json @@ -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", diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index 94708ee56..67e3682f0 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -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" } } diff --git a/dashboard/src/i18n/locales/zh-CN/core/shared.json b/dashboard/src/i18n/locales/zh-CN/core/shared.json index 83be5caa1..106700551 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/shared.json +++ b/dashboard/src/i18n/locales/zh-CN/core/shared.json @@ -57,7 +57,9 @@ "createPersona": "创建新人格", "cancelSelection": "取消", "confirmSelection": "确认选择", - "selectPersonaPool": "选择人格池..." + "selectPersonaPool": "选择人格池...", + "rootFolder": "全部人格", + "emptyFolder": "此文件夹为空" }, "t2iTemplateEditor": { "buttonText": "自定义 T2I 模板", diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index 15121df41..3f8b1b253 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -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": "移动失败" } } diff --git a/dashboard/src/stores/personaStore.ts b/dashboard/src/stores/personaStore.ts new file mode 100644 index 000000000..6eb825851 --- /dev/null +++ b/dashboard/src/stores/personaStore.ts @@ -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 { + 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 { + 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 { + await this.navigateToFolder(this.currentFolderId); + }, + + /** + * 移动 Persona 到文件夹 + */ + async movePersonaToFolder(personaId: string, targetFolderId: string | null): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + }, + } +}); diff --git a/dashboard/src/views/PersonaPage.vue b/dashboard/src/views/PersonaPage.vue index cffeeb549..102028c6d 100644 --- a/dashboard/src/views/PersonaPage.vue +++ b/dashboard/src/views/PersonaPage.vue @@ -2,277 +2,38 @@
- +

mdi-heart{{ t('core.navigation.persona') }}

-

+

{{ tm('page.description') }}

-
- - {{ tm('buttons.create') }} - -
- - - - - - -
- {{ persona.persona_id }} -
- - - - - - mdi-pencil - {{ tm('buttons.edit') }} - - - - - mdi-delete - {{ tm('buttons.delete') }} - - - - -
- - -
- {{ truncateText(persona.system_prompt, 100) }} -
- -
- - {{ tm('labels.presetDialogs', { count: persona.begin_dialogs.length / 2 }) }} - -
- -
- {{ tm('labels.createdAt') }}: {{ formatDate(persona.created_at) }} -
-
-
-
- - - - - mdi-account-group -

{{ tm('empty.title') }}

-

{{ tm('empty.description') }}

- - {{ tm('buttons.createFirst') }} - -
-
-
- - - - - - - + +
- - - - - - - - - {{ viewingPersona.persona_id }} - - - - -
-

{{ tm('form.systemPrompt') }}

-
-                            {{ viewingPersona.system_prompt }}
-                        
-
- -
-

{{ tm('form.presetDialogs') }}

-
- - {{ index % 2 === 0 ? tm('form.userMessage') : tm('form.assistantMessage') }} - -
- {{ dialog }} -
-
-
- -
-

{{ tm('form.tools') }}

-
- - {{ tm('form.allToolsAvailable') }} - -
-
- - {{ toolName }} - -
-
- {{ tm('form.noToolsSelected') }} -
-
- -
-
{{ tm('labels.createdAt') }}: {{ formatDate(viewingPersona.created_at) }}
-
{{ tm('labels.updatedAt') }}: {{ - formatDate(viewingPersona.updated_at) }}
-
-
-
-
- - - - {{ message }} -
diff --git a/dashboard/src/views/persona/CreateFolderDialog.vue b/dashboard/src/views/persona/CreateFolderDialog.vue new file mode 100644 index 000000000..3106d73c9 --- /dev/null +++ b/dashboard/src/views/persona/CreateFolderDialog.vue @@ -0,0 +1,77 @@ + + + diff --git a/dashboard/src/views/persona/FolderBreadcrumb.vue b/dashboard/src/views/persona/FolderBreadcrumb.vue new file mode 100644 index 000000000..9e4c57b60 --- /dev/null +++ b/dashboard/src/views/persona/FolderBreadcrumb.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderCard.vue b/dashboard/src/views/persona/FolderCard.vue new file mode 100644 index 000000000..5ee4a14a0 --- /dev/null +++ b/dashboard/src/views/persona/FolderCard.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderTree.vue b/dashboard/src/views/persona/FolderTree.vue new file mode 100644 index 000000000..13c596990 --- /dev/null +++ b/dashboard/src/views/persona/FolderTree.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/dashboard/src/views/persona/FolderTreeNode.vue b/dashboard/src/views/persona/FolderTreeNode.vue new file mode 100644 index 000000000..c6a511fda --- /dev/null +++ b/dashboard/src/views/persona/FolderTreeNode.vue @@ -0,0 +1,66 @@ + + + diff --git a/dashboard/src/views/persona/MoveTargetNode.vue b/dashboard/src/views/persona/MoveTargetNode.vue new file mode 100644 index 000000000..90e1113f8 --- /dev/null +++ b/dashboard/src/views/persona/MoveTargetNode.vue @@ -0,0 +1,36 @@ + + + diff --git a/dashboard/src/views/persona/MoveToFolderDialog.vue b/dashboard/src/views/persona/MoveToFolderDialog.vue new file mode 100644 index 000000000..aeae03d3a --- /dev/null +++ b/dashboard/src/views/persona/MoveToFolderDialog.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/dashboard/src/views/persona/PersonaCard.vue b/dashboard/src/views/persona/PersonaCard.vue new file mode 100644 index 000000000..1468cda83 --- /dev/null +++ b/dashboard/src/views/persona/PersonaCard.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue new file mode 100644 index 000000000..eeea389e7 --- /dev/null +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -0,0 +1,557 @@ + + + + + diff --git a/dashboard/src/views/persona/index.ts b/dashboard/src/views/persona/index.ts new file mode 100644 index 000000000..322155b93 --- /dev/null +++ b/dashboard/src/views/persona/index.ts @@ -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';