深入理解 EmDash 插件 Admin UI 与字段组件:沙箱页面、编辑器扩展与原生 React 组件

原创2026-09-23 17:31:501,703 阅读
文章标签:CMS后端前端插件系统

深入理解 EmDash 插件 Admin UI 与字段组件:沙箱页面、编辑器扩展与原生 React 组件

EmDash 插件的后台界面(Admin UI)是插件与站点内容管理系统交互的核心界面,涵盖了导航页面、仪表盘卡片、已保存条目编辑器扩展和声明式字段组件等能力。本指南以 templates/starter-cloudflare/.agents/skills/creating-plugins/references/admin-ui.md 为骨架,结合 插件清单 JSON Schema 与 插件运行时测试宿主 的实现细节,完整讲解如何为沙箱插件声明并实现后台界面,以及原生 React 插件的等价做法,帮助读者掌握两条相互隔离、各有边界的 Admin UI 扩展路径。

两条 Admin UI 路径:沙箱声明式与原生 React

在 EmDash 中,插件提供 Admin UI 的方式被严格区分为两条路径,二者不可混用:

  • 沙箱插件(Sandboxed plugins):从一条私有的 admin 路由返回声明式 Block Kit,由宿主渲染。其核心安全边界是:沙箱插件的 JavaScript 永远不会在浏览器中运行——插件只描述 UI 结构(BlockResponse),真正的 DOM 渲染、事件分发与表单提交全部由宿主 Admin 应用接管。因此沙箱插件可以安全地从插件注册表(registry)安装,而无需向站点授予代码执行权限。
  • 原生插件(Native plugins):可以携带真正的 React 组件(src/admin.tsx),插件代码作为站点包的一部分被构建。原生 Admin 代码以站点的权威运行,因此不可从注册表安装,并且必须遵守仓库的 Kumo、本地化、无障碍(accessibility)与 RTL 规范。

理解这条分界线是使用本指南的前提:如果你在写可分发、可注册的插件,走沙箱路径;如果你在维护站点私有插件,且需要完整 React 能力,才考虑原生路径。

沙箱页面与仪表盘组件(Pages & Dashboard Widgets)

在 emdash-plugin.jsonc 中声明

沙箱插件的导航页面与仪表盘卡片在插件清单的 admin 字段中声明:

{
	"admin": {
		"pages": [
			{ "path": "/settings", "label": "Settings", "icon": "settings" },
			{ "path": "/reports", "label": "Reports", "icon": "chart" },
		],
		"widgets": [{ "id": "status", "title": "Plugin status", "size": "half" }],
	},
}

对照 emdash-plugin.schema.json(__schema18、__schema23)可以确认各字段的约束:

字段 约束 说明
pages[].path 正则 ^\/[a-z0-9][a-z0-9/_-]*$,长度 2–128 必须以 / 开头的小写路径
pages[].label 长度 1–128 导航菜单显示名
pages[].icon 长度 1–64 图标标识(宿主映射为图标资源)
widgets[].id 正则 ^[a-z][a-z0-9_-]*$,长度 ≤64 卡片唯一 ID
widgets[].title 长度 1–128 卡片标题
widgets[].size 枚举 full、half、third 仪表盘卡片宽度

pages 与 widgets 数组均最多 32 项;pages[].path 与 label 为必填,widgets[].id 为必填。

挂载路径与交互协议

  • 页面挂载在 /_emdash/admin/plugins/<plugin-id>/<path>,例如插件 my-plugin 的 /settings 页面对应 /_emdash/admin/plugins/my-plugin/settings。
  • 仪表盘卡片尺寸为 full(整行)、half(半宽)、third(三分之一宽)。
  • 任何声明了页面或组件的沙箱插件必须定义 admin 路由,否则声明无法工作。

宿主将一次交互作为 routeCtx.input 传给 admin 路由处理器,交互类型是判别联合(discriminated union):

  • page_load:进入页面时触发,带 page 字段;
  • block_action:点击按钮等块操作时触发,带 action_id、可选 block_id 与 value;
  • form_submit:表单提交时触发,带 action_id、可选 block_id 与 values。

完整的设置页示例

下面是一个完整的沙箱 admin 路由实现(来自原文档核心示例):它渲染一个含 toggle 的表单,保存设置后返回成功 toast:

import type { SandboxedPlugin } from "emdash/plugin";
import type { BlockResponse } from "@emdash-cms/blocks";
import { z } from "zod";

