Ghost 管理后台的页面模板规范:用 Shade 的 ListPage 与 PageHeader 搭建标准管理页
本文基于 Ghost 仓库中的 Shade 页面模板技能文档 展开:当你在 apps/admin 或 apps/activitypub 中创建新的管理页面时,如何先判断“这是什么类型的页面”,再选择对应的 Shade 页面模板(ListPage、PageHeader)进行组装,而不是从头发明一套页面骨架。读完本文,你将掌握 Ghost Admin 的页面类型分类法(taxonomy)、规范的列表页骨架代码、PageHeader 槽位体系的完整用法,以及仓库源码中 ListPage 的吸顶、毛玻璃、出血(full-bleed)实现原理和真实页面(Members)的落地写法。
背景:为什么需要先判断页面类型
Ghost 的 Shade 设计系统(位于 apps/shade 的独立应用)是管理后台的 UI 基础库。该技能文档的 frontmatter 明确声明了触发时机:
name: Shade page templates
description: Pick the right Shade page template (ListPage, PageHeader) for a new
admin page instead of inventing chrome.
Trigger when creating new admin pages or routes in apps/admin or apps/activitypub.
autoTrigger:
- fileEdit: "apps/{admin,activitypub}/src/**/*.tsx"
也就是说,只要你要在 apps/admin 或 apps/activitypub 下新增 .tsx 页面或路由,第一个问题就该是**“这是哪一类页面?”**——然后取用匹配的模式,而不是自己拼装 chrome(页面外壳)。这一点与仓库中 Storybook 文档 page-types.mdx 的表述一致:“When a new feature shows up, the first question is 'what page type is this?'”。
页面类型分类法
文档将 Ghost Admin 的页面归为四类,并给出各自的 chrome 形态:
List page(列表页)
浏览或扫描一组条目。
- 仓库中的真实例子:Members、Tags、Comments、Automations、ActivityPub。
- Chrome 形态:
PageHeader(标题 + 计数 + 搜索/筛选/操作)→ 表格或列表 → 空状态 → 分页。 - 对应模式:
ListPage(组合了PageHeader)。复杂的筛选 UI 使用Filters。
仓库中确实可以找到这些真实使用点:members.tsx、tags.tsx、comments.tsx、automations.tsx、posts-list-screen.tsx 等文件都从 @tryghost/shade/page-templates 导入 ListPage 组织页面结构。
Detail page(详情页)
操作单个条目——查看、编辑或两者兼有。
- 仓库中的真实例子:Post 编辑器。
- Chrome 形态:带面包屑 + 标题 + 元信息的
PageHeader,一个主操作区(保存/取消),以及专门承载该条目内容的主体区域。 - 技能文档中的结论:当时该里程碑尚未提供标准 Detail 模式模板,建议直接使用
PageHeader自行布局主体。从当前源码结构看,仓库后来已补充了 detail-page.tsx 模板(见下文“仓库当前状态”一节),但技能文档给出的保守建议——“不要过早标准化”——依然是理解其设计意图的钥匙。
Settings(设置页)
技能文档明确声明:设置页不在当前页面模板里程碑的范围内。不要硬把设置页塞进 ListPage 或 PageHeader,应沿用周围设置外壳所用的结构。
Workflow / 多步流程
暂无标准——真实例子太少,不应过早标准化。
规范的 ListPage 骨架
技能文档给出的“canonical list page skeleton”(规范列表页骨架)是整篇文档的核心,完整继承如下:
import {ListPage} from '@tryghost/shade/page-templates';
import {PageHeader, ViewBar, FilterBar} from '@tryghost/shade/patterns';
import {Button, EmptyIndicator, Table} from '@tryghost/shade/components';
<ListPage>
<ListPage.Header>
{/* sticky={false} — ListPage.Header owns stickiness and blur */}
<PageHeader sticky={false} blurredBackground={false}>
<PageHeader.Left>
<PageHeader.Title>
Members<PageHeader.Count>{count}</PageHeader.Count>
</PageHeader.Title>
</PageHeader.Left>
<PageHeader.Actions>
<PageHeader.ActionGroup>
<Button>Add member</Button>
</PageHeader.ActionGroup>
</PageHeader.Actions>
</PageHeader>
<ViewBar>{/* optional */}</ViewBar>
<FilterBar>{/* optional — auto-collapses when empty */}</FilterBar>
</ListPage.Header>
<ListPage.Body>
{items.length === 0 ? <EmptyIndicator title='No members yet' /> : <Table>...</Table>}
</ListPage.Body>
</ListPage>
三个导入路径对应 Shade 的三层导出:@tryghost/shade/page-templates(页面级模板)、@tryghost/shade/patterns(模式组件)、@tryghost/shade/components(UI 组件),这些统一从 page-templates.ts 等入口文件再导出。
源码解析:ListPage 为什么“故意做得很薄”
list-page.tsx 的实现只有 88 行,其 JSDoc 自我定位是“intentionally thin”——一个带水平内边距、撑满 flex 父容器的纵向 Stack,把命名插槽作为直接子元素放入即可。值得注意的三个实现细节:
-
吸顶与毛玻璃由
ListPage.Header统一持有。ListPageHeader渲染为一个Stack,关键类名是:sticky top-0 z-50 bg-gradient-to-b from-background via-background/70 to-background/70 backdrop-blur-md dark:bg-black -mx-4 px-4 lg:-mx-5 lg:px-5 py-5其中
-mx-4 px-4 lg:-mx-5 lg:px-5先负向抵消父容器的水平内边距、再重新加回内边距,从而实现“出血”效果:模糊背景横贯整行边缘,而内容仍与正文对齐。 -
ListPage.Body撑满剩余空间。它使用min-h-0 min-w-0 grow pb-4 lg:pb-8,gap="none"。源码注释建议:要在 Body 里垂直居中空的/加载态,给直接子元素加flex-1 flex items-center justify-center。 -
插槽都带
data-*锚点:data-list-page="list-page" | "header" | "body"。真实页面会利用这些锚点做行为联动,例如 members.tsx 中通过node?.closest('[data-list-page="header"]')定位吸顶头部,以便滚动时联动自定义逻辑。
真实用例:Members 页面如何组装
apps/admin/src/members/members.tsx 是该骨架的生产级实例:ListPage 外层是 Box size-full + Container size="page" 的 flex 容器;ListPage.Header 内先放 PageHeader blurredBackground={false} sticky={false}(标题 Members 内联 PageHeader.Count 显示总数,操作区含搜索框、筛选、导入、添加成员按钮),再按条件渲染 FilterBar 与 MultipleActiveSubscriptionsBanner;ListPage.Body 内根据 shouldShowLoading 切换加载态、空状态(EmptyIndicator)与列表。数据请求(useBrowseMembersInfinite)放在页面组件里,而不是放进模式组件——这正是下文 Gotchas 第二条的落地体现。
Storybook 中的 list-page.stories.tsx 则提供了四组可直接对照的故事:With data(带数据表格)、Empty state(空状态)、Minimal(最小化)、With view bar(带视图栏)、With filter bar(带筛选栏),可作为搭建新列表页时的逐屏参考。
PageHeader 的槽位体系
技能文档强调:PageHeader 是槽位式的(slot-based)——用 .Left、.Title、.Count 等子组件组织内容,不要传一堆 props。文档给出的子组件清单与 page-header.tsx 中实际挂到组件上的属性完全一致:
PageHeader.Left— 标题区块容器(纵向 Stack,justify="center")PageHeader.Breadcrumb— 标题上方的小号弱化色面包屑PageHeader.Title— H1 标题,接受内联的CountPageHeader.Count— 标题旁内联的次要计数(tabular-nums,等宽数字避免跳动)PageHeader.Description— 标题下方的段落说明PageHeader.Meta— 标题下方的小号弱化元信息行PageHeader.Actions— 右侧操作区(Inline布局,gap="lg")PageHeader.ActionGroup— 按钮分组容器
源码还有两个文档未展开、但对实际开发有用的行为:
Title会自动分拣子元素。PageHeaderTitle内部用React.Children.forEach遍历 children,把PageHeaderDescription和PageHeaderMeta从 H1 中拆出来、以纵向 Stack 堆叠在标题下方,其余内容留在 H1 行内(page-header.tsx)。所以“计数跟标题同行、描述/元信息在下方”不是靠调用方摆位置,而是组件内建的行为。ActionGroup支持移动端折叠。PageHeaderActionGroup接收可选的mobileMenuBreakpoint(默认640px),并支持PageHeaderActionGroup.Primary(在移动端仍直接展示的主操作)与PageHeaderActionGroup.MobileMenu / MobileMenuTrigger / MobileMenuContent(窗口宽度小于断点时,桌面按钮收进下拉菜单)。从源码结构看,这是一个监听resize的响应式折叠机制(useShouldCollapseActionGroup),新页面如果按钮多,可以直接复用这套约定而不是自己写媒体查询。
另外,PageHeader 根组件自身有 sticky(默认 true)与 blurredBackground(默认 true)两个 props,渲染 <header> 时按需加 sticky top-0 z-50 和渐变模糊背景(page-header.tsx)。
常见坑(Gotchas)
技能文档列出四条硬性约定,每条都有明确的工程原因:
ListPage.Header内的PageHeader必须传sticky={false}(骨架示例中同时关了blurredBackground)。 因为包裹层ListPage.Header已经负责吸顶与模糊;如果PageHeader仍保持 sticky,会形成两个叠在一起的 sticky 容器,破坏滚动行为。- 不要在模式组件里放
useQuery。 状态属于消费方(页面组件),模式是“布局/组合契约”(layout/composition contracts)。前文 Members 页面的写法是正确示范:useBrowseMembersInfinite在页面内发起,ListPage/PageHeader/FilterBar只负责结构。 PageHeader是槽位式的,别传 prop bag。 见上一节的子组件清单。FilterBar为空时自动折叠。 无需条件挂载,直接无条件渲染即可。仓库中FilterBar的实现位于 filter-bar.tsx,ViewBar位于 view-bar.tsx。
当 ListPage 不适用时
文档给出了明确的“停下并重新判断”指引:
- 页面实际是 Detail page → 直接使用
PageHeader;主体用 primitives + components 自己搭。 - 页面实际是 Settings → 超出范围;沿用周围设置外壳的结构。
- 页面是多步流程 → 尚无模式;用 primitives 自行组装。
原文的表述值得原样引用:“If you're tempted to force a non-list shape into ListPage, stop and check whether you're actually building one of those three other shapes.”——如果你正想把非列表形态硬塞进 ListPage,停下来确认你其实在做的是上面三种形态之一。
仓库当前状态:DetailPage 与 ModalPage 已经落地
技能文档写作时 Detail 模式被标注为“not built this milestone”,但当前仓库的 page-templates.ts 已经导出三个模板:DetailPage、ListPage 和 ModalPage,分别位于 detail-page.tsx 与 modal-page.tsx。从源码结构看:
DetailPage与ListPage是姊妹结构:外层Stack使用与ListPage相同的水平内边距(px-4 lg:px-6),保证详情页与来源列表页的左右边缘视觉对齐;DetailPage.Header是非吸顶的头部带,垂直内边距刻意与列表页保持一致(py-5),使面包屑与列表页标题处在同一视觉高度;DetailPage.Body是overflow-y-auto的滚动容器。JSDoc 中给出的组合示例与技能文档“Detail page 用PageHeader自行布局”的思路一脉相承:<DetailPage.Header>内放<PageHeader blurredBackground={false} sticky={false}>,<DetailPage.Body>放实体内容。ModalPage则更轻:一个Box根容器(w-full p-[8vmin] pt-5)加一个ModalPage.Title(大号 H1)。
这说明分类法本身是稳定的,而模板是逐步补齐的——新页面仍应按文档的分类法先判型,再查 Storybook 是否有对应故事。
单一事实来源:Storybook
技能文档最后指明:以 Storybook → Page Templates / Page Types 页面,以及 ListPage 和 PageHeader 的 stories 为单一事实来源(source of truth)。在仓库中对应:
- page-types.mdx — “Page Templates / Page Types” 文档页,内容与本技能文档同源;
- list-page.stories.tsx — “Page Templates / List Page” 故事(
layout: 'fullscreen',含完整文档描述); - detail-page.stories.tsx 与 modal-page.stories.tsx — 后续补齐模板的配套故事。
当文档、技能与实现出现分歧时,建议以 Storybook 故事和上述源码文件为准,因为它们会随代码一起演进;技能文档则沉淀了“何时该用哪个模板”的判断规则。
小结
这套规范的价值在于把“新管理页从 0 到 1”变成了两步决策:先判型(List / Detail / Settings / Workflow),再取模板(ListPage + PageHeader 槽位 + 可选 ViewBar/FilterBar)。它把吸顶、毛玻璃、出血布局、移动端按钮折叠、空筛选折叠这些易错细节收敛进模板与模式组件内部,让业务页面只关心数据来源与条目结构——这与 Ghost 管理后台从旧 Ember 应用向 React 应用(apps/admin、apps/activitypub)迁移期间保持多页面视觉一致性的需求是直接对应的。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00