结构化展示页面(项目、技能、设备、时间线、AI 工具)
Mizuki 在 src/pages/ 下提供一组结构化展示页面(structured showcase pages):它们不渲染 Markdown 文章流,而是把站点的结构化数据(项目、技能、设备、时间线、AI 工具等)聚合成带筛选、分类与卡片网格的独立展示页。本页以已完整验证的 src/pages/projects.astro 为代表性实现,逐行剖析这类页面的统一构建模式:功能开关守卫 → 构建期数据聚合 → i18n 分类映射 → 组件化渲染 → 客户端脚本筛选。
证据边界说明:本次源码阅读预算内完整读取了
src/pages/projects.astro,并通过目录列表确认src/pages/下存在ai-tools.astro、devices.astro、albums.astro、about.astro、friends.astro、anime.astro等同构页面。ai-tools.astro与devices.astro的内部实现未在本页预算内读取,其细节留待各自的目录条目验证;本页对它们的描述仅限于"存在于同一页面目录、遵循同一 Astro 页面约定"这一可验证事实。
Purpose and Scope
本页覆盖:
- 结构化展示页面的统一页面骨架:
MainGridLayout+PageHeader+FilterTabs+ 卡片网格 + 空结果态。 - 功能开关守卫模式:
siteConfig.featurePages.*如何在构建期把未启用的页面重定向到 404。 - 构建期数据聚合:从
src/data/projects读取数据、用Set去重分类、生成带计数与图标的筛选 Tab。 - 分类 → i18n 文案 → 图标的三重映射约定。
- 客户端筛选流水线:
/js/filter-tabs-handler.js+dataAttr+#no-results的协作方式。 - 渐进式图标加载(
loadIconify)与右栏布局脚本的接入方式。
本页有意不覆盖(留给兄弟页面):
- 各展示页专属的数据模型字段细节(如 AI 工具、设备的字段定义)——见各自的数据文件与页面。
- 相册详情页(
albums/[id]/index.astro)与文章渲染(posts/[...slug].astro)的机制。 MainGridLayout、PageHeader等布局组件自身的实现。- 内容创作与内容仓库机制(见仓库
docs/CONTENT_AUTHORING.md、docs/CONTENT_REPOSITORY.md)。
Overview
Mizuki 是一个 Astro 静态站点(见 astro.config.mjs)。除了博客文章流之外,它还提供若干"结构化展示页":把作者维护的结构化清单(做过哪些项目、用什么设备和 AI 工具等)以卡片 + 分类筛选的形式呈现。这类页面解决的问题是:
- 结构化数据的可发现性——比在文章里罗列更易浏览、可按分类过滤。
- 构建期零成本聚合——所有数据在 Astro frontmatter(服务端/构建期)完成去重、计数、文案映射,客户端只做轻量 DOM 过滤。
- 逐页可关停——每个展示页顶部都有
siteConfig.featurePages.<name>守卫,未启用即重定向 404,便于按站点裁剪功能面。
以 /projects 为例,页面渲染出的结构是:
1MainGridLayout
2└── card-base 容器
3 ├── PageHeader(title, subtitle)
4 ├── FilterTabs(tabs, dataAttr="category") ← "全部 / web / mobile / desktop / other…" 带计数
5 ├── #projects-grid ← 1 列(移动端)/ 2 列(md+)ProjectCard 网格
6 └── #no-results ← 默认 hidden 的空结果占位关键概念:
| 概念 | 位置 | 说明 |
|---|---|---|
| feature flag 守卫 | 页面 frontmatter 顶部 | siteConfig.featurePages.projects 为假时 Astro.redirect("/404/") |
| 分类聚合 | frontmatter | new Set(projectsData.map(p => p.category)) 得到去重分类 |
| 分类映射 | getCategoryText / getCategoryIcon | 分类值 → i18n 文案 / Iconify 图标名,未知值优雅降级 |
| 筛选 Tab | filterTabs 数组 | 首项固定为 "all",随后每个分类一项,均带 count |
| 客户端过滤 | /js/filter-tabs-handler.js | 依据 dataAttr="category" 过滤卡片并切换 #no-results |
| 渐进式图标 | loadIconify() | 运行时加载 Iconify,失败仅 console.error 不阻塞页面 |
Architecture
下面架构图中的每个节点都来自 src/pages/projects.astro 中真实 import / 引用的模块:
设计意图解读:
- 分层清晰:页面文件只做"守卫 + 聚合 + 编排",展示细节全部下沉到
@components/features/*与@components/atoms/*,数据下沉到src/data/*,文案下沉到src/i18n/*。这使新增一个结构化展示页(如 AI 工具页)只需复用同一骨架。 - 构建期 vs 客户端的边界:去重、计数、文案映射全部在 frontmatter(构建期)完成,交付到浏览器的只是一份静态 Tab 配置;客户端脚本只负责 DOM 级的显示/隐藏,避免把业务逻辑带进运行时。
- 脚本三件套各司其职:
right-sidebar-layout.js处理布局、loadIconify处理图标按需加载、filter-tabs-handler.js处理筛选交互,均为页面级<script>显式引入,而非全局注入。
Core Flow
/projects 页面从构建到交互的完整控制流:
为什么这样设计:
- 守卫放在第一行可执行代码:
if (!siteConfig.featurePages.projects) return Astro.redirect("/404/")让关停的页面在构建期就产出重定向,不产出任何内容 HTML,也不会执行后续的数据聚合。 - "all" Tab 复用 friends 的 i18n key:
i18n(I18nKey.friendsFilterAll)表明 "全部" 这类通用筛选文案在站点内跨页面复用,减少重复翻译条目。 - 空态默认
hidden:#no-results由服务端渲染但隐藏,客户端筛选无结果时由 handler 揭示,无需额外请求。
Usage Examples
功能开关守卫与数据源引入
页面 frontmatter 顶部:先引入组件与数据,再用 siteConfig.featurePages.projects 守卫。这是所有结构化展示页的通用开场模式。
1---
2import { FilterTabs } from "@components/atoms/filter-tabs";
3import { PageHeader } from "@components/features/page-header";
4import { ProjectCard } from "@components/features/projects";
5import MainGridLayout from "@layouts/MainGridLayout.astro";
6import { Icon } from "astro-icon/components";
7
8import { siteConfig } from "../config";
9import { UNCATEGORIZED } from "../constants/constants";
10import { projectsData } from "../data/projects";
11import I18nKey from "../i18n/i18nKey";
12import i18n from "../i18n/translation";
13
14if (!siteConfig.featurePages.projects) {
15 return Astro.redirect("/404/");
16}Source: projects.astro
要点:@components/* / @layouts/* 是 Astro 别名(在 astro.config.mjs 中定义);UNCATEGORIZED 常量让"未分类"成为一等公民分类而不是特殊字符串散落各处;return Astro.redirect("/404/") 直接终止页面输出。
构建期分类去重
用 Set 从数据里推导出唯一分类集合——页面不硬编码分类列表,新增分类只需在数据文件里加一条记录。
const categories = [
...new Set(projectsData.map((project) => project.category)),
];Source: projects.astro
分类 → i18n 文案 / 图标的映射
两个并行的 switch 把机器可读的分类值翻译成人类文案与图标;未知分类值分别降级为原值与通用文件夹图标,保证数据脏值不会让页面崩溃。
1const getCategoryText = (category: string) => {
2 switch (category) {
3 case "web":
4 return i18n(I18nKey.projectsWeb);
5 case "mobile":
6 return i18n(I18nKey.projectsMobile);
7 case "desktop":
8 return i18n(I18nKey.projectsDesktop);
9 case "other":
10 return i18n(I18nKey.projectsOther);
11 case UNCATEGORIZED:
12 return i18n(I18nKey.uncategorized);
13 default:
14 return category;
15 }
16};
17
18const getCategoryIcon = (category: string) => {
19 switch (category) {
20 case "web":
21 return "material-symbols:language";
22 case "mobile":
23 return "material-symbols:smartphone";
24 case "desktop":
25 return "material-symbols:desktop-windows";
26 case "other":
27 return "material-symbols:widgets";
28 default:
29 return "material-symbols:folder";
30 }
31};Source: projects.astro
生成带计数的筛选 Tab
首项固定为 "all"(复用 friendsFilterAll 的翻译),其余各项由分类派生,并附带该分类的项目数量——计数是构建期算好的,客户端无需重新统计。
1const filterTabs = [
2 {
3 value: "all",
4 label: i18n(I18nKey.friendsFilterAll),
5 icon: "material-symbols:apps",
6 count: projectsData.length,
7 },
8 ...categories.map((category) => ({
9 value: category,
10 label: getCategoryText(category),
11 icon: getCategoryIcon(category),
12 count: projectsData.filter((p) => p.category === category).length,
13 })),
14];
15
16const title = i18n(I18nKey.projects);
17const subtitle = i18n(I18nKey.projectsSubtitle);Source: projects.astro
模板:筛选区 + 卡片网格 + 空态
FilterTabs 通过 dataAttr="category" 告诉客户端 handler 依据哪个 data 属性过滤;卡片网格用响应式类(移动端单列、md: 起双列);#no-results 空态默认 hidden。
1<MainGridLayout title={title} description={subtitle}>
2 <script>
3 import("../scripts/right-sidebar-layout.js");
4 import { loadIconify } from "../utils/icon-loader";
5
6 loadIconify().catch((error) => {
7 console.error("Failed to load Iconify:", error);
8 });
9 </script>
10
11 <script is:inline src="/js/filter-tabs-handler.js"></script>
12
13 <div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
14 <div class="card-base z-10 px-6 sm:px-9 py-6 relative w-full">
15 <PageHeader title={title} subtitle={subtitle} />
16
17 <div class="mb-8">
18 <FilterTabs tabs={filterTabs} dataAttr="category" />
19 </div>
20
21 <div
22 id="projects-grid"
23 class="grid grid-cols-1 md:grid-cols-2 gap-6 items-start"
24 >
25 {projectsData.map((project) => (
26 <ProjectCard project={project} maxTechStack={4} />
27 ))}
28 </div>
29
30 <div id="no-results" class="hidden text-center py-16">
31 <Icon
32 name="material-symbols:search-off-rounded"
33 class="text-6xl text-black/15 dark:text-white/15 mb-4"
34 />
35 <p class="text-black/40 dark:text-white/40 text-lg">
36 No matching projects
37 </p>
38 </div>
39 </div>
40 </div>
41</MainGridLayout>Source: projects.astro
注意 maxTechStack={4}:技术栈标签超过 4 个时由 ProjectCard 自行截断展示,页面把展示裁剪策略参数化传给组件,而不是在页面里预处理数据。
Configuration Options
结构化展示页面涉及的可配置项(依据本页已验证源码):
| 选项 | 类型 | 默认 / 取值 | 说明 |
|---|---|---|---|
siteConfig.featurePages.projects | boolean | 站点配置文件决定 | 为假时 /projects 构建期重定向到 /404/ |
FilterTabs 的 dataAttr | string | "category"(本页) | 客户端 handler 据此读取每个卡片元素上的 data 属性做过滤 |
ProjectCard 的 maxTechStack | number | 4(本页传入) | 卡片上最多展示的技术栈标签数 |
| 网格列数(Tailwind 类) | string | grid-cols-1 md:grid-cols-2 | 移动端单列、≥md 双列 |
| 空态文案 | string | "No matching projects" | #no-results 中的提示文本 |
featurePages之下还存在与ai-tools.astro、devices.astro等页面对应的开关键,但具体键名未在本页预算内从src/config读取验证,故不在此列出。
API Reference
本页文档化的"API"是结构化展示页面骨架中各协作单元的接口约定(签名以 projects.astro 中的真实调用为准):
Astro.redirect(path): never(Astro 框架)
用途:feature flag 为假时终止页面渲染并重定向。
参数:path(string)——目标路径,本页为 "/404/"。
返回:不返回;frontmatter 直接 return。
i18n(key: I18nKey, ...?): string
用途:把 i18n key 翻译为当前语言文案。
参数:key(I18nKey 枚举,如 I18nKey.projects、I18nKey.friendsFilterAll、I18nKey.uncategorized)。
返回:翻译后的字符串。
本页使用的 key:projects、projectsSubtitle、projectsWeb、projectsMobile、projectsDesktop、projectsOther、uncategorized、friendsFilterAll。
FilterTabs 组件
Props:
tabs({ value: string; label: string; icon: string; count: number }[])——筛选项列表,含计数。dataAttr(string)——供/js/filter-tabs-handler.js读取的 data 属性名。
行为:渲染一组可点击 Tab;点击后由全局 handler 依据 data-{dataAttr} 过滤卡片并切换 #no-results。
PageHeader 组件
Props:title(string)、subtitle(string)。渲染页面标题区。
ProjectCard 组件
Props:project(projectsData 元素类型,含 category 等)、maxTechStack(number,技术栈标签展示上限)。
loadIconify(): Promise<...>
用途:运行时按需加载 Iconify 图标运行时。
返回:Promise;本页用 .catch 把失败降级为 console.error("Failed to load Iconify:", error),不阻塞渲染。
filter-tabs-handler.js的具体函数签名未在本页预算内读取,其行为依据是页面中<script is:inline src="/js/filter-tabs-handler.js">与dataAttr="category"/#no-results的组合约定,属"未在源码中验证的实现细节"。
Failure Modes, Edge Cases & Concurrency
依据已验证源码可确认的边界与降级行为:
| 场景 | 行为 | 出处 |
|---|---|---|
| feature flag 关闭 | 构建期 Astro.redirect("/404/"),页面无内容输出 | projects.astro L14-L16 |
| 分类值不在已知枚举中 | 文案降级为原始字符串,图标降级为 material-symbols:folder | getCategoryText / getCategoryIcon 的 default 分支 |
| Iconify 加载失败 | 仅 console.error,页面其余部分照常渲染(图标渐进增强) | projects.astro L78-L80 |
| 筛选无匹配项 | handler 揭示默认 hidden 的 #no-results 空态(含搜索图标与提示文案) | projects.astro L106-L114 |
| 数据为空 | categories 为空集 → Tab 仅剩 "all"(count=0);空态由客户端逻辑兜底 | new Set(...) 与 filterTabs 推导 |
静态站点的天然并发特性:所有聚合都在构建期单线程完成,浏览器端只有 DOM 过滤,无共享可变状态、无竞态窗口。
Performance & Operational Notes
- 构建期预计算:去重(
Set)、计数(filter(...).length)、文案映射全部发生在 Astro frontmatter,HTML 输出即最终 Tab 状态,客户端零计算成本。 - 脚本按需/分包:
right-sidebar-layout.js通过动态import()引入,filter-tabs-handler.js以is:inline外链静态脚本引入;二者与 Iconify 加载器互不依赖,失败互不影响。 - 响应式网格:
grid-cols-1 md:grid-cols-2 gap-6 items-start,items-start避免不同高度卡片被拉伸。 - 运维面:新增/下线一个展示页 = 改一条
featurePages开关 + 对应数据文件;页面文件本身无需分支逻辑。
Extension Points
要新增一个结构化展示页(遵循同一骨架):
- 在
src/data/下新增数据模块(元素至少含一个可作为筛选维度的字段,如category)。 - 新建
src/pages/<name>.astro,复制本页骨架:守卫 →Set去重 → 文案/图标映射 →filterTabs→FilterTabs + 卡片网格 + #no-results。 - 在
@components/features/<name>/下实现专属卡片组件(参照ProjectCard接收整个数据对象 + 展示上限参数的模式)。 - 在 i18n 中补充分类文案 key(通用词如 "全部" 可直接复用现有 key)。
- 在
siteConfig.featurePages中加开关(开关读取处为页面首行守卫)。
Related Links
- 同目录下的其他结构化展示页:ai-tools.astro、devices.astro、about.astro、albums.astro、friends.astro、anime.astro
- 相册详情子路由:albums/[id]/index.astro
- 本页代表实现:projects.astro
- 内容创作与内容仓库约定:docs/CONTENT_AUTHORING.md、docs/CONTENT_REPOSITORY.md、docs/CONTENT_SEPARATION.md