# 模板系统设计 > 借鉴 WordPress 的模板层级 + Gutenberg 块编辑器,适配无头 CMS 架构。 > 后端存储模板配置和 Block 结构化数据,前端负责渲染。 ## 1. 核心概念 | 概念 | 说明 | 类比 WordPress | |------|------|---------------| | **Block** | 最小内容单元(段落、标题、图片、代码等),以 JSON 存储 | Gutenberg Block | | **Block 字段** | Content Type 的 `content` 字段类型为 `blocks`,存储 Block 数组 | `post_content`(富文本) | | **Layout** | 页面骨架,定义有哪些区域(regions) | `header.php` + `footer.php` + `sidebar.php` | | **Page Template** | 将 Layout 绑定到特定 Content Type 或单篇文章 | `single-post.php` / `page.php` | | **Region** | Layout 中的一个插槽,可配置 Block 列表 | `get_sidebar()` / 动态 Widget 区域 | | **模板层级** | 前端根据路由自动匹配最具体的模板 | WordPress Template Hierarchy | ## 2. 架构总览 ``` ┌─ 后端 Rust ──────────────────────────────────────────────┐ │ │ │ content_types/post.toml │ │ fields.content.type = "blocks" ← 块编辑器字段类型 │ │ │ │ layouts 表 │ │ id | name | regions │ │ L1 | default | ["header","nav","content","sidebar", │ │ | | "footer"] │ │ L2 | full | ["header","content","footer"] │ │ │ │ page_templates 表 │ │ id | name | layout | content_type │ │ T1 | Post Detail | L1 | post │ │ T2 | Post Full | L2 | post │ │ T3 | Page Landing | L2 | page │ │ │ │ template_regions 表 (每个区域放哪些 block) │ │ template=T1, region="sidebar" │ │ → [{"type":"recent_posts","limit":5}] │ │ │ └───────────────────────────────────────────────────────────┘ ┌─ 前端 Next.js ────────────────────────────────────────────┐ │ │ │ TemplateProvider │ │ ├─ 查询当前路由 → 匹配 page_template │ │ ├─ 加载 layout → 渲染 regions │ │ └─ 每个 region → 渲染配置的 blocks │ │ │ │ BlockRegistry (前端) │ │ "paragraph" → │ │ "heading" → │ │ "image" → │ │ "code" → │ │ "gallery" → │ │ "recent_posts" → │ │ "newsletter" → │ │ ...插件可注册新 block... │ │ │ │ LayoutRegistry (前端) │ │ "default" → Header + Nav + Content + Sidebar + Footer │ │ "full" → Header + Content + Footer │ │ "landing" → 全自定义 │ │ │ └───────────────────────────────────────────────────────────┘ ``` ## 3. Block 编辑器 ### 3.1 Block 数据结构 内容不再是纯 Markdown 文本,而是结构化的 Block 数组。 `content` 字段存储格式: ```jsonc [ { "type": "heading", "attrs": { "level": 2 }, "content": "为什么选择我们" }, { "type": "paragraph", "content": "我们提供最优质的服务..." }, { "type": "image", "attrs": { "src": "/uploads/team.jpg", "alt": "团队照片", "width": 800 } }, { "type": "columns", "attrs": { "count": 3 }, "children": [ [{ "type": "card", "attrs": { "title": "快速", "icon": "zap" } }], [{ "type": "card", "attrs": { "title": "安全", "icon": "shield" } }], [{ "type": "card", "attrs": { "title": "可靠", "icon": "check" } }] ] }, { "type": "code", "attrs": { "language": "rust" }, "content": "fn main() {}" }, { "type": "newsletter" } // 自定义 block(插件注册) ] ``` ### 3.2 Block 类型定义 每个 Block 统一结构: ```typescript interface Block { type: string; // block 类型标识 content?: string; // 文本内容(paragraph / heading / code) attrs?: Record; // 属性(src / level / language 等) children?: Block[][]; // 嵌套子 block(columns 等) } ``` ### 3.3 内置 Block 类型 | Block 类型 | attrs | 说明 | |-----------|-------|------| | `paragraph` | — | 段落文本,支持内联标记(粗体、链接等) | | `heading` | `level: 1-6` | 标题 | | `image` | `src, alt, width, caption?` | 图片 | | `image_gallery` | `images: [{src, alt}]` | 图片画廊 | | `code` | `language` | 代码块 | | `quote` | `author?, source?` | 引用 | | `list` | `ordered: bool` | 有序/无序列表 | | `columns` | `count: 2-4` | 多栏布局,children 是二维数组 | | `divider` | — | 分隔线 | | `embed` | `url, provider, width, height` | 第三方嵌入(YouTube、Tweet 等) | | `table` | `rows, headers: bool` | 表格 | | `callout` | `variant: info/warning/danger/tip` | 提示框 | | `card` | `title, icon?, image?` | 卡片(常用于 columns 内) | | `raw_html` | — | 原始 HTML(仅管理员可用) | ### 3.4 前端 Block 渲染 ```tsx // components/BlockRenderer.tsx interface BlockProps { type: string; content?: string; attrs?: Record; children?: Block[][]; } const blockRegistry: Record> = { heading: HeadingBlock, paragraph: ParagraphBlock, image: ImageBlock, columns: ColumnsBlock, code: CodeBlock, gallery: GalleryBlock, newsletter: NewsletterBlock, // 插件运行时动态注册更多... }; export function BlockRenderer({ blocks }: { blocks: Block[] }) { return ( <> {blocks.map((block, i) => { const Component = blockRegistry[block.type]; if (!Component) return ; return ; })} ); } ``` 插件可注册自定义 Block: ```tsx // 插件注册 block BlockRegistry.register("newsletter", NewsletterBlock); BlockRegistry.register("pricing_table", PricingTableBlock); ``` ### 3.5 后端兼容 现有 `content` 字段是 Markdown 文本。迁移策略: 1. 新增字段类型 `blocks`,新 Content Type 使用 2. 现有 `richtext` 字段保持 Markdown 存储不变 3. 前端 `BlockRenderer` 遇到 `string` 类型 content 自动走 Markdown 渲染 4. Admin 编辑器:`richtext` 用 Markdown 编辑器,`blocks` 用 Block 编辑器 ## 4. Layout 与 Page Template ### 4.1 模板匹配规则(Template Resolution) 模仿 WordPress 模板层级,前端根据路由自动匹配模板: ``` 请求 /posts/hello-world 1. 查找 page_template: content_type=post, slug="hello-world" → 没找到 2. 查找 page_template: content_type=post, slug=null → 找到 "Post Full" 3. 加载 layout → Header + Content + Footer 4. Content 区域渲染 post.content 的 blocks 请求 /pages/about 1. 查找 page_template: content_type=page, slug="about" → 找到 "Page Landing" 2. 加载对应 layout 3. 渲染 region 配置的 blocks + 文章 content 请求 / (首页) 1. 查找 page_template: content_type=null, slug=null → 找到 "Homepage" 2. 加载 landing layout 3. 所有 region 均由 template region_config 定义 ``` 匹配优先级(从高到低): | 优先级 | 匹配条件 | 示例 | |--------|---------|------| | 1 | `content_type` + `slug` 精确匹配 | `post` + `hello-world` | | 2 | `content_type` + `slug=null` 默认模板 | `post` + null | | 3 | 全局默认 | null + null | ### 4.2 前端 TemplateProvider ```tsx // components/TemplateProvider.tsx async function resolveTemplate( contentType: string | null, slug: string | null, ): Promise { const templates = await api.get("/templates"); // 优先级 1: content_type + slug 精确匹配 let matched = templates.find( (t) => t.content_type === contentType && t.slug === slug, ); // 优先级 2: content_type 默认模板 if (!matched) { matched = templates.find( (t) => t.content_type === contentType && !t.slug, ); } // 优先级 3: 全局默认 if (!matched) { matched = templates.find((t) => !t.content_type && !t.slug); } return matched ?? null; } export function TemplateProvider({ template, data, children, }: { template: PageTemplate; data?: Record; children?: React.ReactNode; }) { const layout = layouts[template.layout_id]; return ( {layout.regions.map((region) => ( {region === "content" && children} {template.region_config[region]?.map((block, i) => ( ))} ))} ); } ``` ## 5. 数据库设计 ### 5.1 layouts 表 ```sql CREATE TABLE layouts ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, regions TEXT NOT NULL, -- JSON: ["header","nav","content","sidebar","footer"] preview TEXT, -- 预览图 URL(可选) is_system BOOLEAN NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); ``` ### 5.2 page_templates 表 ```sql CREATE TABLE page_templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL, layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, content_type TEXT, -- null = 全局/首页 slug TEXT, -- null = 该 content_type 的默认模板 region_config TEXT NOT NULL DEFAULT '{}', -- JSON: region → block 配置数组 is_system BOOLEAN NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(content_type, slug) ); ``` ### 5.3 预置数据 ```sql INSERT INTO layouts (id, name, regions, is_system, created_at, updated_at) VALUES ('layout-default', 'Default', '["header","nav","content","sidebar","footer"]', 1, datetime('now'), datetime('now')), ('layout-full', 'Full Width', '["header","content","footer"]', 1, datetime('now'), datetime('now')), ('layout-landing', 'Landing', '["content"]', 1, datetime('now'), datetime('now')); INSERT INTO page_templates (id, name, layout_id, content_type, slug, region_config, is_system, created_at, updated_at) VALUES ('tpl-post', 'Post Detail', 'layout-default', 'post', NULL, '{"sidebar":[{"type":"recent_posts","attrs":{"limit":5}},{"type":"tags"}]}', 1, datetime('now'), datetime('now')), ('tpl-page', 'Page', 'layout-full', 'page', NULL, '{}', 1, datetime('now'), datetime('now')), ('tpl-home', 'Homepage', 'layout-landing', NULL, NULL, '{"content":[{"type":"hero"},{"type":"featured_posts","attrs":{"limit":3}},{"type":"newsletter"}]}', 1, datetime('now'), datetime('now')); ``` ## 6. API 设计 ### 6.1 Layout CRUD | Method | Path | 说明 | |--------|------|------| | GET | `/api/v1/admin/layouts` | 列出所有布局 | | GET | `/api/v1/admin/layouts/:id` | 获取单个布局 | | POST | `/api/v1/admin/layouts` | 创建布局 | | PUT | `/api/v1/admin/layouts/:id` | 更新布局 | | DELETE | `/api/v1/admin/layouts/:id` | 删除布局(非 system) | ### 6.2 Page Template CRUD | Method | Path | 说明 | |--------|------|------| | GET | `/api/v1/admin/templates` | 列出所有模板 | | GET | `/api/v1/admin/templates/:id` | 获取单个模板 | | POST | `/api/v1/admin/templates` | 创建模板 | | PUT | `/api/v1/admin/templates/:id` | 更新模板(改布局、配 region) | | DELETE | `/api/v1/admin/templates/:id` | 删除模板(非 system) | ### 6.3 模板解析(前端用) | Method | Path | 说明 | |--------|------|------| | GET | `/api/v1/templates/resolve?content_type=post&slug=hello` | 解析匹配的模板 | | GET | `/api/v1/templates/resolve` | 首页模板 | 响应示例: ```json { "template": { "id": "tpl-post", "name": "Post Detail", "layout": { "id": "layout-default", "name": "Default", "regions": ["header", "nav", "content", "sidebar", "footer"] }, "region_config": { "sidebar": [ { "type": "recent_posts", "attrs": { "limit": 5 } }, { "type": "tags" } ] } } } ``` ## 7. Content Type Schema 扩展 `blocks` 字段类型加到 Content Type 定义中: ```toml # content_types/post.toml [fields.content] type = "blocks" # 新字段类型 label = "正文" required = true [fields.excerpt] type = "text" max_length = 500 label = "摘要" ``` `blocks` 字段在数据库中存储为 `TEXT`(JSON 数组),与 `json` 字段类型存储方式相同, 区别在于前端使用 Block 编辑器而非 JSON 文本框。 ## 8. 前端 Admin 页面 ### 8.1 Block 编辑器组件 核心编辑组件,用于 `blocks` 类型字段的输入: ``` components/admin/block-editor/ ├── BlockEditor.tsx # 编辑器主体 ├── BlockToolbar.tsx # 顶部工具栏(插入 block / 拖拽排序) ├── blocks/ # 每个 block 类型的编辑态组件 │ ├── ParagraphEdit.tsx │ ├── HeadingEdit.tsx │ ├── ImageEdit.tsx │ ├── CodeEdit.tsx │ ├── ColumnsEdit.tsx │ └── ... └── BlockSelector.tsx # "+" 按钮弹出的 block 选择面板 ``` ### 8.2 模板管理页面 ``` web/src/app/admin/templates/ ├── page.tsx # 模板列表 + Layout 列表 ├── [id]/page.tsx # 模板编辑:选 Layout + 配置每个 Region 的 blocks └── layouts/ └── page.tsx # Layout 管理(增删 regions) ``` ### 8.3 Admin 侧边栏 layout.tsx 的 menuItems 增加: ```ts { label: "Templates", href: "/admin/templates", icon: LayoutTemplate }, ``` ## 9. 与现有系统集成 ### 9.1 Content Type 字段类型扩展 `FieldType` 枚举新增 `Blocks`: ```rust // src/content_type/schema.rs pub enum FieldType { Text, RichText, // ... 现有类型 Blocks, // 新增 } ``` `blocks` 字段在数据库中存储为 `TEXT`(JSON 数组),与 `json` 字段类型的 migration 逻辑相同。 ### 9.2 与插件系统结合 插件可通过 manifest 注册自定义 Block: ```toml # plugins/newsletter/plugin.toml [[blocks]] type = "newsletter" label = "Newsletter Signup" icon = "mail" component = "blocks/NewsletterBlock" # 前端组件路径 ``` 插件加载时,前端通过 API 获取插件注册的 block 列表,动态加入 `BlockRegistry`。 ### 9.3 与多租户结合 每个租户可有独立的 page_templates 配置(`tenant_id` 列过滤), 不同租户可使用不同的布局和 region block 配置。 ## 10. 实施步骤 | 步骤 | 内容 | 优先级 | |------|------|--------| | 1 | 数据库 migration(layouts + page_templates 表 + 预置数据) | P0 | | 2 | 后端 CRUD API(layout + template) | P0 | | 3 | 后端模板解析 API(`/templates/resolve`) | P0 | | 4 | Content Type 新增 `blocks` 字段类型 | P0 | | 5 | 前端 BlockRegistry + BlockRenderer(只渲染,不编辑) | P0 | | 6 | 前端 TemplateProvider + 模板匹配 | P0 | | 7 | 前端 Admin 模板管理页面 | P1 | | 8 | 前端 Block 编辑器(可视化编辑 block) | P1 | | 9 | 插件注册自定义 Block 机制 | P2 | | 10 | 拖拽排序 block | P2 |