React 管理扩展

本页内容

原生插件可以将受信任的 React 组件加载到 EmDash 管理端。主机保留对导航、页面路由、仪表盘卡片、编辑器布局、内容表格、身份验证与错误边界的控制。插件提供组件以及放置它们所需的元数据。

如果插件只需要设置表单,请从 admin.settingsSchema 开始。它使用主机的表单组件,且不需要 React 入口。沙箱插件也可以使用此生成表单;本页的自定义 React 扩展需要原生插件。

生成的设置表单

在运行时定义中声明 settingsSchema。以下 schema 会生成多行文本字段、选择框、数字输入、开关以及仅写入的密钥输入:

return definePlugin({
	id: "plugin-activity",
	version: "0.1.0",
	admin: {
		settingsSchema: {
			projectName: {
				type: "string",
				label: "Project name",
				description: "Name shown in activity exports",
			},
			notes: {
				type: "string",
				label: "Internal notes",
				multiline: true,
			},
			mode: {
				type: "select",
				label: "Recording mode",
				options: [
					{ value: "creates", label: "New entries only" },
					{ value: "all", label: "New and updated entries" },
				],
				default: "all",
			},
			retentionDays: {
				type: "number",
				label: "Retention in days",
				min: 1,
				max: 365,
				default: 30,
			},
			enabled: {
				type: "boolean",
				label: "Record activity",
				default: true,
			},
			exportToken: {
				type: "secret",
				label: "Export token",
			},
		},
	},
});

可用字段具有以下选项。每种类型都要求 label,description 可选。

typeValueAdditional fields
stringstringdefault, multiline
numbernumberdefault, min, max
booleanbooleandefault
selectstringrequired options: Array<{ value, label }> and optional default
secretstringno additional fields; the stored value is never returned to the browser
urlstringdefault, placeholder
emailstringdefault, placeholder

该表单可从 Plugins 中插件卡片上的设置控件进入。读取或更改需要 plugins:manage。

设置使用插件的命名空间设置存储。名为 retentionDays 的字段对插件可用作 retentionDays:

const retentionDays =
	(await ctx.settings.get<number>("retentionDays")) ?? 30;

当没有存储值时,schema 默认值会填充生成的表单,但 EmDash 不会将这些默认值写入设置存储。运行时读取设置时请应用相同的回退。清除非密钥字段会删除其存储值并将表单恢复为默认值。密钥值在持久化前会加密,且从不返回给浏览器;表单仅报告密钥是否已设置,并允许管理员替换或清除它。

设置标签与描述按声明原样渲染。若这些字符串必须随管理端语言环境变化,请改为构建自定义 React 设置页。

React 入口

受信任的 React 扩展有三个相互关联的声明:

  1. 描述符的 adminEntry 告诉 Astro 要将哪个模块打包进管理端。
  2. 运行时的 admin.entry、admin.pages 与 admin.widgets 描述可见的管理面。
  3. 管理模块导出组件映射,其键与声明的页面路径和小组件 ID 匹配。

描述符只需要模块说明符。页面与小组件元数据属于运行时定义:

export function activityPlugin(): PluginDescriptor {
	return {
		id: "plugin-activity",
		version: "0.1.0",
		format: "native",
		entrypoint: "@example/plugin-activity",
		adminEntry: "@example/plugin-activity/admin",
	};
}

export function createPlugin() {
	return definePlugin({
		id: "plugin-activity",
		version: "0.1.0",
		storage: {
			events: { indexes: ["createdAt"] },
		},
		admin: {
			entry: "@example/plugin-activity/admin",
			pages: [{ path: "/activity", label: "Activity", icon: "clock" }],
			widgets: [{ id: "recent-activity", title: "Recent activity" }],
		},
	});
}

保持 adminEntry 与 admin.entry 相同。前者是构建时导入;后者告诉运行时该插件使用受信任的 React 管理组件。

管理页面

每个页面声明具有以下字段:

FieldRequiredBehavior
pathYesMounts the page at /_emdash/admin/plugins/<plugin-id><path>. Use a leading slash.
labelYesSupplies the sidebar and command-palette label.
iconNoNames a Phosphor icon in kebab, snake, space-separated, or PascalCase form. Unknown names fall back to the plugin icon.

管理模块将每个声明的路径映射到 React 组件。尾部斜杠视为等价,且当不存在 / 页面时,插件根会打开第一个导出的页面。

以下页面加载私有插件路由。控件使用 Kumo,插件 API 请求使用 apiFetch();apiFetch() 会添加 cookie 认证的私有路由所需的 X-EmDash-Request: 1 头。

