完整示例
一个贯通三端的 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
然后:
-
Panel 与 Daemon 控制台分别出现
Panel plugin loaded: hello/Daemon plugin loaded: hello。 -
导航出现 Hello 页面,打开后显示后端返回的
Hello。 -
插件页面可开关该插件,开关同时作用于前后端两半。
从这个例子继续
-
需要持久化:用
ctx.storage(或节点侧的ctx.storage)替代示例里的模块变量。 -
需要被其他插件复用:用
ctx.set(name, value)暴露服务,它会随插件作用域一起消失。 -
需要定时采样:用
ctx.setInterval,卸载即取消,不必写停止逻辑。 -
需要节点端数据:面板后端注入
remote,用ctx.remote.Request调用上面的hello/ping。 -
准备对外发布:见 发布到插件市场。