插件结构
一个插件就是一个导出 apply 的模块,外加可选的 inject。本页汇总清单字段、上下文服务与必须内化的规则。运行时机制见 插件系统。
清单文件 plugin.json
每个运行时插件目录下放一个清单文件。加载器按 plugin.json → manifest.json → package.json 的顺序查找,第一个存在的生效;市场发布产物要求每一端都有 plugin.json。external/<id>/package.json 是插件私有 npm 依赖清单,不是双端插件的市场元数据。
{
"id": "hello",
"name": "Hello Plugin",
"displayName": "Hello Plugin",
"version": "0.1.0",
"description": "A minimal full-stack ElementsPanel plugin.",
"category": "",
"changelog": "",
"priority": 100,
"frontendInject": ["console", "i18n"],
"backend": "src/backend/index.ts",
"frontend": "src/frontend.ts",
"config": { "interval": 30 },
"configFields": [
{ "key": "interval", "type": "number", "title": "Interval", "min": 1, "required": true }
]
}
| 字段 | 说明 |
|---|---|
|
插件唯一标识。缺省时取目录名。重复 id 会被告警并忽略。 |
|
展示信息。市场优先使用 |
|
市场分类与本版本更新日志。双端插件只在 Panel 清单中维护整包元数据。 |
|
加载顺序,数值升序加载。仅在彼此无依赖的插件间确定顺序时使用;真正的依赖请用 |
|
设为 |
|
后端入口。Panel 与 Daemon 都识别 |
|
前端入口,仅 Panel 插件有。识别 |
|
浏览器插件 id 依赖图,不是 Cordis 服务名。运行时会拓扑排序并报告缺失依赖与循环。 |
|
是否进入基础预取层;以及失败/等待时是否阻止应用启动。普通可选插件通常都保持 |
|
原样传给插件作为其配置。 |
|
明确允许暴露给浏览器端的配置。后端 |
|
通用配置表单的字段描述。声明后即可使用内置的通用设置表单,无需自己写读写逻辑(见下文)。 |
两个入口都是可选的:只贡献界面就只写 frontend,只贡献后端能力就只写 backend。后端入口必须是 Node 可加载的 JavaScript,前端入口则由 Vite 编译为浏览器 ESM。
脚手架生成的双端插件由 panel/plugin.json 描述整包,daemon/plugin.json 只写 { "backend": "src/backend/index.ts" }。外部工作区发现时会用工作区目录名作为 Daemon 端的缺省 id;只有 Daemon-only 插件才需要在 Daemon 清单补齐 id 与市场展示信息。
插件的形状
import type { PanelPluginContext } from "../../../../../panel/src/app/plugin";
import { localeMessages } from "../i18n";
export const inject = ["koa", "i18n", "middleware", "roles"];
export function apply(ctx: PanelPluginContext) {
ctx.i18n.define(localeMessages);
// 注册路由、定时任务、翻译……
}
没有 setup,没有 dispose,没有需要实现的生命周期方法。apply 可以是 async。
三条必须内化的规则:
- 注册的每样东西都属于插件自己的作用域。
-
路由、中间件、定时器、翻译、服务,都会在插件卸载时自动撤销。不要写清理代码,也不要从
apply返回 disposer —— cordis v3 会忽略它。确实需要手动撤销时用ctx.effect():ctx.effect(() => { const handle = openSomething(); return () => handle.close(); }); inject只列缺了就完全无法工作的服务。-
cordis 会保持插件处于非激活状态,直到所有被注入的服务都存在;其中任何一个消失,插件会被重新卸载。可选项用
ctx.inject(["thing"], (scoped) ⇒ {…})局部包裹,或读ctx.get("thing")自行降级。 - 日志与定时器来自 cordis。
-
ctx.logger.info(…)以插件 id 为名输出;ctx.setTimeout、ctx.setInterval、ctx.sleep、ctx.throttle、ctx.debounce都会在卸载时取消,所以采样器不需要写stop()。
插件加载失败会被记录并跳过,宿主进程继续运行。
上下文服务
以下是最常用的几项。完整清单以源码声明为准:panel/src/app/plugin/context.ts、frontend/src/plugin/context.ts、daemon/src/plugin/context.ts。
Panel 后端
| 服务 | 提供什么 |
|---|---|
|
命名日志器与作用域定时器( |
|
|
|
|
|
面板实体存储(文件或 Redis)。由 |
|
|
|
|
|
角色常量( |
|
|
|
运行时的审计日志器,独立于监控插件。 |
|
|
|
|
|
|
|
|
|
|
|
请求鉴权。由 |
前端
| 服务 | 提供什么 |
|---|---|
|
同后端。 |
|
基础 Web UI 外壳。所有贡献浏览器界面的插件都应注入它。 |
|
|
|
|
|
|
|
|
|
|
|
菜单项,以及实例、计划任务、终端按钮扩展点。 |
|
|
|
|
|
由同名功能插件提供的领域 API。 |
Daemon 后端
| 服务 | 提供什么 |
|---|---|
|
|
|
节点实体存储。由 |
|
|
|
|
|
|
|
|
|
|
|
Java 运行时、文件子系统、压缩归档能力。 |
|
异步任务、实例预设、计划任务类型、实例生命周期任务。 |
|
能力注册与 |
|
Koa 应用与 Socket.io 服务器。由 |
提供服务的插件不能注入自己提供的服务 —— 需要时改用 ctx.inject() 局部获取。
后端绝对不能做的事
后端禁止在运行时 import 宿主核心模块。 它们已被打进 app.js,再 import 一次会编译出第二份单例(存储子系统、系统配置、i18n 实例)。
-
宿主契约一律用
import type(类型会被擦除)。 -
cordis 的
Service与Logger可以运行时 import:scripts/webpack-cordis-externals.cjs把它保持为宿主与插件共享的外部依赖。 -
不要打包第二份 cordis,不要 import 宿主的
ctx单例。
因此需要继承宿主类的场景,要用接收 ctx 的工厂来构造,而不是直接 import 那个基类。
前端没有后端的单例限制:共享依赖通过宿主 import map 去重。不过对外发布的插件应优先使用 @elements-panel/sdk 与公开上下文服务,避免依赖不稳定的 @/… 内部实现。
插件配置
有两种方式让插件带上设置表单,二者都渲染在插件页面里。
- 声明式表单(推荐)。
-
自己在后端描述字段并实现读写:
ctx.settingsForm.declare({ fields: () => [ { key: "httpPort", type: "number", title: $t("..."), min: 1, max: 65535 }, { key: "ssl", type: "boolean", title: $t("...") }, { key: "sslPemPath", type: "string", title: $t("..."), visibleWhen: "ssl" } ], read: () => ({ ...当前值 }), write: (values) => { /* 校验并持久化 */ } });fields是一个函数,按请求解析,因此语言切换会即时生效。字段类型有string(secret表示密码框)、text、number(min/max)、boolean、select(options)、list(嵌套fields与maxItems)、link(一个跳转按钮)。visibleWhen接受字段名、"name=value"或它们组成的数组。校验只写在write()里,全站一份。 - 通用表单。
-
若插件导出一个 cordis
Configschema,并在清单里声明configFields,就可以直接用内置的通用设置表单,不必自己写存储逻辑:{ "id": "example", "backend": "backend/index.cjs", "config": { "interval": 30 }, "configFields": [ { "key": "interval", "type": "number", "title": "Interval", "min": 1, "required": true } ] }宿主会先校验字段类型、有限数值、上下界与枚举取值,再保存覆盖项。普通插件重新应用作用域;基础/管理插件可能报告
restart-required。只在启动时生效的自定义表单,把表单标为restartRequired: true。后端配置不会进入浏览器可见的清单,需要公开的配置用frontendConfig显式声明。
跨插件服务
用 ctx.set(name, value) 把服务暴露给其他插件与宿主内核。服务属于插件作用域,插件卸载即消失。消费方在使用时解析、缺失时降级:
import { usePluginService } from "@elements-panel/sdk";
interface ExampleService { run(): void }
const example = computed(() => usePluginService<ExampleService>("example"));
usePluginService 是响应式的:其依赖插件加载/卸载时会重新求值。若一个插件缺了某服务就完全无法工作,应该用 inject 交给 cordis 处理顺序。
内置插件的编译
内置插件的入口写在 src/backend/index.ts,由 webpack 编译为 <plugin>/backend/index.cjs,宿主运行时依赖(含 cordis)保持外部化以共享模块实例。因此内置插件的 plugin.json 指向 backend/index.cjs。
编译产物是 gitignore 的构建输出;打包脚本只把 plugin.json 与 backend/、frontend/ 拷进分发目录,src/ 不随包发布。插件前端位于 web/plugins/<plugin>/frontend/,宿主自身的静态资源才位于 web/public/。
外部工作区(external/)不经过这一步:开发模式下直接以 .ts 源码加载,由 ts-node 转译。
外部依赖与生产前端
外部工作区可以在根目录、panel/ 或 daemon/ 维护自己的 package.json 与 node_modules。解析时先找离源文件最近的插件私有依赖,再回退到宿主工作区;Vue、Router、Pinia、vue-i18n、Cordis、Cosmokit、VueUse 与 @elements-panel/sdk 始终共享宿主实例。发布编译器会打包普通 JavaScript 依赖,但不会上传 node_modules、锁文件或 npm 凭据。
生产前端资源使用 /plugins/<folder>@<revision>/frontend/…。revision 覆盖整套前端目录,旧 revision 返回 404;/plugins/events 以 SSE 推送插件图 generation 与心跳,浏览器据此刷新。这里的重载不是保留组件状态的 HMR,基础插件或更新后的后端代码仍可能需要刷新页面或重启进程。
翻译
插件只维护自己用到的文案,放在 <plugin>/src/i18n/,每种语言一个 JSON 文件,旁边一个 index.ts 导出:
import enUS from "./en_US.json";
import zhCN from "./zh_CN.json";
export const localeMessages = { en_us: enUS, zh_cn: zhCN };
把它传给 ctx.i18n.define(localeMessages),并且放在 apply() 的第一行 —— 早于任何可能输出或抛出翻译文本的代码路径。注册本身也是作用域内的效果,插件卸载文案随之消失。
全站级文案由基础的 plugins/i18n 插件拥有。新增全局文案时,需要为该目录下的全部 12 个语言文件都补一份。
下一步
完整示例 把上面的组件拼成一个可运行插件。