mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
feat:支持vue原生插件页面
This commit is contained in:
+158
-19
@@ -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 import schemas
|
||||||
from app.command import Command
|
from app.command import Command
|
||||||
@@ -16,7 +20,6 @@ from app.scheduler import Scheduler
|
|||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
|
|
||||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||||||
|
|
||||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -222,21 +225,71 @@ def install(plugin_id: str,
|
|||||||
def plugin_form(plugin_id: str,
|
def plugin_form(plugin_id: str,
|
||||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件配置表单
|
根据插件ID获取插件配置表单或Vue组件URL
|
||||||
"""
|
"""
|
||||||
conf, model = PluginManager().get_plugin_form(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, model = plugin_instance.get_form_file()
|
||||||
return {
|
return {
|
||||||
"conf": conf,
|
"render_mode": "vue",
|
||||||
"model": model
|
"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="获取插件数据页面")
|
@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获取插件数据页面
|
根据插件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="获取所有插件仪表板元信息")
|
@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()
|
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="获取插件仪表板配置")
|
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
||||||
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||||
"""
|
"""
|
||||||
根据插件ID获取插件仪表板
|
根据插件ID获取插件仪表板
|
||||||
"""
|
"""
|
||||||
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
|
return plugin_dashboard_by_key(plugin_id, "", 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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
@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)
|
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="获取插件配置")
|
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||||
def plugin_config(plugin_id: str,
|
def plugin_config(plugin_id: str,
|
||||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||||
|
|||||||
+9
-64
@@ -1,13 +1,12 @@
|
|||||||
import concurrent
|
import concurrent
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
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.events import FileSystemEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
@@ -220,6 +219,14 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self._running_plugins = {}
|
self._running_plugins = {}
|
||||||
logger.info("插件停止完成")
|
logger.info("插件停止完成")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running_plugins(self):
|
||||||
|
"""
|
||||||
|
获取运行态插件列表
|
||||||
|
:return: 运行态插件列表
|
||||||
|
"""
|
||||||
|
return self._running_plugins
|
||||||
|
|
||||||
def reload_monitor(self):
|
def reload_monitor(self):
|
||||||
"""
|
"""
|
||||||
重新加载插件文件修改监测
|
重新加载插件文件修改监测
|
||||||
@@ -407,68 +414,6 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self.plugindata.del_data(pid)
|
self.plugindata.del_data(pid)
|
||||||
return True
|
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:
|
def get_plugin_state(self, pid: str) -> bool:
|
||||||
"""
|
"""
|
||||||
获取插件状态
|
获取插件状态
|
||||||
|
|||||||
+34
-3
@@ -83,6 +83,14 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_render_mode() -> str:
|
||||||
|
"""
|
||||||
|
获取插件渲染模式
|
||||||
|
:return: 渲染模式,支持:vue/vuetify,默认vuetify
|
||||||
|
"""
|
||||||
|
return "vuetify"
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_api(self) -> List[Dict[str, Any]]:
|
def get_api(self) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -100,8 +108,16 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
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
|
pass
|
||||||
|
|
||||||
@@ -113,6 +129,14 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_page_file() -> Optional[str]:
|
||||||
|
"""
|
||||||
|
获取插件数据页面JS代码源文件(与get_page二选一使用)
|
||||||
|
:return: 编译后的JS代码插件目录下相对路径
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def get_service(self) -> List[Dict[str, Any]]:
|
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]]]:
|
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配置参考:
|
1、col配置参考:
|
||||||
{
|
{
|
||||||
"cols": 12, "md": 6
|
"cols": 12, "md": 6
|
||||||
@@ -148,6 +172,13 @@ class _PluginBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
pass
|
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]]]:
|
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||||||
"""
|
"""
|
||||||
获取插件仪表盘元信息
|
获取插件仪表盘元信息
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ class PluginDashboard(Plugin):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
# 仪表板key
|
# 仪表板key
|
||||||
key: Optional[str] = None
|
key: Optional[str] = None
|
||||||
|
# 演染模式
|
||||||
|
render_mode: Optional[str] = Field(default="vuetify")
|
||||||
# 全局配置
|
# 全局配置
|
||||||
attrs: Optional[dict] = Field(default_factory=dict)
|
attrs: Optional[dict] = Field(default_factory=dict)
|
||||||
# col列数
|
# col列数
|
||||||
cols: Optional[dict] = Field(default_factory=dict)
|
cols: Optional[dict] = Field(default_factory=dict)
|
||||||
# 页面元素
|
# 页面元素
|
||||||
elements: Optional[List[dict]] = Field(default_factory=list)
|
elements: Optional[List[dict]] = Field(default_factory=list)
|
||||||
|
# 页面地址
|
||||||
|
component_url: Optional[str] = None
|
||||||
|
|||||||
Reference in New Issue
Block a user