const interactionSchema = z.discriminatedUnion("type", [
	z.object({ type: z.literal("page_load"), page: z.string() }),
	z.object({
		type: z.literal("block_action"),
		action_id: z.string(),
		block_id: z.string().optional(),
		value: z.unknown().optional(),
	}),
	z.object({
		type: z.literal("form_submit"),
		action_id: z.string(),
		block_id: z.string().optional(),
		values: z.object({ enabled: z.boolean() }),
	}),
]);

function settingsForm(enabled: boolean): BlockResponse {
	return {
		blocks: [
			{ type: "header", text: "Settings" },
			{
				type: "form",
				block_id: "settings",
				fields: [
					{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: enabled },
				],
				submit: { action_id: "save", label: "Save" },
			},
		],
	};
}

const plugin: SandboxedPlugin = {
	routes: {
		admin: {
			permission: "plugins:manage",
			handler: async (routeCtx, ctx) => {
				const parsed = interactionSchema.safeParse(routeCtx.input);
				if (!parsed.success) return { blocks: [] };
				const interaction = parsed.data;
				if (interaction.type === "form_submit" && interaction.action_id === "save") {
					await ctx.settings.set("enabled", interaction.values.enabled === true);
					return {
						...settingsForm(interaction.values.enabled === true),
						toast: { type: "success", message: "Settings saved" },
					};
				}

				const enabled = (await ctx.settings.get<boolean>("enabled")) ?? false;
				return settingsForm(enabled);
			},
		},
	},
};

export default plugin;

