From c692a3c80ed1cc2c9b1848d43bbadd0aeb10c4e8 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 3 May 2025 10:03:44 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81vue=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E6=8F=92=E4=BB=B6=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/plugin.py | 181 +++++++++++++++++++++++++++++++----- app/core/plugin.py | 73 ++------------- app/plugins/__init__.py | 37 +++++++- app/schemas/plugin.py | 4 + 4 files changed, 207 insertions(+), 88 deletions(-) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 348c9210..3f545509 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,6 +1,10 @@ -from typing import Annotated, Any, List, Optional +import inspect +import mimetypes +from typing import Annotated, Any, List, Optional, Callable, Tuple -from fastapi import APIRouter, Depends, Header +from fastapi import APIRouter, Depends, Header, HTTPException +from starlette import status +from starlette.responses import FileResponse from app import schemas from app.command import Command @@ -16,7 +20,6 @@ from app.scheduler import Scheduler from app.schemas.types import SystemConfigKey PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"} - PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" router = APIRouter() @@ -222,21 +225,71 @@ def install(plugin_id: str, def plugin_form(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict: """ - 根据插件ID获取插件配置表单 + 根据插件ID获取插件配置表单或Vue组件URL """ - conf, model = PluginManager().get_plugin_form(plugin_id) - return { - "conf": conf, - "model": model - } + plugin_instance = PluginManager().running_plugins.get(plugin_id) + if not plugin_instance: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") + + # 渲染模式 + render_mode = plugin_instance.get_render_mode() + if render_mode == "vue": + # Vue模式 + try: + vue_component_file, model = plugin_instance.get_form_file() + return { + "render_mode": "vue", + "component_url": f"/plugin/file/{plugin_id.lower()}/{vue_component_file}", + "model": PluginManager().get_plugin_config(plugin_id) or model + } + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_form_file 出错: {str(e)}") + else: + # Vuetify模式 + try: + conf, model = plugin_instance.get_form() + return { + "render_mode": "vuetify", + "conf": conf, + "model": PluginManager().get_plugin_config(plugin_id) or model + } + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}") + return {} @router.get("/page/{plugin_id}", summary="获取插件数据页面") -def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]: +def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict: """ 根据插件ID获取插件数据页面 """ - return PluginManager().get_plugin_page(plugin_id) + plugin_instance = PluginManager().running_plugins.get(plugin_id) + if not plugin_instance: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") + + # 渲染模式 + render_mode = plugin_instance.get_render_mode() + if render_mode == "vue": + # Vue模式 + try: + vue_component_file = plugin_instance.get_page_file() + return { + "render_mode": "vue", + "component_url": f"/plugin/file/{plugin_id.lower()}/{vue_component_file}", + } + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_page_file 出错: {str(e)}") + + else: + try: + page = plugin_instance.get_page() + return { + "render_mode": "vuetify", + "page": page or [] + } + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}") + return {} @router.get("/dashboard/meta", summary="获取所有插件仪表板元信息") @@ -247,22 +300,73 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li return PluginManager().get_plugin_dashboard_meta() +@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") +def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, + _: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]: + """ + 根据插件ID获取插件仪表板 + """ + + def __get_params_count(func: Callable): + """ + 获取函数的参数信息 + """ + signature = inspect.signature(func) + return len(signature.parameters) + + # 获取插件实例 + plugin_instance = PluginManager().running_plugins.get(plugin_id) + if not plugin_instance: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载") + # 渲染模式 + render_mode = plugin_instance.get_render_mode() + if render_mode == "vue": + # Vue模式 + try: + cols, attrs, vue_component_file = plugin_instance.get_dashboard_file(key=key, user_agent=user_agent) + return schemas.PluginDashboard( + id=plugin_id, + name=plugin_instance.plugin_name, + key=key, + render_mode=render_mode, + cols=cols or {}, + attrs=attrs or {}, + component_url=f"/plugin/file/{plugin_id.lower()}/{vue_component_file}", + ) + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_dashboard_file 出错: {str(e)}") + else: + try: + # 检查方法的参数个数 + params_count = __get_params_count(plugin_instance.get_dashboard) + if params_count > 1: + dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent) + elif params_count > 0: + dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent) + else: + dashboard: Tuple = plugin_instance.get_dashboard() + if dashboard: + cols, attrs, elements = dashboard + return schemas.PluginDashboard( + id=plugin_id, + name=plugin_instance.plugin_name, + key=key, + render_mode="vuetify", + cols=cols or {}, + elements=elements, + attrs=attrs or {} + ) + except Exception as e: + logger.error(f"插件 {plugin_id} 调用方法 get_dashboard 出错: {str(e)}") + + @router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置") def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard: """ 根据插件ID获取插件仪表板 """ - return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent) - - -@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置") -def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None, - _: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard: - """ - 根据插件ID获取插件仪表板 - """ - return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent) + return plugin_dashboard_by_key(plugin_id, "", user_agent) @router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response) @@ -286,6 +390,41 @@ def reset_plugin(plugin_id: str, return schemas.Response(success=True) +@app.get("/file/{filepath:path}", summary="获取插件静态文件") +async def plugin_static_file(plugin_id: str, filepath: str, _: schemas.TokenPayload = Depends(verify_token)): + """ + 获取插件静态文件 + """ + # 基础安全检查 + if ".." in filepath or ".." in filepath: + logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") + + plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower() + plugin_file_path = plugin_base_dir / filepath + if not plugin_file_path.exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在") + if not plugin_file_path.is_file(): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件") + + # 判断 MIME 类型 + response_type, _ = mimetypes.guess_type(str(plugin_file_path)) + suffix = plugin_file_path.suffix.lower() + # 强制修正 .mjs 和 .js 的 MIME 类型 + if suffix in ['.js', '.mjs']: + response_type = 'application/javascript' + elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正 + response_type = 'text/css' + elif not response_type: # 对于其他猜不出的类型 + response_type = 'application/octet-stream' + + try: + return FileResponse(plugin_file_path, media_type=response_type) + except Exception as e: + logger.error(f"Error creating/sending FileResponse for {plugin_file_path}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal Server Error") + + @router.get("/{plugin_id}", summary="获取插件配置") def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict: diff --git a/app/core/plugin.py b/app/core/plugin.py index 59a68d7f..e9c291e5 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -1,13 +1,12 @@ import concurrent import concurrent.futures import importlib.util -import inspect import os import time import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -220,6 +219,14 @@ class PluginManager(metaclass=Singleton): self._running_plugins = {} logger.info("插件停止完成") + @property + def running_plugins(self): + """ + 获取运行态插件列表 + :return: 运行态插件列表 + """ + return self._running_plugins + def reload_monitor(self): """ 重新加载插件文件修改监测 @@ -407,68 +414,6 @@ class PluginManager(metaclass=Singleton): self.plugindata.del_data(pid) return True - def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]: - """ - 获取插件表单 - :param pid: 插件ID - """ - plugin = self._running_plugins.get(pid) - if not plugin: - return [], {} - if hasattr(plugin, "get_form"): - return plugin.get_form() or ([], {}) - return [], {} - - def get_plugin_page(self, pid: str) -> List[dict]: - """ - 获取插件页面 - :param pid: 插件ID - """ - plugin = self._running_plugins.get(pid) - if not plugin: - return [] - if hasattr(plugin, "get_page"): - return plugin.get_page() or [] - return [] - - def get_plugin_dashboard(self, pid: str, key: Optional[str] = None, **kwargs) -> Optional[schemas.PluginDashboard]: - """ - 获取插件仪表盘 - :param pid: 插件ID - :param key: 仪表盘key - """ - - def __get_params_count(func: Callable): - """ - 获取函数的参数信息 - """ - signature = inspect.signature(func) - return len(signature.parameters) - - plugin = self._running_plugins.get(pid) - if not plugin: - return None - if hasattr(plugin, "get_dashboard"): - # 检查方法的参数个数 - params_count = __get_params_count(plugin.get_dashboard) - if params_count > 1: - dashboard: Tuple = plugin.get_dashboard(key=key, **kwargs) - elif params_count > 0: - dashboard: Tuple = plugin.get_dashboard(**kwargs) - else: - dashboard: Tuple = plugin.get_dashboard() - if dashboard: - cols, attrs, elements = dashboard - return schemas.PluginDashboard( - id=pid, - name=plugin.plugin_name, - key=key or "", - cols=cols or {}, - elements=elements, - attrs=attrs or {} - ) - return None - def get_plugin_state(self, pid: str) -> bool: """ 获取插件状态 diff --git a/app/plugins/__init__.py b/app/plugins/__init__.py index 1c708c58..4af2b7f9 100644 --- a/app/plugins/__init__.py +++ b/app/plugins/__init__.py @@ -83,6 +83,14 @@ class _PluginBase(metaclass=ABCMeta): """ pass + @staticmethod + def get_render_mode() -> str: + """ + 获取插件渲染模式 + :return: 渲染模式,支持:vue/vuetify,默认vuetify + """ + return "vuetify" + @abstractmethod def get_api(self) -> List[Dict[str, Any]]: """ @@ -100,8 +108,16 @@ class _PluginBase(metaclass=ABCMeta): @abstractmethod def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - 插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + 拼装插件配置页面,插件配置页面使用Vuetify组件拼装,参考:https://vuetifyjs.com/ + :return: 1、页面配置;2、默认数据结构 + """ + pass + + @staticmethod + def get_form_file() -> Tuple[str, Dict[str, Any]]: + """ + 获取插件配置页面JS代码源文件(与get_from二选一使用) + :return: 1、编译后的JS代码插件目录下相对路径;2、默认数据结构 """ pass @@ -113,6 +129,14 @@ class _PluginBase(metaclass=ABCMeta): """ pass + @staticmethod + def get_page_file() -> Optional[str]: + """ + 获取插件数据页面JS代码源文件(与get_page二选一使用) + :return: 编译后的JS代码插件目录下相对路径 + """ + pass + def get_service(self) -> List[Dict[str, Any]]: """ 注册插件公共服务 @@ -128,7 +152,7 @@ class _PluginBase(metaclass=ABCMeta): def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]: """ - 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据) + 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(布局、自动刷新等);3、仪表板页面元素配置json(含数据) 1、col配置参考: { "cols": 12, "md": 6 @@ -148,6 +172,13 @@ class _PluginBase(metaclass=ABCMeta): """ pass + def get_dashboard_file(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]: + """ + 获取插件仪表盘页面JS代码源文件(与get_dashboard二选一使用) + :return: 1、全局配置(布局、自动刷新等);2、仪表板页面元素配置json(含数据);3、编译后的JS代码插件目录下相对路径 + """ + pass + def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]: """ 获取插件仪表盘元信息 diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 3f186f07..ed11216a 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -59,9 +59,13 @@ class PluginDashboard(Plugin): name: Optional[str] = None # 仪表板key key: Optional[str] = None + # 演染模式 + render_mode: Optional[str] = Field(default="vuetify") # 全局配置 attrs: Optional[dict] = Field(default_factory=dict) # col列数 cols: Optional[dict] = Field(default_factory=dict) # 页面元素 elements: Optional[List[dict]] = Field(default_factory=list) + # 页面地址 + component_url: Optional[str] = None