feat(gallery): 原图查看与下载——仓外 originals store 双轨制

- git 仓只进压缩版(构建用);原图落 ~/docker_apps/blog/photos-originals/
  (迁移器/ingest 双入口写入,幂等),build.sh 独立 rsync 到
  staging/photos-originals/(同一静态根,零 nginx 改动)
- lightbox 增「原图」(新标签全尺寸)+「下载」(download 属性带文件名)
- 原图持久保障:store + Phase 0 备份 tar 双留档;存量 21 张已回填(196M)

验证: astro build 11 页 exit 0;dist/gallery 含 lb-original×2 与
      photos-originals 引用;staging/photos-originals 21 张实测

Spec: none (用户功能补req:原图查看/下载,跨 Phase 独立小特性)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 09:12:26 +08:00
1 parent 382e56431f
commit 44bfb23efb
4 files changed
+50 -4

No files matched your search

+3
View File
@@ -13,5 +13,8 @@ pnpm astro build
echo "[build] rsync -> $STAGING"
mkdir -p "$STAGING"
rsync -a --delete "$REPO_DIR/dist/" "$STAGING/"
# 原图仓独立同步(不进 git/dist):/photos-originals/ 供图库"查看原图/下载"
ORIG="$HOME/docker_apps/blog/photos-originals"
if [ -d "$ORIG" ]; then rsync -a "$ORIG/" "$STAGING/photos-originals/"; fi
printf 'built at %s\n' "$(date -Is)" > "$STAGING/.build-stamp"
echo "[build] done: $(find "$STAGING" -name '*.html' | wc -l) html files"
+5 -3
View File
@@ -61,9 +61,11 @@ def main() -> int:
fname = unquote(url.rsplit("/", 1)[1])
slug = slugify(spec.get("displayName") or fname)
data = urllib.request.urlopen(BASE + url, timeout=60).read()
# 入仓前压到长边 2400/q86:原图总计 196M(单张最大 28M)会永久膨胀
# 公开仓历史并撞死 nginx 100M 推送上限;原图已随 Phase 0 备份归档,
# 站内展示由 sharp 出 srcset,2400px 足够 lightbox 全屏
# 原图双留档:仓外 originals store(构建时独立 rsync,服务"查看原图/
# 下载")+ Phase 0 备份 tar;git 仓只进压缩版(196M 事故教训)
STORE = Path.home() / "docker_apps/blog/photos-originals"
STORE.mkdir(parents=True, exist_ok=True)
(STORE / f"{slug}.jpg").write_bytes(data)
(OUT / f"{slug}.jpg").write_bytes(compress(data))
(OUT / f"{slug}.json").write_text(json.dumps({
+5
View File
@@ -27,6 +27,11 @@ def main() -> int:
if size < SKIP_BELOW_BYTES:
skipped += 1
continue
STORE = Path.home() / "docker_apps/blog/photos-originals"
STORE.mkdir(parents=True, exist_ok=True)
# 压缩覆写仓内文件前原件先进 store(幂等:store 已有同名则跳过)
if not (STORE / jpg.name).exists():
(STORE / jpg.name).write_bytes(jpg.read_bytes())
img = Image.open(jpg)
# EXIF 显式保留:重存默认剥离会抽走图库参数条的构建期数据源
exif = img.info.get("exif")
+37 -1
View File
@@ -47,6 +47,8 @@ const cards = await Promise.all(
image: photo.data.image,
exif: formatExif(photo.data.exif),
fullSrc: full.src,
// 原图走仓外 store(构建时独立 rsync 到 /photos-originals/),git 不背 196M
original: `/photos-originals/${photo.id}.jpg`,
w,
h,
s3: spanFor(w, h, 3),
@@ -84,6 +86,7 @@ const first = cards[0];
data-title={card.title}
data-exif={card.exif}
data-full={card.fullSrc}
data-original={card.original}
aria-label={`查看大图:${card.title}${card.exif ? `(${card.exif})` : ''}`}
>
<Image
@@ -119,6 +122,10 @@ const first = cards[0];
<div class="lb-meta">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-exif">{first?.exif}</span>
<span class="lb-orig">
<a id="lb-original" href={first?.original} target="_blank" rel="noopener">原图</a>
<a id="lb-download" href={first?.original} download>下载</a>
</span>
</div>
<button id="lb-next" class="tap lbnav" type="button" aria-label="下一张(→)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -150,6 +157,11 @@ const first = cards[0];
img.alt = btn.ariaLabel ?? '';
titleEl.textContent = btn.dataset.title ?? '';
exifEl.textContent = btn.dataset.exif ?? '';
const orig = btn.dataset.original ?? '';
(document.getElementById('lb-original') as HTMLAnchorElement).href = orig;
const dl = document.getElementById('lb-download') as HTMLAnchorElement;
dl.href = orig;
dl.setAttribute('download', btn.dataset.title ?? 'photo');
if (!reduced.matches) {
img.style.opacity = '0';
requestAnimationFrame(() => {
@@ -315,6 +327,18 @@ const first = cards[0];
font-family: var(--font-mono);
font-size: 12.5px;
}
.lb-orig {
display: inline-flex;
gap: var(--space-3);
margin-left: var(--space-2);
}
.lb-orig a {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.lb-meta {
flex: 1;
min-width: 0;
@@ -352,6 +376,18 @@ const first = cards[0];
}
@media (max-width: 560px) {
.lb-bottom { flex-wrap: wrap; }
.lb-meta { order: 3; flex-basis: 100%; }
.lb-orig {
display: inline-flex;
gap: var(--space-3);
margin-left: var(--space-2);
}
.lb-orig a {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.lb-meta { order: 3; flex-basis: 100%; }
}
</style>