mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-05-23 23:16:47 +00:00
673 lines
20 KiB
Markdown
673 lines
20 KiB
Markdown
# MoviePilot V2 插件开发指南
|
||
|
||
本文档说明如何开发适用于 MoviePilot V2 的插件,并尽量以当前 `MoviePilot` 与 `MoviePilot-Frontend` 主仓库的真实实现为准,而不是停留在早期兼容阶段的概念说明。
|
||
|
||
关联阅读:
|
||
|
||
- [仓库指南](./Repository_Guide.md)
|
||
- [FAQ 索引](./FAQ.md)
|
||
- [MoviePilot 前端模块联邦开发指南](https://github.com/jxxghp/MoviePilot-Frontend/blob/v2/docs/module-federation-guide.md)
|
||
|
||
## 1. 先理解 V2 插件的运行模型
|
||
|
||
V2 插件始终运行在 `MoviePilot` 后端宿主内,当前插件仓库只提供:
|
||
|
||
- 插件源码
|
||
- 插件市场索引
|
||
- 插件图标
|
||
- 插件文档
|
||
|
||
V2 插件的 UI 则有两种模式:
|
||
|
||
- `vuetify`:插件返回 JSON 配置,由 `MoviePilot-Frontend` 负责渲染
|
||
- `vue`:插件提供联邦远程组件,由前端动态加载
|
||
|
||
因此,开发一个 V2 插件通常至少会涉及三个部分:
|
||
|
||
1. 本仓库中的插件实现与元数据
|
||
2. `MoviePilot` 中的插件宿主能力
|
||
3. `MoviePilot-Frontend` 中的渲染与加载逻辑
|
||
|
||
## 2. V2 的版本选择规则
|
||
|
||
MoviePilot 处理插件版本时,当前逻辑可以总结为:
|
||
|
||
1. 宿主先根据当前版本标识优先读取 `package.v2.json`
|
||
2. 若目标插件不在 `package.v2.json` 中,再检查 `package.json`
|
||
3. `package.json` 中只有显式声明了 `"v2": true` 的插件,才会被视为 V2 兼容插件
|
||
|
||
建议按下列方式选型:
|
||
|
||
- **V2 专用实现**:放在 `plugins.v2/<plugin_id_lower>/`,元数据写入 `package.v2.json`
|
||
- **单实现跨版本兼容**:代码继续放在 `plugins/<plugin_id_lower>/`,在 `package.json` 中声明 `"v2": true`
|
||
- **V1/V2 差异已经很大**:不要继续强行共用目录,直接拆到 `plugins.v2/`
|
||
|
||
## 3. 最小 V2 插件骨架
|
||
|
||
一个最小可运行的 V2 插件通常如下:
|
||
|
||
```text
|
||
plugins.v2/
|
||
└── myplugin/
|
||
├── __init__.py
|
||
├── requirements.txt # 可选,只有插件有额外依赖时才需要
|
||
└── README.md # 可选,插件自己的说明文档
|
||
```
|
||
|
||
`__init__.py` 示例:
|
||
|
||
```python
|
||
from typing import Any, Dict, List, Tuple
|
||
|
||
from app.plugins import _PluginBase
|
||
|
||
|
||
class MyPlugin(_PluginBase):
|
||
# 插件在界面中的展示名称
|
||
plugin_name = "我的插件"
|
||
# 插件描述
|
||
plugin_desc = "一个最小可运行的 V2 插件示例。"
|
||
# 插件图标
|
||
plugin_icon = "Moviepilot_A.png"
|
||
# 插件版本,必须和 package.v2.json 中保持一致
|
||
plugin_version = "1.0.0"
|
||
# 作者信息
|
||
plugin_author = "your-name"
|
||
author_url = "https://github.com/your-name"
|
||
# 配置项前缀,建议保持唯一,避免与其他插件冲突
|
||
plugin_config_prefix = "myplugin_"
|
||
# 插件加载顺序,数值越小越早
|
||
plugin_order = 50
|
||
# 插件可见权限级别
|
||
auth_level = 1
|
||
|
||
# 运行时状态字段
|
||
_enabled = False
|
||
_message = "插件尚未初始化"
|
||
|
||
def init_plugin(self, config: dict = None):
|
||
"""根据当前配置初始化插件。"""
|
||
config = config or {}
|
||
self._enabled = bool(config.get("enabled"))
|
||
self._message = config.get("message") or "Hello MoviePilot"
|
||
|
||
def get_state(self) -> bool:
|
||
"""返回插件当前是否启用。"""
|
||
return self._enabled
|
||
|
||
@staticmethod
|
||
def get_command() -> List[Dict[str, Any]]:
|
||
"""没有远程命令时直接返回空列表。"""
|
||
return []
|
||
|
||
def get_api(self) -> List[Dict[str, Any]]:
|
||
"""没有插件 API 时直接返回空列表。"""
|
||
return []
|
||
|
||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||
"""返回配置页 JSON 和默认配置模型。"""
|
||
return [
|
||
{
|
||
"component": "VForm",
|
||
"content": [
|
||
{
|
||
"component": "VRow",
|
||
"content": [
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 4},
|
||
"content": [
|
||
{
|
||
"component": "VSwitch",
|
||
"props": {
|
||
"model": "enabled",
|
||
"label": "启用插件",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"component": "VCol",
|
||
"props": {"cols": 12, "md": 8},
|
||
"content": [
|
||
{
|
||
"component": "VTextField",
|
||
"props": {
|
||
"model": "message",
|
||
"label": "展示文本",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
], {
|
||
"enabled": False,
|
||
"message": "Hello MoviePilot",
|
||
}
|
||
|
||
def get_page(self) -> List[dict]:
|
||
"""返回详情页 JSON。"""
|
||
return [
|
||
{
|
||
"component": "VAlert",
|
||
"props": {
|
||
"type": "info",
|
||
"variant": "tonal",
|
||
"text": self._message,
|
||
},
|
||
}
|
||
]
|
||
|
||
def stop_service(self):
|
||
"""没有后台任务时可以留空。"""
|
||
pass
|
||
```
|
||
|
||
对应的 `package.v2.json` 条目至少应包含:
|
||
|
||
```json
|
||
{
|
||
"MyPlugin": {
|
||
"name": "我的插件",
|
||
"description": "一个最小可运行的 V2 插件示例。",
|
||
"labels": "示例",
|
||
"version": "1.0.0",
|
||
"icon": "Moviepilot_A.png",
|
||
"author": "your-name",
|
||
"system_version": ">=2.12.0",
|
||
"level": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
`system_version` 是可选字段。插件依赖某个 MoviePilot 主系统版本才提供的能力时再声明,格式参考 pip 依赖版本范围;不声明时宿主不会做主系统版本检查。
|
||
|
||
## 4. `_PluginBase` 的核心能力
|
||
|
||
V2 插件的核心宿主基类是 `MoviePilot/app/plugins/__init__.py` 中的 `_PluginBase`。开发时需要优先理解它暴露出来的扩展点。
|
||
|
||
### 4.1 必选方法
|
||
|
||
以下方法通常必须实现:
|
||
|
||
- `init_plugin(self, config: dict = None)`:读取配置并生效
|
||
- `get_state(self) -> bool`:返回当前运行状态
|
||
- `get_api(self) -> List[dict]`:声明插件 API
|
||
- `get_form(self) -> Tuple[page_json, model]`:声明配置页
|
||
- `get_page(self) -> List[dict] | None`:声明详情页
|
||
- `stop_service(self)`:停用插件时清理后台任务、线程、调度器等
|
||
|
||
### 4.2 常用可选方法
|
||
|
||
- `get_command()`:注册远程命令
|
||
- `get_service()`:注册公共服务
|
||
- `get_dashboard()`:声明仪表板内容
|
||
- `get_dashboard_meta()`:声明多仪表板元信息
|
||
- `get_render_mode()`:选择 `vuetify` / `vue`
|
||
- `get_module()`:重载系统模块
|
||
- `get_actions()`:注册工作流动作
|
||
- `get_agent_tools()`:注册智能体工具
|
||
- `get_sidebar_nav()`:Vue 全页插件向主界面侧栏声明入口
|
||
|
||
### 4.3 基类自带的辅助能力
|
||
|
||
基类已经提供了一些很关键的工具方法,通常不需要自行重复造轮子:
|
||
|
||
- `get_config()` / `update_config()`:读取与保存插件配置
|
||
- `get_data_path()`:获取插件自己的数据目录
|
||
- `save_data()` / `get_data()` / `del_data()`:读写插件持久化数据
|
||
- `post_message()`:通过系统通知渠道发消息
|
||
- `self.chain`:插件链式能力入口
|
||
- `self.systemconfig` / `self.plugindata`:宿主已有的配置与数据操作封装
|
||
|
||
## 5. 配置、数据与分身兼容
|
||
|
||
### 5.1 配置读写
|
||
|
||
最常见的模式是:
|
||
|
||
```python
|
||
def init_plugin(self, config: dict = None):
|
||
config = config or {}
|
||
self._enabled = bool(config.get("enabled"))
|
||
|
||
|
||
def _save_current_config(self):
|
||
# 这里保存的是插件自己的配置快照
|
||
self.update_config({
|
||
"enabled": self._enabled,
|
||
})
|
||
```
|
||
|
||
### 5.2 数据保存
|
||
|
||
如果插件要保存运行结果、缓存文件或状态快照,优先使用基类提供的数据目录和数据表:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
|
||
def write_report(self, content: str):
|
||
# 每个插件都有自己的独立数据目录
|
||
report_path: Path = self.get_data_path() / "report.txt"
|
||
report_path.write_text(content, encoding="utf-8")
|
||
|
||
|
||
def save_runtime_state(self, state: dict):
|
||
# 结构化小数据优先放 plugindata
|
||
self.save_data("runtime_state", state)
|
||
```
|
||
|
||
### 5.3 分身友好写法
|
||
|
||
MoviePilot 支持插件分身,因此建议遵守这些规则:
|
||
|
||
- 不要把插件 ID 写死到字符串里到处拼接
|
||
- 优先使用 `self.__class__.__name__`
|
||
- `plugin_config_prefix` 必须唯一
|
||
- 如果你需要通过宿主 API 反向查找自己,优先从当前类名或运行时实例出发
|
||
|
||
这样插件被分身后,配置前缀、类名替换和数据隔离更容易保持正确。
|
||
|
||
## 6. V2 常见能力面
|
||
|
||
### 6.1 远程命令 `get_command()`
|
||
|
||
用于注册 `/xx` 形式的远程命令。最常见的方式是:
|
||
|
||
1. `get_command()` 暴露命令元数据
|
||
2. 监听 `EventType.PluginAction`
|
||
3. 根据 `event_data["action"]` 判断是否是自己的动作
|
||
|
||
示例:
|
||
|
||
```python
|
||
from typing import Any, Dict, List
|
||
|
||
from app.core.event import eventmanager, Event
|
||
from app.schemas.types import EventType
|
||
|
||
|
||
@staticmethod
|
||
def get_command() -> List[Dict[str, Any]]:
|
||
return [
|
||
{
|
||
"cmd": "/my_plugin_run",
|
||
"event": EventType.PluginAction,
|
||
"desc": "执行我的插件",
|
||
"category": "插件命令",
|
||
"data": {
|
||
# 用 action 做路由最稳妥
|
||
"action": "my_plugin_run",
|
||
},
|
||
}
|
||
]
|
||
|
||
|
||
@eventmanager.register(EventType.PluginAction)
|
||
def run_command(self, event: Event):
|
||
event_data = event.event_data or {}
|
||
if event_data.get("action") != "my_plugin_run":
|
||
return
|
||
# 这里写实际业务逻辑
|
||
```
|
||
|
||
### 6.2 插件 API `get_api()`
|
||
|
||
插件 API 会被动态注册到:
|
||
|
||
```text
|
||
/api/v1/plugin/<PluginID>/<path>
|
||
```
|
||
|
||
示例:
|
||
|
||
```python
|
||
def get_api(self) -> List[Dict[str, Any]]:
|
||
return [
|
||
{
|
||
"path": "/history",
|
||
"endpoint": self.get_history,
|
||
"methods": ["GET"],
|
||
# 前端插件页面通过 api 模块调用时,通常使用 bear
|
||
"auth": "bear",
|
||
"summary": "查询插件历史",
|
||
"description": "返回插件最近的处理历史",
|
||
}
|
||
]
|
||
```
|
||
|
||
说明:
|
||
|
||
- `auth` 支持 `apikey` 和 `bear`
|
||
- 面向插件前端页面的接口,通常使用 `bear`
|
||
- 面向外部系统调用的接口,可使用 `apikey`
|
||
- 如无特殊原因,不要默认匿名开放
|
||
|
||
### 6.3 公共服务 `get_service()`
|
||
|
||
服务注册后会出现在 MoviePilot 的服务管理中,适合定时任务、周期刷新、批处理工作。
|
||
|
||
示例:
|
||
|
||
```python
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
|
||
def get_service(self) -> List[Dict[str, Any]]:
|
||
if not self.get_state():
|
||
return []
|
||
return [
|
||
{
|
||
"id": "MyPlugin.Refresh",
|
||
"name": "我的插件定时刷新",
|
||
"trigger": CronTrigger.from_crontab("0 */6 * * *"),
|
||
"func": self.refresh,
|
||
"kwargs": {},
|
||
}
|
||
]
|
||
```
|
||
|
||
注意:
|
||
|
||
- `id` 必须稳定且唯一
|
||
- 禁用插件时要在 `stop_service()` 中清理自己的后台资源
|
||
- 如果服务需要“启用后立刻跑一次”,可配合 `date` 触发器单独注册一条即时任务
|
||
|
||
### 6.4 仪表板 `get_dashboard()` / `get_dashboard_meta()`
|
||
|
||
单仪表板插件可只实现 `get_dashboard()`;多仪表板插件建议额外实现 `get_dashboard_meta()`。
|
||
|
||
示例:
|
||
|
||
```python
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
|
||
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||
return [
|
||
{"key": "summary", "name": "摘要"},
|
||
{"key": "trend", "name": "趋势"},
|
||
]
|
||
|
||
|
||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
||
col_config = {"cols": 12, "md": 6}
|
||
global_config = {
|
||
"title": "我的插件",
|
||
"refresh": 30,
|
||
"border": True,
|
||
}
|
||
page = [
|
||
{
|
||
"component": "VAlert",
|
||
"props": {
|
||
"type": "info",
|
||
"text": f"当前仪表板 key: {key}",
|
||
},
|
||
}
|
||
]
|
||
return col_config, global_config, page
|
||
```
|
||
|
||
### 6.5 工作流动作 `get_actions()`
|
||
|
||
工作流动作适合把插件能力暴露给系统工作流调用。动作函数的第一个参数固定为 `ActionContent`,返回值需要遵循宿主约定。
|
||
|
||
```python
|
||
def get_actions(self) -> List[Dict[str, Any]]:
|
||
return [
|
||
{
|
||
"id": "my_plugin_action",
|
||
"name": "执行我的插件动作",
|
||
"func": self.run_action,
|
||
"kwargs": {
|
||
# 这里可以预置额外参数
|
||
"mode": "fast",
|
||
},
|
||
}
|
||
]
|
||
```
|
||
|
||
### 6.6 系统模块重载 `get_module()`
|
||
|
||
当插件要接管某个系统模块能力时,可通过 `get_module()` 映射方法实现。所有可重载的方法名,需要以 `MoviePilot/app/chain/` 中实际调用的模块名为准。
|
||
|
||
```python
|
||
def get_module(self) -> Dict[str, Any]:
|
||
return {
|
||
# 键名必须与宿主链式调用的模块名一致
|
||
"my_custom_handler": self.handle_custom_logic,
|
||
}
|
||
```
|
||
|
||
这种能力侵入性较强,只有在插件确实要扩展宿主链路时才建议使用。
|
||
|
||
### 6.7 智能体工具 `get_agent_tools()`
|
||
|
||
插件可以为 MoviePilot 的 AI 智能体扩展工具。每个工具类必须继承 `MoviePilotTool`。
|
||
|
||
示例:
|
||
|
||
```python
|
||
from typing import List, Optional, Type
|
||
|
||
from pydantic import BaseModel, Field
|
||
from app.agent.tools.base import MoviePilotTool
|
||
|
||
|
||
class QueryInput(BaseModel):
|
||
"""工具入参模型。"""
|
||
|
||
keyword: str = Field(..., description="要查询的关键字")
|
||
|
||
|
||
class MyQueryTool(MoviePilotTool):
|
||
"""最小智能体工具示例。"""
|
||
|
||
name: str = "my_query_tool"
|
||
description: str = "Query plugin data by keyword."
|
||
args_schema: Type[BaseModel] = QueryInput
|
||
|
||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||
# 这里返回给用户的提示语
|
||
return f"正在查询关键字:{kwargs.get('keyword', '')}"
|
||
|
||
async def run(self, keyword: str, **kwargs) -> str:
|
||
# 这里实现工具实际逻辑
|
||
return f"查询完成:{keyword}"
|
||
|
||
|
||
def get_agent_tools(self) -> List[type]:
|
||
return [MyQueryTool]
|
||
```
|
||
|
||
如果需要真实案例,可以参考本仓库 `plugins.v2/lexiannot/agenttool.py`。
|
||
|
||
## 7. 渲染模式
|
||
|
||
### 7.1 Vuetify JSON 模式
|
||
|
||
这是默认模式,`get_render_mode()` 不需要额外实现,宿主默认按 `vuetify` 处理。
|
||
|
||
适用场景:
|
||
|
||
- 普通配置表单
|
||
- 详情页
|
||
- 轻量数据列表
|
||
- 仪表板卡片
|
||
|
||
规则:
|
||
|
||
- `get_form()` 返回“页面 JSON + 默认模型”
|
||
- `get_page()` 返回页面 JSON
|
||
- `get_dashboard()` 返回“列配置 + 全局配置 + 页面 JSON”
|
||
- `props.model` 等效于 `v-model`
|
||
- `props.show` 等效于 `v-show`
|
||
- 配置页支持 `{{ ... }}` 表达式与 `onxxx` 事件
|
||
|
||
### 7.2 Vue 联邦模式
|
||
|
||
如果插件要完全使用 Vue 组件渲染,需要实现:
|
||
|
||
```python
|
||
from typing import Tuple
|
||
|
||
|
||
def get_render_mode(self) -> Tuple[str, str]:
|
||
# 第二个返回值是远程组件构建产物所在目录
|
||
return "vue", "dist/assets"
|
||
```
|
||
|
||
此时:
|
||
|
||
- `get_form()` / `get_page()` 可返回 `([], {})`、`[]` 或 `None`
|
||
- 前端会通过 `/api/v1/plugin/remotes` 获取远程组件列表
|
||
- 静态资源由 `MoviePilot` 后端负责对外提供
|
||
|
||
若需要独立侧栏页面,还要实现 `get_sidebar_nav()`。
|
||
|
||
### 7.3 侧栏全页入口 `get_sidebar_nav()`
|
||
|
||
只有 Vue 模式插件才会被主界面侧栏聚合。
|
||
|
||
示例:
|
||
|
||
```python
|
||
from typing import Any, Dict, List
|
||
|
||
|
||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||
return [
|
||
{
|
||
"nav_key": "main",
|
||
"title": "我的插件首页",
|
||
"icon": "mdi-puzzle",
|
||
"section": "system",
|
||
"permission": "manage",
|
||
"order": 10,
|
||
},
|
||
{
|
||
"nav_key": "settings",
|
||
"title": "我的插件设置",
|
||
"icon": "mdi-cog",
|
||
"section": "system",
|
||
"permission": "manage",
|
||
"order": 11,
|
||
},
|
||
]
|
||
```
|
||
|
||
当前宿主约束:
|
||
|
||
- `section` 只接受:`start`、`discovery`、`subscribe`、`organize`、`system`
|
||
- `permission` 只接受:`subscribe`、`discovery`、`search`、`manage`、`admin`
|
||
- `nav_key` 不能包含 `/`、`?`、`#`、空格
|
||
|
||
多入口全页插件的联邦暴露名规则,详见前端仓库的模块联邦开发指南。
|
||
|
||
## 8. 公共服务封装建议
|
||
|
||
V2 下很多插件都依赖下载器、媒体服务器、通知渠道等宿主服务。不要自行重复读取系统配置,优先使用宿主帮助类。
|
||
|
||
常见帮助类包括:
|
||
|
||
- `DownloaderHelper`
|
||
- `MediaServerHelper`
|
||
- `NotificationHelper`
|
||
|
||
典型写法:
|
||
|
||
```python
|
||
from app.helper.downloader import DownloaderHelper
|
||
|
||
|
||
def run_with_downloader(self, name: str):
|
||
# 通过帮助类获取已启用的下载器实例和配置
|
||
service = DownloaderHelper().get_service(name=name)
|
||
if not service:
|
||
return False
|
||
downloader = service.instance
|
||
# 这里调用实际下载器能力
|
||
return downloader is not None
|
||
```
|
||
|
||
这样做的好处是:
|
||
|
||
- 自动复用宿主对系统配置的解析
|
||
- 自动获取“启用中的实例”
|
||
- 降低插件和底层模块的耦合
|
||
|
||
## 9. 调试与校验
|
||
|
||
### 9.1 Python 层
|
||
|
||
推荐最小校验:
|
||
|
||
```bash
|
||
python3 -m py_compile plugins.v2/myplugin/__init__.py
|
||
python3 -m compileall plugins.v2/myplugin
|
||
git diff --check
|
||
```
|
||
|
||
### 9.2 API 层
|
||
|
||
如果插件定义了 `get_api()`:
|
||
|
||
- 启动宿主后检查 `/docs`
|
||
- 确认路由实际注册在 `/api/v1/plugin/<PluginID>/...`
|
||
- 区分 `apikey` 与 `bear` 的认证方式是否符合调用场景
|
||
|
||
### 9.3 前端层
|
||
|
||
如果插件使用 Vue 远程组件:
|
||
|
||
- 在前端工程中先执行 `yarn typecheck`
|
||
- 再执行 `yarn build`
|
||
- 确认最终上传的是联邦所需产物,而不是整个前端源码目录
|
||
|
||
## 10. 发布清单
|
||
|
||
发布前建议至少逐项确认:
|
||
|
||
1. 插件目录在 `plugins/` 或 `plugins.v2/` 下位置正确
|
||
2. 目录名与类名小写一致
|
||
3. 元数据已写入正确的索引文件
|
||
4. 索引里的 `version` 与代码里的 `plugin_version` 一致
|
||
5. `history` 已补齐本次变更说明
|
||
6. 若使用 Release 分发,条目已声明 `"release": true`
|
||
7. Python 代码完成最小语法校验
|
||
8. 若有 Vue 远程组件,构建产物已更新
|
||
|
||
## 11. 什么时候还要回去看宿主源码
|
||
|
||
下面这些问题,不建议只看本仓库文档判断:
|
||
|
||
- 插件为什么没有显示在插件市场
|
||
- 插件 API 为什么没有注册成功
|
||
- 服务为什么没有进入服务管理
|
||
- 插件仪表板为什么没有加载
|
||
- Vue 联邦页面为什么没有出现在侧栏
|
||
- 某个 `permission` / `section` / `nav_key` 为什么不生效
|
||
|
||
这类问题本质上都与宿主实现有关,应回到:
|
||
|
||
- `MoviePilot/app/core/plugin.py`
|
||
- `MoviePilot/app/api/endpoints/plugin.py`
|
||
- `MoviePilot/app/plugins/__init__.py`
|
||
- `MoviePilot-Frontend/docs/module-federation-guide.md`
|
||
- `MoviePilot-Frontend/src/utils/federationLoader.ts`
|
||
|
||
## 12. 结论
|
||
|
||
开发 V2 插件时,最重要的不是“把代码放进 `plugins.v2/`”,而是同时把下面三件事做对:
|
||
|
||
1. 运行时契约对齐宿主 `_PluginBase`
|
||
2. 索引元数据与插件代码保持一致
|
||
3. 渲染模式与前端加载方式匹配
|
||
|
||
做到这三点,插件的开发、升级、迁移、分身和发布都会明显顺很多。
|