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

@ -0,0 +1,378 @@
---
/**
* 详情页图集体(走秀 / 街拍共用)。
*
* 设计动机: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
lookIndex: number
}
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
/** 递给客户端的副图投影(look_index + 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-black 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-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="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 look={String(im.lookIndex)} />
</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-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="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 look="im.lookIndex" />
</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 插槽注入。 */}
{hasImages && (
<GalleryLightbox images={previewImages} aspect={aspect}>
<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>