Repository Wiki
LyraVoid/Mizuki

相册页面(本地、外部与加密相册)

Mizuki 的相册页面(/albums)是一个构建期静态生成 + 客户端标签过滤的图片展示能力:通过 scanAlbums() 扫描 public/images/albums 下的目录,将每个含 info.json 的文件夹解析为一个 AlbumGroup,并支持本地模式(图片随站点部署)、外链模式(mode: "external",图片托管在外部)与密码门禁相册(password / passwordHint 字段透传给前端卡片组件)三种形态。

Purpose and Scope

本页完整覆盖相册能力的端到端机制,包括:

  • 相册数据源约定(public/images/albums/<album>/info.json 及目录结构)
  • scanAlbums() 扫描管线的完整控制流:本地模式、外链模式、隐藏相册、加密(密码)相册的判定与处理
  • WebP 自动降级映射(cover.webp 优先、同名 .webp 替代 .jpg/.jpeg/.png)
  • 文件名标签解析约定(文件名_标签1_标签2.ext)
  • /albums 页面的渲染流程:功能开关、FilterTabs 标签过滤、空态/无结果态、响应式网格
  • AlbumGroup / Photo 数据模型
  • 扫描管线的失败模式、边界情况、构建期性能特征与扩展点

有意留给兄弟页面的话题:

  • 相册卡片 AlbumCard 的视觉细节与密码校验的客户端实现,属于组件层主题(本页仅涉及数据契约);
  • FilterTabs 原子组件与 filter-tabs-handler.js 的通用过滤机制属于布局/组件主题;
  • siteConfig.featurePages.* 全量功能开关的配置体系属于站点配置主题,本页只涉及 featurePages.albums 这一项。

Overview

相册页解决的核心问题是:让博主用"放文件 + 写一个 JSON"的最低成本拥有一个摄影集页面,同时兼容三种真实场景:

形态触发条件图片来源典型场景
本地相册info.json 中无 mode: "external"(默认)public/images/albums/<album>/ 内的本地文件图片随站点一起部署、追求加载确定性
外部相册info.json 的 mode === "external"info.photos[].src 指向的任意 URL图床/对象存储托管、避免仓库膨胀
加密(密码)相册info.json 含 password 字段本地或外部均可限制性内容,仅知道口令的访客可看
隐藏相册info.json 的 hidden === true——完全不参与渲染(连卡片都不出现)

关键设计决策:

  1. 构建期扫描而非运行时:所有 fs 调用都是同步的 Node API(readdirSync / statSync / readFileSync),在 Astro 构建时执行一次,产物是纯静态 HTML,运行时零文件系统开销。
  2. 目录即相册(convention over configuration):public/images/albums 下每个子目录就是一个相册,info.json 是唯一必填元数据文件;缺少它则整个相册被跳过并 console.warn。
  3. 渐进式 WebP:本地模式对 jpg/jpeg/png 图片若存在同名 .webp,则输出 .webp 的 URL——发布者只需把优化后的 webp 放进目录即可自动生效,无需改任何配置。
  4. 弱密码门禁而非真加密:扫描器不做任何加密运算,仅把 password / passwordHint 透传进 AlbumGroup,由前端卡片组件消费。这是一个"轻量隐私门禁"的设计取舍。

Architecture

Loading diagram...

架构分四层:

  • 数据源层:约定根目录 public/images/albums,目录名即相册 id,info.json 提供元数据。
  • 扫描管线层(album-scanner.ts):唯一的业务逻辑所在。scanAlbums() 是公开入口,其余函数均为模块私有;processAlbumFolder() 是分支中枢,按 mode 与 hidden 决定相册是否产出、产出何种照片列表。
  • 数据模型层(album.ts):AlbumGroup 与 Photo 两个纯接口,既是扫描器的输出契约,也是页面与组件的输入契约。
  • 页面层(albums.astro):先做功能开关守卫(关闭则 302 到 /404/),再消费扫描结果渲染过滤标签、卡片网格与两种空态。

