Repository Wiki
LyraVoid/Mizuki

结构化页面数据(src/data)

src/data 目录是 Mizuki 主题中"非博客正文"类页面内容(项目、技能、番剧、AI 工具、设备、日记、友链、时间线等)的单一数据源。每个领域对应一个 TypeScript 模块:先用 interface 定义字段契约,再用常量数组承载具体条目,并辅以少量纯函数完成查询过滤与本地化取值,供上层页面组件直接导入消费。

目的与范围

本页覆盖 src/data 结构化数据层的完整设计与实现,包括:

  • 目录构成与"一个领域一个模块"的划分方式
  • 四个代表性模块的深度实现:ai-tools.ts、anime.ts、projects.ts、skills.ts
  • 多语言字段约定 LocaleString 与 getLocaleString 的回退链逻辑
  • projects.ts 的查询辅助函数族(统计、分类过滤、精选、技术栈聚合)
  • 各接口的字段级数据契约与可选字段语义
  • 边界情况、失败模式与扩展方式

以下内容有意留给兄弟页面,不在本页展开:

  • 渲染这些数据的页面组件与 UI 层(Astro 页面 / Svelte 组件)
  • 博客正文类内容(Astro content collections / Markdown 内容)
  • 站点级 i18n 框架本身——本页仅讨论数据侧的 LocaleString 约定与取值函数

此外,devices.ts、diary.ts、friends.ts、timeline.ts 四个文件同样位于本目录(已通过文件清单确认),它们沿用与已验证文件一致的目录约定;本页以四个已深度核实的模块为样例,不再对未读取文件的字段做推测性描述。

概述

这个目录解决什么问题

一个博客主题中,除了 Markdown 文章正文之外,还有大量"结构化但会频繁手工维护"的内容:项目列表、技能雷达、追番记录、常用工具清单等。如果这些数据散落在各个组件内部,修改一条记录就要翻组件代码。src/data 的设计意图是内容与表现分离——例如 projects.ts 文件头注释明确写着:

"Project data configuration file / Used to manage data for the project display page"

skills.ts 也写有同样的说明("Used to manage data for the skill display page")。也就是说,维护者只需编辑数据文件中的数组条目,页面即可自动更新。

核心设计概念

