Files
blog/src/components/pages/GalleryPage.astro
T
wait4xandGLM-5.3 7a2880b65d fix(polish): 上线四缺陷修复——明暗委托/灯箱常开/评论站点名/书评宋体
- 明暗切换:document 级委托(软导航换 DOM 后元素级监听全丢);
  E2E 再抓一坑:SVGElement 非 HTMLElement 实例,守卫用 Element 基类
- 图库:#lb 无条件 display:flex 覆盖 UA 隐藏 → 灯箱"永远打开"盖全页;
  改 #lb:not([open]) 显式 none;时间戳标题清空;灯箱打磨(切换钮
  实底阴影/n/21 计数器/空标题隐藏/原图链接暗底亮蓝)
- 评论:site 'wait4x'→'wait4x.online' 对齐 Artalk 服务端(404 根因)
- 书评字体:文楷→思源宋(@fontsource 预分片);文楷降级金句 --font-kai
- 数据:21 张照片组修正 street→landscape(迁移器收到内部组 id 的坑)
- 新增 tests/e2e.mjs:Playwright 30 项全功能 E2E

验证: node tests/e2e.mjs 30/30 PASS(含软导航后明暗切换回归项)

Spec: 2026-10-25_fix-site-polish

Co-authored-by: GLM-5.3
2026-10-25 10:46:03 +08:00

