From 9473f00c98d61d0bbaaafbf88d11bd333738d071 Mon Sep 17 00:00:00 2001
From: toom1996 <23cm.cn@gmail.com>
Date: Tue, 22 Sep 2026 11:12:08 +0800
Subject: [PATCH] update
---
src/components/DetailCountBadge.astro | 29 ++
src/components/GalleryDetailLightbox.astro | 115 ++++++
src/components/LbDetailsStrip.astro | 26 ++
src/components/RunwayLookCard.astro | 457 ++-------------------
src/components/StreetSnapCard.astro | 43 +-
src/lib/api.ts | 4 +-
src/lib/galleryDetail.ts | 264 ++++++++++++
7 files changed, 492 insertions(+), 446 deletions(-)
create mode 100644 src/components/DetailCountBadge.astro
create mode 100644 src/components/GalleryDetailLightbox.astro
create mode 100644 src/components/LbDetailsStrip.astro
create mode 100644 src/lib/galleryDetail.ts
diff --git a/src/components/DetailCountBadge.astro b/src/components/DetailCountBadge.astro
new file mode 100644
index 0000000..8208560
--- /dev/null
+++ b/src/components/DetailCountBadge.astro
@@ -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})`
+---
+
+
diff --git a/src/components/GalleryDetailLightbox.astro b/src/components/GalleryDetailLightbox.astro
new file mode 100644
index 0000000..4519531
--- /dev/null
+++ b/src/components/GalleryDetailLightbox.astro
@@ -0,0 +1,115 @@
+---
+// 细节图 / 副图 灯箱:由主图大图灯箱里的细节缩略条打开的「单图放大」层。
+// 走秀(RunwayLookCard)与街拍(StreetSnapCard)共用;交互在 lib/galleryDetail.ts。
+import { getI18n } from "@/lib/i18n"
+
+const { t } = getI18n(Astro)
+---
+
+
+
+
+
+
+ {/* 关闭 = 退回上一层(主图大图灯箱),不是直接回图集网格 —— 由 closeDetailLb() 判断来源决定。 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
diff --git a/src/components/LbDetailsStrip.astro b/src/components/LbDetailsStrip.astro
new file mode 100644
index 0000000..ddfff33
--- /dev/null
+++ b/src/components/LbDetailsStrip.astro
@@ -0,0 +1,26 @@
+---
+// 大图灯箱右下角的「当前主图的细节图 / 副图」缩略簇,注入 GalleryLightbox 的 details 具名插槽。
+// 主图放大(lbZoomed)时整簇隐藏;点其中一张进细节灯箱看大图。
+// 走秀与街拍共用,故不放任何页面专有逻辑(只读 lib/galleryDetail.ts 的 currentDetailList)。
+---
+
+
diff --git a/src/components/RunwayLookCard.astro b/src/components/RunwayLookCard.astro
index 740ceee..4cc7dc0 100644
--- a/src/components/RunwayLookCard.astro
+++ b/src/components/RunwayLookCard.astro
@@ -1,6 +1,9 @@
---
import type { RawArticle } from "@/lib/api"
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 { href, ROUTES } from '@/lib/routes'
import { ssrAbs } from "@/lib/request"
@@ -171,25 +174,8 @@ const homeHref = href(locale, ROUTES.home)
- {/* 细节图数量角标:有细节图的主图在左下角标注「张数」(图层图标 + 数字),
- 点它直接开细节灯箱。纯主图(无细节图)不显示任何角标,保持网格干净。
- 数量走 Alpine 的 detailCount()(响应式):未登录取前 PREVIEW_LIMIT 张的细节子树,
- 登录后由 gallery:full 重建映射,两态共用同一条渲染路径。 */}
-
+ {/* 细节图数量角标(共享组件):有细节图的主图左下角标「张数」,点它开细节灯箱。 */}
+
)
})}
@@ -255,20 +241,8 @@ const homeHref = href(locale, ROUTES.home)
- {/* 细节图数量角标:有细节图的主图左下角标「张数」,点它直接开细节灯箱(与首屏图一致) */}
-
+ {/* 细节图数量角标(共享组件,客户端补拉图,与首屏一致) */}
+
@@ -302,421 +276,40 @@ const homeHref = href(locale, ROUTES.home)
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。 走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
{article && previewImages.length > 0 && (
- {/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
- 主图放大(lbZoomed)时整簇隐藏——放大态要专注当前大图;点其中一张进细节灯箱看大图。
- 单个 look 最多 4 张细节图(多数 1~2 张),一排横放即可,不设滚动条。 */}
-
+ {/* 当前主图的细节图缩略簇(共享组件) */}
+
)}
- {/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
- {article && previewImages.length > 0 && (
-
-
-
-
-
- {/* 关闭 = 退回上一层(主图大图灯箱),不是直接回图集网格 —— 由 closeDetailLb() 判断来源决定。 */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
-
-
-
-
- )}
+ {/* 细节图灯箱(共享组件):点大图灯箱里的细节缩略图打开,单张细节图放大查看 */}
+ {article && previewImages.length > 0 && }
diff --git a/src/components/StreetSnapCard.astro b/src/components/StreetSnapCard.astro
index 4ab360a..9899a00 100644
--- a/src/components/StreetSnapCard.astro
+++ b/src/components/StreetSnapCard.astro
@@ -1,6 +1,9 @@
---
import type { RawStreetSnap } from "@/lib/api"
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 { href, ROUTES } from '@/lib/routes'
import { ssrAbs } from "@/lib/request"
@@ -23,6 +26,7 @@ const images = (snap?.images ?? [])
thumb: ssrAbs(im.thumb || im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "",
+ lookIndex: Number(im.look_index ?? 0),
}))
.filter((im) => im.url)
@@ -35,6 +39,12 @@ const previewImages = images.slice(0, PREVIEW_LIMIT)
// 已登录(preview=false)或图片本就不超上限时 lockedCount=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)
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
@@ -49,6 +59,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
data-gallery-title={displayTitle}
data-gallery-brand=""
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"
>
{
@@ -67,6 +78,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
{previewImages.map((im, i) => (
+ {/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它开副图灯箱 */}
+
+
{im.name && (
{im.name}
@@ -137,6 +152,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
+
@@ -193,33 +210,33 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
}
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
- 街拍无细节图分组,故不传 details 插槽。 */}
+ 当前主图的副图经 details 插槽注入(与走秀一致)。 */}
{snap && previewImages.length > 0 && (
-
+
+
+
)}
+ {/* 副图(细节)灯箱:点大图灯箱右下角的副图缩略图打开 */}
+ {snap && previewImages.length > 0 && }
diff --git a/src/lib/api.ts b/src/lib/api.ts
index f7e1c5a..2320149 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -121,7 +121,9 @@ export interface RawStreetSnap {
id: string
title: 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 // 已登录时该图集是否已收藏(随详情接口返回)
}
diff --git a/src/lib/galleryDetail.ts b/src/lib/galleryDetail.ts
new file mode 100644
index 0000000..ac053dc
--- /dev/null
+++ b/src/lib/galleryDetail.ts
@@ -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
+ /** 细节灯箱是否由主图大图灯箱打开(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>(
+ base: T,
+): T & DetailGalleryExtras {
+ return {
+ ...base,
+
+ currentDetailName: "",
+ detailLbOpen: false,
+ detailLbIndex: 0,
+ detailLbZoomed: false,
+ detailImages: [] as DetailImage[],
+ detailImagesByLook: {} as Record,
+ 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 = {}
+ 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("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
+ })
+ },
+ }
+}