后台

本页内容

EmDash 后台是嵌入在 Astro 站点中的 React 单页应用,为编辑与管理员提供完整的内容管理界面。

架构概览

┌────────────────────────────────────────────────────────────────┐
│                    Astro 外壳                                  │
│  /_emdash/admin/[...path].astro                              │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    React SPA                             │  │
│  │                                                          │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐   │  │
│  │  │  TanStack   │  │  TanStack   │  │     Kumo        │   │  │
│  │  │   Router    │  │   Query     │  │   组件          │   │  │
│  │  └─────────────┘  └─────────────┘  └─────────────────┘   │  │
│  │                                                          │  │
│  │  ┌────────────────────────────────────────────────────┐  │  │
│  │  │           REST API 客户端                         │  │  │
│  │  │           /_emdash/api/*                           │  │  │
│  │  └────────────────────────────────────────────────────┘  │  │
│  └──────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────┘

后台是 React「大型岛屿」。Astro 负责外壳与鉴权;后台内的导航与渲染全部在客户端完成。

技术栈

层级技术作用
路由TanStack Router类型安全的客户端路由
数据TanStack Query服务端状态、缓存、变更
UIKumo可访问组件(Base UI + Tailwind)
表格TanStack Table排序、筛选、分页
表单React Hook Form + Zod与服务器 schema 一致的校验
图标Phosphor统一图标
编辑器TipTap富文本编辑(Portable Text)

路由结构

后台挂载在 /_emdash/admin/,使用客户端路由。

路径界面
/仪表盘
/content/:collection内容列表
/content/:collection/:id内容编辑器
/content/:collection/new新建条目
/media媒体库
/content-typesSchema 构建器(仅管理员)
/menus导航菜单
/widgets小部件区域
/taxonomies分类 / 标签管理
/settings站点设置
/plugins/:pluginId/*插件页面

清单驱动的 UI

后台不把集合或插件写死在代码里,而是从服务器拉取清单(manifest)。

GET /_emdash/api/manifest

响应:

{
	"collections": [
		{
			"slug": "posts",
			"label": "Blog Posts",
			"labelSingular": "Post",
			"icon": "file-text",
			"supports": ["drafts", "revisions", "preview"],
			"fields": [
				{ "slug": "title", "type": "string", "required": true },
				{ "slug": "content", "type": "portableText" }
			]
		}
	],
	"plugins": [
		{
			"id": "audit-log",
			"label": "Audit Log",
			"adminPages": [{ "path": "history", "label": "Audit History" }],
			"widgets": [{ "id": "recent-activity", "title": "Recent Activity" }]
		}
	],
	"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
	"version": "abc123"
}

后台仅凭此清单构建导航、表单与编辑器。优点包括:

  • Schema 变更立即生效 — 无需重建后台
  • 插件 UI 自动接入 — 页面与小组件来自清单
  • 边界类型安全 — Zod schema 保留在服务器端

数据流

  1. 加载后台 SPA — 初始化 TanStack Router
  2. 获取清单 — TanStack Query 缓存集合/插件元数据
  3. 构建导航 — 侧栏由清单生成
  4. 用户导航 — 客户端路由,无整页刷新
  5. 拉取数据 — TanStack Query 请求 REST API
  6. 渲染表单 — 由清单中的字段描述生成字段编辑器
  7. 提交变更 — 通过 TanStack Query 变更与乐观更新
  8. 服务端校验 — 服务端 Zod,错误以 JSON 返回

REST API 端点

后台仅通过 REST API 通信。

内容 API

方法端点用途
GET/api/content/:collection列出条目
POST/api/content/:collection创建条目
GET/api/content/:collection/:id获取条目
PUT/api/content/:collection/:id更新条目
DELETE/api/content/:collection/:id软删除
GET/api/content/:collection/:id/revisions修订列表
POST/api/content/:collection/:id/preview-url生成预览 URL

Schema API

方法端点用途
GET/api/schema导出完整 schema
GET/api/schema/collections列出集合
POST/api/schema/collections创建集合
PUT/api/schema/collections/:slug更新集合
DELETE/api/schema/collections/:slug删除集合
POST/api/schema/collections/:slug/fields添加字段
PUT/api/schema/collections/:slug/fields/:field更新字段
DELETE/api/schema/collections/:slug/fields/:field删除字段

媒体 API

方法端点用途
GET/api/media列出媒体
POST/api/media/upload-url获取签名上传 URL
POST/api/media/:id/confirm确认上传完成
DELETE/api/media/:id删除媒体
GET/api/media/file/:key提供媒体文件

其他 API

端点用途
/api/settings站点设置(GET/POST)
/api/menus/*导航菜单
/api/widget-areas/*小部件管理
/api/taxonomies/*分类术语
/api/admin/plugins/*插件状态

分页

所有列表端点均使用基于游标的分页。

{
  "items": [...],
  "nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"
}

获取下一页:

GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9

插件后台 UI

插件可通过页面与仪表盘小部件扩展后台。集成会生成带静态 import 的虚拟模块。

// virtual:emdash/plugin-admins(生成)
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
import * as pluginAdmin1 from "@emdash-cms/plugin-analytics/admin";

export const pluginAdmins = {
	seo: pluginAdmin0,
	analytics: pluginAdmin1,
};

插件页面

插件页面挂载在 /_emdash/admin/plugins/:pluginId/* 下。

// @emdash-cms/plugin-seo/src/admin.tsx
export const pages = [
	{
		path: "settings",
		component: SEOSettingsPage,
		label: "SEO Settings",
	},
];

渲染 URL:/_emdash/admin/plugins/seo/settings

仪表盘小部件

插件可向仪表盘添加小部件。

export const widgets = [
	{
		id: "seo-overview",
		component: SEOWidget,
		title: "SEO Overview",
		size: "half", // "full" | "half" | "third"
	},
];

鉴权

后台外壳路由由 Astro 中间件强制鉴权。

// 简化的中间件示例
export async function onRequest({ request, locals }, next) {
	const session = await getSession(request);

	if (request.url.includes("/_emdash/admin")) {
		if (!session?.user) {
			return redirect("/_emdash/admin/login");
		}
		locals.user = session.user;
	}

	return next();
}

后台 SPA 本身不处理登录;设置会话 Cookie 的是 Astro 页面。

基于角色的访问

不同角色可见的后台范围不同。

角色可见区块
Editor仪表盘、分配的集合、媒体
Admin另含内容类型、全部集合、设置
Developer另含 CLI 访问、生成的类型

清单端点会按请求用户角色过滤集合与功能。

内容编辑器

内容编辑器根据字段定义动态生成表单。

// 简化的编辑器渲染示例
function ContentEditor({ collection, fields }) {
	return (
		<form>
			{fields.map((field) => (
				<FieldWidget
					key={field.slug}
					type={field.type}
					label={field.label}
					required={field.required}
					options={field.options}
				/>
			))}
		</form>
	);
}

每种字段类型有对应的小组件。

字段类型小组件
string文本输入
text文本域
number数字输入
boolean开关
datetime日期时间选择器
select下拉框
multiSelect多选
portableTextTipTap 编辑器
image媒体选择器
reference条目选择器

富文本编辑器

Portable Text 字段使用 TipTap(ProseMirror)编辑。

用户输入 → TipTap(ProseMirror JSON)→ 保存 → Portable Text(DB)
加载 → Portable Text(DB)→ TipTap(ProseMirror JSON)→ 展示

在加载/保存边界通过 portableTextToProsemirror()prosemirrorToPortableText() 转换。

支持的块:

  • 段落、标题(H1–H6)
  • 项目符号与编号列表
  • 引用、代码块
  • 图片(来自媒体库)
  • 链接

来自插件或导入的未知块以只读占位保留。

媒体库

媒体库提供:

  • 网格与列表视图
  • 按类型、日期搜索与筛选
  • 拖放上传
  • 带元数据的图片预览
  • 批量选择与删除

上传使用签名 URL,由客户端直传存储。

  1. 请求上传 URLPOST /api/media/upload-url
  2. 直传 — 客户端向签名 URL(R2/S3)PUT 文件
  3. 确认上传POST /api/media/:id/confirm
  4. 服务端提取元数据 — 尺寸、MIME 类型等

该方式可绕过 Workers 请求体大小限制,并获得真实上传进度。

下一步