414 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import { Image, getImage } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import type { PhotoExif } from '../../lib/exif';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
const photos = await getCollection('photos');
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
function formatExif(exif: PhotoExif | undefined): string {
if (!exif) return '';
const shutter =
exif.exposureTime === undefined
? undefined
: exif.exposureTime >= 1
? `${exif.exposureTime}s`
: `1/${Math.round(1 / exif.exposureTime)}s`;
return [
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
exif.lens,
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
shutter,
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
]
.filter(Boolean)
.join(' · ');
}
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
const ROW = 0.0092;
const GAP = 0.0162;
const spanFor = (w: number, h: number, cols: number) =>
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
const cards = await Promise.all(
photos.map(async (photo) => {
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
const w = photo.data.image.width ?? 4;
const h = photo.data.image.height ?? 3;
const full = await getImage({ src: photo.data.image, width: 1440 });
return {
group: photo.data.group,
title: photo.data.title,
alt: photo.data.alt ?? photo.data.title,
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),
s2: spanFor(w, h, 2),
};
}),
);
const first = cards[0];
---
<BaseLayout title={`${t('gallery_title', locale)} · wait4x`}>
<h1 class="page-title">{t('gallery_title', locale)}</h1>
<div class="g-main">
<fieldset class="filters">
<legend class="sr-only">{t('gallery_filter_legend', locale)}</legend>
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
<label class="tap chip" for="f-all">{t('gallery_filter_all', locale)}</label>
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
<label class="tap chip" for="f-street">{t('gallery_filter_street', locale)}</label>
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
<label class="tap chip" for="f-landscape">{t('gallery_filter_landscape', locale)}</label>
</fieldset>
<div class="stage">
<ul class="wall">
{cards.map((card) => (
<li
class="shot"
data-group={card.group}
style={`aspect-ratio:${card.w}/${card.h}; --s3:${card.s3}; --s2:${card.s2}`}
>
<button
type="button"
class="shot-btn"
data-title={card.title}
data-exif={card.exif}
data-full={card.fullSrc}
data-original={card.original}
aria-label={t('gallery_view_aria', locale).replace('{title}', card.title) +
(card.exif ? t('gallery_view_aria_exif', locale).replace('{exif}', card.exif) : '')}
>
<Image
src={card.image}
alt={card.alt}
widths={[320, 560, 720]}
sizes="(min-width: 820px) 30vw, 46vw"
loading="lazy"
/>
</button>
</li>
))}
</ul>
</div>
<p class="g-note">{t('gallery_count', locale).replace('{n}', String(cards.length))}</p>
</div>
<dialog id="lb" aria-label={t('gallery_lightbox_label', locale)}>
<button id="lb-close" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_close', locale)}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="m5 5 14 14M19 5 5 19"></path>
</svg>
</button>
<div class="lb-figure">
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
</div>
<div class="lb-bottom">
<button id="lb-prev" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_prev', locale)}>
<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">
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
</svg>
</button>
<div class="lb-meta">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-exif">{first?.exif}</span>
<span id="lb-count">1 / {cards.length}</span>
<span class="lb-orig">
<a id="lb-original" href={first?.original} target="_blank" rel="noopener">{t('gallery_lightbox_original', locale)}</a>
<a id="lb-download" href={first?.original} download>{t('gallery_lightbox_download', locale)}</a>
</span>
</div>
<button id="lb-next" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_next', locale)}>
<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">
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
</svg>
</button>
</div>
</dialog>
</BaseLayout>
<script>
// 灯箱:原生 dialog,←→ 切换、Esc 原生关闭;触屏快滑翻页是键盘的等价物。
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装
document.addEventListener('astro:page-load', () => {
const lb = document.getElementById('lb') as HTMLDialogElement | null;
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
if (!lb || shots.length === 0) return;
const img = document.getElementById('lb-img') as HTMLImageElement;
const titleEl = document.getElementById('lb-title') as HTMLElement;
const exifEl = document.getElementById('lb-exif') as HTMLElement;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let idx = 0;
const show = (i: number) => {
idx = (i + shots.length) % shots.length;
const btn = shots[idx]!;
img.src = btn.dataset.full ?? '';
img.alt = btn.ariaLabel ?? '';
const title = btn.dataset.title ?? '';
titleEl.textContent = title;
titleEl.style.display = title ? '' : 'none';
exifEl.textContent = btn.dataset.exif ?? '';
exifEl.style.display = btn.dataset.exif ? '' : 'none';
const countEl = document.getElementById('lb-count');
if (countEl) countEl.textContent = `${idx + 1} / ${shots.length}`;
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(() => {
img.style.opacity = '1';
});
}
if (!lb.open) lb.showModal();
};
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
lb.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
show(idx - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
show(idx + 1);
}
});
let x0: number | null = null;
lb.addEventListener('touchstart', (e) => {
x0 = e.touches[0]!.clientX;
}, { passive: true });
lb.addEventListener('touchend', (e) => {
if (x0 === null) return;
const dx = e.changedTouches[0]!.clientX - x0;
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
x0 = null;
}, { passive: true });
});
</script>
<style>
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
:global(main#main:has(.g-main)) {
max-width: calc(var(--measure) + 176px);
}
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
.filters {
border: none;
padding: 0;
margin: var(--space-3) 0 var(--space-4);
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.sr-only,
.f-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.chip {
padding-inline: 13px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 13px;
color: var(--ink-3);
cursor: pointer;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
.stage { container-type: inline-size; }
.wall {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
grid-auto-flow: dense;
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
}
@media (min-width: 820px) {
.wall { grid-template-columns: repeat(3, 1fr); }
}
.shot {
grid-row-end: span var(--s2, 10);
border-radius: 10px;
overflow: hidden;
}
@media (min-width: 820px) {
.shot { grid-row-end: span var(--s3, 12); }
}
.shot-btn {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
}
.shot-btn :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.g-note {
margin-top: var(--space-3);
font-size: 12.5px;
color: var(--ink-4);
}
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
/* 关闭态显式 none:UA 对 dialog:not([open]) 的默认隐藏会被上面的
display:flex 覆盖——灯箱页面加载即全屏盖住图库的根因 */
#lb:not([open]) { display: none; }
#lb {
inset: 0;
width: 100%;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
display: flex;
flex-direction: column;
padding: 0;
}
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
.lb-figure {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: var(--space-5) var(--space-3) var(--space-2);
}
#lb-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
border-radius: 6px;
transition: opacity 160ms var(--ease);
}
.lb-bottom {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4) var(--space-4);
color: var(--code-ink);
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: #9db4ff; /* 灯箱恒为暗底:用亮蓝替代主题 --accent(亮色态下对比不足) */
text-decoration: underline;
text-underline-offset: 0.2em;
}
.lb-meta {
flex: 1;
min-width: 0;
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
}
.lb-meta strong {
color: #fff;
font-size: 13px;
font-family: var(--font-sans);
font-weight: 600;
}
.lb-meta span {
opacity: 0.82;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lbnav {
flex: none;
width: 44px;
height: 44px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(20, 24, 30, 0.55);
color: #e6e9ee;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
}
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
#lb-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
}
@media (max-width: 560px) {
.lb-bottom { flex-wrap: wrap; }
.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: #9db4ff; /* 灯箱恒为暗底:用亮蓝替代主题 --accent(亮色态下对比不足) */
text-decoration: underline;
text-underline-offset: 0.2em;
}
.lb-meta { order: 3; flex-basis: 100%; }
}
</style>