日记页面
日记页面(Diary / 日记)是 Mizuki 主题提供的功能页面之一,用于以时间流( Moments )的形式展示站长发布的短内容随笔。它同时支持两种数据来源:仓库内静态数据(src/data/diary.ts)的构建期渲染,以及可选的 Memos 自托管服务 API 的浏览器端动态拉取,并在客户端完成标签筛选、相对时间格式化、图片布局与 HTML 转义。
Purpose and Scope
本页面文档化日记功能的完整实现机制,覆盖:
- 页面入口
src/pages/diary.astro的服务端渲染流程(frontmatter 数据准备、功能开关守卫、布局与空状态) - 数据层
src/data/diary.ts的DiaryItem模型、getDiaryList()与getAllTags()逻辑 - 浏览器端 Memos API 集成:条件脚本注入、
transformMemosToDiary()数据转换、排序规则、卡片字符串渲染、XSS 转义与相对时间计算 - 标签筛选(
FilterTabs+filter-tabs-handler.js+data-tags属性)的协作方式 - 相关配置项(
siteConfig.featurePages.diary、siteConfig.diaryApiUrl)与 i18n 文案键
以下内容有意留给兄弟页面,本页不展开:
- 站点全局配置项的完整定义与加载方式,请参见站点配置相关页面
MainGridLayout主网格布局与右侧栏布局脚本(right-sidebar-layout.js)的通用机制,请参见布局相关页面FilterTabs原子组件与/js/filter-tabs-handler.js的通用筛选实现(相册页亦复用),请参见通用组件页面- 相册等其他功能页面的实现,请参见对应的功能页面
Overview
日记页面解决的问题:长篇文章(posts)不适合承载碎片化的生活随记。日记页面以"卡片流"呈现短内容,每条记录包含正文、可选图片(1~3 张有专属布局,更多走网格)、可选位置(location.placeholder)、可选标签(tags),并按时间倒序排列,置顶条目优先。
该能力有两条并行数据通路:
- 静态通路(默认):
src/data/diary.ts内硬编码的diaryData数组在构建期由getDiaryList()排序后,通过MomentCard组件服务端渲染成 HTML。这条通路无网络请求、SEO 友好、零运行时依赖。 - 动态通路(可选):当
siteConfig.diaryApiUrl非空时,页面尾部条件注入一段is:inline内联脚本,在浏览器中拉取 Memos API(自托管微博式服务),把PUBLIC且NORMAL状态的 memo 转换为日记卡片并替换#diary-list容器内容。
设计意图:静态通路保证"开箱即用"——不配置任何外部服务也能得到一个有示例数据的页面;动态通路则把数据主权交还给用户自托管的 Memos 实例,且转换/渲染逻辑全部在客户端完成,构建产物不依赖外部 API 可用性。
两条通路的 UI 结构保持一致(同一套卡片样式类、同一个筛选容器),区别仅在数据来源与渲染时机。
Architecture
架构说明:
- 左上(构建期):
diary.astro的 frontmatter 是唯一的页面入口。它从siteConfig读取功能开关与 API 地址,从src/data/diary.ts读取静态数据,从 i18n 层读取文案,然后在MainGridLayout内渲染横幅、FilterTabs与MomentCard列表。 - 右下(浏览器端):这是与构建期并列的第二条数据通路。内联脚本从
#diary-list的data-*dataset 属性中读取运行时参数(API 地址、相对时间文案),避免在客户端脚本里硬编码服务端配置——这是 Astro 中把服务端数据传递给is:inline脚本的标准做法。 - 筛选层:
filter-tabs-handler.js是共享脚本(相册页同样使用),通过卡片上的data-tags属性实现标签过滤,不与数据通路耦合,因此静态与动态两条通路都能被筛选。 - 图标按需加载:
loadIconify()动态加载 Iconify 运行时,供内联脚本渲染的iconify-icon元素(如位置图钉material-symbols:location-on)使用。
页面入口与功能开关
日记页面通过功能开关控制可见性,未启用时直接短路返回 404 重定向:
1---
2if (!siteConfig.featurePages.diary) {
3 return Astro.redirect("/404/");
4}
5
6const moments = getDiaryList();
7const allTags = getAllTags();
8---return Astro.redirect(...) 出现在 frontmatter 顶层即中止渲染,这是 Astro 惯用的"页面守卫"模式。相比在构建配置里排除路由,这种方式让功能页的启停只需改一个布尔配置项,而无需改动文件结构。
frontmatter 随后完成三件事:取数据(getDiaryList() / getAllTags())、组装筛选标签页(见下)、解析 i18n 文案(diaryMinutesAgo / diaryHoursAgo / diaryDaysAgo / diary / diarySubtitle)以及读取 diaryApiUrl。
筛选标签页的组装
1const filterTabs = [
2 {
3 value: "all",
4 label: i18n(I18nKey.albumsFilterAll),
5 icon: "material-symbols:apps",
6 count: moments.length,
7 },
8 ...allTags.map((tag) => ({
9 value: tag,
10 label: tag,
11 count: moments.filter((m) => m.tags?.includes(tag)).length,
12 })),
13];首个标签页固定为 all(复用相册页的 albumsFilterAll 文案,体现"全部"语义跨页复用),其后为每个静态数据中出现的标签生成一个 tab,并即时计算计数。标签页仅在有数据且有标签时渲染:
1{moments.length > 0 && allTags.length > 0 && (
2 <div class="mb-8">
3 <FilterTabs tabs={filterTabs} dataAttr="tags" />
4 </div>
5)}dataAttr="tags" 告诉通用筛选处理器:卡片容器上的筛选属性名是 data-tags(动态渲染的卡片同样携带该属性,因此两条通路共用一套筛选)。
服务端渲染的容器与空状态
1<div id="diary-list" class="space-y-4" data-memos-api={diaryApiUrl || ""} data-minutes-ago={minutesAgo} data-hours-ago={hoursAgo} data-days-ago={daysAgo}>
2 {moments.map((moment, index) => (
3 <MomentCard moment={moment} index={index} minutesAgo={minutesAgo} hoursAgo={hoursAgo} daysAgo={daysAgo} />
4 ))}
5</div>#diary-list 是整个动态通路的核心挂载点:它既承载静态渲染的 MomentCard,又通过四个 data-* 属性把服务端配置"走私"给内联脚本。空状态有两层:#no-results(初始 hidden,由筛选处理器在无匹配时显示)与 moments.length === 0 时的构建期兜底提示块(见 diary.astro),分别覆盖"筛选后无结果"与"没有任何数据"两种情形。
数据层:src/data/diary.ts
数据层定义了日记条目的契约与查询函数,全部为纯函数、无副作用,便于构建期调用。
DiaryItem 模型
1export interface DiaryItem {
2 id: number;
3 content: string;
4 date: string;
5 images?: string[];
6 location?: string;
7 mood?: string;
8 tags?: string[];
9}字段语义:id 为序号;date 为 ISO 8601 字符串(含时区后缀 Z,如 "2025-01-15T10:30:00Z");images / location / mood / tags 均为可选字段,渲染层对它们逐一做了存在性判断,因此允许"纯文字""图文""带定位""带心情"等任意组合。仓库内 diaryData 数组内置一条示例数据(樱花随记 + 两张图片),作为静态通路的开箱体验。
查询函数
1// 获取日记列表(按时间倒序)
2export const getDiaryList = (limit?: number) => {
3 const sortedData = [...diaryData].sort(
4 (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
5 );
6
7 if (limit && limit > 0) {
8 return sortedData.slice(0, limit);
9 }
10
11 return sortedData;
12};
13
14// 获取所有标签
15export const getAllTags = () => {
16 const tags = new Set<string>();
17 for (const item of diaryData) {
18 if (item.tags) {
19 for (const tag of item.tags) {
20 tags.add(tag);
21 }
22 }
23 }
24 return Array.from(tags).sort();
25};getDiaryList() 的两个细节值得注意:先 [...diaryData] 展开拷贝再排序(避免原地 sort() 污染模块级数组,保证多次调用结果稳定一致);limit 参数支持 undefined 与非正数两种"不限制"语义,limit > 0 才截断。getAllTags() 用 Set 天然去重,最后 sort() 保证标签页顺序确定(字母序),避免每次构建产物因集合遍历顺序不同而产生 diff 噪音。
Core Flow:双通路数据流
流程说明:
- 静态通路先行:构建期完成全部守卫、数据获取与 HTML 渲染,浏览器拿到的首屏即是完整卡片列表——即使 Memos 服务宕机,页面仍然可用。
- 动态通路渐进增强:内联脚本只在
diaryApiUrl非空时被注入({diaryApiUrl && (<script is:inline>...)},见 diary.astro),注入后以#diary-list的 dataset 为配置源,取不到容器或 API 地址时直接 return,不打扰静态结果。 - base URL 推导:
apiUrl.replace(/\/api\/.*$/, "")把https://host/api/v1/memos之类的 API 地址裁剪为站点根地址,用于拼接附件的绝对路径{baseUrl}/file/{a.name}/{a.filename}——这样用户只需配置一个 API 地址,无需再配附件域名。
Memos → Diary 的字段映射
transformMemosToDiary() 是两条数据通路的"适配器",把 Memos 的领域模型归一化为本页的日记结构:
1function transformMemosToDiary(memos) {
2 return memos
3 .filter(function (m) {
4 return (
5 m.visibility === "PUBLIC" && m.state === "NORMAL"
6 );
7 })
8 .map(function (m, i) {
9 const images =
10 m.attachments && m.attachments.length > 0
11 ? m.attachments
12 .filter(function (a) {
13 return a.type.indexOf("image/") === 0;
14 })
15 .map(function (a) {
16 return (
17 baseUrl + "/file/" + a.name + "/" + a.filename
18 );
19 })
20 : [];
21 return {
22 id: i,
23 content: m.content,
24 date: m.createTime,
25 tags: m.tags || [],
26 images: images.length > 0 ? images : undefined,
27 location: m.location ? m.location.placeholder : undefined,
28 pinned: m.pinned,
29 };
30 })
31 .sort(function (a, b) {
32 if (a.pinned && !b.pinned) {return -1;}
33 if (!a.pinned && b.pinned) {return 1;}
34 return new Date(b.date).getTime() - new Date(a.date).getTime();
35 });
36}映射要点与设计意图:
- 可见性双重过滤:只保留
visibility === "PUBLIC"且state === "NORMAL"的 memo,确保私密与已归档/软删除内容绝不外泄到公开页面。这是在不可信的第三方数据源上做的最小白名单。 - 附件类型白名单:
a.type.indexOf("image/") === 0只放行 MIME 以image/开头的附件,避免视频/文件附件破坏图片网格布局。 - 空值归一化:
images.length > 0 ? images : undefined、m.tags || []——让下游渲染逻辑只需判断"有没有",不必区分[]与undefined。 - 排序是双键的:先按
pinned(置顶优先),再按时间倒序。静态通路的getDiaryList()只有单键时间倒序,两者差异源于 Memos 原生提供置顶语义。 pinned为透传字段:映射结果保留pinned,与静态DiaryItem接口相比多出的字段,供排序与(可选的)UI 标记使用。
相对时间格式化
1function formatRelativeTime(dateString) {
2 const date = new Date(dateString);
3 const diffInMinutes = Math.floor(
4 (Date.now() - date.getTime()) / (1000 * 60),
5 );
6 if (diffInMinutes < 60)
7 {return diffInMinutes + minutesAgo;}
8 if (diffInMinutes < 1440)
9 {return Math.floor(diffInMinutes / 60) + hoursAgo;}
10 return Math.floor(diffInMinutes / 1440) + daysAgo;
11}三档阈值:60 分钟内显示"N 分钟前",24 小时内显示"N 小时前",之后显示"N 天前"。时间后缀文案(minutesAgo 等)来自 i18n 并经 data-minutes-ago 等 dataset 注入,因此这段纯客户端逻辑天然多语言——不需要在浏览器里引入 i18n 运行时。选择相对时间而非绝对日期,是微博式短内容流的惯例:读者关心"多久之前",而非精确时刻。
XSS 防护:escapeHtml
1function escapeHtml(text) {
2 const map = {
3 "&": "&",
4 "<": "<",
5 ">": ">",
6 '"': """,
7 "'": "'",
8 };
9 return text.replace(/[&<>"']/g, function (c) {
10 return map[c];
11 });
12}因为动态通路采用字符串拼装 + innerHTML 替换的渲染方式(而非框架的自动转义),所有来自 Memos 的不可信文本(图片 URL、标签、位置占位符)在拼入 HTML 前必须手动转义五个危险字符。& 必须最先处理——转义表以 & 开头并由正则一次性替换,避免了二次转义(& → &amp;)的经典陷阱。
图片布局与卡片渲染
图片按数量分派专属布局类,1~3 张有专门样式,4 张及以上走通用网格:
1function getImageLayoutClass(count) {
2 if (count === 1) {return "diary-images-single";}
3 if (count === 2) {return "diary-images-double";}
4 if (count === 3) {return "diary-images-triple";}
5 return "diary-images-grid";
6}随后 renderMomentCards() 把每条日记拼装为卡片 HTML 字符串。图片片段的拼装展示了转义与懒加载的组合使用:
1const imgs = moment.images
2 .map(function (img, i) {
3 return (
4 '<div class="relative rounded-lg overflow-hidden aspect-square cursor-pointer">' +
5 '<a href="javascript:void(0)" data-src="' +
6 escapeHtml(img) +
7 '" data-fancybox="diary-' +
8 index +
9 '-' +
10 i +
11 '" class="block w-full h-full">' +
12 '<img src="' +
13 escapeHtml(img) +
14 '" alt="diary moment image" class="w-full h-full object-cover transition-transform duration-300 hover:scale-105" loading="lazy" decoding="async" />' +
15 "</a></div>"
16 );
17 })
18 .join("");
19imagesHtml =
20 '<div class="diary-images grid gap-2 mb-3 ' +
21 layoutClass +
22 '">' +
23 imgs +
24 "</div>";设计意图:
data-fancybox="diary-{index}-{i}":每张图片归入独立的 fancybox 分组,点击放大时按组内导航,不会跨卡片串图。loading="lazy"+decoding="async":卡片流可能很长,视口外图片不抢占带宽,解码不阻塞主线程——对图片密集的时间流是关键性能手段。aspect-square+object-cover:不依赖原图比例,统一裁切为方图,保证网格整齐。- 标签与位置片段同样遵循"存在才渲染"原则:标签渲染为
btn-regular小胶囊(escapeHtml(tag)转义),位置渲染为iconify-icon图钉 + 转义后的location.placeholder(见 diary.astro)。 - 卡片根元素携带
data-tags属性(diary.astro),使动态渲染的卡片与静态MomentCard一样能被filter-tabs-handler.js过滤。
Configuration Options
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
siteConfig.featurePages.diary | boolean | —(未读取到仓库内默认值) | 日记页面功能开关。为假时页面 frontmatter 直接 Astro.redirect("/404/"),构建产物中该路由不可访问。 |
siteConfig.diaryApiUrl | string | ""(页面侧兜底:siteConfig.diaryApiUrl || "") | Memos 实例的 API 地址,例如 https://memos.example.com/api/v1/memos。非空时页面注入内联脚本走动态通路;浏览器端会把它裁剪掉 /api/... 后缀得到附件 base URL。 |
data-memos-api | string(DOM dataset) | "" | #diary-list 容器上的 dataset 属性,向内联脚本传递 API 地址。 |
data-minutes-ago / data-hours-ago / data-days-ago | string(DOM dataset) | "minutes ago" 等英文兜底 | 相对时间后缀文案,来自 i18n(diaryMinutesAgo / diaryHoursAgo / diaryDaysAgo),脚本内以 || 提供英文默认值。 |
说明:
featurePages/diaryApiUrl的完整类型定义位于站点配置文件中;本次源工具预算内未读取到该文件原文,具体默认值请以仓库配置源码为准(未在源码中核实到的信息不在此虚构)。
API Reference
getDiaryList(limit?: number): DiaryItem[]
返回按 date 时间倒序排列的日记列表。
Parameters:
limit(number, 可选):返回条数上限。仅当limit > 0时生效;省略或非正数时返回全部。
Returns: DiaryItem[]——diaryData 的排序副本(不修改原数组)。
getAllTags(): string[]
返回静态数据中出现过的全部标签。
Parameters: 无。
Returns: string[]——去重后的标签数组,按字母序升序排列。
transformMemosToDiary(memos: Memo[]): DiaryItem[](内联脚本内部函数,非导出)
把 Memos API 返回的原始 memo 数组转换为本页日记结构。
Parameters:
memos:Memos API 返回的数组,每项含visibility、state、content、createTime、tags、attachments、location、pinned字段。
Returns: 过滤(PUBLIC + NORMAL)、字段映射、双键排序(pinned 优先,时间倒序)后的对象数组,字段含 id / content / date / tags / images / location / pinned。
formatRelativeTime(dateString: string): string(内部函数)
Parameters:
dateString:ISO 时间字符串。
Returns: 形如 "5 minutes ago" / "3 hours ago" / "2 days ago" 的相对时间文本,后缀文案取自 dataset。
escapeHtml(text: string): string(内部函数)
Parameters:
text:待转义的不可信文本。
Returns: 转义 & < > " ' 之后的 HTML 安全字符串。
getImageLayoutClass(count: number): string(内部函数)
Parameters:
count:图片数量。
Returns: diary-images-single / diary-images-double / diary-images-triple / diary-images-grid 之一。
Failure Modes, Edge Cases & Concurrency
- 功能未启用:
featurePages.diary为假时整页短路为 404 重定向,不会渲染任何内容。 - 动态通路的三重静默降级:内联脚本在「容器不存在」「
dataset.memosApi为空」两种情况下直接return,保留静态渲染结果;脚本本身也仅在diaryApiUrl非空时才被注入。因此 Memos 服务故障、配置为空都不影响页面可用性——这属于渐进增强设计,而非缺陷。 - Memos 返回私密/异常数据:
visibility !== "PUBLIC"或state !== "NORMAL"的条目被硬过滤,不会出现在前端。 - 非图片附件:MIME 不以
image/开头的附件被过滤掉,images归一化为undefined,卡片退化为纯文字卡片。 - XSS 边界:所有进入
innerHTML的动态文本(图片 URL、标签、位置)都经escapeHtml;但m.content(正文)按 Memos 原文输出以保留其富文本/Markdown 渲染——信任边界依赖 Memos 端的输出净化。 - 空状态分层:
#no-results(筛选无匹配,由筛选处理器显示)与moments.length === 0(构建期数据为空)是两个独立提示块,互不替代。 - 时区一致性:静态数据使用
Z后缀的 UTC ISO 字符串;客户端new Date()解析后与Date.now()相减,相对时间在不同时区的浏览器上仍然正确。 - 排序稳定性:静态通路排序前先复制数组,模块级
diaryData不会被原地修改;多次调用getDiaryList()结果一致。 - 并发性:整个动态通路是浏览器端单次
fetch+ 同步字符串拼装 + 一次innerHTML替换,无竞态面;fetch未被await的额外并发逻辑包裹,页面无重复请求路径。
Performance & Operational Notes
- 首屏零外部依赖:静态通路在构建期产出完整 HTML;
filter-tabs-handler.js与 Iconify 运行时才是浏览器端增量加载项,且loadIconify()失败仅console.error(见 diary.astro),不阻断页面。 - 图片懒加载:动态通路所有
<img>带loading="lazy"与decoding="async",长列表的带宽与主线程成本被推迟到滚动接近时。 - 字符串拼装 vs DOM API:
renderMomentCards一次性join再整体替换innerHTML,比逐节点appendChild减少重排次数;代价是必须手工escapeHtml。 - 运维提示:切换静态↔动态通路只需增删
diaryApiUrl一项配置;排查动态通路问题时,优先在浏览器控制台检查document.getElementById("diary-list").dataset.memosApi是否为空——这是最常见的"动态不生效"原因(内联脚本未被注入)。
Extension Points
- 新增静态日记:直接向
src/data/diary.ts的diaryData数组追加DiaryItem对象即可,标签页与计数在构建期自动更新。 - 新增日记字段:在
DiaryItem接口加可选字段,然后在MomentCard与renderMomentCards()两条渲染路径同步渲染该字段(注意后者需escapeHtml)。 - 接入其他微博式后端:仿照
transformMemosToDiary()编写新的适配函数,保持输出契约(content/date/tags/images/location/pinned)不变,渲染与筛选层无需改动——适配器隔离了数据源差异。 - 图片布局扩展:
getImageLayoutClass的分支是显式枚举,新增例如"4 张专属布局"只需在函数内加分支并补充对应 CSS 类。
Related Links
- src/pages/diary.astro — 页面入口:SSR 渲染 + Memos 动态通路内联脚本
- src/data/diary.ts — 数据层:
DiaryItem模型与查询函数 - src/components/features/diary —
MomentCard卡片组件(静态通路渲染单元) - src/components/atoms/filter-tabs —
FilterTabs原子组件(相册页共用) - src/scripts/right-sidebar-layout.js — 右侧栏布局脚本(页面引用)
- src/utils/icon-loader —
loadIconify()图标按需加载工具