import { Button, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
import * as React from "react";

interface ActivitySummary {
	count: number;
}

export function ActivityPage() {
	const { i18n } = useLingui();
	const [summary, setSummary] = React.useState<ActivitySummary>();
	const [error, setError] = React.useState<string>();

	const load = React.useCallback(async () => {
		setError(undefined);
		try {
			const response = await apiFetch(
				"/_emdash/api/plugins/plugin-activity/summary",
			);
			setSummary(
				await parseApiResponse<ActivitySummary>(
					response,
					i18n._({ id: "activity.load-error", message: "Could not load activity" }),
				),
			);
		} catch (cause) {
			setError(cause instanceof Error ? cause.message : String(cause));
		}
	}, [i18n]);

	React.useEffect(() => {
		void load();
	}, [load]);

	return (
		<section className="space-y-4">
			<h1 className="text-2xl font-semibold">
				{i18n._({ id: "activity.title", message: "Activity" })}
			</h1>
			{summary ? (
				<p>
					{i18n._({ id: "activity.count", message: "Event count" })}: {summary.count}
				</p>
			) : error ? (
				<p role="alert" className="text-kumo-danger">{error}</p>
			) : (
				<Loader />
			)}
			<Button type="button" onClick={() => void load()}>
				{i18n._({ id: "activity.refresh", message: "Refresh" })}
			</Button>
		</section>
	);
}

在原生运行时中定义对应路由。原生处理程序接收一个上下文参数:

routes: {
	summary: {
		permission: "plugins:read",
		handler: async (ctx) => ({
			count: await ctx.storage.events.count(),
		}),
	},
},

私有路由默认使用仅管理员的 plugins:manage 权限。声明与操作匹配的最窄现有权限。仅对面向未认证互联网流量的端点使用 public: true。

从管理入口导出页面:

import type { PluginAdminExports } from "emdash";

import { ActivityPage } from "./ActivityPage.js";

export const pages: PluginAdminExports["pages"] = {
	"/activity": ActivityPage,
};

页面标签会经过管理端的共享 Lingui 实例。诸如 Settings 的标签在存在翻译时使用管理端翻译。插件可以将自己的消息目录加载到共享实例,用于插件特定的标签与组件消息;否则声明的英文消息为回退。

在导入管理入口时加载插件目录,并在管理员更改语言环境后再次加载。以下小型德语目录使用与页面和小部件示例相同的 ID:

import { i18n } from "@lingui/core";

const catalogs: Record<string, Record<string, string>> = {
	de: {
		Activity: "Aktivität",
		"activity.title": "Aktivität",
		"activity.count": "Ereignisanzahl",
		"activity.refresh": "Aktualisieren",
		"activity.load-error": "Aktivität konnte nicht geladen werden",
		"activity.unavailable": "Nicht verfügbar",
		"activity.default-locale": "Standardsprache",
	},
};

function loadPluginCatalog() {
	const messages = catalogs[i18n.locale];
	if (!messages || "activity.title" in i18n.messages) return;
	i18n.load(i18n.locale, messages);
}

loadPluginCatalog();
i18n.on("change", loadPluginCatalog);

在导出组件之前,为了注册副作用而导入加载器:

import "./i18n.js";

// Page, widget, panel, and column exports follow.

语言环境更改时,管理端会替换其活动目录。change 监听器恢复插件消息,消息 ID 检查可防止 i18n.load() 触发循环。对于更多语言环境,用插件的 Lingui 构建生成消息对象,而不是手工维护。将 @lingui/core 与 @lingui/react 保持为 peer dependency,以便插件使用主机的共享实例。

仪表盘小部件

小部件声明有必需的 id 以及可选的 title 与 size:

admin: {
	entry: "@example/plugin-activity/admin",
	widgets: [
		{ id: "recent-activity", title: "Recent activity", size: "half" },
	],
},

在相同 ID 下导出组件。此小部件读取与页面相同的摘要路由,并仅提供卡片内容;EmDash 提供周围的仪表盘卡片与标题。

import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import type { PluginAdminExports } from "emdash";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";

interface ActivitySummary {
	count: number;
}

async function loadSummary(fallbackMessage: string) {
	const response = await apiFetch(
		"/_emdash/api/plugins/plugin-activity/summary",
	);
	return parseApiResponse<ActivitySummary>(
		response,
		fallbackMessage,
	);
}

function RecentActivityWidget() {
	const { i18n } = useLingui();
	const { data, isLoading, isError } = useQuery({
		queryKey: ["plugin-activity", "summary"],
		queryFn: () =>
			loadSummary(
				i18n._({ id: "activity.load-error", message: "Could not load activity" }),
			),
	});

	return (
		<p>
			{i18n._({ id: "activity.count", message: "Event count" })}:{" "}
			{isLoading
				? "…"
				: isError
					? i18n._({ id: "activity.unavailable", message: "Unavailable" })
					: (data?.count ?? 0)}
		</p>
	);
}

export const widgets: PluginAdminExports["widgets"] = {
	"recent-activity": RecentActivityWidget,
};

主机将组件放在仪表盘卡片内,并将 title 渲染为标题。保持组件紧凑,不要添加第二个卡片外壳。size 接受 full、half 或 third;它作为布局提示存储,但当前仪表盘在其响应式两列网格中渲染插件小部件,且不应用该提示。

内容编辑器面板

编辑器面板会向已保存条目的设置侧栏添加由主机框定的区块。它不会为新条目挂载,因为尚不存在已保存的 entry。

面板与内容列表列直接从受信任的管理模块发现。它们需要 adminEntry 与 admin.entry 以便模块加载,但不需要 admin.pages 或 admin.widgets 中的条目。

import type {
	ContentEditorPanelContext,
	ContentEditorPanelExtension,
} from "@emdash-cms/admin";
import { useLingui } from "@lingui/react";

function ActivityPanel({ entry, collection, locale }: ContentEditorPanelContext) {
	const { i18n } = useLingui();
	const displayLocale =
		locale ??
		i18n._({ id: "activity.default-locale", message: "Default locale" });

	return (
		<p className="text-sm text-kumo-subtle">
			{collection}/{entry.slug} ({displayLocale})
		</p>
	);
}

export const contentEditorPanels = [
	{
		id: "activity-summary",
		title: "Activity summary",
		component: ActivityPanel,
		collections: ["posts", "pages"],
		order: 10,
	},
] satisfies readonly ContentEditorPanelExtension[];

面板字段具有以下行为:

  • id、title 与 component 为必需。ID 在此插件的面板中必须唯一。
  • collections 是集合名称数组或谓词。省略则对每个集合显示面板。
  • minRole 是数字可见性阈值。它不授权 API 调用。
  • order 将较小值排在前面。并列时使用插件 ID 与面板 ID。

组件接收已保存的 entry、其 collection 以及解析后的 locale。保持布局适配窄侧栏。EmDash 隔离组件与集合谓词失败,使一个面板无法卸载编辑器。

内容列表列

内容列表列为活动集合列表添加只读单元格。主机仍拥有分页、行操作、加载与空状态以及表格本身。列不会在回收站中显示。

以下列使用 visibleItems 获取一页状态。每个单元格使用相同的 React Query 键,因此请求共享一个结果,而不是每行发出一个请求。

import { useQuery } from "@tanstack/react-query";
import type {
	ContentListColumnCellContext,
	ContentListColumnExtension,
} from "@emdash-cms/admin";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";

async function loadStatuses(
	collection: string,
	locale: string | undefined,
	ids: readonly string[],
) {
	const response = await apiFetch(
		"/_emdash/api/plugins/plugin-activity/statuses",
		{
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ collection, locale, ids }),
		},
	);
	return parseApiResponse<Record<string, string>>(
		response,
		"Could not load activity statuses",
	);
}