数据流向是单向的:文件系统 → 扫描器 → 类型化对象 → 静态 HTML。没有任何运行时回读文件系统的路径。

核心实现:扫描管线

入口 scanAlbums()

扫描入口只做三件事:定位根目录、收集子目录名、逐个交给 processAlbumFolder()。目录不存在时返回空数组而不是抛错——这让"没建过相册目录"的站点也能正常构建:

typescript
1export async function scanAlbums(): Promise<AlbumGroup[]> { 2 const albumsDir = path.join(process.cwd(), "public/images/albums"); 3 const albums: AlbumGroup[] = []; 4 5 // 检查目录是否存在 6 if (!fs.existsSync(albumsDir)) { 7 console.warn("相册目录不存在:", albumsDir); 8 return []; 9 } 10 11 // 获取所有子文件夹 12 const albumFolders = fs 13 .readdirSync(albumsDir, { withFileTypes: true }) 14 .filter((dirent) => dirent.isDirectory()) 15 .map((dirent) => dirent.name); 16 17 // 处理每个相册文件夹 18 for (const folder of albumFolders) { 19 const albumPath = path.join(albumsDir, folder); 20 const album = await processAlbumFolder(albumPath, folder); 21 if (album) { 22 albums.push(album); 23 } 24 } 25 26 return albums; 27}

Source: src/utils/album-scanner.ts

要点:

  • withFileTypes: true + dirent.isDirectory() 保证只有目录才被视为相册,散落在根目录下的文件被天然排除。
  • 尽管函数是 async,内部全是同步 fs 调用——async 签名主要是为了给 processAlbumFolder() 的 await 留出将来异步化(如换成 fs.promises)的余地,当前并不产生真正的并发。
  • 返回 null 的相册直接被丢弃,albums 只包含通过全部校验的相册。

分支中枢 processAlbumFolder()

这是整个能力最关键的函数,负责:校验 info.json → 解析 JSON → 按 mode 分流 → 过滤隐藏相册 → 组装 AlbumGroup。

typescript
1async function processAlbumFolder( 2 folderPath: string, 3 folderName: string, 4): Promise<AlbumGroup | null> { 5 // 检查必要文件 6 const infoPath = path.join(folderPath, "info.json"); 7 8 if (!fs.existsSync(infoPath)) { 9 console.warn(`相册 ${folderName} 缺少 info.json 文件`); 10 return null; 11 } 12 13 // 读取相册信息 14 const infoContent = fs.readFileSync(infoPath, "utf-8"); 15 interface AlbumInfo { 16 mode?: string; 17 cover?: string; 18 photos?: Record<string, unknown>[]; 19 hidden?: boolean; 20 title?: string; 21 description?: string; 22 date?: string; 23 location?: string; 24 tags?: string[]; 25 password?: string; 26 passwordHint?: string; 27 } 28 let info: AlbumInfo; 29 try { 30 info = JSON.parse(infoContent); 31 } catch (e) { 32 console.error(`相册 ${folderName} 的 info.json 格式错误:`, e); 33 return null; 34 } 35 36 // 检查是否为外链模式 37 const isExternalMode = info.mode === "external"; 38 let photos: Photo[] = []; 39 let cover: string; 40 41 if (isExternalMode) { 42 // 外链模式:从 info.json 中获取封面和照片 43 if (!info.cover) { 44 console.warn(`相册 ${folderName} 外链模式缺少 cover 字段`); 45 return null; 46 } 47 48 cover = info.cover as string; 49 photos = processExternalPhotos( 50 (info.photos ?? []) as Parameters<typeof processExternalPhotos>[0], 51 folderName, 52 ); 53 } else { 54 // 本地模式:检查本地文件 55 let coverPath = path.join(folderPath, "cover.webp"); 56 const hasWebpCover = fs.existsSync(coverPath); 57 if (!hasWebpCover) { 58 coverPath = path.join(folderPath, "cover.jpg"); 59 if (!fs.existsSync(coverPath)) { 60 console.warn(`相册 ${folderName} 缺少 cover 文件`); 61 return null; 62 } 63 } 64 65 cover = hasWebpCover 66 ? `/images/albums/${folderName}/cover.webp` 67 : `/images/albums/${folderName}/cover.jpg`; 68 photos = scanPhotos(folderPath, folderName); 69 } 70 71 // 检查是否隐藏相册 72 if (info.hidden === true) { 73 console.log(`相册 ${folderName} 已设置为隐藏,跳过显示`); 74 return null; 75 } 76 77 // 构建相册对象 78 return { 79 id: folderName, 80 title: info.title || folderName, 81 description: info.description || "", 82 cover, 83 date: info.date || new Date().toISOString().split("T")[0], 84 location: info.location || "", 85 tags: info.tags || [], 86 photos, 87 password: info.password || undefined, 88 passwordHint: info.passwordHint || undefined, 89 }; 90}

Source: src/utils/album-scanner.ts

值得注意的设计细节:

  1. AlbumInfo 是局部接口:info.json 的 schema 没有被提升为共享类型,而是内联在函数体里。这使扫描器成为 schema 的唯一定义点,但也意味着 info.json 的字段约定没有被导出供工具复用。
  2. 隐藏检查放在封面校验之后:hidden === true 的相册会先经历完整的文件校验(缺封面同样会 return null),最后才被跳过。也就是说隐藏相册的目录仍需满足结构约定,只是不产出数据。
  3. 默认值全部在组装阶段收口:title 回退到目录名、date 回退到当天(toISOString().split("T")[0])、password || undefined 把空字符串归一为 undefined,保证下游组件判断 password 真值即可。
  4. 外链模式强校验 cover:本地模式封面可从文件系统推断,外链模式没有可推断来源,因此 cover 缺失直接判废整个相册。

本地模式 scanPhotos():文件名标签与 WebP 降级

typescript
1function scanPhotos(folderPath: string, albumId: string): Photo[] { 2 const photos: Photo[] = []; 3 const files = fs.readdirSync(folderPath); 4 5 const imageExtensions = [ 6 ".jpg", 7 ".jpeg", 8 ".png", 9 ".gif", 10 ".webp", 11 ".svg", 12 ".avif", 13 ".bmp", 14 ".tiff", 15 ".tif", 16 ]; 17 18 const imageFiles = files.filter((file) => { 19 const ext = path.extname(file).toLowerCase(); 20 return ( 21 imageExtensions.includes(ext) && 22 file !== "cover.jpg" && 23 file !== "cover.webp" 24 ); 25 }); 26 27 const fileWebpMap = new Map<string, string>(); 28 for (const file of imageFiles) { 29 const baseName = path.basename(file, path.extname(file)); 30 const ext = path.extname(file).toLowerCase(); 31 if (ext === ".jpg" || ext === ".jpeg" || ext === ".png") { 32 if (imageFiles.includes(`${baseName}.webp`)) { 33 fileWebpMap.set(file, `${baseName}.webp`); 34 } 35 } 36 } 37 38 imageFiles.forEach((file, index) => { 39 const filePath = path.join(folderPath, file); 40 const stats = fs.statSync(filePath); 41 42 const { baseName, tags } = parseFileName(file); 43 44 const src = fileWebpMap.has(file) 45 ? `/images/albums/${albumId}/${fileWebpMap.get(file)}` 46 : `/images/albums/${albumId}/${file}`; 47 48 photos.push({ 49 id: `${albumId}-photo-${index}`, 50 src, 51 alt: baseName, 52 title: baseName, 53 tags: tags, 54 date: stats.mtime.toISOString().split("T")[0], 55 }); 56 }); 57 58 return photos; 59}

Source: src/utils/album-scanner.ts

三个行为需要特别注意:

  • 封面文件被排除在照片列表之外:file !== "cover.jpg" && file !== "cover.webp",避免封面重复出现在相册内容里。
  • 同名 WebP 优先:fileWebpMap 把 foo.jpg 映射到 foo.webp。注意原始的 foo.jpg 依然会保留在 imageFiles 里,因此若目录同时存在 foo.jpg 与 foo.webp,会产出两张 Photo(一张 src 指向 foo.webp,一张指向自身 foo.webp),这是发布时需要留意的去重边界。
  • 照片日期来自文件 mtime:stats.mtime.toISOString().split("T")[0],意味着拷贝文件会重置照片日期;需要稳定排序时应改用 info.json 的外链模式显式提供 date。

文件名标签解析 parseFileName()

约定格式为 文件名_标签1_标签2.扩展名,用下划线分隔:

typescript
1function parseFileName(fileName: string): { baseName: string; tags: string[] } { 2 // 匹配文件名中的标签,格式为:文件名_标签1_标签2.扩展名 3 const parts = path.basename(fileName, path.extname(fileName)).split("_"); 4 5 if (parts.length >= 3) { 6 // 前 N-2 部分作为基本名称,最后 2 部分作为标签 7 const baseName = parts.slice(0, -2).join("_"); 8 const tags = parts.slice(-2); 9 return { baseName, tags }; 10 } 11 12 if (parts.length === 2) { 13 // 第一部分作为基本名称,第二部分作为标签 14 return { baseName: parts[0], tags: [parts[1]] }; 15 } 16 17 // 如果没有标签,返回不带扩展名的文件名 18 const baseName = path.basename(fileName, path.extname(fileName)); 19 return { baseName, tags: [] }; 20}

Source: src/utils/album-scanner.ts

最多取 2 个标签(parts.slice(-2)),其余部分并入名称。该函数只影响单张照片的 alt / title / tags,与相册级 info.json 的 tags(驱动页面顶部 FilterTabs)是两套独立机制。

外链模式 processExternalPhotos()

typescript
1function processExternalPhotos( 2 externalPhotos: { 3 src: string; 4 id?: string; 5 thumbnail?: string; 6 alt?: string; 7 title?: string; 8 description?: string; 9 tags?: string[]; 10 date?: string; 11 location?: string; 12 width?: number; 13 height?: number; 14 }[], 15 albumId: string, 16): Photo[] { 17 const photos: Photo[] = []; 18 19 externalPhotos.forEach((photo, index) => { 20 if (!photo.src) { 21 console.warn(`相册 ${albumId} 的第 ${index + 1} 张照片缺少 src 字段`); 22 return; 23 } 24 25 photos.push({ 26 id: photo.id || `${albumId}-external-photo-${index}`, 27 src: photo.src, 28 thumbnail: photo.thumbnail, 29 alt: photo.alt || photo.title || `Photo ${index + 1}`, 30 title: photo.title, 31 description: photo.description, 32 tags: photo.tags || [], 33 date: photo.date || new Date().toISOString().split("T")[0], 34 location: photo.location, 35 width: photo.width, 36 height: photo.height, 37 }); 38 }); 39 40 return photos; 41}

Source: src/utils/album-scanner.ts

外链模式是本地模式的"显式版":所有本地模式从文件系统推断的信息(缩略图、宽高、日期、位置)在这里都改为由 info.json 显式声明。缺 src 的条目只跳过自身(return 跳出本次 forEach),不会废掉整个相册——与缺 cover 废掉整个相册形成对比:封面是相册存在的必要条件,单张照片不是。源码中被注释掉的 camera / lens / settings 字段表明 EXIF 类元数据是预留的扩展方向。

数据模型

Loading diagram...

Photo 与 AlbumGroup 均为纯 TypeScript 接口,无运行时行为:

typescript
1export interface Photo { 2 id?: string; 3 src: string; 4 alt?: string; 5 title?: string; 6 thumbnail?: string; 7 tags?: string[]; 8 description?: string; 9 date?: string; 10 location?: string; 11 width?: number; 12 height?: number; 13} 14 15export interface AlbumGroup { 16 id: string; 17 title: string; 18 description?: string; 19 cover: string; 20 date: string; 21 location?: string; 22 tags?: string[]; 23 photos: Photo[]; 24 password?: string; 25 passwordHint?: string; 26}

Source: src/types/album.ts

唯一的必填字段是 Photo.src 与 AlbumGroup 的 id/title/cover/date/photos,其余全部可选。password/passwordHint 出现在 AlbumGroup 上而非 Photo 上——门禁粒度是整个相册,不是单张照片。

页面渲染流程

Loading diagram...

页面 frontmatter 的完整逻辑:

astro
1--- 2import { FilterTabs } from "@components/atoms/filter-tabs"; 3import { AlbumCard } from "@components/features/albums"; 4import { PageHeader } from "@components/features/page-header"; 5import MainGridLayout from "@layouts/MainGridLayout.astro"; 6import { Icon } from "astro-icon/components"; 7 8import { siteConfig } from "../config"; 9import I18nKey from "../i18n/i18nKey"; 10import { i18n } from "../i18n/translation"; 11import { scanAlbums } from "../utils/album-scanner"; 12 13if (!siteConfig.featurePages.albums) { 14 return Astro.redirect("/404/"); 15} 16 17const albumsData = await scanAlbums(); 18 19// Collect all unique tags for filtering 20const allTags = [...new Set(albumsData.flatMap((a) => a.tags || []))].sort(); 21 22const filterTabs = [ 23 { 24 value: "all", 25 label: i18n(I18nKey.albumsFilterAll), 26 icon: "material-symbols:apps", 27 count: albumsData.length, 28 }, 29 ...allTags.map((tag) => ({ 30 value: tag, 31 label: tag, 32 count: albumsData.filter((a) => a.tags?.includes(tag)).length, 33 })), 34]; 35 36const title = i18n(I18nKey.albums); 37const subtitle = i18n(I18nKey.albumsSubtitle); 38---

Source: src/pages/albums.astro

关键点:

  1. 功能开关是第一道守卫:featurePages.albums 为 false 时在 frontmatter 阶段直接 Astro.redirect("/404/"),扫描根本不会执行。
  2. 标签来自相册级 info.json.tags,不是照片级文件名标签。flatMap((a) => a.tags || []) + new Set 去重后排序,生成 "all + 每个标签" 的 tab 数组,每个 tab 附带实时统计的 count。
  3. 双空态设计:albumsData.length === 0(没有相册)显示 "photo-library" 图标的空态引导;#no-results(有相册但客户端过滤后全被隐藏)预置为 hidden,由 filter-tabs-handler.js 在客户端切换显示。
  4. 响应式网格:默认 1 列 → >= 640px 2 列 → #main-grid[data-layout-mode="grid"](用户切换到网格布局模式)3 列,小屏网格模式回落 1 列:
css
1#albums-grid { 2 grid-template-columns: 1fr; 3} 4 5@media (width >= 640px) { 6 #albums-grid { 7 grid-template-columns: repeat(2, minmax(0, 1fr)); 8 } 9} 10 11#main-grid[data-layout-mode="grid"] #albums-grid { 12 grid-template-columns: repeat(3, minmax(0, 1fr)); 13} 14 15@media (width < 640px) { 16 #main-grid[data-layout-mode="grid"] #albums-grid { 17 grid-template-columns: 1fr; 18 } 19}

Source: src/pages/albums.astro

卡片渲染与无结果占位:

astro
1<div id="albums-grid" class="grid gap-6 items-start"> 2 {albumsData.map((album) => <AlbumCard album={album} />)} 3</div> 4 5<div id="no-results" class="hidden text-center py-16"> 6 <Icon 7 name="material-symbols:search-off-rounded" 8 class="text-6xl text-black/15 dark:text-white/15 mb-4" 9 /> 10 <p class="text-black/40 dark:text-white/40 text-lg"> 11 {i18n(I18nKey.albumsNoResults)} 12 </p> 13</div>

Source: src/pages/albums.astro

配置选项

配置项类型默认值位置说明
siteConfig.featurePages.albumsboolean视站点配置而定src/config.ts总开关;false 时 /albums 重定向到 /404/
info.json → modestring无(非 external 均视为本地模式)各相册目录设为 "external" 启用外链模式
info.json → titlestring目录名各相册目录相册标题
info.json → descriptionstring""各相册目录相册描述
info.json → coverstring本地模式自动推断;外链模式必填各相册目录封面 URL
info.json → datestring当天 YYYY-MM-DD各相册目录相册日期
info.json → locationstring""各相册目录拍摄地点
info.json → tagsstring[][]各相册目录相册级标签,驱动 FilterTabs
info.json → photosobject[][]各相册目录(外链模式)外链照片数组,每项至少含 src
info.json → passwordstringundefined各相册目录存在即启用密码门禁(透传给 AlbumCard)
info.json → passwordHintstringundefined各相册目录密码提示(透传给 AlbumCard)
info.json → hiddenbooleanfalse各相册目录true 时整个相册被跳过,不渲染卡片

API 参考

scanAlbums(): Promise<AlbumGroup[]>(src/utils/album-scanner.ts)

扫描 public/images/albums 下所有合法相册目录,返回 AlbumGroup 数组。这是扫描管线唯一的导出 API。

返回:AlbumGroup[] —— 仅包含通过完整校验的相册。下列情况对应条目会被剔除:

  • 根目录不存在(返回 [])
  • 相册目录缺 info.json
  • info.json JSON 解析失败
  • 本地模式缺 cover.webp 与 cover.jpg
  • 外链模式缺 cover 字段
  • hidden === true

副作用:上述各类剔除均伴随 console.warn / console.error / console.log 构建日志,可据此排查"相册为什么没出现"。

processAlbumFolder(folderPath, folderName): Promise<AlbumGroup | null>(模块私有)

分支中枢。见前文"分支中枢"一节的完整源码与逐行解释。

scanPhotos(folderPath, albumId): Photo[](模块私有)

本地模式照片收集。10 种受支持扩展名:.jpg .jpeg .png .gif .webp .svg .avif .bmp .tiff .tif;显式排除 cover.jpg / cover.webp;应用 fileWebpMap WebP 降级;id 生成规则 ${albumId}-photo-${index};date 取文件 mtime。

processExternalPhotos(externalPhotos, albumId): Photo[](模块私有)

外链模式照片映射。缺 src 的条目跳过自身并告警;id 缺省生成 ${albumId}-external-photo-${index};alt 回退链为 photo.alt || photo.title || Photo ${index + 1}。

parseFileName(fileName): { baseName, tags }(模块私有)

按 _ 切分去扩展名后的文件名:≥3 段取前 N-2 段为名称、最后 2 段为标签;2 段取 1 名称 1 标签;1 段无标签。

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

失败模式一览

失败场景检测点处理方式对页面影响
public/images/albums 不存在scanAlbums() 的 existsSyncconsole.warn + 返回 []页面显示空态引导(photo-library 图标 + 文案)
相册目录缺 info.jsonprocessAlbumFolder()console.warn + return null该相册被剔除,其余正常
info.json JSON 语法错误JSON.parse 的 try/catchconsole.error(含异常对象)+ return null该相册被剔除
本地模式缺 cover.webp 且缺 cover.jpg封面两级探测console.warn + return null该相册被剔除
外链模式缺 coverprocessAlbumFolder()console.warn + return null该相册被剔除
hidden === true组装前检查console.log + return null相册不渲染(隐藏语义)
外链 photos[] 某项缺 srcprocessExternalPhotos()console.warn + 跳过该项仅该照片缺失
目录下无任何合法图片scanPhotos() 自然为空无告警相册 photos: [],封面仍在

所有失败均为软失败:扫描器从不抛出异常中断构建,最坏结果是页面呈现空态。这与"个人博客静态站"的定位一致——内容缺失不应导致部署失败,构建日志承担全部可观测性。

边界情况

  • 同名双格式重复:目录同时存在 foo.jpg 与 foo.webp 时,两者都进入 imageFiles,产出两张 Photo(src 都指向 .webp)。若只想要一张,发布时应只保留 webp 或仅保留原格式之一。
  • mtime 不稳定:本地照片 date 依赖文件系统 mtime,git clone / 文件拷贝会改变它。需要确定性日期时用外链模式显式写 date。
  • hidden 相册仍需结构合法:隐藏检查发生在封面校验之后,因此隐藏相册缺封面时日志会出现"缺少 cover 文件"而非"已设置为隐藏"。
  • 空标签过滤:allTags.length === 0 时 FilterTabs 整体不渲染(条件 albumsData.length > 0 && allTags.length > 0),避免出现只有一个 "all" 的无意义 tab 栏。
  • password || undefined 归一化:info.json 里写 "password": "" 等价于未设置,下游不会误判为加密相册。
  • info.json 中的未知字段被静默忽略:AlbumInfo 局部接口只消费声明的字段。

并发

不存在运行时并发问题——扫描只发生在 Astro 构建进程内,产物是静态 HTML。构建期内部是串行的(for...of 顺序 await processAlbumFolder),相册之间无并行。对相册数量极大(数百个目录、上万张图)的站点,串行同步 fs 会成为构建耗时的一部分,但每个操作都是本地元数据读取(readdirSync / statSync / 小 JSON 读取),不读取图片内容本身,因此开销可控。

性能与运维要点

  • 图片内容不进扫描:扫描器只读目录项与 stat 元数据,从不解码图片。构建耗时与图片体积基本无关,与文件数量线性相关。
  • 静态产物零运行时成本:/albums 是 SSG 输出,客户端只有 filter-tabs-handler.js 做显隐过滤与 loadIconify() 的图标按需加载。
  • 排查"相册没出现":按构建日志顺序检查——① 有没有 "相册目录不存在";② 有没有 "缺少 info.json 文件";③ "的 info.json 格式错误";④ "缺少 cover 文件" / "外链模式缺少 cover 字段";⑤ "已设置为隐藏,跳过显示"。
  • 图片体积治理:本地模式鼓励 cover.webp + 同名 .webp(自动优先),public/images/albums 随静态资源直接部署;外链模式则完全绕开仓库体积问题。

扩展点

  1. 新增照片元数据字段:在外链分支扩展参数对象即可(源码中已预留被注释的 camera / lens / settings),再在 Photo 接口同步加字段;本地模式若要支持同类字段,需在 parseFileName() 约定中扩展或改为读取 sidecar 文件。
  2. 改外链照片校验策略:processExternalPhotos() 当前对缺 src 是"跳过单条",如需"废掉整个相册"可在此调整。
  3. 异步化扫描:scanAlbums() 已是 async,将内部同步 fs 换成 fs.promises 并对相册目录做 Promise.all 并行即可,调用方无需改动。
  4. AlbumInfo 提升为共享类型:目前 info.json schema 内联在 processAlbumFolder() 中,若要写校验脚本/编辑器补全,可把它提到 src/types/album.ts 导出复用。
  5. 新增相册形态:现有分支以 info.mode 判定,新增形态(如 "protected" 强校验模式)时在 processAlbumFolder() 的 isExternalMode 分流处增加分支,并保持 hidden / password 语义不变。

相关链接

注:受源码探索预算限制,AlbumCard.astro 与 albums.css 的内部实现未在本次读取范围内展开,本文档中关于该组件的描述仅基于 albums.astro 中 <AlbumCard album={album} /> 的调用契约(传入整个 AlbumGroup,含 password / passwordHint)。

Sources

(3 files)
src/pages
src/types