插件结构

更新于 2026-09-29在 GitHub 上编辑

一个插件就是一个导出 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

插件唯一标识。缺省时取目录名。重复 id 会被告警并忽略。

name / displayName / version / description

展示信息。市场优先使用 displayName,否则回退到 name;description 同时用于列表与详情,不存在 summary 字段。

category / changelog

市场分类与本版本更新日志。双端插件只在 Panel 清单中维护整包元数据。

priority

加载顺序,数值升序加载。仅在彼此无依赖的插件间确定顺序时使用;真正的依赖请用 inject 表达。

enabled

设为 false 则整个插件被跳过。插件页的开关写回的是 data/plugin-overrides.json,而非本文件。

backend

后端入口。Panel 与 Daemon 都识别 panel / daemon、backend、main、entry 这几个字段名(按序取第一个存在的)。

frontend

前端入口,仅 Panel 插件有。识别 frontend 与 ui 两个字段名。

frontendInject

浏览器插件 id 依赖图,不是 Cordis 服务名。运行时会拓扑排序并报告缺失依赖与循环。

frontendImmediate / frontendRequired

是否进入基础预取层;以及失败/等待时是否阻止应用启动。普通可选插件通常都保持 false。

config

原样传给插件作为其配置。

frontendConfig

明确允许暴露给浏览器端的配置。后端 config 永远不会进入公开前端清单。

configFields

通用配置表单的字段描述。声明后即可使用内置的通用设置表单,无需自己写读写逻辑(见下文)。

两个入口都是可选的:只贡献界面就只写 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 后端

服务 提供什么

ctx.logger / ctx.timer

命名日志器与作用域定时器(setTimeout / setInterval / sleep / throttle / debounce)。

ctx.settings

config(面板配置)与 save()。

ctx.settingsForm

declare({ fields, read, write }) —— 用数据描述插件自己的配置表单,由 plugins/config 渲染。

ctx.storage

面板实体存储(文件或 Redis)。由 plugins/storage 提供。

ctx.i18n

$t、i18next 与 define(messages)。

ctx.middleware

permission、validator、instanceAccess、speedLimit。

ctx.roles

角色常量(ADMIN、USER 等)。

ctx.identity

of(requestCtx)、canAccessInstance(…​)、accessPolicy、users、stats —— 身份、实例归属与由守卫报告的能力。

ctx.operations

运行时的审计日志器,独立于监控插件。

ctx.instances

getDetails(references)。

ctx.overview

provide(fn) 向 GET /api/overview 追加字段。

ctx.plugins

loaded、inventory()、setEnabled()、configure(),以及浏览器拉取的前端清单。

ctx.koa

app、use(middleware)、router(prefix?)。卸载时一并移除。由 plugins/server 提供。

ctx.remote

services(节点子系统)、Request、RequestTimeoutError。由 plugins/node 提供。

ctx.guard

请求鉴权。由 plugins/user 提供。

前端

服务 提供什么

ctx.logger / ctx.timer

同后端。

ctx.console

基础 Web UI 外壳。所有贡献浏览器界面的插件都应注入它。

ctx.vue

app、pinia、router。原样存放;不要把 context 变成响应式的。

ctx.i18n

instance、define(messages)。

ctx.routes

add(route)、revision、isPluginRoute(path)、ownerOf(path)。路由可设 meta.public(免登录)、meta.immersive(隐藏外壳)、meta.mainMenu(出现在导航)、meta.icon。

ctx.ui

component(name, comp) 注册全局组件;globalComponent(comp) 挂载一个随插件生命周期存在的组件。

ctx.slots

register(name, component, options)。当前插槽为 shell.overlay、shell.header.leading、shell.header.actions;新插件优先用插槽,globalComponent() 只作兼容包装。

ctx.menus / ctx.actions

菜单项,以及实例、计划任务、终端按钮扩展点。

ctx.desktop

app、view、open 等桌面模式接口(见 桌面模式)。

ctx.plugins

loaded、diagnostics、load、unload、reload、refresh、audit。诊断包含状态、依赖、缺失服务与 revision。

ctx.user / ctx.market / ctx.node / ctx.file / ctx.terminal / ctx.mod

由同名功能插件提供的领域 API。

Daemon 后端

服务 提供什么

ctx.settings

config、save()、setLanguage(lang)。

ctx.storage

节点实体存储。由 plugins/storage 提供。

ctx.i18n

$t 与 define(messages)。

ctx.settingsForm

declare({ fields, read, write }) —— 节点没有浏览器界面,表单描述由面板渲染。

ctx.middleware

uploadSpeedLimit、uploadFileCheck。

ctx.protocol

on(event, handler)、use(middleware)、response、responseError、error、msg、ROLE、IGNORE。

ctx.instances

subsystem、Instance、Config、Command、UpdateAction、commandStringToArray、fileManager、headers。

ctx.javaManager / ctx.files / ctx.archive

Java 运行时、文件子系统、压缩归档能力。

ctx.tasks / ctx.presets / ctx.schedules / ctx.instanceLifecycle

异步任务、实例预设、计划任务类型、实例生命周期任务。

ctx.features / ctx.overview

能力注册与 info/overview 字段追加。

ctx.koa / ctx.websocket

Koa 应用与 Socket.io 服务器。由 plugins/server 提供。

提供服务的插件不能注入自己提供的服务 —— 需要时改用 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 Config schema,并在清单里声明 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 个语言文件都补一份。

下一步

完整示例 把上面的组件拼成一个可运行插件。