Repository Wiki
LyraVoid/Mizuki

结构化展示页面(项目、技能、设备、时间线、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 工具等)以卡片 + 分类筛选的形式呈现。这类页面解决的问题是:

  1. 结构化数据的可发现性——比在文章里罗列更易浏览、可按分类过滤。
  2. 构建期零成本聚合——所有数据在 Astro frontmatter(服务端/构建期)完成去重、计数、文案映射,客户端只做轻量 DOM 过滤。
  3. 逐页可关停——每个展示页顶部都有 siteConfig.featurePages.<name> 守卫,未启用即重定向 404,便于按站点裁剪功能面。

以 /projects 为例,页面渲染出的结构是:

text
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/")
分类聚合frontmatternew Set(projectsData.map(p => p.category)) 得到去重分类
分类映射getCategoryText / getCategoryIcon分类值 → i18n 文案 / Iconify 图标名,未知值优雅降级
筛选 TabfilterTabs 数组首项固定为 "all",随后每个分类一项,均带 count
客户端过滤/js/filter-tabs-handler.js依据 dataAttr="category" 过滤卡片并切换 #no-results
渐进式图标loadIconify()运行时加载 Iconify,失败仅 console.error 不阻塞页面

Architecture

下面架构图中的每个节点都来自 src/pages/projects.astro 中真实 import / 引用的模块:

Loading diagram...

设计意图解读:

  • 分层清晰:页面文件只做"守卫 + 聚合 + 编排",展示细节全部下沉到 @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 页面从构建到交互的完整控制流:

Loading diagram...

为什么这样设计:

  • 守卫放在第一行可执行代码: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 守卫。这是所有结构化展示页的通用开场模式。

astro
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 从数据里推导出唯一分类集合——页面不硬编码分类列表,新增分类只需在数据文件里加一条记录。

ts
const categories = [ ...new Set(projectsData.map((project) => project.category)), ];

Source: projects.astro

分类 → i18n 文案 / 图标的映射

两个并行的 switch 把机器可读的分类值翻译成人类文案与图标;未知分类值分别降级为原值与通用文件夹图标,保证数据脏值不会让页面崩溃。

ts
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 的翻译),其余各项由分类派生,并附带该分类的项目数量——计数是构建期算好的,客户端无需重新统计。

ts
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。

astro
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.projectsboolean站点配置文件决定为假时 /projects 构建期重定向到 /404/
FilterTabs 的 dataAttrstring"category"(本页)客户端 handler 据此读取每个卡片元素上的 data 属性做过滤
ProjectCard 的 maxTechStacknumber4(本页传入)卡片上最多展示的技术栈标签数
网格列数(Tailwind 类)stringgrid-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:foldergetCategoryText / 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

要新增一个结构化展示页(遵循同一骨架):

  1. 在 src/data/ 下新增数据模块(元素至少含一个可作为筛选维度的字段,如 category)。
  2. 新建 src/pages/<name>.astro,复制本页骨架:守卫 → Set 去重 → 文案/图标映射 → filterTabs → FilterTabs + 卡片网格 + #no-results。
  3. 在 @components/features/<name>/ 下实现专属卡片组件(参照 ProjectCard 接收整个数据对象 + 展示上限参数的模式)。
  4. 在 i18n 中补充分类文案 key(通用词如 "全部" 可直接复用现有 key)。
  5. 在 siteConfig.featurePages 中加开关(开关读取处为页面首行守卫)。

Sources

(1 files)