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 與建置命令。