首页
/ Ghost 管理后台的页面模板规范:用 Shade 的 ListPage 与 PageHeader 搭建标准管理页

Ghost 管理后台的页面模板规范:用 Shade 的 ListPage 与 PageHeader 搭建标准管理页

2026-09-06 13:30:40作者:沈韬淼Beryl

本文基于 Ghost 仓库中的 Shade 页面模板技能文档 展开:当你在 apps/adminapps/activitypub 中创建新的管理页面时,如何先判断“这是什么类型的页面”,再选择对应的 Shade 页面模板(ListPagePageHeader)进行组装,而不是从头发明一套页面骨架。读完本文,你将掌握 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/adminapps/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.tsxtags.tsxcomments.tsxautomations.tsxposts-list-screen.tsx 等文件都从 @tryghost/shade/page-templates 导入 ListPage 组织页面结构。

Detail page(详情页)

操作单个条目——查看、编辑或两者兼有。

  • 仓库中的真实例子:Post 编辑器。
  • Chrome 形态:带面包屑 + 标题 + 元信息的 PageHeader,一个主操作区(保存/取消),以及专门承载该条目内容的主体区域。
  • 技能文档中的结论:当时该里程碑尚未提供标准 Detail 模式模板,建议直接使用 PageHeader 自行布局主体。从当前源码结构看,仓库后来已补充了 detail-page.tsx 模板(见下文“仓库当前状态”一节),但技能文档给出的保守建议——“不要过早标准化”——依然是理解其设计意图的钥匙。

Settings(设置页)

技能文档明确声明:设置页不在当前页面模板里程碑的范围内。不要硬把设置页塞进 ListPagePageHeader,应沿用周围设置外壳所用的结构。

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,把命名插槽作为直接子元素放入即可。值得注意的三个实现细节:

  1. 吸顶与毛玻璃由 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 先负向抵消父容器的水平内边距、再重新加回内边距,从而实现“出血”效果:模糊背景横贯整行边缘,而内容仍与正文对齐。

  2. ListPage.Body 撑满剩余空间。它使用 min-h-0 min-w-0 grow pb-4 lg:pb-8gap="none"。源码注释建议:要在 Body 里垂直居中空的/加载态,给直接子元素加 flex-1 flex items-center justify-center

  3. 插槽都带 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 显示总数,操作区含搜索框、筛选、导入、添加成员按钮),再按条件渲染 FilterBarMultipleActiveSubscriptionsBannerListPage.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 标题,接受内联的 Count
  • PageHeader.Count — 标题旁内联的次要计数(tabular-nums,等宽数字避免跳动)
  • PageHeader.Description — 标题下方的段落说明
  • PageHeader.Meta — 标题下方的小号弱化元信息行
  • PageHeader.Actions — 右侧操作区(Inline 布局,gap="lg"
  • PageHeader.ActionGroup — 按钮分组容器

源码还有两个文档未展开、但对实际开发有用的行为:

  1. Title 会自动分拣子元素PageHeaderTitle 内部用 React.Children.forEach 遍历 children,把 PageHeaderDescriptionPageHeaderMeta 从 H1 中拆出来、以纵向 Stack 堆叠在标题下方,其余内容留在 H1 行内(page-header.tsx)。所以“计数跟标题同行、描述/元信息在下方”不是靠调用方摆位置,而是组件内建的行为。
  2. 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)

技能文档列出四条硬性约定,每条都有明确的工程原因:

  1. ListPage.Header 内的 PageHeader 必须传 sticky={false}(骨架示例中同时关了 blurredBackground)。 因为包裹层 ListPage.Header 已经负责吸顶与模糊;如果 PageHeader 仍保持 sticky,会形成两个叠在一起的 sticky 容器,破坏滚动行为。
  2. 不要在模式组件里放 useQuery 状态属于消费方(页面组件),模式是“布局/组合契约”(layout/composition contracts)。前文 Members 页面的写法是正确示范:useBrowseMembersInfinite 在页面内发起,ListPage/PageHeader/FilterBar 只负责结构。
  3. PageHeader 是槽位式的,别传 prop bag。 见上一节的子组件清单。
  4. FilterBar 为空时自动折叠。 无需条件挂载,直接无条件渲染即可。仓库中 FilterBar 的实现位于 filter-bar.tsxViewBar 位于 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 已经导出三个模板:DetailPageListPageModalPage,分别位于 detail-page.tsxmodal-page.tsx。从源码结构看:

  • DetailPageListPage 是姊妹结构:外层 Stack 使用与 ListPage 相同的水平内边距(px-4 lg:px-6),保证详情页与来源列表页的左右边缘视觉对齐;DetailPage.Header非吸顶的头部带,垂直内边距刻意与列表页保持一致(py-5),使面包屑与列表页标题处在同一视觉高度;DetailPage.Bodyoverflow-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 页面,以及 ListPagePageHeader 的 stories 为单一事实来源(source of truth)。在仓库中对应:

当文档、技能与实现出现分歧时,建议以 Storybook 故事和上述源码文件为准,因为它们会随代码一起演进;技能文档则沉淀了“何时该用哪个模板”的判断规则。

小结

这套规范的价值在于把“新管理页从 0 到 1”变成了两步决策:先判型(List / Detail / Settings / Workflow),再取模板(ListPage + PageHeader 槽位 + 可选 ViewBar/FilterBar)。它把吸顶、毛玻璃、出血布局、移动端按钮折叠、空筛选折叠这些易错细节收敛进模板与模式组件内部,让业务页面只关心数据来源与条目结构——这与 Ghost 管理后台从旧 Ember 应用向 React 应用(apps/adminapps/activitypub)迁移期间保持多页面视觉一致性的需求是直接对应的。

登录后查看全文
热门项目推荐
相关项目推荐