Files
frontend_v2/src/components/DetailGalleryView.astro
toom1996 0f4301b5c3 update
2026-10-22 19:58:50 +08:00

378 lines
18 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.

---
/**
* 详情页图集体(走秀 / 街拍共用)。
*
* 设计动机:RunwayLookCard 与 StreetSnapCard 的「标题 + 图集网格 + 未登录占位遮罩 + 大图/副图灯箱」
* 逐块同构,差异只有几处声明式开关:
* - aspect 主图与灯箱缩略图比例(当前两页都 2/3 —— 后端素材统一 2:3,见下方 prop 说明)
* - showCaptions 图注:街拍有(图片名),走秀无
* - countLabel 工具栏计数文案:LOOKS / PHOTOS
* - defaultDensity 默认每行张数(走秀与街拍均 5,都可在工具栏切到 3)
* - kind 决定已登录补全量用哪个取数函数(走秀/街拍各一个接口)
* - slot after-grid 图集之后的内容(走秀的系列描述;街拍没有)
*
* 故把「不变的标记」收口在本组件,把上面这些差异用 props / 插槽注入;
* 页面级差异(SSR 取数、TDK、meta.type、标题里的 \t 归一)仍留在两个详情视图与两个薄壳里。
*
* 与灯箱的关系:大图灯箱 GalleryLightbox / 副图灯箱 GalleryDetailLightbox / 交互 lib/gallery.ts
* 早就共享;本组件只是把它们的装配(含 details 插槽)也一并收口。
*
* ⚠️ Alpine 脚本注意:脚本是打包模块,读不到 props,故 rootId / alpineName / kind / favType /
* defaultDensity 全部经根节点 data-* 传下去,脚本在 alpine:init 时读一次。
*/
import GalleryLightbox from "@/components/GalleryLightbox.astro"
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
import DetailCountBadge from "@/components/DetailCountBadge.astro"
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
import { getI18n } from "@/lib/i18n"
interface GalleryImage {
id: string
url: string
thumb: string
raw: string
name: string
}
interface Props {
/** 模块身份:决定已登录补全量走哪个详情接口(runway=图集接口 / street=街拍接口) */
kind: "runway" | "street"
/** 根节点 id(灯箱交互靠它查根元素上的 data-*) */
rootId: string
/** Alpine 组件注册名(两个页面各一个,避免同名互相覆盖) */
alpineName: string
/** 图集网格 id(灯箱按它查 [data-image-index] / [data-fav-id]) */
galleryId: string
/** 单图收藏类型:runway_image | snap_image */
favType: string
/**
* 主图与灯箱缩略图比例。
* 后端素材统一 2:3(实测 S4 原图 240×360、high 样式 720×1080,宽高比均 0.6667),
* 故走秀与街拍都传 "2/3" —— 此时 object-cover 零裁切;写成 3/4 会把 2:3 的图
* 强行按 0.75 裁掉上下各约 11%(一行 5 张、格子变小时尤其明显)。
* 保留本 prop 只是为将来某模块素材比例真的不同时能单独覆盖,不是"两页各一套"。
*/
aspect: "2/3" | "3/4"
/** 是否取到了实体(false 时渲染「缺 id / 取数失败」空态)。不用 title 代替:标题允许为空 */
hasItem: boolean
/** H1 文案(调用方已处理好多语言 / \t 归一) */
title: string
/** SSR 首屏图片(调用方已切到 PREVIEW_LIMIT 张) */
previewImages: GalleryImage[]
/** 工具栏计数(真实总数优先) */
totalCount: number
/** 计数单位文案:LOOKS / PHOTOS */
countLabel: string
/** 后端截断了图集(未登录) */
preview: boolean
/** 占位遮罩格数(补足「剩余 N 张」) */
lockedCount: number
/** 递给客户端的副图投影(id + detail),与登录后完整 images 同构解析 */
detailPayload: unknown
/** 图集元信息:写进 data-*,供收藏 / 灯箱使用 */
galleryUid: string
galleryTitle: string
galleryBrand: string
/** 是否渲染图片名图注(街拍有、走秀无) */
showCaptions?: boolean
/** 默认每行张数(移动端固定 2 列,此值只影响 md+) */
defaultDensity?: 3 | 5
/** 取数失败 / 缺 id 时的提示文案(调用方已按语言算好) */
errorText: string
/** 空态返回首页链接 */
homeHref: string
}
const {
kind,
rootId,
alpineName,
galleryId,
favType,
aspect,
hasItem,
title,
previewImages,
totalCount,
countLabel,
preview,
lockedCount,
detailPayload,
galleryUid,
galleryTitle,
galleryBrand,
showCaptions = false,
defaultDensity = 5,
errorText,
homeHref,
} = Astro.props
const { t } = getI18n(Astro)
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
const imgAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
const hasImages = previewImages.length > 0
---
<div
id={rootId}
x-data={`${alpineName}()`}
data-detail-gallery
data-alpine-name={alpineName}
data-gallery-id={galleryId}
data-gallery-kind={kind}
data-default-density={String(defaultDensity)}
data-fav-type={favType}
data-gallery-uid={galleryUid}
data-gallery-title={galleryTitle}
data-gallery-brand={galleryBrand}
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"
>
{
hasItem ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<h1 class="font-bold leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{title}
</h1>
{hasImages && (
<section class="mt-16">
{/* 工具栏:图集计数 + 每行张数切换(移动端固定 2 列,density 只影响 md+) */}
<div class="flex items-center justify-between mb-5 border-b border-black/10 pb-3">
<span class="text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">
{totalCount} {countLabel}
</span>
<div class="flex items-center gap-1" role="group" aria-label="Images per row">
<span class="text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-600)] mr-2 hidden sm:inline">PER ROW</span>
{/* 只有「3 张 / 5 张」两档,用竖条图标直观表达列数(避免文字数字按钮) */}
<button
type="button"
@click="density = 3"
:class="density === 3 ? 'bg-black text-white' : 'bg-transparent text-black hover:bg-black/5'"
class="w-8 h-8 flex items-center justify-center border border-black/15 transition-colors cursor-pointer"
:aria-pressed="density === 3"
aria-label="3 per row"
title="3 per row"
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1.5" y="2" width="3" height="12" />
<rect x="6.5" y="2" width="3" height="12" />
<rect x="11.5" y="2" width="3" height="12" />
</svg>
</button>
<button
type="button"
@click="density = 5"
:class="density === 5 ? 'bg-black text-white' : 'bg-transparent text-black hover:bg-black/5'"
class="w-8 h-8 flex items-center justify-center border border-black/15 transition-colors cursor-pointer"
:aria-pressed="density === 5"
aria-label="5 per row"
title="5 per row"
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1" y="2" width="2" height="12" />
<rect x="4" y="2" width="2" height="12" />
<rect x="7" y="2" width="2" height="12" />
<rect x="10" y="2" width="2" height="12" />
<rect x="13" y="2" width="2" height="12" />
</svg>
</button>
</div>
</div>
<div id={galleryId} class="grid gap-4" :class="gridClass()">
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-main-id={im.id}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="cursor-zoom-in group"
>
{/* 图片与覆盖层(收藏 / 副图角标)必须包在同一个 relative 盒里:
角标是 absolute bottom-2,若锚点落在含图注的 figure 上,就会压在图注上而不是图片左下角。 */}
<div class="relative bg-[var(--c-gray-100)] overflow-hidden">
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class={`w-full ${imgAspect} 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 mainId={im.id} />
</div>
{showCaptions && im.name && (
<figcaption class="mt-2 h-[15px] text-[11px] leading-none uppercase tracking-widest text-[var(--c-gray-600)] truncate">
{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="cursor-pointer group"
>
{/* 与真实图片等高的图位:锁图标 + 提示,点击跳登录 */}
<div class={`w-full ${imgAspect} bg-[var(--c-gray-100)] 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>
{/* 与真实图注等高的空白衬垫,保证遮罩格与真实图格总高一致。
这里不放「+N photos」文案:lockedCount 是剩余总数,逐格重复显示会误导。 */}
{showCaptions && <div class="mt-2 h-[15px]" aria-hidden="true"></div>}
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-main-id="im.id"
: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="cursor-zoom-in group"
>
<div class="relative bg-[var(--c-gray-100)] overflow-hidden">
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class={`w-full ${imgAspect} 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 mainId="im.id" />
</div>
{showCaptions && (
<template x-if="im.name">
<figcaption class="mt-2 h-[15px] text-[11px] leading-none uppercase tracking-widest text-[var(--c-gray-600)] truncate" x-text="im.name"></figcaption>
</template>
)}
</figure>
</template>
</div>
</section>
)}
{/* 图集之后的页面专属内容(走秀的系列描述;街拍不传) */}
<slot name="after-grid" />
</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">
{errorText}
</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>
)
}
{/* 大图灯箱(共享):当前主图的副图经 details 插槽注入。左栏缩略图条已移除(见 GalleryLightbox)。 */}
{hasImages && (
<GalleryLightbox>
<LbDetailsStrip slot="details" aspect={aspect} />
</GalleryLightbox>
)}
{/* 副图灯箱(共享):点大图灯箱右下角的副图缩略图、或网格左下角角标打开 */}
{hasImages && <GalleryDetailLightbox aspect={aspect} />}
</div>
<script>
import { getArticleDetailAuthed, getStreetSnapDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { createDetailGallery } from "@/lib/galleryDetail"
// 模块身份 → 已登录补全量的取数函数。脚本读不到 props,故身份走根节点的 data-gallery-kind。
const FETCHERS = {
runway: getArticleDetailAuthed,
street: getStreetSnapDetailAuthed,
} as const
document.addEventListener("alpine:init", () => {
// 一个页面只有一个详情图集(网格 + 两个灯箱都在同一根节点内)。
const root = document.querySelector<HTMLElement>("[data-detail-gallery]")
if (!root) return
const kind = root.dataset.galleryKind === "street" ? "street" : "runway"
const name = root.dataset.alpineName || "articleView"
const density = Number(root.dataset.defaultDensity) === 3 ? 3 : 5
Alpine.data(name, () => ({
...createDetailGallery(
createGalleryView({
rootSelector: `#${root.id}`,
gallerySelector: `#${root.dataset.galleryId}`,
favType: root.dataset.favType || "runway_image",
fetchDetail: FETCHERS[kind],
}),
),
// 每行张数(应对图集图片很多):3 / 5 两档,移动端固定 2 列。
density,
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
gridClass(): string {
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
},
}))
})
</script>