相册页面(本地、外部与加密相册)
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 | —— | 完全不参与渲染(连卡片都不出现) |
关键设计决策:
- 构建期扫描而非运行时:所有
fs调用都是同步的 Node API(readdirSync/statSync/readFileSync),在 Astro 构建时执行一次,产物是纯静态 HTML,运行时零文件系统开销。 - 目录即相册(convention over configuration):
public/images/albums下每个子目录就是一个相册,info.json是唯一必填元数据文件;缺少它则整个相册被跳过并console.warn。 - 渐进式 WebP:本地模式对
jpg/jpeg/png图片若存在同名.webp,则输出.webp的 URL——发布者只需把优化后的 webp 放进目录即可自动生效,无需改任何配置。 - 弱密码门禁而非真加密:扫描器不做任何加密运算,仅把
password/passwordHint透传进AlbumGroup,由前端卡片组件消费。这是一个"轻量隐私门禁"的设计取舍。
Architecture
架构分四层:
- 数据源层:约定根目录
public/images/albums,目录名即相册id,info.json提供元数据。 - 扫描管线层(
album-scanner.ts):唯一的业务逻辑所在。scanAlbums()是公开入口,其余函数均为模块私有;processAlbumFolder()是分支中枢,按mode与hidden决定相册是否产出、产出何种照片列表。 - 数据模型层(
album.ts):AlbumGroup与Photo两个纯接口,既是扫描器的输出契约,也是页面与组件的输入契约。 - 页面层(
albums.astro):先做功能开关守卫(关闭则 302 到/404/),再消费扫描结果渲染过滤标签、卡片网格与两种空态。
数据流向是单向的:文件系统 → 扫描器 → 类型化对象 → 静态 HTML。没有任何运行时回读文件系统的路径。
核心实现:扫描管线
入口 scanAlbums()
扫描入口只做三件事:定位根目录、收集子目录名、逐个交给 processAlbumFolder()。目录不存在时返回空数组而不是抛错——这让"没建过相册目录"的站点也能正常构建:
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。
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
值得注意的设计细节:
AlbumInfo是局部接口:info.json的 schema 没有被提升为共享类型,而是内联在函数体里。这使扫描器成为 schema 的唯一定义点,但也意味着info.json的字段约定没有被导出供工具复用。- 隐藏检查放在封面校验之后:
hidden === true的相册会先经历完整的文件校验(缺封面同样会return null),最后才被跳过。也就是说隐藏相册的目录仍需满足结构约定,只是不产出数据。 - 默认值全部在组装阶段收口:
title回退到目录名、date回退到当天(toISOString().split("T")[0])、password || undefined把空字符串归一为undefined,保证下游组件判断password真值即可。 - 外链模式强校验
cover:本地模式封面可从文件系统推断,外链模式没有可推断来源,因此cover缺失直接判废整个相册。
本地模式 scanPhotos():文件名标签与 WebP 降级
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.扩展名,用下划线分隔:
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()
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 类元数据是预留的扩展方向。
数据模型
Photo 与 AlbumGroup 均为纯 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 上——门禁粒度是整个相册,不是单张照片。
页面渲染流程
页面 frontmatter 的完整逻辑:
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
关键点:
- 功能开关是第一道守卫:
featurePages.albums为false时在 frontmatter 阶段直接Astro.redirect("/404/"),扫描根本不会执行。 - 标签来自相册级
info.json.tags,不是照片级文件名标签。flatMap((a) => a.tags || [])+new Set去重后排序,生成 "all + 每个标签" 的 tab 数组,每个 tab 附带实时统计的count。 - 双空态设计:
albumsData.length === 0(没有相册)显示 "photo-library" 图标的空态引导;#no-results(有相册但客户端过滤后全被隐藏)预置为hidden,由filter-tabs-handler.js在客户端切换显示。 - 响应式网格:默认 1 列 →
>= 640px2 列 →#main-grid[data-layout-mode="grid"](用户切换到网格布局模式)3 列,小屏网格模式回落 1 列:
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
卡片渲染与无结果占位:
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.albums | boolean | 视站点配置而定 | src/config.ts | 总开关;false 时 /albums 重定向到 /404/ |
info.json → mode | string | 无(非 external 均视为本地模式) | 各相册目录 | 设为 "external" 启用外链模式 |
info.json → title | string | 目录名 | 各相册目录 | 相册标题 |
info.json → description | string | "" | 各相册目录 | 相册描述 |
info.json → cover | string | 本地模式自动推断;外链模式必填 | 各相册目录 | 封面 URL |
info.json → date | string | 当天 YYYY-MM-DD | 各相册目录 | 相册日期 |
info.json → location | string | "" | 各相册目录 | 拍摄地点 |
info.json → tags | string[] | [] | 各相册目录 | 相册级标签,驱动 FilterTabs |
info.json → photos | object[] | [] | 各相册目录(外链模式) | 外链照片数组,每项至少含 src |
info.json → password | string | undefined | 各相册目录 | 存在即启用密码门禁(透传给 AlbumCard) |
info.json → passwordHint | string | undefined | 各相册目录 | 密码提示(透传给 AlbumCard) |
info.json → hidden | boolean | false | 各相册目录 | true 时整个相册被跳过,不渲染卡片 |
API 参考
scanAlbums(): Promise<AlbumGroup[]>(src/utils/album-scanner.ts)
扫描 public/images/albums 下所有合法相册目录,返回 AlbumGroup 数组。这是扫描管线唯一的导出 API。
返回:AlbumGroup[] —— 仅包含通过完整校验的相册。下列情况对应条目会被剔除:
- 根目录不存在(返回
[]) - 相册目录缺
info.json info.jsonJSON 解析失败- 本地模式缺
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() 的 existsSync | console.warn + 返回 [] | 页面显示空态引导(photo-library 图标 + 文案) |
相册目录缺 info.json | processAlbumFolder() | console.warn + return null | 该相册被剔除,其余正常 |
info.json JSON 语法错误 | JSON.parse 的 try/catch | console.error(含异常对象)+ return null | 该相册被剔除 |
本地模式缺 cover.webp 且缺 cover.jpg | 封面两级探测 | console.warn + return null | 该相册被剔除 |
外链模式缺 cover | processAlbumFolder() | console.warn + return null | 该相册被剔除 |
hidden === true | 组装前检查 | console.log + return null | 相册不渲染(隐藏语义) |
外链 photos[] 某项缺 src | processExternalPhotos() | 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随静态资源直接部署;外链模式则完全绕开仓库体积问题。
扩展点
- 新增照片元数据字段:在外链分支扩展参数对象即可(源码中已预留被注释的
camera/lens/settings),再在Photo接口同步加字段;本地模式若要支持同类字段,需在parseFileName()约定中扩展或改为读取 sidecar 文件。 - 改外链照片校验策略:
processExternalPhotos()当前对缺src是"跳过单条",如需"废掉整个相册"可在此调整。 - 异步化扫描:
scanAlbums()已是async,将内部同步fs换成fs.promises并对相册目录做Promise.all并行即可,调用方无需改动。 AlbumInfo提升为共享类型:目前info.jsonschema 内联在processAlbumFolder()中,若要写校验脚本/编辑器补全,可把它提到src/types/album.ts导出复用。- 新增相册形态:现有分支以
info.mode判定,新增形态(如"protected"强校验模式)时在processAlbumFolder()的isExternalMode分流处增加分支,并保持hidden/password语义不变。
相关链接
- 相册扫描工具:src/utils/album-scanner.ts
- 相册页面:src/pages/albums.astro
- 数据模型:src/types/album.ts
- 相册卡片组件:src/components/features/albums/AlbumCard.astro(密码校验的客户端实现位于该组件,本页未展开)
- 相册样式:src/styles/albums.css
注:受源码探索预算限制,
AlbumCard.astro与albums.css的内部实现未在本次读取范围内展开,本文档中关于该组件的描述仅基于albums.astro中<AlbumCard album={album} />的调用契约(传入整个AlbumGroup,含password/passwordHint)。