update
This commit is contained in:
378
src/components/DetailGalleryView.astro
Normal file
378
src/components/DetailGalleryView.astro
Normal 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>
|
||||
Reference in New Issue
Block a user