This commit is contained in:
toom1996
2026-09-22 21:39:45 +08:00
parent 9473f00c98
commit a8fcafac10
15 changed files with 816 additions and 716 deletions

View File

@ -1,9 +1,13 @@
---
/**
* 街拍详情卡片(薄壳)。
*
* 只负责街拍特有的事:把后端 RawStreetSnap 映射成图集数据、算未登录占位格数、
* 把标题里的字面 \t(地点 \t 场次)归一成 " · "。标记与交互全部交给共享的
* DetailGalleryView(与走秀同一套),差异经 props 声明:带图片名图注等。
*/
import type { RawStreetSnap } from "@/lib/api"
import GalleryLightbox from "@/components/GalleryLightbox.astro"
import DetailCountBadge from "@/components/DetailCountBadge.astro"
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
import DetailGalleryView from "@/components/DetailGalleryView.astro"
import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from '@/lib/routes'
import { ssrAbs } from "@/lib/request"
@ -20,7 +24,7 @@ const { locale, t } = getI18n(Astro)
const images = (snap?.images ?? [])
.map((im) => ({
id: im.id,
id: im.id ?? "",
url: ssrAbs(im.image || ""),
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。
thumb: ssrAbs(im.thumb || im.image || ""),
@ -30,6 +34,12 @@ const images = (snap?.images ?? [])
}))
.filter((im) => im.url)
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(look_index + detail),
// 供 lib/galleryDetail.ts 构建 look_index → 副图 映射(与登录后完整 images 同构解析)。
const detailPayload = (snap?.images ?? [])
.filter((im) => (im.detail?.length ?? 0) > 0)
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
@ -39,204 +49,36 @@ const previewImages = images.slice(0, PREVIEW_LIMIT)
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(look_index + detail),
// 供 lib/galleryDetail.ts 构建 look_index → 副图 映射(与登录后完整 images 同构解析)。
const detailPayload = (snap?.images ?? [])
.filter((im) => (im.detail?.length ?? 0) > 0)
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
const homeHref = href(locale, ROUTES.home)
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
const errorText = error === "missing"
? t('No street snap ID specified')
: t('Failed to load street snap. Please refresh or try later.')
---
<div
id="street-snap-view"
x-data="streetSnapView()"
data-fav-type="snap_image"
data-gallery-uid={snap?.id ?? ""}
data-gallery-title={displayTitle}
data-gallery-brand=""
data-preview={preview ? "true" : "false"}
data-details={JSON.stringify(detailPayload)}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
snap ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{displayTitle}
</h1>
{previewImages.length > 0 && (
<section class="mt-16">
<div
id="snap-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-look-index={im.lookIndex}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它开副图灯箱 */}
<DetailCountBadge look={String(im.lookIndex)} />
{im.name && (
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">
{im.name}
</figcaption>
)}
</figure>
) )}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[var(--c-gray-100)] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[var(--c-gray-600)] group-hover:text-[var(--c-gray-700)] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url" >
<figure
:data-image-index="5 + i"
:data-look-index="im.lookIndex"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<DetailCountBadge look="im.lookIndex" />
<template x-if="im.name">
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
</div>
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-widest mb-4">
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
>
← {t('Back to Index')}
</a>
</div>
)
}
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
当前主图的副图经 details 插槽注入(与走秀一致)。 */}
{snap && previewImages.length > 0 && (
<GalleryLightbox images={previewImages} aspect="3/4">
<LbDetailsStrip slot="details" />
</GalleryLightbox>
)}
{/* 副图(细节)灯箱:点大图灯箱右下角的副图缩略图打开 */}
{snap && previewImages.length > 0 && <GalleryDetailLightbox />}
</div>
<script>
import { getStreetSnapDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { createDetailGallery } from "@/lib/galleryDetail"
// 街拍详情:与走秀共用「图集 + 大图灯箱 + 细节(副图)灯箱」
// (lib/gallery.ts + lib/galleryDetail.ts),无额外扩展。
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () =>
createDetailGallery(
createGalleryView({
rootSelector: "#street-snap-view",
gallerySelector: "#snap-gallery",
favType: "snap_image",
fetchDetail: getStreetSnapDetailAuthed,
}),
),
)
})
</script>
<DetailGalleryView
kind="street"
rootId="street-snap-view"
alpineName="streetSnapView"
galleryId="snap-gallery"
favType="snap_image"
aspect="2/3"
hasItem={!!snap}
title={displayTitle}
previewImages={previewImages}
totalCount={imageTotal || images.length}
countLabel="PHOTOS"
preview={preview}
lockedCount={lockedCount}
detailPayload={detailPayload}
galleryUid={snap?.id ?? ""}
galleryTitle={displayTitle}
galleryBrand=""
showCaptions
defaultDensity={5}
errorText={errorText}
homeHref={homeHref}
/>