React 管理頁面和小工具

本頁內容

原生外掛可以透過自訂 React 頁面和儀表板小工具擴充管理面板——沙盒外掛則使用 Block Kit 描述其 UI,因為將外掛 JavaScript 載入管理介面會破壞沙盒隔離。

如果你的外掛只需要一個設定表單,自動產生的 admin.settingsSchema 表單(參見你的第一個原生外掛)無需撰寫任何 React 即可涵蓋大多數情況。僅在需要比 settingsSchema 提供的更豐富的 UI 時才使用自訂元件。

管理進入點

帶有管理 UI 的外掛從 admin 進入點匯出 pageswidgets 物件:

import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";

export const widgets = {
	"seo-overview": SEODashboardWidget,
};

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

package.json 中設定進入點:

{
	"exports": {
		".": "./dist/index.js",
		"./admin": "./dist/admin.js"
	}
}

definePlugin() 中參照:

definePlugin({
	id: "seo",
	version: "1.0.0",

	admin: {
		entry: "@my-org/plugin-seo/admin",
		pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
		widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
	},
});

描述符需要一個對應的 adminEntry,以便 EmDash 在建置時知道在哪裡找到元件:

adminEntry: "@my-org/plugin-seo/admin",

管理頁面

管理頁面是掛載在 /_emdash/admin/plugins/<plugin-id>/<path> 下的 React 元件。

頁面定義

admin.pages 下宣告每個頁面,包含路徑、標籤和圖示:

admin: {
	pages: [
		{
			path: "/settings",
			label: "Settings",
			icon: "settings",
		},
		{
			path: "/reports",
			label: "Reports",
			icon: "chart",
		},
	],
}

請用英文宣告標籤。管理介面在呈現側邊欄和命令面板之前,會透過其共享的 Lingui 實例處理標籤,因此載入自己訊息目錄的外掛(使用英文標籤作為訊息 ID)可以免費獲得本地化導覽。與管理介面自身訊息(SettingsDashboard 等)相符的標籤即使沒有外掛目錄也會使用管理介面的翻譯;沒有目錄項目的標籤按宣告時的樣子呈現。

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

// 將外掛的編譯目錄合併到管理介面的 i18n 實例中。
// 當管理介面的語系為德語時,"Reports" 現在呈現為 "Berichte"。
const catalogs: Record<string, Record<string, string>> = {
	de: { Reports: "Berichte", Settings: "Einstellungen" },
};

function mergeCatalog() {
	const messages = catalogs[i18n.locale];
	if (messages && !("Reports" in i18n.messages)) i18n.load(i18n.locale, messages);
}

mergeCatalog();
// 管理介面的語系切換器在變更時會*取代*目錄,因此需要重新合併。
// 上面的哨兵檢查防止遞迴(load() 會觸發 "change")。
i18n.on("change", mergeCatalog);

頁面元件

以下元件透過外掛 API 鉤子讀取和儲存設定:

import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";

export function SettingsPage() {
	const api = usePluginAPI();
	const [settings, setSettings] = useState<Record<string, unknown>>({});
	const [saving, setSaving] = useState(false);

	useEffect(() => {
		api.get("settings").then(setSettings);
	}, []);

	const handleSave = async () => {
		setSaving(true);
		await api.post("settings/save", settings);
		setSaving(false);
	};

	return (
		<div>
			<h1>外掛設定</h1>

			<label>
				網站標題
				<input
					type="text"
					value={(settings.siteTitle as string) || ""}
					onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
				/>
			</label>

			<button onClick={handleSave} disabled={saving}>
				{saving ? "儲存中..." : "儲存設定"}
			</button>
		</div>
	);
}

外掛 API 鉤子

usePluginAPI() 呼叫外掛的路由時會自動加入外掛 ID 前綴和 X-EmDash-Request: 1 CSRF 標頭:

import { usePluginAPI } from "@emdash-cms/admin";

function MyComponent() {
	const api = usePluginAPI();

	const data = await api.get("status");                       // GET /_emdash/api/plugins/<id>/status
	await api.post("settings/save", { enabled: true });          // 使用 JSON 正文 POST
	const result = await api.get("history?limit=50");            // 支援查詢參數
}

儀表板小工具

小工具顯示在管理儀表板上,提供一目了然的資訊。

小工具定義

admin.widgets 下宣告每個小工具,包含 ID、標題和大小:

admin: {
	widgets: [
		{
			id: "seo-overview",
			title: "SEO Overview",
			size: "half",   // "full" | "half" | "third"
		},
	],
}

小工具元件

以下元件在掛載時取得資料並呈現精簡的摘要:

import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";

