This commit is contained in:
toom1996
2026-09-13 00:49:42 +08:00
parent 19fb597bb4
commit c85c561a99
19 changed files with 552 additions and 245 deletions

BIN
dev.log

Binary file not shown.

View File

@ -88,6 +88,9 @@ const fullLoginLabel = t('go to full login')
}, },
close() { close() {
// 未打开时直接返回:本函数的 ESC 监听挂在 window 上,页面里任何一次 ESC 都会调到它。
// 若无条件清掉 body 滚动锁,会把别的浮层(图片灯箱等)的滚动锁一起抢掉。
if (!this.isOpen) return
this.isOpen = false this.isOpen = false
this.pending = null this.pending = null
this.error = "" this.error = ""

View File

@ -52,7 +52,6 @@
</ul> </ul>
</div> </div>
<footer class="showsmodal-foot"> <footer class="showsmodal-foot">
<!-- <span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span> -->
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button> <button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer> </footer>
</div> </div>
@ -142,18 +141,12 @@
.showsmodal-name { .showsmodal-name {
@apply flex-1 truncate; @apply flex-1 truncate;
} }
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty { .showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px]; @apply py-6 text-center text-[#999] text-[13px];
} }
.showsmodal-foot { .showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888]; @apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
} }
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok { .showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85; @apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
} }

View File

@ -1,5 +1,5 @@
--- ---
import { useTranslations, DEFAULT_LOCALE, type Locale, getI18n } from "@/i18n/utils"; import { getI18n } from "@/i18n/utils";
interface Crumb { interface Crumb {
/** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */ /** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */

View File

@ -70,14 +70,6 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
> >
Social Media Social Media
</li> </li>
<!-- <li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li> -->
</ul> </ul>
</div> </div>
@ -140,11 +132,6 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
} }
</div> </div>
<!-- 灵感 面板 -->
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板 --> <!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak> <div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" /> <ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
@ -203,13 +190,6 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
/* 防闪烁标签 */ /* 防闪烁标签 */
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 横向画廊样式 */ /* 横向画廊样式 */
.hs-section { @apply mb-9; } .hs-section { @apply mb-9; }
.hs-stage { @apply relative; } .hs-stage { @apply relative; }

View File

