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 | 服务端状态、缓存、变更 |
| UI | Kumo | 可访问组件(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-types | Schema 构建器(仅管理员) |
/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 保留在服务器端
数据流
- 加载后台 SPA — 初始化 TanStack Router
- 获取清单 — TanStack Query 缓存集合/插件元数据
- 构建导航 — 侧栏由清单生成
- 用户导航 — 客户端路由,无整页刷新
- 拉取数据 — TanStack Query 请求 REST API
- 渲染表单 — 由清单中的字段描述生成字段编辑器
- 提交变更 — 通过 TanStack Query 变更与乐观更新
- 服务端校验 — 服务端 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 | 多选 |
portableText | TipTap 编辑器 |
image | 媒体选择器 |
reference | 条目选择器 |
富文本编辑器
Portable Text 字段使用 TipTap(ProseMirror)编辑。
用户输入 → TipTap(ProseMirror JSON)→ 保存 → Portable Text(DB)
加载 → Portable Text(DB)→ TipTap(ProseMirror JSON)→ 展示
在加载/保存边界通过 portableTextToProsemirror() 与 prosemirrorToPortableText() 转换。
支持的块:
- 段落、标题(H1–H6)
- 项目符号与编号列表
- 引用、代码块
- 图片(来自媒体库)
- 链接
来自插件或导入的未知块以只读占位保留。
媒体库
媒体库提供:
- 网格与列表视图
- 按类型、日期搜索与筛选
- 拖放上传
- 带元数据的图片预览
- 批量选择与删除
上传使用签名 URL,由客户端直传存储。
- 请求上传 URL —
POST /api/media/upload-url - 直传 — 客户端向签名 URL(R2/S3)PUT 文件
- 确认上传 —
POST /api/media/:id/confirm - 服务端提取元数据 — 尺寸、MIME 类型等
该方式可绕过 Workers 请求体大小限制,并获得真实上传进度。