Repository Wiki
LyraVoid/Mizuki

日记页面

日记页面(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),并按时间倒序排列,置顶条目优先。

该能力有两条并行数据通路:

  1. 静态通路(默认):src/data/diary.ts 内硬编码的 diaryData 数组在构建期由 getDiaryList() 排序后,通过 MomentCard 组件服务端渲染成 HTML。这条通路无网络请求、SEO 友好、零运行时依赖。
  2. 动态通路(可选):当 siteConfig.diaryApiUrl 非空时,页面尾部条件注入一段 is:inline 内联脚本,在浏览器中拉取 Memos API(自托管微博式服务),把 PUBLIC 且 NORMAL 状态的 memo 转换为日记卡片并替换 #diary-list 容器内容。

设计意图:静态通路保证"开箱即用"——不配置任何外部服务也能得到一个有示例数据的页面;动态通路则把数据主权交还给用户自托管的 Memos 实例,且转换/渲染逻辑全部在客户端完成,构建产物不依赖外部 API 可用性。

两条通路的 UI 结构保持一致(同一套卡片样式类、同一个筛选容器),区别仅在数据来源与渲染时机。

Architecture

Loading diagram...

架构说明:

  • 左上(构建期):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 重定向:

astro
1--- 2if (!siteConfig.featurePages.diary) { 3 return Astro.redirect("/404/"); 4} 5 6const moments = getDiaryList(); 7const allTags = getAllTags(); 8---

diary.astro

return Astro.redirect(...) 出现在 frontmatter 顶层即中止渲染,这是 Astro 惯用的"页面守卫"模式。相比在构建配置里排除路由,这种方式让功能页的启停只需改一个布尔配置项,而无需改动文件结构。

frontmatter 随后完成三件事:取数据(getDiaryList() / getAllTags())、组装筛选标签页(见下)、解析 i18n 文案(diaryMinutesAgo / diaryHoursAgo / diaryDaysAgo / diary / diarySubtitle)以及读取 diaryApiUrl。

筛选标签页的组装

astro
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];

diary.astro

首个标签页固定为 all(复用相册页的 albumsFilterAll 文案,体现"全部"语义跨页复用),其后为每个静态数据中出现的标签生成一个 tab,并即时计算计数。标签页仅在有数据且有标签时渲染:

astro
1{moments.length > 0 && allTags.length > 0 && ( 2 <div class="mb-8"> 3 <FilterTabs tabs={filterTabs} dataAttr="tags" /> 4 </div> 5)}

diary.astro

dataAttr="tags" 告诉通用筛选处理器:卡片容器上的筛选属性名是 data-tags(动态渲染的卡片同样携带该属性,因此两条通路共用一套筛选)。

服务端渲染的容器与空状态

astro
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.astro

#diary-list 是整个动态通路的核心挂载点:它既承载静态渲染的 MomentCard,又通过四个 data-* 属性把服务端配置"走私"给内联脚本。空状态有两层:#no-results(初始 hidden,由筛选处理器在无匹配时显示)与 moments.length === 0 时的构建期兜底提示块(见 diary.astro),分别覆盖"筛选后无结果"与"没有任何数据"两种情形。

数据层:src/data/diary.ts

数据层定义了日记条目的契约与查询函数,全部为纯函数、无副作用,便于构建期调用。

DiaryItem 模型

typescript
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}

diary.ts

字段语义:id 为序号;date 为 ISO 8601 字符串(含时区后缀 Z,如 "2025-01-15T10:30:00Z");images / location / mood / tags 均为可选字段,渲染层对它们逐一做了存在性判断,因此允许"纯文字""图文""带定位""带心情"等任意组合。仓库内 diaryData 数组内置一条示例数据(樱花随记 + 两张图片),作为静态通路的开箱体验。

查询函数

typescript
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};

diary.ts

