Files
frontend_v2/src/components/Item.astro
toom1996 afff2d9a49 update
2026-09-14 21:30:15 +08:00

698 lines
33 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 type { RawArticle } from "@/lib/api"
import GalleryLightbox from "@/components/GalleryLightbox.astro"
import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from '@/lib/routes'
import { ssrAbs } from "@/lib/request"
interface Props {
article: RawArticle | null
error?: "missing" | "failed" | null
preview?: boolean // 后端未登录截断图片集时返回 true,渲染「剩余 N 张」占位遮罩
imageTotal?: number // 图片集真实总数(后端截断前),渲染「剩余 N 张」占位遮罩
}
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
const { locale, t } = getI18n(Astro)
// 改造后:后端详情接口 images 仅返回主图(look 图),细节图在 details 分组,需经「细节入口」展开。
// 故此处 images 即为主图集合,默认只渲染它们;每张主图带 look_index 用于关联其细节图。
const images = (article?.images ?? [])
.map((im) => ({
id: im.id,
url: ssrAbs(im.image || ""),
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
thumb: ssrAbs(im.thumb || im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "",
lookIndex: Number(im.look_index ?? 0),
}))
.filter((im) => im.url)
// 每个主图(look_index)对应的细节图数量,用于网格卡片左下角标「该主图有几张细节图」。
// 来源与灯箱右下角细节簇一致:后端详情接口的 details 分组(按 look_index 聚合)。
// SSR 首屏图直接查这张表;登录后追加的 extraImages 由 Alpine 的 detailCount() 读同一份数据。
const detailsByLook = new Map<number, number>()
for (const d of (article?.details ?? []) as { look_index?: number; images?: unknown[] }[]) {
const li = Number(d.look_index ?? 0)
if (li) detailsByLook.set(li, (d.images ?? []).length)
}
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
// 未登录预览占位遮罩数:补足「剩余 N 张」空图,提示登录后可见,避免误以为图集只有预览上限那几张。
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
const descParas = (article?.description || "")
.split(/\n\n+/)
.map((s) => s.trim())
.filter(Boolean)
const homeHref = href(locale, ROUTES.home)
---
<div
id="article-view"
x-data="articleView()"
data-fav-type="runway_image"
data-gallery-uid={article?.id ?? ""}
data-gallery-title={article?.title ?? ""}
data-gallery-brand={article?.brand_name ?? ""}
data-preview={preview ? "true" : "false"}
data-details={JSON.stringify(article?.details ?? [])}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
>
{
article ? (
<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">
{article.title}
</h1>
{previewImages.length > 0 && (
<section class="mt-16">
{/* 每行张数切换(应对图集图片很多的情况):2/3/4/5 列,移动端固定 2 列 */}
<div class="flex items-center justify-between mb-5 border-b border-black/10 pb-3">
<span class="text-[11px] font-mono uppercase tracking-[0.22em] text-[#999]">
{(imageTotal || images.length)} LOOKS
</span>
<div class="flex items-center gap-1" role="group" aria-label="Images per row">
<span class="text-[11px] font-mono uppercase tracking-[0.18em] text-[#999] mr-2 hidden sm:inline">PER ROW</span>
{/* 每行张数切换:只有「3 张 / 5 张」两档,用竖条图标直观表达列数(避免文字数字按钮)。
density 只影响 md+;移动端固定 2 列(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="article-gallery"
class="grid gap-4"
:class="gridClass()"
>
{previewImages.map((im, i) => {
const dc = detailsByLook.get(im.lookIndex) || 0
return (
<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-[#f2f2f2] cursor-zoom-in group"
>
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class="w-full aspect-[2/3] 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>
{/* 细节图数量角标:有细节图的主图在左下角标注「张数」(图层图标 + 数字),
点它直接开细节灯箱。纯主图(无细节图)不显示任何角标,保持网格干净。 */}
{dc > 0 && (
<button
type="button"
@click.stop={`openDetailLb(${im.lookIndex})`}
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] font-mono uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
title={`${dc} detail${dc > 1 ? "s" : ""}`}
aria-label={`${dc} detail${dc > 1 ? "s" : ""}`}
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="13" height="13" rx="1.5" />
<path d="M8 21h11a2 2 0 0 0 2-2V8" />
</svg>
{dc}
</button>
)}
</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-[#f2f2f2] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[2/3] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] 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] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 真实图已无 look 序号 caption,占位格也改为绝对定位的 +N photos,
不撑高格子,保证遮罩格与真实图片格总高一致 */}
<span class="absolute left-2 bottom-2 z-10 text-[10px] font-mono uppercase tracking-[0.14em] text-[#b8b8b8] pointer-events-none">+{lockedCount} {t('photos')}</span>
</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-[#f2f2f2] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[2/3] 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>
{/* 细节图数量角标:有细节图的主图左下角标「张数」,点它直接开细节灯箱(与首屏图一致) */}
<button
type="button"
x-show="detailCount(im.lookIndex) > 0"
@click.stop="openDetailLb(im.lookIndex)"
class="absolute left-2 bottom-2 z-10 flex items-center gap-1 px-1.5 py-1 bg-white/85 backdrop-blur-sm border border-black/15 text-[#111] text-[10px] font-mono uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
:title="`${detailCount(im.lookIndex)} detail${detailCount(im.lookIndex) > 1 ? 's' : ''}`"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="13" height="13" rx="1.5" />
<path d="M8 21h11a2 2 0 0 0 2-2V8" />
</svg>
<span x-text="detailCount(im.lookIndex)"></span>
</button>
</figure>
</template>
</div>
</section>
)}
{descParas.length > 0 && (
<section class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
{descParas.map((p) => (
<p>{p}</p>
))}
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] font-mono 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(交互)。
走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
{article && previewImages.length > 0 && (
<GalleryLightbox images={previewImages} aspect="2/3">
{/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
主图放大(lbZoomed)时整簇隐藏——放大态要专注当前大图;点其中一张进细节灯箱看大图。
单个 look 最多 4 张细节图(多数 1~2 张),一排横放即可,不设滚动条。 */}
<div
slot="details"
id="lb-details"
x-show="!lbZoomed && currentDetailList.length > 0"
x-cloak
class="absolute right-4 sm:right-6 bottom-[72px] z-30 flex flex-col items-end gap-2"
>
<span class="font-mono text-[10px] uppercase tracking-[0.22em] text-[#999]">Details</span>
<div class="flex max-w-[min(70vw,440px)] gap-2 overflow-x-auto no-scrollbar">
<template x-for="(d, i) in currentDetailList" :key="d.url">
<button
type="button"
@click.stop="openDetailLbAt(i)"
class="relative flex-none w-[52px] sm:w-[68px] aspect-[2/3] overflow-hidden border border-black/15 bg-[#eee] cursor-zoom-in transition-colors hover:border-black"
:aria-label="`Detail ${i + 1}`"
>
<img :src="d.thumb" :alt="d.name || ''" loading="lazy" decoding="async" class="w-full h-full object-cover" />
</button>
</template>
</div>
</div>
</GalleryLightbox>
)}
{/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
{article && previewImages.length > 0 && (
<div
x-show="detailLbOpen"
x-cloak
@keydown.window.escape="detailLbOpen && closeDetailLb()"
@keydown.window.arrow-right="detailLbOpen && nextDetailLb()"
@keydown.window.arrow-left="detailLbOpen && prevDetailLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
aria-hidden="true"
>
<div
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 border border-black/15 bg-white/90 px-3 py-1 font-mono text-[11px] tracking-[0.22em] text-[#111] backdrop-blur-md"
x-text="`${String(detailLbIndex + 1).padStart(2, '0')} / ${String(detailImages.length).padStart(2, '0')}`"
></div>
<button
login-required
type="button"
@click.stop="toggleFav(currentDetailFavId, currentDetailFavUrl)"
:class="isFav(currentDetailFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
aria-label={t('save')}
>
<svg class="w-[18px] h-[18px]" 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>
{/* 关闭 = 退回上一层(主图大图灯箱),不是直接回图集网格 —— 由 closeDetailLb() 判断来源决定。 */}
<button
type="button"
@click="closeDetailLb()"
class="absolute top-5 right-5 z-30 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
aria-label={t('Close')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<div
id="detail-lb-thumbs"
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 no-scrollbar"
>
<template x-for="(im, i) in detailImages" :key="im.url">
<button
type="button"
@click="showDetailLb(i)"
class="relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-cover bg-center"
:style="`background-image:url('${im.thumb}')`"
x-bind:class="detailLbIndex === i ? 'opacity-100 ring-1 ring-black' : ''"
:aria-label="String(i + 1)"
>
<span class="absolute left-1 top-1 z-10 bg-black/70 px-1 py-0.5 font-mono text-[8px] sm:text-[9px] tracking-[0.16em] text-white backdrop-blur-sm" x-text="String(i + 1).padStart(2, '0')"></span>
</button>
</template>
</div>
<button
type="button"
@click.stop="prevDetailLb()"
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
aria-label={t('Previous image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M15 6l-6 6 6 6" />
</svg>
</button>
<button
type="button"
@click.stop="nextDetailLb()"
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
aria-label={t('Next image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M9 6l6 6-6 6" />
</svg>
</button>
<div
id="detail-lb-main"
@click.self="closeDetailLb()"
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
x-bind:class="detailLbZoomed
? 'overflow-y-auto overflow-x-hidden items-start'
: 'items-center'"
>
<img
id="detail-lb-image"
src=""
alt=""
@click="toggleDetailZoom()"
decoding="async"
class="block select-none transition-opacity duration-200"
x-bind:class="detailLbZoomed
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
/>
</div>
</div>
<div
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
x-text="currentDetailName"
></div>
</div>
)}
</div>
<script>
import { getArticleDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { toAbs } from "@/lib/request"
interface DetailImage {
id: string
url: string
thumb: string
raw: string
name: string
}
// 走秀页在大图灯箱之上叠加的部分:每行张数(density)+ 细节图灯箱。
//
// 为什么要把这个类型写出来:`Alpine.data` 的泛型 T 靠**对象字面量**推断,而这里用了对象展开
// ({...createGalleryView()}),推断会退化成 {},于是方法里的 this 只剩 Alpine 魔法属性,
// 所有 this.xxx 都报「属性不存在」。给回调标注返回类型,T 才有确定值。
// (@types/alpinejs 那个自引用泛型约束的老毛病,详见 MEMORY.md。)
type ArticleExtras = {
density: number
currentDetailName: string
detailLbOpen: boolean
detailLbIndex: number
detailLbZoomed: boolean
detailImages: DetailImage[]
detailImagesByLook: Record<number, DetailImage[]>
detailLbFromLb: boolean
lbReturnIndex: number
init(): void
gridClass(): string
getImageLookIndex(index: number): number
currentDetailList: DetailImage[]
openDetailLbAt(idx: number): void
buildDetailMap(): void
detailCount(li: number): number
openDetailLb(li: number, startIndex?: number, fromLb?: boolean): void
closeDetailLb(): void
showDetailLb(index: number): void
nextDetailLb(): void
prevDetailLb(): void
normalizeDetailIndex(index: number): number
updateDetailImage(): void
detailImageUrl(index: number): string
detailImageName(index: number): string
currentDetailFavId: string
currentDetailFavUrl: string
toggleDetailZoom(): void
scrollDetailThumbIntoView(): void
preloadDetailAround(): void
}
// 图集 + 大图灯箱的共享逻辑在 lib/gallery.ts;本组件只叠加走秀独有的「每行张数」与「细节图灯箱」。
document.addEventListener("alpine:init", () => {
Alpine.data(
"articleView",
(): ReturnType<typeof createGalleryView> & ArticleExtras => ({
...createGalleryView({
rootSelector: "#article-view",
gallerySelector: "#article-gallery",
favType: "runway_image",
fetchDetail: getArticleDetailAuthed,
}),
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
density: 5,
// 细节图灯箱:点大图灯箱里的细节缩略图打开,展示该 look 的细节图集合。
currentDetailName: "",
detailLbOpen: false,
detailLbIndex: 0,
detailLbZoomed: false,
detailImages: [] as { id: string; url: string; thumb: string; raw: string; name: string }[],
// look_index -> 该主图细节图数组(init 由 data-details 解析构建),供大图灯箱右下角展示与细节灯箱使用。
detailImagesByLook: {} as Record<number, { id: string; url: string; thumb: string; raw: string; name: string }[]>,
// 细节灯箱是否由主图大图灯箱打开(true 时关闭细节灯箱要退回主图,而不是掉回图集网格);
// lbReturnIndex 记住退回时的图片序号。
detailLbFromLb: false,
lbReturnIndex: 0,
init() {
// 本组件独有:解析后端 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
this.buildDetailMap()
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
this.galleryInit()
},
// ===== 每行张数(密度) =====
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
gridClass() {
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
},
// 读第 index 张大图所属的主图序号(look_index),来自 figure 上的 data-look-index。
// SSR 首屏图与客户端补拉的 extraImages 都带该属性,故大图灯箱里也能定位到当前 look。
getImageLookIndex(index: number): number {
const li = Number(this.getImageElement(index)?.dataset.lookIndex ?? 0)
return Number.isFinite(li) ? li : 0
},
// 当前大图所属 look 的细节图列表:供大图灯箱右下角那一簇缩略图直接渲染。
// 依赖响应式的 lbIndex,切图/翻页时自动跟随;该 look 没有细节图时返回空数组(整簇隐藏)。
get currentDetailList(): { id: string; url: string; thumb: string; raw: string; name: string }[] {
const li = this.getImageLookIndex(this.lbIndex)
return li ? (this.detailImagesByLook[li] || []) : []
},
// 点大图灯箱里的细节缩略图:先关大图灯箱(二者同为 z-[100],避免叠层),
// 再打开细节灯箱并定位到该 look 的第 idx 张细节图。
// 记下来源与当前位置,使细节灯箱关闭时能「退回主图」而不是直接掉回图集网格。
openDetailLbAt(idx: number) {
const li = this.getImageLookIndex(this.lbIndex)
const list = this.detailImagesByLook[li]
if (!list || !list.length) return
this.detailLbFromLb = true
this.lbReturnIndex = this.lbIndex
this.closeLb()
this.openDetailLb(li, idx, true)
},
// ===== 细节图分组 =====
// 由根节点 data-details(后端按 look_index 聚合的细节图 JSON)构建 look_index -> 图片数组映射。
buildDetailMap() {
try {
const raw = this.root()?.dataset.details
const arr: any[] = raw ? JSON.parse(raw) : []
const map: Record<number, { id: string; url: string; thumb: string; raw: string; name: string }[]> = {}
for (const d of arr) {
const li = Number(d.look_index ?? 0)
if (!li) continue
map[li] = (d.images || []).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
thumb: toAbs(im.thumb || im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
}
this.detailImagesByLook = map
} catch {
this.detailImagesByLook = {}
}
},
// 网格角标用:某主图(look_index)的细节图数量。无细节图返回 0。
detailCount(li: number): number {
const arr = this.detailImagesByLook[Number(li) || 0]
return arr ? arr.length : 0
},
// 打开细节灯箱,展示该 look 的细节图。startIndex 指定起始张(默认第 1 张)——
// 大图灯箱里点第 N 张细节缩略图时按 N 打开,避免总是回到第一张。
// fromLb=true 表示由大图灯箱进入(关闭时退回主图);false 表示从网格角标直接进(关闭回网格)。
openDetailLb(li: number, startIndex = 0, fromLb = false) {
const list = this.detailImagesByLook[li]
if (!list || !list.length) return
this.detailLbFromLb = fromLb
this.detailImages = list
this.detailLbIndex = this.normalizeDetailIndex(startIndex)
this.detailLbZoomed = false
this.detailLbOpen = true
document.body.style.overflow = "hidden"
this.updateDetailImage()
requestAnimationFrame(() => {
this.scrollDetailThumbIntoView()
this.preloadDetailAround()
})
},
// 关闭细节灯箱。若它是从主图大图灯箱进来的,则「退回主图」——重新打开大图灯箱并回到原来的图片序号;
// 否则(未来若有其它入口)才是真正关闭、恢复页面滚动。
closeDetailLb() {
this.detailLbOpen = false
this.detailLbZoomed = false
if (this.detailLbFromLb) {
this.detailLbFromLb = false
this.openLb(this.lbReturnIndex)
return
}
document.body.style.overflow = ""
},
showDetailLb(index: number) {
if (!this.detailImages.length) return
this.detailLbIndex = this.normalizeDetailIndex(index)
this.detailLbZoomed = false
this.updateDetailImage()
const main = document.getElementById("detail-lb-main")
if (main) main.scrollTop = 0
requestAnimationFrame(() => {
this.scrollDetailThumbIntoView()
this.preloadDetailAround()
})
},
nextDetailLb() {
this.showDetailLb(this.detailLbIndex + 1)
},
prevDetailLb() {
this.showDetailLb(this.detailLbIndex - 1)
},
normalizeDetailIndex(index: number) {
const n = this.detailImages.length
if (!n) return 0
return ((index % n) + n) % n
},
updateDetailImage() {
const image = document.getElementById("detail-lb-image") as HTMLImageElement | null
if (!image) return
image.src = this.detailImageUrl(this.detailLbIndex)
image.alt = this.detailImageName(this.detailLbIndex)
this.currentDetailName = this.detailImageName(this.detailLbIndex)
},
detailImageUrl(index: number): string {
return this.detailImages[index]?.url || ""
},
detailImageName(index: number): string {
return this.detailImages[index]?.name || ""
},
get currentDetailFavId() {
return this.detailImages[this.detailLbIndex]?.id || ""
},
get currentDetailFavUrl() {
return this.detailImages[this.detailLbIndex]?.raw || ""
},
toggleDetailZoom() {
this.detailLbZoomed = !this.detailLbZoomed
const main = document.getElementById("detail-lb-main")
if (main) main.scrollTop = 0
},
scrollDetailThumbIntoView() {
const thumbs = document.getElementById("detail-lb-thumbs") as HTMLElement | null
if (!thumbs) return
const children = Array.from(thumbs.querySelectorAll<HTMLElement>("button"))
const el = children[this.detailLbIndex]
if (!el) return
const target = el.offsetTop - (thumbs.clientHeight - el.clientHeight) / 2
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
},
preloadDetailAround() {
const indexes = [
this.detailLbIndex,
this.normalizeDetailIndex(this.detailLbIndex - 1),
this.normalizeDetailIndex(this.detailLbIndex + 1),
]
indexes.forEach((index) => {
const url = this.detailImageUrl(index)
if (!url) return
const image = new Image()
image.decoding = "async"
image.src = url
})
},
}),
)
})
</script>