深入理解 EmDash 插件 Admin UI 与字段组件:沙箱页面、编辑器扩展与原生 React 组件
深入理解 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;
两个值得注意的实践要点:
- 在执行产生副作用的写操作之前,务必校验交互。
routeCtx.input的类型是unknown,宿主不会替你保证其结构。上述示例用 zod 的discriminatedUnion做了运行时校验,校验失败时返回空blocks({ blocks: <a href="https://link.gitcode.com/i/6b4a674d7d93a1a43320e594ccc669d5" target="_blank">] })而不是直接抛错。关于交互、块与元素的确切形状,请阅读 [Block Kit 参考。 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 字符;title1–128;route1–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)都必须是私有路由。宿主在调用该路由之前会:
- 重新加载已保存条目(而不是信任前端传来的数据);
- 检查条目所有权(ownership);
- 检查路由权限(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_inputnumber_inputtoggleselectmedia_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 技能。