getDiaryList() 的两个细节值得注意:先 [...diaryData] 展开拷贝再排序(避免原地 sort() 污染模块级数组,保证多次调用结果稳定一致);limit 参数支持 undefined 与非正数两种"不限制"语义,limit > 0 才截断。getAllTags() 用 Set 天然去重,最后 sort() 保证标签页顺序确定(字母序),避免每次构建产物因集合遍历顺序不同而产生 diff 噪音。

Core Flow:双通路数据流

Loading diagram...

流程说明:

  1. 静态通路先行:构建期完成全部守卫、数据获取与 HTML 渲染,浏览器拿到的首屏即是完整卡片列表——即使 Memos 服务宕机,页面仍然可用。
  2. 动态通路渐进增强:内联脚本只在 diaryApiUrl 非空时被注入({diaryApiUrl && (<script is:inline>...)},见 diary.astro),注入后以 #diary-list 的 dataset 为配置源,取不到容器或 API 地址时直接 return,不打扰静态结果。
  3. base URL 推导:apiUrl.replace(/\/api\/.*$/, "") 把 https://host/api/v1/memos 之类的 API 地址裁剪为站点根地址,用于拼接附件的绝对路径 {baseUrl}/file/{a.name}/{a.filename}——这样用户只需配置一个 API 地址,无需再配附件域名。

Memos → Diary 的字段映射

transformMemosToDiary() 是两条数据通路的"适配器",把 Memos 的领域模型归一化为本页的日记结构:

javascript
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}

diary.astro

映射要点与设计意图:

  • 可见性双重过滤:只保留 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 标记使用。

相对时间格式化

javascript
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}

diary.astro

三档阈值:60 分钟内显示"N 分钟前",24 小时内显示"N 小时前",之后显示"N 天前"。时间后缀文案(minutesAgo 等)来自 i18n 并经 data-minutes-ago 等 dataset 注入,因此这段纯客户端逻辑天然多语言——不需要在浏览器里引入 i18n 运行时。选择相对时间而非绝对日期,是微博式短内容流的惯例:读者关心"多久之前",而非精确时刻。

XSS 防护:escapeHtml

javascript
1function escapeHtml(text) { 2 const map = { 3 "&": "&amp;", 4 "<": "&lt;", 5 ">": "&gt;", 6 '"': "&quot;", 7 "'": "&#039;", 8 }; 9 return text.replace(/[&<>"']/g, function (c) { 10 return map[c]; 11 }); 12}

diary.astro

因为动态通路采用字符串拼装 + innerHTML 替换的渲染方式(而非框架的自动转义),所有来自 Memos 的不可信文本(图片 URL、标签、位置占位符)在拼入 HTML 前必须手动转义五个危险字符。& 必须最先处理——转义表以 & 开头并由正则一次性替换,避免了二次转义(&amp; → &amp;amp;)的经典陷阱。

图片布局与卡片渲染

图片按数量分派专属布局类,1~3 张有专门样式,4 张及以上走通用网格:

javascript
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}

diary.astro

随后 renderMomentCards() 把每条日记拼装为卡片 HTML 字符串。图片片段的拼装展示了转义与懒加载的组合使用:

javascript
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>";

diary.astro

设计意图:

  • 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.diaryboolean—(未读取到仓库内默认值)日记页面功能开关。为假时页面 frontmatter 直接 Astro.redirect("/404/"),构建产物中该路由不可访问。
siteConfig.diaryApiUrlstring""(页面侧兜底:siteConfig.diaryApiUrl || "")Memos 实例的 API 地址,例如 https://memos.example.com/api/v1/memos。非空时页面注入内联脚本走动态通路;浏览器端会把它裁剪掉 /api/... 后缀得到附件 base URL。
data-memos-apistring(DOM dataset)""#diary-list 容器上的 dataset 属性,向内联脚本传递 API 地址。
data-minutes-ago / data-hours-ago / data-days-agostring(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 类。

Sources

(2 files)
src/data
src/pages