概念说明
单一数据源每个领域只有一个模块文件持有数据,页面不内嵌业务数据
类型先行每个模块先声明 interface,字段类型为字面量联合(literal union)而非宽松 string
纯函数查询过滤/统计/聚合逻辑以导出的纯函数形式与数据同文件存放
i18n 内建需要多语言的字段使用 LocaleString(语言键 → 文案)而非裸字符串
Iconify 图标icon 字段存放 Iconify 图标名(如 material-symbols:smart-toy、logos:javascript)
主题色部分条目带 color 字段供卡片着色(如 #C97758、#F7DF1E)

两种导出风格并存

一个值得注意的实现细节:anime.ts 使用默认导出(export default localAnimeList),而其余模块使用命名导出(projectsData、skillsData、aiToolsData)。这意味着消费番剧数据时使用 import animeList from ...,消费其他数据时使用 import { projectsData } from ...。这是历史演进留下的不一致,新增数据文件时建议遵循命名导出的主流风格。

架构

Loading diagram...

架构说明:

  • 最底层是类型契约:Project、Skill、AnimeItem、AITool 等接口通过 TypeScript 的字面量联合类型(如 status: "completed" | "in-progress" | "planned")把枚举取值压缩进类型系统。写错枚举值会在编译期而非运行期暴露。
  • 中间层是数据常量:projectsData、skillsData 等是同步导出的模块级常量数组,不含任何异步加载或网络请求逻辑——数据在构建期即随模块打包。
  • 辅助层是纯函数:projects.ts 把针对项目数据的 4 个查询函数与数据放在同一文件,ai-tools.ts 提供 getLocaleString 处理多语言回退。这些函数不修改数据、无副作用,可以安全地在服务端渲染时调用。
  • 最上层是页面组件:按数据文件头部注释声明的用途,项目页/技能页等组件导入数据或查询函数完成渲染。虚线箭头表示 TypeScript 的静态类型约束(编译期关系,非运行期调用)。

数据模型详解

Project — 项目数据契约(src/data/projects.ts)

Project 是四个已核实契约中字段最多的,覆盖项目展示所需的全部维度:

字段类型必填说明
idstring✅项目唯一标识(如 "mizuki"、"folkpatch")
titlestring✅项目名称
descriptionstring✅项目简介
imagestring✅封面图路径(如 "/assets/projects/mizuki.webp",可为空字符串)
category"web" | "mobile" | "desktop" | "other"✅项目分类
techStackstring[]✅技术栈列表
status"completed" | "in-progress" | "planned"✅项目状态
liveDemostring❌在线演示链接
sourceCodestring❌源码仓库链接
visitUrlstring❌访问地址
startDatestring✅开始日期("2024-01-01" 格式)
endDatestring❌结束日期
featuredboolean❌是否精选(getFeaturedProjects 依赖此字段)
tagsstring[]❌标签
showImageboolean❌是否展示图片(无图项目设为 false)
typescript
1export interface Project { 2 id: string; 3 title: string; 4 description: string; 5 image: string; 6 category: "web" | "mobile" | "desktop" | "other"; 7 techStack: string[]; 8 status: "completed" | "in-progress" | "planned"; 9 liveDemo?: string; 10 sourceCode?: string; 11 visitUrl?: string; 12 startDate: string; 13 endDate?: string; 14 featured?: boolean; 15 tags?: string[]; 16 showImage?: boolean; 17}

Source: projects.ts

接口定义后紧接一个可直接参考的真实条目示例——注意 folktool 条目展示了"无图项目"的标准写法:image: "" 配合 showImage: false:

typescript
1 { 2 id: "folktool", 3 title: "FolkTool", 4 description: 5 "A fast ROOT flashing tool for FolkPatch with a graphical interface and automated operations, simplifying the complex flashing process.", 6 image: "", 7 category: "desktop", 8 techStack: ["Flutter", "Dart", "C++", "CMake"], 9 status: "completed", 10 sourceCode: "https://github.com/LyraVoid/FolkTool", 11 startDate: "2026-02-01", 12 endDate: "2026-02-28", 13 tags: ["Android", "Tool", "Desktop"], 14 showImage: false, 15 },

Source: projects.ts

设计意图:liveDemo / sourceCode / visitUrl 拆成三个可选字段而非一个对象,页面组件可按存在性分别渲染不同按钮;showImage 单独存在是为了让"刻意不放图"与"暂时没图"(image: "" 但未显式关闭)可被区分。

Skill — 技能数据契约(src/data/skills.ts)

typescript
1export interface Skill { 2 id: string; 3 name: string; 4 description: string; 5 icon: string; // Iconify icon name 6 category: "frontend" | "backend" | "database" | "tools" | "other"; 7 level: "beginner" | "intermediate" | "advanced" | "expert"; 8 experience: { 9 years: number; 10 months: number; 11 }; 12 projects?: string[]; // Related project IDs 13 certifications?: string[]; 14 color?: string; // Skill card theme color 15}

Source: skills.ts

字段要点:

  • experience 是内嵌对象而非数字年数,{ years: 3, months: 6 } 可精确表达不满整年的经验,页面可自行换算为"3.5 年"之类的展示形式。
  • projects?: string[] 存放的是关联项目 ID(注释明确写"Related project IDs"),这构成了 Skill → Project 的弱外键关系——但注意源码中 projects.ts 并未提供按 ID 反查项目的函数,该关联目前是单向的(见"边界情况")。
  • icon 使用 Iconify 命名(如 logos:javascript、logos:react),color 为卡片主题色(如 #F7DF1E)。

真实条目示例:

typescript
1 { 2 id: "javascript", 3 name: "JavaScript", 4 description: 5 "Modern JavaScript development, including ES6+ syntax, asynchronous programming, and modular development.", 6 icon: "logos:javascript", 7 category: "frontend", 8 level: "advanced", 9 experience: { years: 3, months: 6 }, 10 projects: ["mizuki-blog", "portfolio-website", "data-visualization-tool"], 11 color: "#F7DF1E", 12 },

Source: skills.ts

AnimeItem — 番剧数据契约(src/data/anime.ts)

typescript
1// 本地番剧数据配置 2export interface AnimeItem { 3 title: string; 4 status: "watching" | "completed" | "planned"; 5 rating: number; 6 cover: string; 7 description: string; 8 episodes: string; 9 year: string; 10 genre: string[]; 11 studio: string; 12 link: string; 13 progress: number; 14 totalEpisodes: number; 15 startDate: string; 16 endDate: string; 17}

Source: anime.ts

设计要点:

  • 无 id 字段——title 即天然主键,这是与其他三个模块的显著差异。
  • status 只有三个枚举值,但注意真实数据中出现 progress: 12 且 totalEpisodes: 12 却 status: "planned" 的条目(如 "Is the Order a Rabbit?"),说明状态与进度是两个独立维度,页面不应由进度推导状态。
  • 日期字段用 "2022-07" 月粒度字符串,与 Project 的 "2024-01-01" 日粒度不同。
  • 数组声明为模块私有(const localAnimeList),最后 export default 导出。
typescript
1 { 2 title: "Lycoris Recoil", 3 status: "completed", 4 rating: 9.8, 5 cover: "/assets/anime/lkls.webp", 6 description: "Girl's gunfight", 7 episodes: "12 episodes", 8 year: "2022", 9 genre: ["Action", "Slice of life"], 10 studio: "A-1 Pictures", 11 link: "https://www.bilibili.com/bangumi/media/md28338623", 12 progress: 12, 13 totalEpisodes: 12, 14 startDate: "2022-07", 15 endDate: "2022-09", 16 },

Source: anime.ts

AITool 与 LocaleString — AI 工具数据契约(src/data/ai-tools.ts)

这是唯一内建多语言支持的模块。先看语言类型与取值函数:

typescript
1export type LocaleString = Partial< 2 Record<"en" | "zh_CN" | "zh_TW" | "ja", string> 3>; 4 5export function getLocaleString(value: LocaleString, lang: string): string { 6 return value[lang as keyof LocaleString] ?? value["en"] ?? ""; 7}

Source: ai-tools.ts

getLocaleString 的回退链是三级的:先精确匹配请求语言 → 不存在则回退英文 → 仍不存在返回空字符串。Partial 保证每种语言都是可选的,作者可以只维护 en 和 zh_CN 两种文案。返回空字符串而非抛错,意味着缺翻译时页面渲染空白文案而不是崩溃。

AITool 契约及两个先行导出的枚举类型:

typescript
1export type AIToolCategory = 2 | "chat" 3 | "coding" 4 | "image" 5 | "audio" 6 | "video" 7 | "writing" 8 | "search" 9 | "other"; 10 11export type AIToolFrequency = 12 | "daily" 13 | "weekly" 14 | "occasional" 15 | "experimental"; 16 17export interface AITool { 18 id: string; 19 name: string; 20 description: LocaleString; 21 icon: string; 22 category: AIToolCategory; 23 frequency: AIToolFrequency; 24 url?: string; 25 usage?: LocaleString; 26 tags?: string[]; 27 color?: string; 28}

Source: ai-tools.ts

真实条目示例(展示 usage 字段如何按语言分别给出使用场景说明):

typescript
1// Replace the examples below with your own AI tools 2export const aiToolsData: AITool[] = [ 3 { 4 id: "example-chat", 5 name: "Example Chat AI", 6 description: { 7 en: "A conversational AI assistant for writing and reasoning.", 8 zh_CN: "用于写作与推理的对话式 AI 助手。", 9 }, 10 icon: "material-symbols:smart-toy", 11 category: "chat", 12 frequency: "daily", 13 url: "https://example.com", 14 usage: { 15 en: "Daily: writing, brainstorming", 16 zh_CN: "每天:写作、思路梳理", 17 }, 18 tags: ["Chat"], 19 color: "#C97758", 20 },

Source: ai-tools.ts

frequency 枚举区分使用频率(daily/weekly/occasional/experimental),是其他模块没有的维度,说明 AI 工具页设计上要按"当前常用程度"而非单纯分类组织。

契约差异对比

Loading diagram...

四个契约的共同骨架是"标识 + 名称 + 描述 + 分类 + 状态/程度 + 媒体资源",差异体现在领域专属维度:Project 有大量链接与精选标记,Skill 有经验年限内嵌对象,AnimeItem 有观看进度,AITool 有使用频率与多语言字段。

核心流程:查询辅助函数族(projects.ts)

projects.ts 除了数据本身,还导出一组纯函数。这是整个数据层中唯一存在"派生逻辑"的地方,值得逐个走读。

typescript
1// Get project statistics 2export const getProjectStats = () => { 3 const total = projectsData.length; 4 const completed = projectsData.filter((p) => p.status === "completed").length; 5 const inProgress = projectsData.filter( 6 (p) => p.status === "in-progress", 7 ).length; 8 const planned = projectsData.filter((p) => p.status === "planned").length; 9 10 return { 11 total, 12 byStatus: { 13 completed, 14 inProgress, 15 planned, 16 }, 17 }; 18};

Source: projects.ts

getProjectStats 连续三次遍历数组分别统计三种状态——这是可读性优先的实现(而非单次循环累加),对几十条量级的项目列表无性能影响,返回扁平的 { total, byStatus: {...} } 结构方便页面直接解构。

typescript
1// Get projects by category 2export const getProjectsByCategory = (category?: string) => { 3 if (!category || category === "all") { 4 return projectsData; 5 } 6 return projectsData.filter((p) => p.category === category); 7}; 8 9// Get featured projects 10export const getFeaturedProjects = () => { 11 return projectsData.filter((p) => p.featured); 12}; 13 14// Get all tech stacks 15export const getAllTechStack = () => { 16 const techSet = new Set<string>(); 17 projectsData.forEach((project) => { 18 project.techStack.forEach((tech) => { 19 techSet.add(tech); 20 }); 21 }); 22 return Array.from(techSet).sort(); 23};

Source: projects.ts

三个函数的设计意图:

  • getProjectsByCategory 的参数是可选的 category?: string(而非 Project["category"]),因为页面的分类筛选 UI 需要传入哨兵值 "all" 表示不过滤。!category || category === "all" 同时兜住"未传"与"全选"两种语义,返回原数组引用(不复制),调用方若对返回值做 push 会污染源数据。
  • getFeaturedProjects 利用 featured?: boolean 的可选性:未声明的条目值为 undefined,被 filter 视为假值自动排除,因此只有显式写 featured: true 才会入选——新增普通项目时可以完全省略该字段。
  • getAllTechStack 用 Set 对所有项目的技术栈去重,再 Array.from(...).sort() 保证输出稳定有序。这是构建筛选下拉框选项的唯一来源,意味着页面技术栈选项永远与实际数据一致,无需另行维护。

页面消费数据的时序

Loading diagram...

关键点:整个链路没有任何运行时 IO。数据是模块级常量,函数是同步纯函数,因此可以在 Astro 组件的 frontmatter(服务端)直接调用,零水合成本。这是把查询函数与数据放同一文件的重要原因——无需 DI、无需异步初始化。

使用示例

示例 1:在页面中消费项目数据与派生数据

typescript
1import { projectsData, getProjectStats, getFeaturedProjects } from "../data/projects"; 2 3// frontmatter 中即可直接计算,结果在构建期固化进 HTML 4const stats = getProjectStats(); 5const featured = getFeaturedProjects(); 6const allProjects = projectsData;

Source: projects.ts

示例 2:多语言文案取值(带回退)

typescript
1import { aiToolsData, getLocaleString } from "../data/ai-tools"; 2 3const chatTool = aiToolsData[0]; 4// 请求 ja 不存在时回退 en 5getLocaleString(chatTool.description, "zh_CN"); 6// → "用于写作与推理的对话式 AI 助手。" 7getLocaleString(chatTool.usage, "ja"); 8// → "Daily: writing, brainstorming"(ja 缺失,回退 en)

Source: ai-tools.ts

示例 3:按分类过滤项目(含哨兵值语义)

typescript
1import { getProjectsByCategory, getAllTechStack } from "../data/projects"; 2 3getProjectsByCategory("web"); // 只取 web 类项目 4getProjectsByCategory("all"); // 返回完整 projectsData 引用 5getProjectsByCategory(); // 同上,未传参即不过滤 6getAllTechStack(); // Set 去重 + 字典序排序后的全站技术栈

Source: projects.ts

API 参考

getProjectStats(): { total: number; byStatus: { completed: number; inProgress: number; planned: number } }

返回项目统计信息。byStatus 中三个计数之和恒等于 total。

参数: 无 返回: 扁平统计对象,页面可直接解构使用 副作用: 无(纯函数)

Source: projects.ts

getProjectsByCategory(category?: string): Project[]

按分类过滤项目。

参数:

  • category(string,可选):目标分类。undefined、空值或 "all" 时返回完整数组引用(不过滤、不复制)

返回: 匹配的 Project[];无条件匹配时返回空数组 副作用: 无,但未过滤分支返回原引用,调用方不应修改其元素

Source: projects.ts

getFeaturedProjects(): Project[]

参数: 无 返回: 所有 featured === true 的项目(未声明该字段的条目被排除) 副作用: 无

Source: projects.ts

getAllTechStack(): string[]

参数: 无 返回: 全站技术栈去重后的字典序升序数组 副作用: 无

Source: projects.ts

getLocaleString(value: LocaleString, lang: string): string

参数:

  • value(LocaleString):语言键到文案的映射
  • lang(string):请求语言代码(如 "en"、"zh_CN")

返回: 三级回退链 value[lang] ?? value["en"] ?? "" 的结果 抛出: 永不抛出;语言键缺失时返回空字符串

Source: ai-tools.ts

边界情况、失败模式与并发

这个数据层没有运行时 IO 与异步逻辑,因此传统意义上的"失败模式"极少,但以下边界行为值得维护者知晓:

1. 编译期校验是第一道防线

所有枚举字段都是字面量联合类型而非 string。把 status: "done" 写进 Project 会在构建期报错,而非渲染出错误状态徽章。代价是类型重命名为纯数据契约——数据文件因此天然可被任意工具(脚本、CI 校验)独立消费。

2. getLocaleString 的空字符串回退

typescript
return value[lang as keyof LocaleString] ?? value["en"] ?? "";

Source: ai-tools.ts

  • 请求语言缺失 → 回退英文;英文也缺失(理论上 LocaleString 为空对象时)→ 返回 ""。
  • 返回空字符串而非抛错是有意为之:缺一条翻译只影响该条目文案,不会让整页构建失败。副作用是"漏翻译"在界面上表现为静默空白,需靠人工巡检或自定义 lint 发现。
  • 注意 ?? 空值合并运算符的语义:只有 null/undefined 触发回退,若文案本身是空串 "" 则不会回退。

3. getProjectsByCategory 返回原数组引用

category === "all" 或未传参时直接返回 projectsData 而非副本。任何对返回值的原地修改(push/splice/元素字段赋值)都会污染模块级源数据,并影响同一次构建中所有其他消费者。函数契约隐含"返回值只读"的约定。

4. Skill.projects 关联是单向且未校验的

skillsData 中 projects: ["mizuki-blog", ...] 引用的 ID 与 projectsData 中的 id(如 "mizuki")没有外键约束,也没有任何一致性校验函数。事实上示例数据里两者已不匹配(技能引用 mizuki-blog,项目定义 mizuki),说明页面渲染时并未真正做 ID 关联查询,该字段当前是"文档性弱引用"。扩展时应注意这一点。

5. 状态与进度解耦(anime.ts)

AnimeItem 中存在 progress: 12, totalEpisodes: 12 但 status: "planned" 的真实条目,证明两者独立维护,页面不应用 progress === totalEpisodes 推导 completed。

6. 并发

模块级常量数组在 Node 模块缓存下天然单例。Astro 服务端渲染时多个请求共享同一数组——由于所有查询函数均为无副作用的纯函数(只 filter/forEach/Set 读操作),共享读取是安全的。唯一的并发红线仍是第 3 点:任何组件都不得原地修改这些导出数组。

配置选项

本目录不读取任何外部配置文件(无环境变量、无 appsettings 类机制),全部"配置"即数据文件本身。可视为配置的是各契约中的可选字段:

可选字段所属模块语义
liveDemo / sourceCode / visitUrlProject三个链接独立可选,页面按存在性渲染按钮
featuredProject缺省即非精选;getFeaturedProjects 依赖
showImageProject区分"无图"与"刻意不展示图"
usageAITool多语言使用场景说明
url / tags / colorAITool外链、标签、卡片主题色
projects / certifications / colorSkill关联项目 ID、证书、主题色
(无)AnimeItem全部字段必填,最"扁平"的契约

性能与运维要点

  • 构建期求值:数据与查询函数在模块加载时求值,渲染页面时零运行时开销;getProjectStats 的三次 filter 遍历对几十条数据无感知。
  • 无缓存层:数据量级决定不需要缓存;若未来数据膨胀到需要远程拉取,应在该层之上引入异步加载,而不是把 fetch 混进数据文件。
  • 文件即运维界面:更新一个项目只需改 projects.ts 一处数组条目并重新构建——这正是"内容与表现分离"的运维收益。
  • 资源路径约定:图片统一走 /assets/...(如 /assets/projects/mizuki.webp、/assets/anime/lkls.webp),需保证 public 目录同步存放对应文件,否则 image 字段指向 404(数据层不做存在性校验)。

扩展点

新增一个数据领域

沿用目录既有模式即可:在 src/data/ 新建 xxx.ts,先写 interface Xxx { ... }(枚举字段用字面量联合),再导出 export const xxxData: Xxx[] = [...],必要时附查询纯函数。建议遵循命名导出主流风格(避免 anime.ts 的默认导出特例)。

为既有契约加字段

由于类型是开放的 interface(非 sealed),新增可选字段是零破坏的:旧条目不写新字段即可,页面用 ?. 防御性读取。例如给 Project 加 highlight?: string 不会影响任何现有条目。

新增多语言领域

复用 ai-tools.ts 的方案:description: LocaleString + 页面渲染时调 getLocaleString(value, lang)。若站点新增语言(如 ko),需扩展 LocaleString 的 Record<"en" | "zh_CN" | "zh_TW" | "ja", string> 键集合——这是一个集中式修改点。

谨慎的扩展方向

  • 给查询函数加记忆化:当前函数极轻,加缓存纯属负收益。
  • 把 Skill.projects 变成真外键:需要先统一两边 ID 命名,再补 getProjectsByIds 之类的反查函数;示例数据当前的 ID 不一致说明该方向尚未被实际需要。
  • 数据外移到 JSON/YAML:会失去字面量联合类型的编译期校验,除非另行引入 schema 校验工具,得不偿失。

测试

源码中未发现针对 src/data 的测试文件;类型系统承担了主要的正确性保障(枚举值、必填字段)。查询函数逻辑简单(filter / Set),当前依赖 TypeScript 编译与构建期渲染作为隐式验证。若要加固,最低成本的切入点是为 getLocaleString 的回退链与 getProjectsByCategory 的哨兵值分支补单元测试。

相关链接