This commit is contained in:
toom1996
2026-09-14 21:30:15 +08:00
parent 548155625d
commit afff2d9a49
52 changed files with 2997 additions and 2753 deletions

View File

@ -1,8 +1,9 @@
---
import type { RawArticle } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import GalleryLightbox from "@/components/GalleryLightbox.astro"
import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from '@/lib/routes'
import { ssrBase } from "@/lib/request"
import { ssrAbs } from "@/lib/request"
interface Props {
article: RawArticle | null
@ -14,21 +15,15 @@ interface Props {
const { article, error = null, preview = false, imageTotal = 0 } = Astro.props
const { locale, t } = getI18n(Astro)
const full = (u: string): string => {
if (!u) return ""
if (/^https?:\/\//i.test(u)) return u
return ssrBase().replace(/\/$/, "") + u
}
// 改造后:后端详情接口 images 仅返回主图(look 图),细节图在 details 分组,需经「细节入口」展开。
// 故此处 images 即为主图集合,默认只渲染它们;每张主图带 look_index 用于关联其细节图。
const images = (article?.images ?? [])
.map((im) => ({
id: im.id,
url: full(im.image || ""),
url: ssrAbs(im.image || ""),
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
thumb: full(im.thumb || im.image || ""),
thumb: ssrAbs(im.thumb || im.image || ""),
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "",
lookIndex: Number(im.look_index ?? 0),
@ -292,164 +287,15 @@ const homeHref = href(locale, ROUTES.home)
)
}
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
{article && previewImages.length > 0 && (
<div
x-show="lbOpen"
x-cloak
@keydown.window.escape="lbOpen && closeLb()"
@keydown.window.arrow-right="lbOpen && nextLb()"
@keydown.window.arrow-left="lbOpen && prevLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
aria-hidden="true"
>
{/* 计数:mono + 0.22em 字距(全站「小标与计数」档),直角细框,不用圆角药丸 */}
<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(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
></div>
<button
login-required
type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-5 right-[68px] 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('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>
<button
type="button"
@click="closeLb()"
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="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"
>
{previewImages.map((im, i) => (
<button
type="button"
data-lb-index={i}
data-thumb-url={im.thumb}
@click={`showLb(${i})`}
class="lb-thumb relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
aria-label={`${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">
{String(i + 1).padStart(2, "0")}
</span>
</button>
))}
<template x-for="(im, i) in extraImages" :key="im.url">
<button
type="button"
:data-lb-index="5 + i"
:data-thumb-url="im.thumb"
@click="showLb(5 + i)"
class="lb-thumb relative block w-full aspect-[2/3] mb-2 overflow-hidden cursor-pointer transition-opacity duration-200 opacity-40 hover:opacity-90 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
:aria-label="String(5 + 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(5 + i + 1).padStart(2, '0')"></span>
</button>
</template>
</div>
<button
type="button"
@click.stop="prevLb()"
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] 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-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M6 14l6-6 6 6" />
</svg>
</button>
<button
type="button"
@click.stop="nextLb()"
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] 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-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M6 10l6 6 6-6" />
</svg>
</button>
<button
type="button"
@click.stop="prevLb()"
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="nextLb()"
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="lb-main"
@click.self="closeLb()"
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
x-bind:class="lbZoomed
? 'overflow-y-auto overflow-x-hidden items-start'
: 'items-center'"
>
<img
id="lb-image"
src=""
alt=""
@click="toggleZoom()"
decoding="async"
class="block select-none transition-opacity duration-200"
x-bind:class="lbZoomed
? '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-11 z-20 px-6 text-center font-mono text-[11px] uppercase tracking-[0.2em] text-black/60 pointer-events-none"
x-text="currentImageName"
></div>
<div
x-show="!lbZoomed"
class="absolute left-0 right-0 bottom-4 z-20 px-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-black/40 pointer-events-none transition-opacity duration-200"
>
{t('Click image to zoom · Scroll to view · Esc to close')}
</div>
<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
@ -469,7 +315,7 @@ const homeHref = href(locale, ROUTES.home)
</template>
</div>
</div>
</div>
</GalleryLightbox>
)}
{/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
@ -493,7 +339,7 @@ const homeHref = href(locale, ROUTES.home)
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 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"
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">
@ -586,41 +432,73 @@ const homeHref = href(locale, ROUTES.home)
<script>
import { getArticleDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { toAbs } from "@/lib/request"
import { getUser } from "@/lib/auth"
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
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", () => ({
lbOpen: false,
lbIndex: 0,
lbZoomed: false,
imageCount: 0,
currentImageName: "",
currentDetailName: "",
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
favIds: [],
favType: "runway_image",
galleryUid: "",
galleryTitle: "",
galleryBrand: "",
// 登录态快照:用响应式属性暴露给 x-show 表达式(Alpine 表达式作用域访问不到模块 import 的 getUser,
// 故在此用 getUser() 初始化,避免 x-show="!getUser()..." 求值报错导致遮罩卡在 x-cloak 隐藏)。
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录态图集级(gallery,r=/s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
galleryFav: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[],
Alpine.data(
"articleView",
(): ReturnType<typeof createGalleryView> & ArticleExtras => ({
...createGalleryView({
rootSelector: "#article-view",
gallerySelector: "#article-gallery",
favType: "runway_image",
fetchDetail: getArticleDetailAuthed,
}),
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
// 默认 5 张(进入图集详情页即展示 5 列网格,紧凑浏览)。
density: 5,
// 细节图灯箱:点大图灯箱里的细节缩略图打开,展示该 look 的细节图集合。
currentDetailName: "",
detailLbOpen: false,
detailLbIndex: 0,
detailLbZoomed: false,
@ -633,119 +511,16 @@ const homeHref = href(locale, ROUTES.home)
lbReturnIndex: 0,
init() {
this.favType = this.$el.dataset.favType || "runway_image"
this.galleryUid = this.$el.dataset.galleryUid || ""
this.galleryTitle = this.$el.dataset.galleryTitle || ""
this.galleryBrand = this.$el.dataset.galleryBrand || ""
this.preview = this.$el.dataset.preview === "true"
this.isAuthed = !!getUser()
// 解析后端返回的 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
// 本组件独有:解析后端 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
this.buildDetailMap()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
this.initThumbLazyLoading()
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
if (getUser()) this.loadAuthedState()
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
// 共享初始化(登录态、图片计数、缩略图懒加载、收藏态、深链定位、登录后补全量)
this.galleryInit()
},
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
// gallery 级(r=)收藏态广播给页面右下角 fav-fab;image 级(i=)收藏态直接重建 favIds。
async loadAuthedState() {
if (!getUser()) return
try {
const full = await getArticleDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
this.galleryFav = !!full.favorited
window.dispatchEvent(
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
)
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
const favIds: string[] = []
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
// 超预览上限才追加第 6 张起图片(前 5 张已由 SSR 渲染,仅补收藏态)
if (this.preview) {
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
thumb: toAbs(im.thumb || im.image || ""),
raw: im.image || "",
name: im.name || "",
lookIndex: Number(im.look_index ?? 0),
}))
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
;(full.images || []).slice(5).forEach(mark)
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
}
// 前 5 张收藏态
;(full.images || []).slice(0, 5).forEach(mark)
this.favIds = favIds
} catch {
/* 失败维持本地/截断态,门禁仍在 */
}
},
// ?img=<图片uid> 深链:个人中心点单图收藏 → 打开所属图集并滚动定位到该图。
// 默认只定位 + 短暂高亮,不弹灯箱(不打断浏览);显式带 &lb=1 才自动开灯箱。
maybeLocateImageFromUrl() {
if (typeof window === "undefined") return
const q = new URLSearchParams(window.location.search)
const want = q.get("img")
if (!want) return
const figures = Array.from(
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
)
const idx = figures.findIndex((el) => el.dataset.favId === want)
if (idx < 0) return
const fig = figures[idx] as HTMLElement
requestAnimationFrame(() => {
if (q.get("lb") === "1") {
this.openLb(idx)
return
}
fig.scrollIntoView({ behavior: "smooth", block: "center" })
fig.classList.add("locate-flash")
setTimeout(() => fig.classList.remove("locate-flash"), 2600)
})
},
getImageElement(index: number) {
return document.querySelector<HTMLElement>(
`#article-gallery [data-image-index="${index}"]`
)
},
getImageUrl(index: number) {
return this.getImageElement(index)?.dataset.imageUrl || ""
},
getImageName(index: number) {
return this.getImageElement(index)?.dataset.imageName || ""
},
getImageFavId(index: number) {
return this.getImageElement(index)?.dataset.favId || ""
},
// 收藏用的图地址:读 figure 上的相对路径(存库时不带宿主)
getImageFavUrl(index: number) {
return this.getImageElement(index)?.dataset.favUrl || ""
// ===== 每行张数(密度) =====
// 基础 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。
@ -775,183 +550,11 @@ const homeHref = href(locale, ROUTES.home)
this.openDetailLb(li, idx, true)
},
// ===== 单图收藏 =====
// 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏,
// 复杂度与图片数成正比,与用户收藏总量无关。
allImageFavIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>("#article-gallery [data-fav-id]"),
)
.map((el) => el.dataset.favId || "")
.filter(Boolean)
},
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
this.favIds = ids.filter((id) => isFavorited(id))
},
isFav(id: string) {
return !!id && this.favIds.includes(id)
},
toggleFav(id: string, url: string) {
if (!id) return
toggleImageFavorite({
imageId: id,
type: this.favType === "snap_image" ? "snap_image" : "runway_image",
imageUrl: url,
parentUid: this.galleryUid,
title: this.galleryTitle,
brand: this.galleryBrand,
})
this.favIds = this.isFav(id)
? this.favIds.filter((x: string) => x !== id)
: [...this.favIds, id]
},
// 灯箱里的收藏按钮跟随当前大图
get currentFavId() {
return this.getImageFavId(this.lbIndex)
},
get currentFavUrl() {
return this.getImageFavUrl(this.lbIndex)
},
openLb(index = 0) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
this.lbZoomed = false
this.lbOpen = true
document.body.style.overflow = "hidden"
this.updateLightboxImage()
requestAnimationFrame(() => {
this.scrollThumbIntoView()
this.preloadAround()
})
},
closeLb() {
this.lbOpen = false
this.lbZoomed = false
document.body.style.overflow = ""
},
showLb(index: number) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
this.lbZoomed = false
this.updateLightboxImage()
const main = document.getElementById("lb-main")
if (main) {
main.scrollTop = 0
}
requestAnimationFrame(() => {
this.scrollThumbIntoView()
this.preloadAround()
})
},
nextLb() {
this.showLb(this.lbIndex + 1)
},
prevLb() {
this.showLb(this.lbIndex - 1)
},
normalizeIndex(index: number) {
if (!this.imageCount) return 0
return ((index % this.imageCount) + this.imageCount) % this.imageCount
},
updateLightboxImage() {
const image = document.getElementById("lb-image") as HTMLImageElement | null
if (!image) return
const url = this.getImageUrl(this.lbIndex)
const name = this.getImageName(this.lbIndex)
image.src = url
image.alt = name
this.currentImageName = name
},
toggleZoom() {
this.lbZoomed = !this.lbZoomed
const main = document.getElementById("lb-main")
if (main) {
main.scrollTop = 0
}
},
scrollThumbIntoView() {
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
if (!thumbs) return
const active = thumbs.querySelector<HTMLElement>(
`[data-lb-index="${this.lbIndex}"]`
)
if (!active) return
const target =
active.offsetTop -
(thumbs.clientHeight - active.clientHeight) / 2
thumbs.scrollTo({
top: Math.max(0, target),
behavior: "smooth",
})
},
preloadAround() {
const indexes = [
this.lbIndex,
this.normalizeIndex(this.lbIndex - 1),
this.normalizeIndex(this.lbIndex + 1),
]
indexes.forEach((index: number) => {
const url = this.getImageUrl(index)
if (!url) return
const image = new Image()
image.decoding = "async"
image.src = url
})
},
// ===== 每行张数(密度) =====
// 只有 3 / 5 两档(工具栏图标按钮切换);移动端固定 2 列(5 列在手机上不可用)。
// 基础 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")
},
// ===== 细节图分组 =====
// 由根节点 data-details(后端按 look_index 聚合的细节图 JSON)构建 look_index -> 图片数组映射。
buildDetailMap() {
try {
const raw = this.$el.dataset.details
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) {
@ -1022,8 +625,12 @@ const homeHref = href(locale, ROUTES.home)
})
},
nextDetailLb() { this.showDetailLb(this.detailLbIndex + 1) },
prevDetailLb() { this.showDetailLb(this.detailLbIndex - 1) },
nextDetailLb() {
this.showDetailLb(this.detailLbIndex + 1)
},
prevDetailLb() {
this.showDetailLb(this.detailLbIndex - 1)
},
normalizeDetailIndex(index: number) {
const n = this.detailImages.length
@ -1047,8 +654,12 @@ const homeHref = href(locale, ROUTES.home)
return this.detailImages[index]?.name || ""
},
get currentDetailFavId() { return this.detailImages[this.detailLbIndex]?.id || "" },
get currentDetailFavUrl() { return this.detailImages[this.detailLbIndex]?.raw || "" },
get currentDetailFavId() {
return this.detailImages[this.detailLbIndex]?.id || ""
},
get currentDetailFavUrl() {
return this.detailImages[this.detailLbIndex]?.raw || ""
},
toggleDetailZoom() {
this.detailLbZoomed = !this.detailLbZoomed
@ -1072,7 +683,7 @@ const homeHref = href(locale, ROUTES.home)
this.normalizeDetailIndex(this.detailLbIndex - 1),
this.normalizeDetailIndex(this.detailLbIndex + 1),
]
indexes.forEach((index: number) => {
indexes.forEach((index) => {
const url = this.detailImageUrl(index)
if (!url) return
const image = new Image()
@ -1080,82 +691,7 @@ const homeHref = href(locale, ROUTES.home)
image.src = url
})
},
// TODO 这是干啥的
initThumbLazyLoading() {
const thumbs = document.querySelectorAll<HTMLElement>(
"#lb-thumbs [data-thumb-url]"
)
if (!thumbs.length) return
if (!("IntersectionObserver" in window)) {
thumbs.forEach((thumb) => {
thumb.style.backgroundImage =
`url("${thumb.dataset.thumbUrl}")`
})
return
}
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const element = entry.target as HTMLElement
const url = element.dataset.thumbUrl
if (url) {
element.style.backgroundImage = `url("${url}")`
}
obs.unobserve(element)
})
},
{
root: document.getElementById("lb-thumbs"),
rootMargin: "300px",
}
)
thumbs.forEach((thumb) => observer.observe(thumb))
},
}))
}),
)
})
</script>
<style>
[x-cloak] {
display: none !important;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 单图收藏爱心:默认描边,已收藏实心 */
.fav-btn svg {
fill: none;
}
.fav-btn.is-fav svg {
fill: currentColor;
}
/* ?img= 深链定位提示:短暂黑色描边闪烁 */
.locate-flash {
outline: 3px solid #111;
outline-offset: 4px;
}
/* 触屏没有 hover,收藏按钮常驻显示,否则永远点不到 */
@media (hover: none) {
.fav-btn {
opacity: 1 !important;
}
}
</style>