export function SEOWidget() {
	const api = usePluginAPI();
	const [data, setData] = useState({ score: 0, issues: [] });

	useEffect(() => {
		api.get("analyze").then(setData);
	}, []);

	return (
		<div className="widget-content">
			<div className="score">{data.score}%</div>
			<ul>
				{data.issues.map((issue, i) => (
					<li key={i}>{(issue as { message: string }).message}</li>
				))}
			</ul>
		</div>
	);
}

小工具大小

大小描述
full儀表板全寬
half儀表板半寬
third儀表板三分之一寬

小工具根據螢幕寬度自動換行。

匯出結構

管理進入點匯出兩個物件:

import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";

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

export const widgets = {
	status: StatusWidget,
	overview: OverviewWidget,
};

使用管理元件

EmDash 為常見模式提供了預建元件:

import {
	Card,
	Button,
	Input,
	Select,
	Toggle,
	Table,
	Pagination,
	Alert,
	Loading,
} from "@emdash-cms/admin";

function SettingsPage() {
	return (
		<Card title="設定">
			<Input label="API 金鑰" type="password" />
			<Toggle label="啟用" defaultChecked />
			<Button variant="primary">儲存</Button>
		</Card>
	);
}

自動產生的設定 UI

如果你的外掛只需要一個設定表單,使用 admin.settingsSchema 即可,無需自訂元件:

admin: {
	settingsSchema: {
		apiKey: { type: "secret", label: "API Key" },
		enabled: { type: "boolean", label: "Enabled", default: true },
	},
},

EmDash 會自動產生設定頁面。僅在需要超出基本表單的行為時才使用自訂 React 頁面。

導覽

外掛頁面顯示在管理側邊欄的外掛名稱下。順序與 admin.pages 陣列一致,如下所示:

admin: {
	pages: [
		{ path: "/settings", label: "Settings", icon: "settings" },  // 第一個
		{ path: "/history", label: "History", icon: "history" },     // 第二個
		{ path: "/reports", label: "Reports", icon: "chart" },        // 第三個
	],
}

建置設定

管理元件需要一個獨立的建置進入點。以下打包器設定同時建置伺服器和管理進入點:

tsdown

export default {
	entry: {
		index: "src/index.ts",
		admin: "src/admin.tsx",
	},
	format: "esm",
	dts: true,
	external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],
};

tsup

export default {
	entry: ["src/index.ts", "src/admin.tsx"],
	format: "esm",
	dts: true,
	external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],
};

將 React 和 EmDash admin 保持為外部相依性,以避免打包重複。

外掛啟用/停用

當外掛在管理介面中被停用時:

  • 側邊欄連結被隱藏。
  • 儀表板小工具不會呈現。
  • 管理頁面回傳 404。
  • 後端鉤子繼續執行(為了資料安全)。

外掛可以檢查其啟用狀態:

const enabled = await ctx.kv.get<boolean>("_emdash:enabled");

完整範例

以下外掛定義了一個儀表板頁面、一個設定頁面和一個小工具,執行時期和管理進入點位於不同的檔案中。src/index.ts 檔案包含描述符和執行時期:

import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";

export function analyticsPlugin(): PluginDescriptor {
	return {
		id: "analytics",
		version: "1.0.0",
		format: "native",
		entrypoint: "@my-org/plugin-analytics",
		adminEntry: "@my-org/plugin-analytics/admin",
		adminPages: [
			{ path: "/dashboard", label: "Dashboard", icon: "chart" },
			{ path: "/settings", label: "Settings", icon: "settings" },
		],
		adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
	};
}

export function createPlugin() {
	return definePlugin({
		id: "analytics",
		version: "1.0.0",

		capabilities: ["network:request"],
		allowedHosts: ["api.analytics.example.com"],

		storage: {
			events: { indexes: ["type", "createdAt"] },
		},

		admin: {
			entry: "@my-org/plugin-analytics/admin",
			settingsSchema: {
				trackingId: { type: "string", label: "Tracking ID" },
				enabled: { type: "boolean", label: "Enabled", default: true },
			},
			pages: [
				{ path: "/dashboard", label: "Dashboard", icon: "chart" },
				{ path: "/settings", label: "Settings", icon: "settings" },
			],
			widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
		},

		routes: {
			stats: {
				handler: async (ctx) => {
					const today = new Date().toISOString().split("T")[0];
					const count = await ctx.storage.events.count({
						createdAt: { gte: today },
					});
					return { today: count };
				},
			},
		},
	});
}

export default createPlugin;

src/admin.tsx 檔案將頁面路徑和小工具 ID 對應到其 React 元件:

import { EventsWidget } from "./components/EventsWidget";
import { DashboardPage } from "./components/DashboardPage";
import { SettingsPage } from "./components/SettingsPage";

export const widgets = {
	"events-today": EventsWidget,
};

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