update
This commit is contained in:
29
src/components/DetailCountBadge.astro
Normal file
29
src/components/DetailCountBadge.astro
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
// 网格主图左下角的「细节图 / 副图 张数」角标:有则显示(图层图标 + 数字),点它直接开细节灯箱;
|
||||||
|
// 纯主图(无细节图)不渲染。数量走 Alpine 的 detailCount()(响应式)—— 未登录取前 PREVIEW_LIMIT 张
|
||||||
|
// 的细节子树,登录后由 gallery:full 重建映射,两态共用同一条渲染路径。
|
||||||
|
//
|
||||||
|
// look:Alpine 表达式,表示该主图的 look_index。SSR 首屏传字面量(如 "3"),
|
||||||
|
// 客户端补拉的 x-for 里传表达式(如 "im.lookIndex")。
|
||||||
|
interface Props {
|
||||||
|
look: string
|
||||||
|
}
|
||||||
|
const { look } = Astro.props
|
||||||
|
const count = `detailCount(${look})`
|
||||||
|
---
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-show={`${count} > 0`}
|
||||||
|
x-cloak
|
||||||
|
@click.stop={`openDetailLb(${look})`}
|
||||||
|
:title={`${count} + ' detail' + (${count} > 1 ? 's' : '')`}
|
||||||
|
:aria-label={`${count} + ' detail' + (${count} > 1 ? 's' : '')`}
|
||||||
|
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] uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<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={count}></span>
|
||||||
|
</button>
|
||||||
115
src/components/GalleryDetailLightbox.astro
Normal file
115
src/components/GalleryDetailLightbox.astro
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
// 细节图 / 副图 灯箱:由主图大图灯箱里的细节缩略条打开的「单图放大」层。
|
||||||
|
// 走秀(RunwayLookCard)与街拍(StreetSnapCard)共用;交互在 lib/galleryDetail.ts。
|
||||||
|
import { getI18n } from "@/lib/i18n"
|
||||||
|
|
||||||
|
const { t } = getI18n(Astro)
|
||||||
|
---
|
||||||
|
|
||||||
|
<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 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 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 text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
||||||
|
x-text="currentDetailName"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
26
src/components/LbDetailsStrip.astro
Normal file
26
src/components/LbDetailsStrip.astro
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
// 大图灯箱右下角的「当前主图的细节图 / 副图」缩略簇,注入 GalleryLightbox 的 details 具名插槽。
|
||||||
|
// 主图放大(lbZoomed)时整簇隐藏;点其中一张进细节灯箱看大图。
|
||||||
|
// 走秀与街拍共用,故不放任何页面专有逻辑(只读 lib/galleryDetail.ts 的 currentDetailList)。
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
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="text-[10px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">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-[var(--c-gray-200)] 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>
|
||||||
@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
import type { RawArticle } from "@/lib/api"
|
import type { RawArticle } from "@/lib/api"
|
||||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||||
|
import DetailCountBadge from "@/components/DetailCountBadge.astro"
|
||||||
|
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
|
||||||
|
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
|
||||||
import { getI18n } from "@/lib/i18n"
|
import { getI18n } from "@/lib/i18n"
|
||||||
import { href, ROUTES } from '@/lib/routes'
|
import { href, ROUTES } from '@/lib/routes'
|
||||||
import { ssrAbs } from "@/lib/request"
|
import { ssrAbs } from "@/lib/request"
|
||||||
@ -171,25 +174,8 @@ const homeHref = href(locale, ROUTES.home)
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 细节图数量角标:有细节图的主图在左下角标注「张数」(图层图标 + 数字),
|
{/* 细节图数量角标(共享组件):有细节图的主图左下角标「张数」,点它开细节灯箱。 */}
|
||||||
点它直接开细节灯箱。纯主图(无细节图)不显示任何角标,保持网格干净。
|
<DetailCountBadge look={String(im.lookIndex)} />
|
||||||
数量走 Alpine 的 detailCount()(响应式):未登录取前 PREVIEW_LIMIT 张的细节子树,
|
|
||||||
登录后由 gallery:full 重建映射,两态共用同一条渲染路径。 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
x-show={`detailCount(${im.lookIndex}) > 0`}
|
|
||||||
x-cloak
|
|
||||||
@click.stop={`openDetailLb(${im.lookIndex})`}
|
|
||||||
:title={`detailCount(${im.lookIndex}) + ' detail' + (detailCount(${im.lookIndex}) > 1 ? 's' : '')`}
|
|
||||||
:aria-label={`detailCount(${im.lookIndex}) + ' detail' + (detailCount(${im.lookIndex}) > 1 ? 's' : '')`}
|
|
||||||
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] uppercase tracking-[0.14em] cursor-pointer hover:bg-black hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<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>
|
</figure>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -255,20 +241,8 @@ const homeHref = href(locale, ROUTES.home)
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* 细节图数量角标:有细节图的主图左下角标「张数」,点它直接开细节灯箱(与首屏图一致) */}
|
{/* 细节图数量角标(共享组件,客户端补拉图,与首屏一致) */}
|
||||||
<button
|
<DetailCountBadge look="im.lookIndex" />
|
||||||
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] 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>
|
</figure>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -302,421 +276,40 @@ const homeHref = href(locale, ROUTES.home)
|
|||||||
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。 走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
|
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。 走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
|
||||||
{article && previewImages.length > 0 && (
|
{article && previewImages.length > 0 && (
|
||||||
<GalleryLightbox images={previewImages} aspect="2/3">
|
<GalleryLightbox images={previewImages} aspect="2/3">
|
||||||
{/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
|
{/* 当前主图的细节图缩略簇(共享组件) */}
|
||||||
主图放大(lbZoomed)时整簇隐藏——放大态要专注当前大图;点其中一张进细节灯箱看大图。
|
<LbDetailsStrip slot="details" />
|
||||||
单个 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="text-[10px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">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-[var(--c-gray-200)] 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>
|
</GalleryLightbox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
|
{/* 细节图灯箱(共享组件):点大图灯箱里的细节缩略图打开,单张细节图放大查看 */}
|
||||||
{article && previewImages.length > 0 && (
|
{article && previewImages.length > 0 && <GalleryDetailLightbox />}
|
||||||
<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 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 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 text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
|
|
||||||
x-text="currentDetailName"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getArticleDetailAuthed } from "@/lib/api"
|
import { getArticleDetailAuthed } from "@/lib/api"
|
||||||
import { createGalleryView } from "@/lib/gallery"
|
import { createGalleryView } from "@/lib/gallery"
|
||||||
import { toAbs } from "@/lib/request"
|
import { createDetailGallery } from "@/lib/galleryDetail"
|
||||||
|
|
||||||
interface DetailImage {
|
// 走秀详情:与街拍共用「图集 + 大图灯箱 + 细节图灯箱」
|
||||||
id: string
|
// (lib/gallery.ts + lib/galleryDetail.ts);本组件只叠加走秀独有的「每行张数」。
|
||||||
url: string
|
|
||||||
thumb: string
|
|
||||||
raw: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 走秀页在大图灯箱之上叠加的部分:每行张数(density)+ 细节图灯箱。
|
|
||||||
//
|
|
||||||
// 为什么要把这个类型写出来:`Alpine.data` 的泛型 T 靠**对象字面量**推断,而这里用了对象展开
|
|
||||||
// ({...createGalleryView()}),推断会退化成 {},于是方法里的 this 只剩 Alpine 魔法属性,
|
|
||||||
// 所有 this.xxx 都报「属性不存在」。给回调标注返回类型,T 才有确定值。
|
|
||||||
// (@types/alpinejs 那个自引用泛型约束的老毛病。)
|
|
||||||
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
|
|
||||||
buildDetailMapFrom(images: any[]): 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", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data(
|
Alpine.data("articleView", () => ({
|
||||||
"articleView",
|
...createDetailGallery(
|
||||||
(): ReturnType<typeof createGalleryView> & ArticleExtras => ({
|
createGalleryView({
|
||||||
...createGalleryView({
|
rootSelector: "#article-view",
|
||||||
rootSelector: "#article-view",
|
gallerySelector: "#article-gallery",
|
||||||
gallerySelector: "#article-gallery",
|
favType: "runway_image",
|
||||||
favType: "runway_image",
|
fetchDetail: getArticleDetailAuthed,
|
||||||
fetchDetail: getArticleDetailAuthed,
|
}),
|
||||||
}),
|
),
|
||||||
|
|
||||||
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
|
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
|
||||||
density: 5,
|
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() {
|
|
||||||
// 本组件独有:从 SSR 载荷构建「细节入口」用的 look_index -> 细节图 映射。
|
|
||||||
this.buildDetailMap()
|
|
||||||
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图的细节子树;登录后 loadAuthedState 拿回完整图集,
|
|
||||||
// 需据其重建映射,否则第 6 张起的角标 / 细节灯箱仍为空(详见 lib/gallery.ts 的 gallery:full)。
|
|
||||||
// 先挂监听再初始化:galleryInit() 内部可能立刻触发一次登录态取数。
|
|
||||||
window.addEventListener("gallery:full", ((e: Event) => {
|
|
||||||
const imgs = (e as CustomEvent).detail?.images
|
|
||||||
this.buildDetailMapFrom(Array.isArray(imgs) ? imgs : [])
|
|
||||||
}) as EventListener)
|
|
||||||
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
|
|
||||||
this.galleryInit()
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 每行张数(密度) =====
|
|
||||||
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
|
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
|
||||||
gridClass() {
|
gridClass(): string {
|
||||||
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
|
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)
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 细节图分组 =====
|
|
||||||
// 由「主图数组」构建 look_index -> 细节图数组 映射。两个来源同构,共用本函数:
|
|
||||||
// ① SSR:根节点 data-details(只含带细节图的主图投影 [{ look_index, detail }])
|
|
||||||
// ② 登录后:详情接口返回的完整 images(细节图已嵌套在每张主图的 detail 下)
|
|
||||||
buildDetailMapFrom(images: any[]) {
|
|
||||||
const map: Record<number, { id: string; url: string; thumb: string; raw: string; name: string }[]> = {}
|
|
||||||
for (const im of images || []) {
|
|
||||||
const li = Number(im?.look_index ?? 0)
|
|
||||||
const kids: any[] = Array.isArray(im?.detail) ? im.detail : []
|
|
||||||
if (!li || !kids.length) continue
|
|
||||||
map[li] = kids.map((d: any) => ({
|
|
||||||
id: d.id || "",
|
|
||||||
url: toAbs(d.image || ""),
|
|
||||||
thumb: toAbs(d.thumb || d.image || ""),
|
|
||||||
raw: d.image || "",
|
|
||||||
name: d.name || "",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
this.detailImagesByLook = map
|
|
||||||
},
|
|
||||||
|
|
||||||
buildDetailMap() {
|
|
||||||
try {
|
|
||||||
const raw = this.root()?.dataset.details
|
|
||||||
this.buildDetailMapFrom(raw ? JSON.parse(raw) : [])
|
|
||||||
} 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>
|
</script>
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
import type { RawStreetSnap } from "@/lib/api"
|
import type { RawStreetSnap } from "@/lib/api"
|
||||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||||
|
import DetailCountBadge from "@/components/DetailCountBadge.astro"
|
||||||
|
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
|
||||||
|
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
|
||||||
import { getI18n } from "@/lib/i18n"
|
import { getI18n } from "@/lib/i18n"
|
||||||
import { href, ROUTES } from '@/lib/routes'
|
import { href, ROUTES } from '@/lib/routes'
|
||||||
import { ssrAbs } from "@/lib/request"
|
import { ssrAbs } from "@/lib/request"
|
||||||
@ -23,6 +26,7 @@ const images = (snap?.images ?? [])
|
|||||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||||
name: im.name || "",
|
name: im.name || "",
|
||||||
|
lookIndex: Number(im.look_index ?? 0),
|
||||||
}))
|
}))
|
||||||
.filter((im) => im.url)
|
.filter((im) => im.url)
|
||||||
|
|
||||||
@ -35,6 +39,12 @@ const previewImages = images.slice(0, PREVIEW_LIMIT)
|
|||||||
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
|
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
|
||||||
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
|
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
|
||||||
|
|
||||||
|
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(look_index + detail),
|
||||||
|
// 供 lib/galleryDetail.ts 构建 look_index → 副图 映射(与登录后完整 images 同构解析)。
|
||||||
|
const detailPayload = (snap?.images ?? [])
|
||||||
|
.filter((im) => (im.detail?.length ?? 0) > 0)
|
||||||
|
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
|
||||||
|
|
||||||
const homeHref = href(locale, ROUTES.home)
|
const homeHref = href(locale, ROUTES.home)
|
||||||
|
|
||||||
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
|
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
|
||||||
@ -49,6 +59,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
data-gallery-title={displayTitle}
|
data-gallery-title={displayTitle}
|
||||||
data-gallery-brand=""
|
data-gallery-brand=""
|
||||||
data-preview={preview ? "true" : "false"}
|
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"
|
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
@ -67,6 +78,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
{previewImages.map((im, i) => (
|
{previewImages.map((im, i) => (
|
||||||
<figure
|
<figure
|
||||||
data-image-index={i}
|
data-image-index={i}
|
||||||
|
data-look-index={im.lookIndex}
|
||||||
data-image-url={im.url}
|
data-image-url={im.url}
|
||||||
data-image-name={im.name}
|
data-image-name={im.name}
|
||||||
data-fav-id={im.id}
|
data-fav-id={im.id}
|
||||||
@ -98,6 +110,9 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它开副图灯箱 */}
|
||||||
|
<DetailCountBadge look={String(im.lookIndex)} />
|
||||||
|
|
||||||
{im.name && (
|
{im.name && (
|
||||||
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">
|
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">
|
||||||
{im.name}
|
{im.name}
|
||||||
@ -137,6 +152,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
<template x-for="(im, i) in extraImages" :key="im.url" >
|
<template x-for="(im, i) in extraImages" :key="im.url" >
|
||||||
<figure
|
<figure
|
||||||
:data-image-index="5 + i"
|
:data-image-index="5 + i"
|
||||||
|
:data-look-index="im.lookIndex"
|
||||||
:data-image-url="im.url"
|
:data-image-url="im.url"
|
||||||
:data-image-name="im.name"
|
:data-image-name="im.name"
|
||||||
:data-fav-id="im.id"
|
:data-fav-id="im.id"
|
||||||
@ -165,6 +181,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<DetailCountBadge look="im.lookIndex" />
|
||||||
<template x-if="im.name">
|
<template x-if="im.name">
|
||||||
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2" x-text="im.name"></figcaption>
|
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2" x-text="im.name"></figcaption>
|
||||||
</template>
|
</template>
|
||||||
@ -193,33 +210,33 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
|||||||
}
|
}
|
||||||
|
|
||||||
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
|
||||||
街拍无细节图分组,故不传 details 插槽。 */}
|
当前主图的副图经 details 插槽注入(与走秀一致)。 */}
|
||||||
{snap && previewImages.length > 0 && (
|
{snap && previewImages.length > 0 && (
|
||||||
<GalleryLightbox images={previewImages} aspect="3/4" />
|
<GalleryLightbox images={previewImages} aspect="3/4">
|
||||||
|
<LbDetailsStrip slot="details" />
|
||||||
|
</GalleryLightbox>
|
||||||
)}
|
)}
|
||||||
|
{/* 副图(细节)灯箱:点大图灯箱右下角的副图缩略图打开 */}
|
||||||
|
{snap && previewImages.length > 0 && <GalleryDetailLightbox />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getStreetSnapDetailAuthed } from "@/lib/api"
|
import { getStreetSnapDetailAuthed } from "@/lib/api"
|
||||||
import { createGalleryView } from "@/lib/gallery"
|
import { createGalleryView } from "@/lib/gallery"
|
||||||
|
import { createDetailGallery } from "@/lib/galleryDetail"
|
||||||
|
|
||||||
// 图集 + 大图灯箱的共享逻辑全在 lib/gallery.ts;街拍详情没有额外扩展,只补一个 init 调共享初始化。
|
// 街拍详情:与走秀共用「图集 + 大图灯箱 + 细节(副图)灯箱」
|
||||||
// 回调显式标注返回类型:对象展开会让 Alpine.data 的泛型 T 退化成 {},this.xxx 全部报错(见 RunwayLookCard.astro 说明)。
|
// (lib/gallery.ts + lib/galleryDetail.ts),无额外扩展。
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data(
|
Alpine.data("streetSnapView", () =>
|
||||||
"streetSnapView",
|
createDetailGallery(
|
||||||
(): ReturnType<typeof createGalleryView> & { init(): void } => ({
|
createGalleryView({
|
||||||
...createGalleryView({
|
|
||||||
rootSelector: "#street-snap-view",
|
rootSelector: "#street-snap-view",
|
||||||
gallerySelector: "#snap-gallery",
|
gallerySelector: "#snap-gallery",
|
||||||
favType: "snap_image",
|
favType: "snap_image",
|
||||||
fetchDetail: getStreetSnapDetailAuthed,
|
fetchDetail: getStreetSnapDetailAuthed,
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
init() {
|
|
||||||
this.galleryInit()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -121,7 +121,9 @@ export interface RawStreetSnap {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
cover: string
|
cover: string
|
||||||
images?: { id?: string; image: string; thumb?: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用;thumb 供灯箱缩略图条用(后端未返回时前端回落展示图)
|
// 详情接口 images 仅含主图(is_detail=0),每张主图的副图(细节图)嵌套在它自己的 detail 子数组下。
|
||||||
|
// 复用 RawArticleImage(与走秀详情同形):look_index 标识主图序号,detail 为副图。
|
||||||
|
images?: RawArticleImage[]
|
||||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
264
src/lib/galleryDetail.ts
Normal file
264
src/lib/galleryDetail.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
// src/lib/galleryDetail.ts —— 详情页的「细节图 / 副图」扩展(走秀与街拍共用)。
|
||||||
|
//
|
||||||
|
// 叠加在 lib/gallery.ts 的 createGalleryView 之上,提供:
|
||||||
|
// - 由 SSR 载荷(根节点 data-details)或登录后完整 images 构建 look_index → 细节图 映射
|
||||||
|
// - 网格角标用的 detailCount()
|
||||||
|
// - 细节图灯箱(打开 / 翻页 / 缩放 / 收藏 / 退回主图)
|
||||||
|
//
|
||||||
|
// 用途:走秀详情(RunwayLookCard)与街拍详情(StreetSnapCard)逐行同构,故抽到此处共用;
|
||||||
|
// 组件仍可在工厂返回值之上叠加自身独有的部分(如走秀的「每行张数」)。
|
||||||
|
//
|
||||||
|
// 注意:与 lib/gallery.ts 同样的坑 —— 返回值会被 `{...createDetailGallery(...)}` 展开进
|
||||||
|
// Alpine.data 的对象字面量,故这里用**显式返回类型**给对象字面量做上下文类型标注,
|
||||||
|
// 让方法 / getter 里的 `this` 拿到完整类型(含 createGalleryView 的成员)。
|
||||||
|
|
||||||
|
import { toAbs } from "./request"
|
||||||
|
import type { createGalleryView } from "./gallery"
|
||||||
|
|
||||||
|
/** 一张细节图(已拼成可访问 URL)。 */
|
||||||
|
export interface DetailImage {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
thumb: string
|
||||||
|
raw: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailGalleryExtras {
|
||||||
|
currentDetailName: string
|
||||||
|
detailLbOpen: boolean
|
||||||
|
detailLbIndex: number
|
||||||
|
detailLbZoomed: boolean
|
||||||
|
detailImages: DetailImage[]
|
||||||
|
/** look_index → 该主图的细节图数组(init 由 data-details 构建,登录后由 gallery:full 重建)。 */
|
||||||
|
detailImagesByLook: Record<number, DetailImage[]>
|
||||||
|
/** 细节灯箱是否由主图大图灯箱打开(true 时关闭要退回主图,而不是掉回网格)。 */
|
||||||
|
detailLbFromLb: boolean
|
||||||
|
lbReturnIndex: number
|
||||||
|
|
||||||
|
/** 当前大图所属主图的细节图列表(供大图灯箱右下角缩略簇渲染)。 */
|
||||||
|
currentDetailList: DetailImage[]
|
||||||
|
currentDetailFavId: string
|
||||||
|
currentDetailFavUrl: string
|
||||||
|
|
||||||
|
init(): void
|
||||||
|
getImageLookIndex(index: number): number
|
||||||
|
openDetailLbAt(idx: number): void
|
||||||
|
buildDetailMapFrom(images: any[]): 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
|
||||||
|
toggleDetailZoom(): void
|
||||||
|
scrollDetailThumbIntoView(): void
|
||||||
|
preloadDetailAround(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 createGalleryView 的返回值之上叠加「细节图」扩展。
|
||||||
|
* 用法:`{ ...createDetailGallery(createGalleryView(cfg)), ...组件自身扩展 }`
|
||||||
|
*/
|
||||||
|
export function createDetailGallery<T extends ReturnType<typeof createGalleryView>>(
|
||||||
|
base: T,
|
||||||
|
): T & DetailGalleryExtras {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
|
||||||
|
currentDetailName: "",
|
||||||
|
detailLbOpen: false,
|
||||||
|
detailLbIndex: 0,
|
||||||
|
detailLbZoomed: false,
|
||||||
|
detailImages: [] as DetailImage[],
|
||||||
|
detailImagesByLook: {} as Record<number, DetailImage[]>,
|
||||||
|
detailLbFromLb: false,
|
||||||
|
lbReturnIndex: 0,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// 本扩展独有:从 SSR 载荷构建「细节入口」用的 look_index → 细节图 映射。
|
||||||
|
this.buildDetailMap()
|
||||||
|
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图的细节子树;登录后 loadAuthedState 拿回完整图集,
|
||||||
|
// 需据其重建映射,否则第 6 张起的角标 / 细节灯箱仍为空(详见 lib/gallery.ts 的 gallery:full)。
|
||||||
|
// 先挂监听再初始化:galleryInit() 内部可能立刻触发一次登录态取数。
|
||||||
|
window.addEventListener("gallery:full", ((e: Event) => {
|
||||||
|
const imgs = (e as CustomEvent).detail?.images
|
||||||
|
this.buildDetailMapFrom(Array.isArray(imgs) ? imgs : [])
|
||||||
|
}) as EventListener)
|
||||||
|
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
|
||||||
|
this.galleryInit()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 读第 index 张大图所属的主图序号(look_index),来自 figure 上的 data-look-index。
|
||||||
|
getImageLookIndex(index: number): number {
|
||||||
|
const li = Number(this.getImageElement(index)?.dataset.lookIndex ?? 0)
|
||||||
|
return Number.isFinite(li) ? li : 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// 当前大图所属主图的细节图列表;该主图没有细节图时返回空数组(整簇隐藏)。
|
||||||
|
get currentDetailList(): DetailImage[] {
|
||||||
|
const li = this.getImageLookIndex(this.lbIndex)
|
||||||
|
return li ? this.detailImagesByLook[li] || [] : []
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点大图灯箱里的细节缩略图:先关大图灯箱(同为 z-[100],避免叠层),再开细节灯箱定位到 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)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 由「主图数组」构建 look_index → 细节图数组 映射。两个来源同构,共用本函数:
|
||||||
|
// ① SSR:根节点 data-details(只含带细节图的主图投影 [{ look_index, detail }])
|
||||||
|
// ② 登录后:详情接口返回的完整 images(细节图已嵌套在每张主图的 detail 下)
|
||||||
|
buildDetailMapFrom(images: any[]) {
|
||||||
|
const map: Record<number, DetailImage[]> = {}
|
||||||
|
for (const im of images || []) {
|
||||||
|
const li = Number(im?.look_index ?? 0)
|
||||||
|
const kids: any[] = Array.isArray(im?.detail) ? im.detail : []
|
||||||
|
if (!li || !kids.length) continue
|
||||||
|
map[li] = kids.map((d: any) => ({
|
||||||
|
id: d.id || "",
|
||||||
|
url: toAbs(d.image || ""),
|
||||||
|
thumb: toAbs(d.thumb || d.image || ""),
|
||||||
|
raw: d.image || "",
|
||||||
|
name: d.name || "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
this.detailImagesByLook = map
|
||||||
|
},
|
||||||
|
|
||||||
|
buildDetailMap() {
|
||||||
|
try {
|
||||||
|
const raw = this.root()?.dataset.details
|
||||||
|
this.buildDetailMapFrom(raw ? JSON.parse(raw) : [])
|
||||||
|
} catch {
|
||||||
|
this.detailImagesByLook = {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 网格角标用:某主图(look_index)的细节图数量。无细节图返回 0。
|
||||||
|
detailCount(li: number): number {
|
||||||
|
const arr = this.detailImagesByLook[Number(li) || 0]
|
||||||
|
return arr ? arr.length : 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// 打开细节灯箱。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
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user