@ -1,6 +1,5 @@
--- ---
import type { RawArticle } from "@/lib/api" import type { RawArticle } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils" import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { ssrBase } from "@/lib/api" import { ssrBase } from "@/lib/api"
@ -21,15 +20,30 @@ const full = (u: string): string => {
return ssrBase().replace(/\/$/, "") + u return ssrBase().replace(/\/$/, "") + u
} }
// 改造后:后端详情接口 images 仅返回主图(look 图),细节图在 details 分组,需经「细节入口」展开。
// 故此处 images 即为主图集合,默认只渲染它们;每张主图带 look_index 用于关联其细节图。
const images = (article?.images ?? []) const images = (article?.images ?? [])
.map((im) => ({ .map((im) => ({
id: im.id, id: im.id,
url: full(im.image || ""), url: full(im.image || ""),
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
thumb: full(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)
// 每个主图(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 张图片;其余由登录门禁提示, // 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。 // 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5 const PREVIEW_LIMIT = 5
@ -45,21 +59,6 @@ const descParas = (article?.description || "")
.filter(Boolean) .filter(Boolean)
const homeHref = href(locale, ROUTES.home) const homeHref = href(locale, ROUTES.home)
const archiveHref = href(locale, ROUTES.shows)
const crumbs: { label: string; href: string }[] = [
// {
// label: t("Runway looks"),
// href: archiveHref,
// },
]
if (article?.brand_id) {
crumbs.push({
label: article.brand_name || "Brand",
href: `${archiveHref}?brand_id=${article.brand_id}`,
})
}
--- ---
<div <div
@ -70,30 +69,72 @@ if (article?.brand_id) {
data-gallery-title={article?.title ?? ""} data-gallery-title={article?.title ?? ""}
data-gallery-brand={article?.brand_name ?? ""} data-gallery-brand={article?.brand_name ?? ""}
data-preview={preview ? "true" : "false"} 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" class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
> >
{ {
article ? ( article ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16"> <article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
{/* <Breadcrumb items={crumbs}>
<span slot="current" class="max-w-[60vw] truncate">
{article.title}
</span>
</Breadcrumb> */}
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8"> <h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{article.title} {article.title}
</h1> </h1>
{previewImages.length > 0 && ( {previewImages.length > 0 && (
<section class="mt-16"> <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 <div
id="article-gallery" id="article-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4" class="grid gap-4"
:class="gridClass()"
> >
{previewImages.map((im, i) => ( {previewImages.map((im, i) => {
const dc = detailsByLook.get(im.lookIndex) || 0
return (
<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}
@ -106,7 +147,7 @@ if (article?.brand_id) {
alt={im.name} alt={im.name}
loading={i < 6 ? "eager" : "lazy"} loading={i < 6 ? "eager" : "lazy"}
decoding="async" decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" class="w-full aspect-[2/3] object-cover group-hover:opacity-90 transition-opacity"
/> />
{/* 单图收藏:hover 显示,已收藏常驻实心 */} {/* 单图收藏:hover 显示,已收藏常驻实心 */}
@ -125,13 +166,26 @@ if (article?.brand_id) {
</svg> </svg>
</button> </button>
{im.name && ( {/* 细节图数量角标:有细节图的主图在左下角标注「张数」(图层图标 + 数字),
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2"> 点它直接开细节灯箱。纯主图(无细节图)不显示任何角标,保持网格干净。 */}
{im.name} {dc > 0 && (
</figcaption> <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> </figure>
) )} )
})}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见, {/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */} 避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
@ -144,7 +198,7 @@ if (article?.brand_id) {
class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden" class="relative bg-[#f2f2f2] cursor-pointer group overflow-hidden"
> >
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */} {/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[#c4c4c4] group-hover:text-[#9a9a9a] transition-colors pointer-events-none"> <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"> <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"> <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" /> <rect x="4.5" y="10.5" width="15" height="10" rx="2" />
@ -153,9 +207,9 @@ if (article?.brand_id) {
<span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span> <span class="text-[10px] font-mono uppercase tracking-[0.18em]">{t('login to view')}</span>
</div> </div>
</div> </div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 look01 等小字): {/* 真实图已无 look 序号 caption,占位格也改为绝对定位的 +N photos,
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */} 不撑高格子,保证遮罩格与真实图片格总高一致 */}
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#cfcfcf] mt-2">+{lockedCount} {t('photos')}</figcaption> <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> </figure>
)) ))
} }
@ -164,6 +218,7 @@ if (article?.brand_id) {
<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"
@ -176,8 +231,9 @@ if (article?.brand_id) {
:alt="im.name" :alt="im.name"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" class="w-full aspect-[2/3] object-cover group-hover:opacity-90 transition-opacity"
/> />
<button <button
login-required login-required
type="button" type="button"
@ -192,9 +248,20 @@ if (article?.brand_id) {
<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>
<template x-if="im.name"> {/* 细节图数量角标:有细节图的主图左下角标「张数」,点它直接开细节灯箱(与首屏图一致) */}
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name"></figcaption> <button
</template> 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> </figure>
</template> </template>
</div> </div>
@ -229,15 +296,16 @@ if (article?.brand_id) {
<div <div
x-show="lbOpen" x-show="lbOpen"
x-cloak x-cloak
@keydown.window.escape="closeLb()" @keydown.window.escape="lbOpen && closeLb()"
@keydown.window.arrow-right="nextLb()" @keydown.window.arrow-right="lbOpen && nextLb()"
@keydown.window.arrow-left="prevLb()" @keydown.window.arrow-left="lbOpen && prevLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none" class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
aria-hidden="true" aria-hidden="true"
> >
{/* 计数:mono + 0.22em 字距(全站「小标与计数」档),直角细框,不用圆角药丸 */}
<div <div
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md" 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="`${lbIndex + 1} / ${imageCount}`" x-text="`${String(lbIndex + 1).padStart(2, '0')} / ${String(imageCount).padStart(2, '0')}`"
></div> ></div>
<button <button
@ -245,7 +313,7 @@ if (article?.brand_id) {
type="button" type="button"
@click.stop="toggleFav(currentFavId, currentFavUrl)" @click.stop="toggleFav(currentFavId, currentFavUrl)"
:class="isFav(currentFavId) ? 'is-fav' : ''" :class="isFav(currentFavId) ? 'is-fav' : ''"
class="fav-btn absolute top-[18px] right-[70px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer" 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')} 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"> <svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@ -256,30 +324,30 @@ if (article?.brand_id) {
<button <button
type="button" type="button"
@click="closeLb()" @click="closeLb()"
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer" 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')} 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> </button>
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative"> <div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<div <div
id="lb-thumbs" 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 bg-gradient-to-b from-black/5 to-transparent no-scrollbar" 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) => ( {previewImages.map((im, i) => (
<button <button
type="button" type="button"
data-lb-index={i} data-lb-index={i}
data-thumb-url={im.url} data-thumb-url={im.thumb}
@click={`showLb(${i})`} @click={`showLb(${i})`}
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center" 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) x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
aria-label={`${i + 1}`} aria-label={`${i + 1}`}
> >
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm"> <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")} {String(i + 1).padStart(2, "0")}
</span> </span>
</button> </button>
@ -289,15 +357,13 @@ if (article?.brand_id) {
<button <button
type="button" type="button"
:data-lb-index="5 + i" :data-lb-index="5 + i"
:data-thumb-url="im.url" :data-thumb-url="im.thumb"
@click="showLb(5 + i)" @click="showLb(5 + i)"
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center" 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) x-bind:class="lbIndex === Number($el.dataset.lbIndex) ? 'opacity-100 ring-1 ring-black' : ''"
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
:aria-label="String(5 + i + 1)" :aria-label="String(5 + i + 1)"
> >
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm" x-text="String(5 + i + 1).padStart(2, '0')"></span> <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> </button>
</template> </template>
</div> </div>
@ -305,7 +371,7 @@ if (article?.brand_id) {
<button <button
type="button" type="button"
@click.stop="prevLb()" @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/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer" 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')} aria-label={t('Previous image')}
> >
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"> <svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
@ -316,7 +382,7 @@ if (article?.brand_id) {
<button <button
type="button" type="button"
@click.stop="nextLb()" @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/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer" 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')} aria-label={t('Next image')}
> >
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"> <svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
@ -327,7 +393,7 @@ if (article?.brand_id) {
<button <button
type="button" type="button"
@click.stop="prevLb()" @click.stop="prevLb()"
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer" 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')} aria-label={t('Previous image')}
> >
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"> <svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
@ -338,11 +404,11 @@ if (article?.brand_id) {
<button <button
type="button" type="button"
@click.stop="nextLb()" @click.stop="nextLb()"
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer" 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')} aria-label={t('Next image')}
> >
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"> <svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M9 6l6 6 6-6" /> <path d="M9 6l6 6-6 6" />
</svg> </svg>
</button> </button>
@ -369,48 +435,156 @@ if (article?.brand_id) {
</div> </div>
<div <div
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6" 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" x-text="currentImageName"
></div> ></div>
<div <div
x-show="!lbZoomed" x-show="!lbZoomed"
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200" 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')} {t('Click image to zoom · Scroll to view · Esc to close')}
</div> </div>
{/* 当前 look 的细节图:右下角一小簇缩略图(竖版主图右下角本就是空白,不遮挡主图)。
主图放大(lbZoomed)时整簇隐藏——放大态要专注当前大图;点其中一张进细节灯箱看大图。
单个 look 最多 4 张细节图(多数 1~2 张),一排横放即可,不设滚动条。 */}
<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="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>
</div>
)}
{/* ===== 细节图灯箱:点大图灯箱里的细节缩略图打开,单张细节图放大查看 ===== */}
{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 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>
)} )}
</div> </div>
<script> <script>
// type ArticleView = {
// lbOpen: boolean
// lbIndex: number
// lbZoomed: boolean
// touchStartX: number
// touchStartY: number
// imageCount: number
// currentImageName: string
// init(): void
// getImageElement(index: number): HTMLElement | null
// getImageUrl(index: number): string
// getImageName(index: number): string
// openLb(index?: number): void
// closeLb(): void
// showLb(index: number): void
// nextLb(): void
// prevLb(): void
// normalizeIndex(index: number): number
// updateLightboxImage(): void
// toggleZoom(): void
// scrollThumbIntoView(): void
// preloadAround(): void
// initThumbLazyLoading(): void
// handleTouchStart(event: TouchEvent): void
// handleTouchEnd(event: TouchEvent): void
// }
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api" import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites" import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
@ -419,10 +593,9 @@ if (article?.brand_id) {
lbOpen: false, lbOpen: false,
lbIndex: 0, lbIndex: 0,
lbZoomed: false, lbZoomed: false,
touchStartX: 0,
touchStartY: 0,
imageCount: 0, imageCount: 0,
currentImageName: "", currentImageName: "",
currentDetailName: "",
// 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正) // 单图收藏态:已收藏的图片 id 列表(本地即时 + 服务端校正)
favIds: [], favIds: [],
@ -437,8 +610,23 @@ if (article?.brand_id) {
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张) // 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false, preview: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染) // 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[], extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[],
showAll: false,
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
// 默认 5 张(进入图集详情页即展示 5 列网格,紧凑浏览)。
density: 5,
// 细节图灯箱:点大图灯箱里的细节缩略图打开,展示该 look 的细节图集合。
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() { init() {
this.favType = this.$el.dataset.favType || "runway_image" this.favType = this.$el.dataset.favType || "runway_image"
@ -448,6 +636,9 @@ if (article?.brand_id) {
this.preview = this.$el.dataset.preview === "true" this.preview = this.$el.dataset.preview === "true"
this.isAuthed = !!getUser() this.isAuthed = !!getUser()
// 解析后端返回的 details 分组(按 look_index 聚合细节图),供「细节入口」展开灯箱。
this.buildDetailMap()
this.imageCount = document.querySelectorAll( this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]" "#article-gallery [data-image-index]"
).length ).length
@ -472,26 +663,14 @@ if (article?.brand_id) {
const mapped = (full.images || []).slice(5).map((im: any) => ({ const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "", id: im.id || "",
url: toAbs(im.image || ""), url: toAbs(im.image || ""),
thumb: toAbs(im.thumb || im.image || ""),
raw: im.image || "", raw: im.image || "",
name: im.name || "", name: im.name || "",
lookIndex: Number(im.look_index ?? 0),
})) }))
this.extraImages = mapped this.extraImages = mapped
this.showAll = true // 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
// VIP:前 PREVIEW_LIMIT 张是 SSR 静态渲染的普通样式图(SSR 无用户令牌, // 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
// 后端回落 normal)。VIP 已登录后此处把首屏图也替换为高清原图(后端对 VIP 返回 HD URL),
// 否则 VIP 仅第 6 张起看高清,首屏仍是普通图,体验不一致。
const me = getUser()
if (me && me.tier === "vip") {
const hd = (full.images || []).slice(0, 5).map((im: any) => toAbs(im.image || ""))
hd.forEach((url: string, i: number) => {
if (!url) return
const fig = document.querySelector<HTMLElement>(`#article-gallery [data-image-index="${i}"]`)
if (!fig) return
fig.dataset.imageUrl = url
const img = fig.querySelector("img")
if (img) img.setAttribute("src", url)
})
}
await this.$nextTick() await this.$nextTick()
this.imageCount = document.querySelectorAll( this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]" "#article-gallery [data-image-index]"
@ -551,6 +730,33 @@ if (article?.brand_id) {
return this.getImageElement(index)?.dataset.favUrl || "" return this.getImageElement(index)?.dataset.favUrl || ""
}, },
// 读第 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)
},
// ===== 单图收藏 ===== // ===== 单图收藏 =====
// 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏, // 只把「当前这篇图集的全部图片 id」发给后端问哪些已收藏,
// 复杂度与图片数成正比,与用户收藏总量无关。 // 复杂度与图片数成正比,与用户收藏总量无关。
@ -719,6 +925,147 @@ if (article?.brand_id) {
}) })
}, },
// ===== 每行张数(密度) =====
// 只有 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 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: number) => {
const url = this.detailImageUrl(index)
if (!url) return
const image = new Image()
image.decoding = "async"
image.src = url
})
},
// TODO 这是干啥的 // TODO 这是干啥的
initThumbLazyLoading() { initThumbLazyLoading() {
const thumbs = document.querySelectorAll<HTMLElement>( const thumbs = document.querySelectorAll<HTMLElement>(

View File

@ -378,10 +378,6 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalKeyword = "" this.modalKeyword = ""
this.modalLetter = "HOT" this.modalLetter = "HOT"
this.modalOpen = true this.modalOpen = true
// 锁根滚动并补偿滚动条宽度,避免背景页面横向偏移
// const sbw = window.innerWidth - document.documentElement.clientWidth
// document.documentElement.style.overflow = "hidden"
// if (sbw > 0) document.documentElement.style.paddingRight = sbw + "px"
await this.modalLoadHot() await this.modalLoadHot()
}, },
closeBrandModal() { closeBrandModal() {

View File

@ -40,7 +40,6 @@ const previewImages = images.slice(0, PREVIEW_LIMIT)
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0 const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
const homeHref = href(locale, ROUTES.home) const homeHref = href(locale, ROUTES.home)
const archiveHref = href(locale, ROUTES.streetSnaps)
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现 // theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
const displayTitle = (snap?.title || "").replace(/\t/g, " · ") const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
@ -360,8 +359,6 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
lbOpen: false, lbOpen: false,
lbIndex: 0, lbIndex: 0,
lbZoomed: false, lbZoomed: false,
touchStartX: 0,
touchStartY: 0,
imageCount: 0, imageCount: 0,
currentImageName: "", currentImageName: "",
@ -379,7 +376,6 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
preview: false, preview: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染) // 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[], extraImages: [] as { id: string; url: string; raw: string; name: string }[],
showAll: false,
init() { init() {
this.favType = this.$el.dataset.favType || "snap_image" this.favType = this.$el.dataset.favType || "snap_image"
@ -415,20 +411,8 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
name: im.name || "", name: im.name || "",
})) }))
this.extraImages = mapped this.extraImages = mapped
this.showAll = true // 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
// VIP:首屏前 PREVIEW_LIMIT 张为 SSR 普通样式图,已登录 VIP 替换为高清原图(同 Item.astro)。 // 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
const me = getUser()
if (me && me.tier === "vip") {
const hd = (full.images || []).slice(0, 5).map((im: any) => toAbs(im.image || ""))
hd.forEach((url: string, i: number) => {
if (!url) return
const fig = document.querySelector<HTMLElement>(`#snap-gallery [data-image-index="${i}"]`)
if (!fig) return
fig.dataset.imageUrl = url
const img = fig.querySelector("img")
if (img) img.setAttribute("src", url)
})
}
await this.$nextTick() await this.$nextTick()
this.imageCount = document.querySelectorAll( this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]" "#snap-gallery [data-image-index]"

View File

@ -1,8 +1,6 @@
--- ---
import Breadcrumb from "@/components/Breadcrumb.astro" import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from '@/i18n/utils' import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES } from '@/lib/routes'
import { toAbs } from '@/lib/api'
import "@/styles/look-grid.css" import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro) const { locale, t } = getI18n(Astro)

View File

@ -38,10 +38,7 @@ export const dict: Record<string, TranslationEntry> = {
'resort': { cn: '度假系列' }, 'resort': { cn: '度假系列' },
'pre-fall': { cn: '早秋系列' }, 'pre-fall': { cn: '早秋系列' },
'signup': { cn: '注册' },
// ── 账号 / Account ── // ── 账号 / Account ──
'hi': { cn: '你好' },
'password': { cn: '密码' }, 'password': { cn: '密码' },
'account': { cn: '账户' }, 'account': { cn: '账户' },
'sign out': { cn: '退出登录' }, 'sign out': { cn: '退出登录' },
@ -57,60 +54,32 @@ export const dict: Record<string, TranslationEntry> = {
'members': { cn: '会员专区' }, 'members': { cn: '会员专区' },
'back to site': { cn: '返回网站' }, 'back to site': { cn: '返回网站' },
'email': { cn: '邮箱' }, 'email': { cn: '邮箱' },
'my collections': { cn: '我的收藏' },
'my history': { cn: '浏览历史' }, 'my history': { cn: '浏览历史' },
'coming soon': { cn: '敬请期待' }, 'coming soon': { cn: '敬请期待' },
// ── 账户中心 / Account Center(企业级个人中心)── // ── 账户中心 / Account Center(企业级个人中心)──
'overview': { cn: '概览' },
'sessions & security': { cn: '会话与安全' },
'settings': { cn: '设置' },
'saved looks': { cn: '收藏造型' }, 'saved looks': { cn: '收藏造型' },
'active sessions': { cn: '活跃会话' },
'member since': { cn: '加入于' },
'role': { cn: '角色' }, 'role': { cn: '角色' },
'internal member': { cn: '内部成员' },
'items': { cn: '条' }, 'items': { cn: '条' },
'account overview': { cn: '账户概览' },
'account details': { cn: '账户详情' },
'username': { cn: '用户名' }, 'username': { cn: '用户名' },
'edit profile': { cn: '编辑资料' },
'view archive': { cn: '浏览档案' },
'no collections yet': { cn: '暂无收藏内容' },
'no history yet': { cn: '暂无浏览记录' },
'browse the archive': { cn: '去浏览档案' }, 'browse the archive': { cn: '去浏览档案' },
'current session': { cn: '当前会话' },
'this device': { cn: '此设备' },
'last active': { cn: '最近活跃' },
'sign out all devices': { cn: '退出所有设备' },
'single session note': { cn: '为保障账号安全,同一账号仅允许在一处登录,新登录会顶替旧会话。' },
'preferences': { cn: '偏好设置' },
'appearance': { cn: '外观' }, 'appearance': { cn: '外观' },
'notifications': { cn: '通知' },
'soon': { cn: '即将推出' }, 'soon': { cn: '即将推出' },
'quick stats': { cn: '数据概览' },
'now': { cn: '刚刚' }, 'now': { cn: '刚刚' },
'member profile': { cn: '会员资料' }, 'member profile': { cn: '会员资料' },
'vip': { cn: '高级会员' }, 'vip': { cn: '高级会员' },
'save': { cn: '收藏' }, 'save': { cn: '收藏' },
'saved': { cn: '已收藏' }, 'saved': { cn: '已收藏' },
'single image': { cn: '单图' },
'galleries': { cn: '图集' }, 'galleries': { cn: '图集' },
'images': { cn: '单图' }, 'images': { cn: '单图' },
'remove': { cn: '移除' }, 'remove': { cn: '移除' },
'cancel': { cn: '取消' },
'save changes': { cn: '保存修改' },
'display name': { cn: '昵称' },
'bio': { cn: '简介' },
'clear history': { cn: '清空历史' }, 'clear history': { cn: '清空历史' },
'no saved looks yet': { cn: '暂无收藏造型' }, 'no saved looks yet': { cn: '暂无收藏造型' },
'no history yet': { cn: '暂无浏览记录' }, 'no history yet': { cn: '暂无浏览记录' },
'all': { cn: '全部' }, 'all': { cn: '全部' },
'image': { cn: '图片' }, 'image': { cn: '图片' },
'gallery': { cn: '图集' }, 'gallery': { cn: '图集' },
'viewed gallery': { cn: '浏览的图集' },
'light': { cn: '浅色' }, 'light': { cn: '浅色' },
'dark': { cn: '深色' },
'on': { cn: '开' }, 'on': { cn: '开' },
'off': { cn: '关' }, 'off': { cn: '关' },
@ -118,8 +87,6 @@ export const dict: Record<string, TranslationEntry> = {
'back to index': { cn: '返回首页' }, 'back to index': { cn: '返回首页' },
'back to top': { cn: '回到顶部' },
'close': { cn: '关闭' }, 'close': { cn: '关闭' },
'previous image': { cn: '上一张' }, 'previous image': { cn: '上一张' },
@ -142,18 +109,6 @@ export const dict: Record<string, TranslationEntry> = {
'© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' }, '© 2026 portfolio studio. all rights reserved.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
// ── 页脚工具 / Footer tools ──
'color studio tools': { cn: '色彩工作室工具' },
'ai color trend generator': { cn: 'AI 色彩趋势生成器' },
'photo swatch extractor': { cn: '图片色板提取器' },
'upload': { cn: '上传' },
'reset b&w theme': { cn: '重置黑白主题' },
'default': { cn: '默认' }, 'default': { cn: '默认' },
// ── 页脚简介 / Manifesto ── // ── 页脚简介 / Manifesto ──

View File

@ -71,7 +71,7 @@ const headers = {
<!-- 顶部主导航栏 --> <!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase"> <nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8"> <div class="flex flex-wrap items-center gap-6 md:gap-8">
<a href={href(locale, ROUTES.portfolio)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent"> <a href={href(locale, ROUTES.home)} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">
{t('Portfolio')} {t('Portfolio')}
<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none"> <span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
2026 2026
@ -146,13 +146,16 @@ const headers = {
</ul> </ul>
</div> </div>
<div class="flex items-center gap-3" x-data="authMenu"> <div class="flex items-center gap-3" x-data="authMenu">
<!-- 未登录:Login 链接(Alpine 登录后置为隐藏) --> <!-- 未登录:Login 链接(Alpine 登录后置为隐藏)。
<a x-show="!user" x-cloak href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a> login-redirect:交给 AuthModal 的全局门禁捕获,带上「当前页」跳登录页,
登录成功后回到原页,而不是一律落到个人中心(无 JS 时回落 href 到 /login)。 -->
<a x-show="!user" x-cloak login-redirect href={href(locale, ROUTES.login)} class="hover:opacity-60 transition-opacity duration-200 uppercase tracking-[0.5px] text-[13px]">{t('Login')}</a>
<!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) --> <!-- 已登录:账号下拉(hover 展开,与语言切换器同款交互) -->
<div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false"> <div x-show="user" x-cloak class="relative" @mouseenter="open = true" @mouseleave="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open"> <button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="flex items-center gap-1.5 cursor-pointer hover:opacity-60 transition-opacity duration-200 p-1 -m-1 normal-case text-[13px] tracking-[0.5px]" aria-haspopup="menu" :aria-expanded="open">
<span x-text="shortName"></span> <span x-text="shortName"></span>
<span x-show="user?.tier === 'vip'" x-cloak class="inline-block border border-black bg-black px-1.5 py-[1px] font-mono text-[9px] uppercase leading-none tracking-[0.2em] text-white" aria-label="VIP">VIP</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="acct-chevron w-[14px] h-[14px] transition-transform duration-200" :class="open ? 'rotate-180' : ''" aria-hidden="true">
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
</svg> </svg>

View File

@ -60,6 +60,22 @@ export interface ArticlePage {
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁 preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
} }
// 后端单张图片(与 PublicArticleImage 对齐):主图或细节图。
export interface RawArticleImage {
id?: string // 图片自身编码串(i/j 前缀),单图收藏用
image: string // 该接口场景的主用地址(详情=展示样式 !style:high;列表=缩略图 !style:thumb)
thumb?: string // 缩略图地址(!style:thumb),仅详情接口返回:供大图灯箱左栏缩略图条 / 右下角细节缩略图等小尺寸场景用,省带宽
name?: string
is_detail?: number // 0=主图 1=细节图(详情接口 images 仅含主图;细节在 details 分组)
look_index?: number // 细节图归属的主图序号(与 images 主图的 look_index 一致)
}
// 某个主图(look)对应的细节图集合(与 RunwayLookDetails 对齐),详情页「细节入口」按需展开。
export interface RawLookDetails {
look_index: number // 主图序号
images: RawArticleImage[] // 该主图的细节图列表
}
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。 // 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
export type RawArticle = { export type RawArticle = {
id: string | number // 后端为 hashid 编码串(string) id: string | number // 后端为 hashid 编码串(string)
@ -68,8 +84,11 @@ export type RawArticle = {
cover: string cover: string
brand_name: string brand_name: string
brand_id?: string | number // hashid 编码串 brand_id?: string | number // hashid 编码串
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回) image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回);改造后仅计主图数
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用 // 详情接口 images 仅含主图(look 图),默认展示;细节图在 details 分组,需经「细节入口」展开。
images?: RawArticleImage[]
// 按主图分组的细节图(「细节入口」展开用);无细节时为空数组或不返回。
details?: RawLookDetails[]
} }
export interface AuthUser { export interface AuthUser {
@ -95,7 +114,6 @@ export interface SsgPopularBrand {
// 仅本模块内部使用(toAbs / request 等),不对外导出。 // 仅本模块内部使用(toAbs / request 等),不对外导出。
function getBase(): string { function getBase(): string {
// if (import.meta.env.DEV) return "https://seo.23cm.cn"
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api) // 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
return (BASE_API || "").replace(/\/$/, "") return (BASE_API || "").replace(/\/$/, "")
} }
@ -487,6 +505,9 @@ export interface HistoryMeta {
cover: string cover: string
brand: string brand: string
type: "street" | "runway" type: "street" | "runway"
// 后端回传的**规范 id**(带类型前缀,如 r0008CZYI)。历史/收藏里可能存在早期无前缀的 id,
// 后端对无前缀串也能解析并回传规范 id,故用它做「同一篇文章」的判据与链接目标。
id: string
} }
// 单篇回查:按编码串首字符判断类型(s 开头 = 街拍,其余 = 走秀)。失败返回 null,由调用方容忍。 // 单篇回查:按编码串首字符判断类型(s 开头 = 街拍,其余 = 走秀)。失败返回 null,由调用方容忍。
@ -507,6 +528,7 @@ export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
cover: (d.cover || "").toString(), cover: (d.cover || "").toString(),
brand: (d.brand_name || d.brand || "").toString(), brand: (d.brand_name || d.brand || "").toString(),
type, type,
id: (d.id || sid).toString(),
} }
} catch { } catch {
return null return null

View File

@ -20,9 +20,19 @@ export interface HistoryData {
} }
export interface HistoryEntry { export interface HistoryEntry {
id: string // 文章对外编码串:r=xxx(走秀) / s=xxx(街拍) id: string // 对外编码串(服务端存储的原样值):r=xxx(走秀) / s=xxx(街拍)。删除时必须原样回传
viewedAt: number viewedAt: number
data?: HistoryData // 账户页展示时回查详情获得;本地/回查失败时缺失(卡片占位渲染) data?: HistoryData // 账户页展示时回查详情获得;本地/回查失败时缺失(卡片占位渲染)
canonId?: string // 回查得到的「规范 id」(带类型前缀)。早期数据可能缺前缀(如 0008CZYI),
// 同一篇文章因此会存成两行;链接统一用规范 id,保证只指向一个地址。
}
// 去掉一个「类型前缀」后的串,作为「同一篇文章」的判据:r=走秀 / s=街拍 / i=走秀单图 / j=街拍单图。
// 早期历史数据没有类型前缀(0008CZYI),与新数据(r0008CZYI)指向同一篇,只按原样 id 去重会漏,
// 故本地缓存按「去前缀」后的串判同。只剥已知前缀,避免误伤本身以字母开头的 hashid 本体。
function dedupeKey(id: string): string {
const s = String(id || "")
return /^[rsij]/.test(s) ? s.slice(1) : s
} }
// 后端 HistoryItem 响应结构({data:{items:[...]}} 解包后,仅 id + viewed_at) // 后端 HistoryItem 响应结构({data:{items:[...]}} 解包后,仅 id + viewed_at)
@ -71,8 +81,9 @@ function serverMode(): boolean {
export function recordHistory(id: string): void { export function recordHistory(id: string): void {
if (!id) return if (!id) return
const entry: HistoryEntry = { id, viewedAt: Date.now() } const entry: HistoryEntry = { id, viewedAt: Date.now() }
// 本地去重(同 id 只保留一条并提到最前) // 本地去重:按「去类型前缀」后的串判同,避免同一篇文章因 id 形式不同(旧数据无前缀)留两条并排
const list = getLocal().filter((h) => h.id !== id) const key = dedupeKey(id)
const list = getLocal().filter((h) => dedupeKey(h.id) !== key)
list.unshift(entry) list.unshift(entry)
if (list.length > 200) list.length = 200 if (list.length > 200) list.length = 200
setLocal(list) setLocal(list)
@ -90,12 +101,26 @@ export interface HistPageResult {
lastPage: number lastPage: number
} }
// 按「规范 id」收敛同一篇文章,保留首次出现的那条(服务端已按 viewed_at 倒序 → 即最新的一条)。
// 用于消除「同一篇文章两行并排」:早期历史数据没有类型前缀,与带前缀的新数据指向同一篇。
function dedupeEntries(list: HistoryEntry[]): HistoryEntry[] {
const seen = new Set<string>()
const out: HistoryEntry[] = []
for (const h of list) {
const k = h.canonId || dedupeKey(h.id)
if (seen.has(k)) continue
seen.add(k)
out.push(h)
}
return out
}
/** /**
* 拉取浏览历史的一页(登录态走服务端分页 + 逐篇回查公开详情;未登录回落本地缓存)。 * 拉取浏览历史的一页(登录态走服务端分页 + 逐篇回查公开详情;未登录回落本地缓存)。
*/ */
export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPageResult> { export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPageResult> {
if (!serverMode()) { if (!serverMode()) {
const local = getLocal() const local = dedupeEntries(getLocal())
return { items: local, total: local.length, lastPage: 1 } return { items: local, total: local.length, lastPage: 1 }
} }
try { try {
@ -113,23 +138,29 @@ export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPage
base.map(async (h) => { base.map(async (h) => {
try { try {
const meta = await getHistoryMeta(h.id) const meta = await getHistoryMeta(h.id)
if (meta) h.data = meta if (meta) {
h.data = meta
h.canonId = meta.id // 回查同时拿到规范 id(无前缀的旧 id 也能拿到带前缀的结果)
}
} catch { } catch {
/* 单条回查失败:保留无 data,卡片走占位渲染 */ /* 单条回查失败:保留无 data,卡片走占位渲染 */
} }
return h return h
}), }),
) )
return { items, total: Number(r?.total || 0), lastPage: Number(r?.last_page || 1) } // 收敛同一篇文章的历史行(旧数据缺前缀会与正常行并存);删除仍用原样 id,故不影响删除。
return { items: dedupeEntries(items), total: Number(r?.total || 0), lastPage: Number(r?.last_page || 1) }
} catch { } catch {
const local = getLocal() const local = dedupeEntries(getLocal())
return { items: local, total: local.length, lastPage: 1 } return { items: local, total: local.length, lastPage: 1 }
} }
} }
/** 删除一条历史(按文章 id)。 */ /** 删除一条历史(按文章 id)。 */
export function removeHistory(id: string): void { export function removeHistory(id: string): void {
setLocal(getLocal().filter((h) => h.id !== id)) // 本地按「去类型前缀」判同删除,避免旧数据留下同篇的孪生行
const key = dedupeKey(id)
setLocal(getLocal().filter((h) => dedupeKey(h.id) !== key))
if (serverMode()) { if (serverMode()) {
authJson("DELETE", `/me/history/${encodeURIComponent(id)}`).catch(() => { authJson("DELETE", `/me/history/${encodeURIComponent(id)}`).catch(() => {
/* 失败不回滚本地 */ /* 失败不回滚本地 */

View File

@ -8,11 +8,7 @@ import { getRelativeLocaleUrl } from 'astro:i18n';
/** 逻辑路由片段(不含 locale 前缀)。 */ /** 逻辑路由片段(不含 locale 前缀)。 */
export const ROUTES = { export const ROUTES = {
home: '/', home: '/',
shows: '/shows',
brands: '/brands',
portfolio: '/portfolio',
login: '/login', login: '/login',
register: '/register',
account: '/account', account: '/account',
runwayLooks: '/runway-looks', runwayLooks: '/runway-looks',
// 街拍 // 街拍

View File

@ -204,9 +204,7 @@ const browseLabel = t("browse the archive")
favPage: 1, favPage: 1,
favPerPage: 24, favPerPage: 24,
favLastPage: 1, favLastPage: 1,
favTotal: 0,
history: [] as any[], history: [] as any[],
historyCount: 0,
histPage: 1, histPage: 1,
histPerPage: 24, histPerPage: 24,
histLastPage: 1, histLastPage: 1,
@ -231,14 +229,12 @@ const browseLabel = t("browse the archive")
try { try {
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery") const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
this.favorites = r.items this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage this.favLastPage = r.lastPage
this.favPage = p this.favPage = p
this.favoritesCount = r.total this.favoritesCount = r.total
} catch { } catch {
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery")) const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
this.favorites = local this.favorites = local
this.favTotal = local.length
this.favLastPage = 1 this.favLastPage = 1
this.favPage = 1 this.favPage = 1
this.favoritesCount = local.length this.favoritesCount = local.length
@ -277,9 +273,10 @@ const browseLabel = t("browse the archive")
}, },
itemHref(f: any): string { itemHref(f: any): string {
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图; // 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
// 图集收藏与历史仍用自身 uid。 // 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
const isImg = f.kind === "image" const isImg = f.kind === "image"
const uid = isImg && f.parentUid ? f.parentUid : f.id const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
const base = clientHref(this.locale, ROUTES.item(uid)) const base = clientHref(this.locale, ROUTES.item(uid))
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
}, },

View File

@ -37,9 +37,12 @@ preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0 imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。 // 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
const meta = isSnap const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" } ? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" } : { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save") const saveLabel = t("save")
const savedLabel = t("saved") const savedLabel = t("saved")
--- ---

View File

@ -205,9 +205,7 @@ const browseLabel = t("browse the archive")
favPage: 1, favPage: 1,
favPerPage: 24, favPerPage: 24,
favLastPage: 1, favLastPage: 1,
favTotal: 0,
history: [] as any[], history: [] as any[],
historyCount: 0,
histPage: 1, histPage: 1,
histPerPage: 24, histPerPage: 24,
histLastPage: 1, histLastPage: 1,
@ -232,14 +230,12 @@ const browseLabel = t("browse the archive")
try { try {
const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery") const r = await fetchFavoritesPage(p, this.favPerPage, k === "image" ? "image" : "gallery")
this.favorites = r.items this.favorites = r.items
this.favTotal = r.total
this.favLastPage = r.lastPage this.favLastPage = r.lastPage
this.favPage = p this.favPage = p
this.favoritesCount = r.total this.favoritesCount = r.total
} catch { } catch {
const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery")) const local = getFavorites().filter((f) => (f.kind || "gallery") === (k === "image" ? "image" : "gallery"))
this.favorites = local this.favorites = local
this.favTotal = local.length
this.favLastPage = 1 this.favLastPage = 1
this.favPage = 1 this.favPage = 1
this.favoritesCount = local.length this.favoritesCount = local.length
@ -278,9 +274,10 @@ const browseLabel = t("browse the archive")
}, },
itemHref(f: any): string { itemHref(f: any): string {
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图; // 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
// 图集收藏与历史仍用自身 uid。 // 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
const isImg = f.kind === "image" const isImg = f.kind === "image"
const uid = isImg && f.parentUid ? f.parentUid : f.id const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
const base = clientHref(this.locale, ROUTES.item(uid)) const base = clientHref(this.locale, ROUTES.item(uid))
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
}, },

View File

@ -37,9 +37,12 @@ preview = data.preview ?? false
imageTotal = data.imageTotal ?? 0 imageTotal = data.imageTotal ?? 0
// 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。 // 供客户端「收藏 / 历史」使用的精简元信息(服务端已拿到,直接透传,避免前端再发请求)。
// id 优先取**接口回传的规范 id**(带类型前缀):用户若通过旧的无前缀链接进来(如 /item/0008CZYI),
// 也保证收藏 / 历史写入的是规范形式,不会与既有记录形成「同一篇文章两行」。
const canonId = String((isSnap ? snap?.id : article?.id) ?? id ?? "")
const meta = isSnap const meta = isSnap
? { id: id ?? "", type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" } ? { id: canonId, type: "street", title: snap?.title ?? "", cover: snap?.cover ?? "", brand: "" }
: { id: id ?? "", type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" } : { id: canonId, type: "runway", title: article?.title ?? "", cover: article?.cover ?? "", brand: article?.brand_name ?? "" }
const saveLabel = t("save") const saveLabel = t("save")
const savedLabel = t("saved") const savedLabel = t("saved")
--- ---

View File

@ -220,7 +220,6 @@
} }
.pg.is-current { @apply bg-black text-white cursor-default; } .pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; } .pg:disabled { @apply opacity-30 cursor-default; }
.pg.enabled:hover { @apply bg-black text-white; }
.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; } .pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; }
.pg-wrap { @apply inline-flex items-center; } .pg-wrap { @apply inline-flex items-center; }