结构化页面数据(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 ...。这是历史演进留下的不一致,新增数据文件时建议遵循命名导出的主流风格。
架构
架构说明:
- 最底层是类型契约:
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 是四个已核实契约中字段最多的,覆盖项目展示所需的全部维度:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | ✅ | 项目唯一标识(如 "mizuki"、"folkpatch") |
title | string | ✅ | 项目名称 |
description | string | ✅ | 项目简介 |
image | string | ✅ | 封面图路径(如 "/assets/projects/mizuki.webp",可为空字符串) |
category | "web" | "mobile" | "desktop" | "other" | ✅ | 项目分类 |
techStack | string[] | ✅ | 技术栈列表 |
status | "completed" | "in-progress" | "planned" | ✅ | 项目状态 |
liveDemo | string | ❌ | 在线演示链接 |
sourceCode | string | ❌ | 源码仓库链接 |
visitUrl | string | ❌ | 访问地址 |
startDate | string | ✅ | 开始日期("2024-01-01" 格式) |
endDate | string | ❌ | 结束日期 |
featured | boolean | ❌ | 是否精选(getFeaturedProjects 依赖此字段) |
tags | string[] | ❌ | 标签 |
showImage | boolean | ❌ | 是否展示图片(无图项目设为 false) |
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:
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)
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)。
真实条目示例:
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)
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导出。
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)
这是唯一内建多语言支持的模块。先看语言类型与取值函数:
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 契约及两个先行导出的枚举类型:
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 字段如何按语言分别给出使用场景说明):
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 工具页设计上要按"当前常用程度"而非单纯分类组织。
契约差异对比
四个契约的共同骨架是"标识 + 名称 + 描述 + 分类 + 状态/程度 + 媒体资源",差异体现在领域专属维度:Project 有大量链接与精选标记,Skill 有经验年限内嵌对象,AnimeItem 有观看进度,AITool 有使用频率与多语言字段。
核心流程:查询辅助函数族(projects.ts)
projects.ts 除了数据本身,还导出一组纯函数。这是整个数据层中唯一存在"派生逻辑"的地方,值得逐个走读。
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: {...} } 结构方便页面直接解构。
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()保证输出稳定有序。这是构建筛选下拉框选项的唯一来源,意味着页面技术栈选项永远与实际数据一致,无需另行维护。
页面消费数据的时序
关键点:整个链路没有任何运行时 IO。数据是模块级常量,函数是同步纯函数,因此可以在 Astro 组件的 frontmatter(服务端)直接调用,零水合成本。这是把查询函数与数据放同一文件的重要原因——无需 DI、无需异步初始化。
使用示例
示例 1:在页面中消费项目数据与派生数据
1import { projectsData, getProjectStats, getFeaturedProjects } from "../data/projects";
2
3// frontmatter 中即可直接计算,结果在构建期固化进 HTML
4const stats = getProjectStats();
5const featured = getFeaturedProjects();
6const allProjects = projectsData;Source: projects.ts
示例 2:多语言文案取值(带回退)
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:按分类过滤项目(含哨兵值语义)
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 的空字符串回退
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 / visitUrl | Project | 三个链接独立可选,页面按存在性渲染按钮 |
featured | Project | 缺省即非精选;getFeaturedProjects 依赖 |
showImage | Project | 区分"无图"与"刻意不展示图" |
usage | AITool | 多语言使用场景说明 |
url / tags / color | AITool | 外链、标签、卡片主题色 |
projects / certifications / color | Skill | 关联项目 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 的哨兵值分支补单元测试。
相关链接
- projects.ts — 项目数据契约与查询函数族
- skills.ts — 技能数据契约
- anime.ts — 番剧数据(默认导出特例)
- ai-tools.ts — AI 工具数据与
LocaleString本地化 - devices.ts / diary.ts / friends.ts / timeline.ts — 同目录其他数据模块(本页未深入核实其内部字段)