function ActivityCell({
	item,
	visibleItems,
	collection,
	locale,
}: ContentListColumnCellContext) {
	const ids = visibleItems.map((visibleItem) => visibleItem.id);
	const { data } = useQuery({
		queryKey: ["plugin-activity", "statuses", collection, locale ?? null, ids],
		queryFn: () => loadStatuses(collection, locale, ids),
	});

	return <span>{data?.[item.id] ?? "-"}</span>;
}

export const contentListColumns = [
	{
		id: "activity",
		label: "Activity",
		cell: ActivityCell,
		collections: ["posts", "pages"],
		align: "end",
		order: 10,
	},
] satisfies readonly ContentListColumnExtension[];

列字段具有以下行为:

  • id、label 与 cell 为必需。ID 在此插件的列中必须唯一。
  • header 用组件替换标题内容;label 仍为主机回退。
  • collections、minRole 与 order 的行为与面板对应项相同。
  • align 接受 start 或 end,并对从左到右与从右到左的语言环境使用逻辑对齐。

列不能添加仅浏览器的排序或过滤。这些控件只会影响已加载的游标页,而不是整个服务器端集合。

已禁用的插件

当管理员禁用插件时,EmDash 会从其管理端移除其页面、小部件、面板与列。其私有路由返回未找到,其钩子停止运行。重新启用插件会重建钩子管线,并再次使其受信任的管理导出可用。

打包入口

将管理模块与服务器运行时分开导出,以便 Astro 可以用主机的 React、Kumo 与 Lingui 实例将其打包到浏览器。分发原生插件 提供完整的包布局、导出、peer dependency 与构建命令。