698 lines
33 KiB
Plaintext
698 lines
33 KiB
Plaintext
---
|
||
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>
|