两个值得注意的实践要点:

  1. 在执行产生副作用的写操作之前,务必校验交互。routeCtx.input 的类型是 unknown,宿主不会替你保证其结构。上述示例用 zod 的 discriminatedUnion 做了运行时校验,校验失败时返回空 blocks({ blocks: <a href="https://link.gitcode.com/i/6b4a674d7d93a1a43320e594ccc669d5" target="_blank">] })而不是直接抛错。关于交互、块与元素的确切形状,请阅读 [Block Kit 参考。
  2. admin 路由需要 permission: "plugins:manage",宿主在调用处理器前会检查当前用户是否具备该权限。

设置模式(settingsSchema)与 ctx.settings

插件 CLI 会把 admin.settingsSchema 原样保留在注册表清单(registry manifest)和生成的 descriptor 中,因此宿主可以根据它自动生成一个设置表单。两条沙箱桥(sandbox bridges)的 ctx.settings 都经由与那个表单相同的 options records 路由,语义完全一致:

  • 读:ctx.settings.get("<key>");
  • 写:ctx.settings.set(...);
  • 删除、列表操作、基于版本的(revision-based)操作也在同一个命名空间上,且 Cloudflare 与 Node/workerd 行为一致。

清单中 settingsSchema 支持的字段类型,见 emdash-plugin.schema.json(__schema27 的 oneOf 分支):

类型 额外属性
string default、multiline(是否多行)
number default、min、max
boolean default
select options: [{ value, label }]、default
secret 写操作专用(见下)
url default、placeholder
email default、placeholder

secret 字段与加密密钥

secret 类型的设置字段在 Admin 响应中是**只写(write-only)**的:宿主不会把它回显给前端。它会在持久化之前被加密。这带来一个硬性前提:

站点必须提供 EMDASH_ENCRYPTION_KEY。密钥缺失、错误或被篡改时,系统会**以失败关闭(fail closed)**的方式拒绝读写,而不是降级暴露明文。

该键在核心包中由 plugins/settings.ts 与 config/secrets.ts 消费,并通过 cli/commands/secrets.ts 等命令管理。运维上要求:把加密密钥列表与数据库备份放在一起妥善保管(密钥丢失意味着密文无法恢复)。

另外,为了兼容存量数据,通过 EmDash 0.x 的 ctx.kv.get("settings:<key>") 读取的历史设置仍然可用。

沙箱已保存条目扩展:编辑器面板与操作(Editor Panels & Actions)

除了全局页面与仪表盘卡片,沙箱插件还可以向**内容编辑器的已保存条目(saved entry)**页面注入扩展。

声明方式

{
	"admin": {
		"editorPanels": [
			{
				"id": "health",
				"title": "Content health",
				"route": "editor/health",
				"collections": ["posts"],
			},
		],
		"editorActions": [
			{
				"id": "repair",
				"label": "Repair metadata",
				"route": "editor/repair",
				"placement": "overflow",
				"style": "danger",
				"confirm": {
					"title": "Repair?",
					"text": "This changes the saved entry.",
					"confirm": "Repair",
					"deny": "Cancel",
				},
			},
		],
	},
}

Schema 约束(__schema32–__schema44):

  • editorPanels[].id:^[a-z][a-z0-9_-]*$,≤64 字符;title 1–128;route 1–128;collections 最多 64 个集合 slug(^[a-z][a-z0-9_]*$,≤63);可选 order 为整数,范围 -1000~1000(用于面板排序)。
  • editorActions[].placement 枚举为 toolbar 或 overflow(溢出菜单);style 枚举为 default 或 danger;confirm 对象必填 title/text/confirm/deny,且其 style 固定为 danger 常量——即危险操作的确认框样式由宿主锁定。

执行边界与安全模型

每一个被引用的路由(route)都必须是私有路由。宿主在调用该路由之前会:

  1. 重新加载已保存条目(而不是信任前端传来的数据);
  2. 检查条目所有权(ownership);
  3. 检查路由权限(route permission)。

并且,routeCtx.ui.entry 中只包含规范化的 collection、id、locale 与 version——宿主不会向插件发送字段值(field values)或未保存的编辑器状态。这是刻意设计的数据最小化边界:插件拿不到草稿中的敏感内容。

面板与操作的行为语义

面板(Panels):

  • 默认**折叠(collapsed)**展示,用户展开后宿主发送 panel_load 交互;
  • 之后与普通页面一样,接收常规的 block_action 与 form_submit 交互;
  • 每次交互后返回 BlockResponse。

操作(Actions):

  • 当编辑器存在未保存更改时,操作按钮被禁用(disabled),防止在草稿状态上执行破坏性动作;
  • 触发时收到 editor_action 交互,返回一个可选的 toast,以及二选一的结果:
    • refresh: true(刷新条目数据),或
    • 结构化的 navigate 目标(跳转到其他页面);
  • 不允许同时请求 refresh 和 navigation——响应要么刷新、要么跳转,二者互斥。

用测试宿主驱动该边界

原文档明确指出,可以使用 createPluginRuntimeTestHost().admin 来测试这些交互边界。在 runtime-host.ts 中可以看到完整的测试 API 签名:

  • loadEditorPanel(panelId, collection, entryId, options) — 加载面板;
  • actEditorPanel(panelId, collection, entryId, actionId, options) — 触发面板块操作;
  • submitEditorPanel(panelId, collection, entryId, actionId, values, options) — 提交面板表单;
  • invokeEditorAction(actionId, collection, entryId, options) — 触发编辑器操作,返回 ContentEditorActionResponse。

这些方法对应于宿主真实的分发路径(dispatchPluginApiRequest、dispatchPluginEditorExtensionApiRequest),因此测试结果可以较好地映射到生产行为。

沙箱声明式字段组件(Declarative Field Widgets)

核心包(core)与 Admin 内置了一条声明式字段组件路径:插件不需要任何前端代码,只需在清单里声明一组 Block Kit 元素,宿主就能在内容编辑器的字段编辑区渲染它们。

声明与选用

{
	"admin": {
		"fieldWidgets": [
			{
				"name": "event-picker",
				"label": "Event",
				"fieldTypes": ["json"],
				"elements": [
					{ "type": "text_input", "action_id": "eventId", "label": "Event ID" },
					{ "type": "toggle", "action_id": "featured", "label": "Featured" },
				],
			},
		],
	},
}

Schema(__schema30/__schema31):fieldWidgets 最多 32 项,每项必填 name、label、fieldTypes;elements 每个元素必填 type 与 action_id。fieldTypes 的合法枚举为:string、text、number、integer、boolean、datetime、select、multiSelect、portableText、image、file、reference、json、slug、repeater。

一个内容模型(schema)字段通过 widget: "pluginId:widgetName" 来选用该组件。编辑器会把一个以每个元素的 action_id 为键的对象存入字段值,因此文档建议为这个对象使用 json 字段。需要留意一个来自原文档的诚实说明:manifest schema 虽允许其他兼容字段类型,但仓库目前没有端到端测试证明组合对象能通过这些类型保存——所以在生产使用中,请优先选择 json 字段。

当前渲染器支持的元素

截至本指南对应版本,字段组件渲染器支持以下 Block Kit 元素类型:

  • text_input
  • number_input
  • toggle
  • select
  • media_picker

其他 Block Kit 元素类型在此界面中会显示**"不支持的元素"(unsupported-element)**提示,而不是被静默忽略。

产物流转与测试现状

  • emdash-plugin.jsonc 接受 admin.fieldWidgets,插件 CLI 会把定义携带到 bundle manifest 与生成的 descriptor 中,供注册表安装使用;
  • 这一"产物往返"(artifact round-trip)由插件 CLI、共享 manifest 与 plugin-test 的测试覆盖;
  • 但浏览器 E2E 测试夹具目前测试的是原生 React 取色器(ColorPicker),而不是从注册表安装的声明式组件。因此原文档明确建议:为所选元素自行验证真实编辑器中的渲染与值持久化行为,不要仅依赖单元测试结论。

本地化信息:routeCtx.ui

沙箱 admin 路由的 routeCtx.ui 携带宿主认证过的(host-attested)Admin locale、文本方向(text direction)与界面(surface)。插件可以用它来在运行时返回的 Block Kit 响应里挑选本地化文本。但注意:manifest 元数据中的 label 是静态字符串——注册表插件不会把翻译目录(translation catalogs)交给宿主,这是当前本地化的边界。

原生 React 页面、组件与字段

如果插件以站点私有的方式运行(不通过注册表分发),可以使用原生路径:设置 admin.entry 并导出 React 组件。

export const pages = {
	"/settings": SettingsPage,
};

export const widgets = {
	status: StatusWidget,
};

export const fields = {
	picker: ColorPickerField,
};

插件定义(definePlugin)指向该入口并声明其 UI 表面:

definePlugin({
	id: "color",
	version: "1.0.0",
	admin: {
		entry: "@my-org/plugin-color/admin",
		pages: [{ path: "/settings", label: "Settings" }],
		widgets: [{ id: "status", title: "Status", size: "half" }],
		fieldWidgets: [{ name: "picker", label: "Color picker", fieldTypes: ["string"] }],
	},
});

原生 Admin 代码的纪律要求:

  • 必须遵守仓库的 Kumo(UI 体系)、本地化(i18n)、**无障碍(accessibility)**与 RTL 规范;
  • 它以站点的权威运行,因此不受沙箱边界保护;
  • 它不可从注册表安装——只能作为站点代码仓库的一部分存在。

测试 Admin 边界:createPluginRuntimeTestHost().admin

无论走哪条路径,@emdash-cms/plugin-test 的运行时测试宿主都是验证 Admin 交互的首选工具。除了上文编辑器扩展的四个方法,宿主 admin 命名空间还提供(见 runtime-host.ts):

方法 作用
loadPage(path, options) 触发页面 page_load,返回 BlockResponse
loadWidget(id, options) 触发仪表盘组件加载
act(page, actionId, options) 模拟块操作(可带 blockId、value)
submit(page, actionId, values, options) 模拟表单提交
loadEditorPanel / actEditorPanel / submitEditorPanel 面板生命周期与交互
invokeEditorAction(...) 编辑器操作,返回 ContentEditorActionResponse

选项对象可传入 locale、contentLocale 与 user(UserInfo),用于模拟不同语言环境与权限身份。宿主本身运行在 cloudflare:test 之上(reset()、env bindings),并直接使用 CloudflareSandboxRunner 执行沙箱插件代码,与生产路径一致。

小结

  • 沙箱插件通过 emdash-plugin.jsonc 的 admin 字段声明页面、仪表盘组件、设置模式、字段组件与编辑器扩展,用一条私有的 admin 路由返回声明式 Block Kit;插件 JS 永不进浏览器,安全边界清晰,可从注册表安装。
  • 原生插件通过 admin.entry 导出 React 组件,能力完整但以站点权威运行、不可分发。
  • 交互协议统一为 page_load / block_action / form_submit / panel_load / editor_action,routeCtx.input 是 unknown,写操作前必须校验。
  • 设置通过 ctx.settings 读写,secret 字段写后即加密,站点必须配置 EMDASH_ENCRYPTION_KEY 且失败关闭。
  • 字段组件渲染器当前支持 text_input、number_input、toggle、select、media_picker 五种元素,其余元素显示不支持提示。

更完整的交互形状与块元素定义可继续阅读 Block Kit 参考 与 沙箱边界参考,完整插件开发流程见 creating-plugins 技能。

登录后查看全文
emdash