完整示例

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

一个贯通三端的 hello 插件:面板后端提供 HTTP 接口与配置表单,前端提供一个页面并挂到导航,节点端提供一个协议事件。

先创建工作区:

npm run create-plugin -- hello

脚手架生成的入口只有一行日志;本页把它逐步填成一个可用的插件。

面板清单

external/hello/panel/plugin.json:

{
  "id": "hello",
  "name": "hello panel plugin",
  "displayName": "Hello Plugin",
  "version": "0.1.0",
  "description": "A custom ElementsPanel panel plugin.",
  "category": "",
  "changelog": "",
  "priority": 100,
  "frontendInject": ["console", "i18n"],
  "backend": "src/backend/index.ts",
  "frontend": "src/frontend.ts"
}

市场先读取 Panel 清单,因此双端插件的 displayName、version、description、category、changelog 都只在这里维护(见 发布到插件市场)。backend / frontend 是入口,frontendInject 是浏览器插件 id 依赖图。

面板后端

external/hello/panel/src/backend/index.ts:

import type { PanelPluginContext } from "../../../../../panel/src/app/plugin";

export const inject = ["koa", "middleware", "roles", "settingsForm"];

export function apply(ctx: PanelPluginContext) {
  // 示例状态。生产插件通常改用 ctx.storage 持久化。
  let greeting = "Hello";

  // 用数据描述配置表单,由 plugins/config 渲染,插件自己不画界面。
  ctx.settingsForm.declare({
    fields: () => [
      { key: "greeting", type: "string", title: "Greeting" },
      { key: "shout", type: "boolean", title: "Uppercase" }
    ],
    read: () => ({ greeting, shout: false }),
    write: (values) => {
      const next = String(values.greeting ?? "").trim();
      if (!next) throw new Error("Greeting must not be empty.");
      greeting = next; // 校验放在 write() 里,只此一份
    }
  });

  // ctx 是插件上下文,Koa 的上下文是另一个东西,所以先提出来。
  // 这是只读 GET 示例,使用同源登录 Cookie 即可;写操作应保留请求令牌校验。
  const requireAdmin = ctx.middleware.permission({ level: ctx.roles.ADMIN, token: false });
  const router = ctx.koa.router("/api/hello");

  router.get("/", requireAdmin, async (requestCtx) => {
    requestCtx.body = { greeting };
  });
}

要点:

  • inject 列出缺了就跑不起来的服务。这里需要 koa 注册路由、middleware 与 roles 做权限、settingsForm 声明配置表单(该服务由 plugins/config 提供)。

  • 没有卸载逻辑。路由、表单声明都属于插件作用域,插件一卸载它们就消失。

  • 校验只写在 write() 里,不分散到前端。

面板前端

external/hello/panel/src/frontend.ts:

import type { PanelFrontendPluginContext } from "@elements-panel/sdk";
import HelloPage from "./HelloPage.vue";

export const inject = ["console", "routes"];

export function apply(ctx: PanelFrontendPluginContext) {
  ctx.routes.add({
    path: "/hello",
    component: HelloPage,
    meta: { mainMenu: true, icon: "mdi-hand-wave" }
  });
}

external/hello/panel/src/HelloPage.vue:

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";

const { t } = useI18n();
const greeting = ref("");
const error = ref("");

onMounted(async () => {
  try {
    const response = await fetch("/api/hello");
    if (!response.ok) throw new Error(String(response.status));
    const packet = await response.json();
    greeting.value = packet.data.greeting;
  } catch (e) {
    error.value = e instanceof Error ? e.message : String(e);
  }
});
</script>

<template>
  <div>
    <h1>{{ greeting || t("loading") }}</h1>
    <p v-if="error">{{ error }}</p>
  </div>
</template>

要点:

  • 前端可以运行时 import 核心代码与 @/…​:它被编译进同一个 Vite 模块图,cordis 已去重。

  • 除基础的 i18n 与 console 外,贡献界面的插件都应注入 console,这样在外壳就绪前插件保持非激活。

  • 菜单项由路由派生,meta.mainMenu 一设即可,无需另外注册。

节点端

external/hello/daemon/plugin.json:

{
  "backend": "src/backend/index.ts"
}

双端插件由 panel/plugin.json 描述整包,Daemon 清单故意只声明执行入口。只有真正的 Daemon-only 工作区才在 daemon/plugin.json 中填写 id、版本与市场展示信息。

external/hello/daemon/src/backend/index.ts:

import type { DaemonPluginContext } from "../../../../../daemon/src/plugin";

export const inject = ["protocol"];

export function apply(ctx: DaemonPluginContext) {
  // 第一个参数是协议上下文,不是插件上下文,故命名为 routerCtx。
  ctx.protocol.on("hello/ping", (routerCtx) => {
    ctx.protocol.response(routerCtx, { pong: true });
  });
}

要点:

  • 命名:ctx 是插件上下文,协议处理器自己的上下文叫 routerCtx,Koa 处理器叫 requestCtx。三者不可混用。

  • ctx.protocol.on() 有一个已知限制:路由在 socket 连接时把处理器列表拷给该连接,所以连接建立之后再注册的处理器对该连接不可见。插件都在监听开始前加载,这只影响在运行的节点上热重载插件 —— 重连节点或重启节点即可。

运行

npm run dev

然后:

  1. Panel 与 Daemon 控制台分别出现 Panel plugin loaded: hello / Daemon plugin loaded: hello。

  2. 导航出现 Hello 页面,打开后显示后端返回的 Hello。

  3. 插件页面可开关该插件,开关同时作用于前后端两半。

从这个例子继续

  • 需要持久化:用 ctx.storage(或节点侧的 ctx.storage)替代示例里的模块变量。

  • 需要被其他插件复用:用 ctx.set(name, value) 暴露服务,它会随插件作用域一起消失。

  • 需要定时采样:用 ctx.setInterval,卸载即取消,不必写停止逻辑。

  • 需要节点端数据:面板后端注入 remote,用 ctx.remote.Request 调用上面的 hello/ping。

  • 准备对外发布:见 发布到插件市场。