This commit is contained in:
toom1996
2026-09-22 21:39:45 +08:00
parent 9473f00c98
commit a8fcafac10
15 changed files with 816 additions and 716 deletions

View File

@ -103,3 +103,18 @@
### 待办(未做) ### 待办(未做)
- **运行期烟测**:起 pgvector Docker 实连,验证 GORM 把 `vector(64)` 列读进 `sql.NullString`(pgx text 格式扫描)无报错。尚未跑。 - **运行期烟测**:起 pgvector Docker 实连,验证 GORM 把 `vector(64)` 列读进 `sql.NullString`(pgx text 格式扫描)无报错。尚未跑。
- 汉明阈值 10 可按真实样本微调。 - 汉明阈值 10 可按真实样本微调。
## 修复 `cmd/dbtool dump` 的 42601 + Scan 报错 ✅(22:25–22:50)
`go run main.go dump` 导出 `brand` 表时连报两错,已逐个修掉:
### 错 1:SQLSTATE 42601 `at or near ")")`
- 根因:`colQuery` 里 `c.column_name = $1` 这行第 5 列 `(c.column_default LIKE 'nextval(%'))` 多了一个右括号(应为 `nextval(%'`);多余的 `)` 让整段变 `... = $1))` 语法错。
- 修复:改为 `(c.column_default LIKE 'nextval(%')`(单右括号,与 pkQuery 风格一致;该 `)` 是外层比较表达式收尾)。
- 验证手段:另写 `_diag2` 临时诊断确认 `nextval(%')`(单)可查、`nextval(%'))`(双)必 42601,定位到就是这行。
### 错 2:`Scan error on column index 4 ... couldn't convert <nil> into type bool`
- 根因:同上第 5 列当 `column_default` 为 NULL 时 `NULL LIKE 'nextval(%'` 返回 NULL,`rows.Scan(&isIdent bool)` 无法接收 NULL → 报错(`brand` 表无 serial 列,default 全 NULL 触发)。
- 修复:改为 `COALESCE(c.column_default LIKE 'nextval(%', false)`,把 NULL 收为 false。
### 验证
`go run main.go dump` → `✓ 已导出 15 张表 -> db_dump.json`,无报错。已删除 `_diag2` 临时诊断目录。

View File

@ -0,0 +1,151 @@
---
/**
* 列表页共享外壳:走秀 / 街拍(以及未来同构模块)的「眉头 + 筛选 pills + 左侧栏 + 右卡片网格 + 分页」标记。
*
* 设计动机:RunwayLooks.astro 与 StreetSnaps.astro 的这段模板逐块同构,差异只有 CSS 类名
* 前缀(shows* / snaps*)与几个开关量。本组件收口「不变的标记」,把「变的差异」通过
* props / 具名插槽注入:
* - prefix 类名前缀,look-grid.css 里两套前缀成对定义、样式同源
* - alpineName Alpine 组件注册名(须与页面 <script> 内 Alpine.data(name, ...) 一致)
* - title 眉头 H1 文案(页面已 t() 处理)
* - groups 侧栏通用筛选分组,透传给 RefineSidebar
* - skeletonLines 骨架屏信息区行数(走秀 3 行、街拍 2 行,与真实卡片位数对齐)
* - emptyText 空态文案(页面传入,已是最终语言)
* - slot sidebar-extra 页面专属筛选分组(走秀的 Designer/品牌)
* - slot modals 页面专属弹窗(走秀的 BrandModal),须在 x-data 作用域内
*
* 注意:本组件不自带状态,内部 Alpine 指令(pills / loading / items / pageList /
* collapsedGroups / loadPage / prevPage 等)一律引用父级 x-data 的作用域;
* Alpine.data 的注册仍留在各页面 <script> 内。卡片数据来自各页 extra 提供的
* cardTitle / cardMeta / getCardSlots。
*/
import CollectionCard from "@/components/CollectionCard.astro"
import RefineSidebar from "@/components/RefineSidebar.astro"
import { getI18n } from "@/lib/i18n"
import "@/styles/look-grid.css"
interface Option {
value: string | number
label: string
}
interface Group {
key: string
title: string
options: Option[]
selected: string
}
interface Props {
/** CSS 类名前缀:走秀 'shows'、街拍 'snaps'(look-grid.css 两套前缀成对定义) */
prefix: "shows" | "snaps"
/** Alpine 组件名,须与页面 <script> 中 Alpine.data(name, ...) 的注册名一致 */
alpineName: string
/** 眉头 H1 文案(页面已 t() 处理) */
title: string
/** 侧栏通用筛选分组,透传给 RefineSidebar */
groups: Group[]
/** 骨架屏信息区行数:走秀 3 行、街拍 2 行(与真实卡片标题/副标题位数对齐) */
skeletonLines?: 2 | 3
/** 空态文案(页面传入,已是最终语言) */
emptyText: string
}
const { prefix, alpineName, title, groups, skeletonLines = 2, emptyText } = Astro.props
const { locale } = getI18n(Astro)
// 骨架屏信息区各行的固定高度(与真实卡片的标题 / 副标题 / 补充行对齐),按 skeletonLines 截取。
// 字面量写在此处以保证 Tailwind JIT 能扫描到这些 arbitrary 值。
const SKELETON_LINE_CLASSES = [
"h-[41px] mb-[10px] rounded-[2px]",
"h-[29px] mb-[14px] rounded-[2px]",
"h-[38px] rounded-[2px]",
]
const skeletonLineClasses = SKELETON_LINE_CLASSES.slice(0, skeletonLines)
---
<div class={`${prefix}root w-full`} x-data={`${alpineName}()`} data-locale={locale} data-filter-groups={JSON.stringify(groups)}>
<!-- 顶部:极简眉头 -->
<header class={`${prefix}head`}>
<div>
<h1 class={`${prefix}h1`}>{title}</h1>
</div>
</header>
<!-- 已激活筛选 pills:pills 是方法(见 lib/collection-page.ts 里关于展开求值 getter 的说明),故写作 pills() -->
<div class={`${prefix}pills`} x-show="pills().length > 0" x-cloak>
<template x-for="(p, i) in pills()" :key="i">
<span class={`${prefix}pill`}>
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar + 右卡片网格 -->
<div class={`${prefix}body`}>
<!-- LEFT SIDEBAR -->
<aside class={`${prefix}aside`}>
<div class={`${prefix}aside-head`}>
<!-- REFINE 标题当前停用 -->
</div>
<div class={`${prefix}aside-body`}>
<!-- 页面专属分组(走秀的 Designer/品牌)插在这里,通用分组由 RefineSidebar 渲染 -->
<slot name="sidebar-extra" />
<RefineSidebar groups={groups} />
</div>
</aside>
<!-- MAIN GRID -->
<main class={`${prefix}main`}>
<div class={`${prefix}grid`} :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
<template x-if="loading && items.length === 0">
<template x-for="i in skeletonCount" :key="'skel-' + i">
<div class={`${prefix}card ${prefix}card--skel`} aria-hidden="true">
<div class={`${prefix}card-left`}>
<div class={`${prefix}card-img`}></div>
</div>
<div class={`${prefix}card-right`}>
<div class={`${prefix}card-info`}>
{skeletonLineClasses.map((line) => (
<div class={`${prefix}line ${line}`}></div>
))}
</div>
<div class={`${prefix}card-strip`}>
{Array.from({ length: 5 }).map(() => (
<div class={`${prefix}card-thumb`}></div>
))}
</div>
</div>
</div>
</template>
</template>
<!-- Empty State -->
<template x-if="!loading && items.length === 0">
<div class={`${prefix}empty`}>{emptyText}</div>
</template>
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<CollectionCard />
</template>
</div>
<nav class={`${prefix}pager`} x-show="lastPage > 1" aria-label="Pagination">
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList()" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
<!-- 页面专属弹窗(走秀 BrandModal)插在这里,确保在 x-data 作用域内 -->
<slot name="modals" />
</div>

View File

@ -0,0 +1,378 @@
---
/**
* 详情页图集体(走秀 / 街拍共用)。
*
* 设计动机:RunwayLookCard 与 StreetSnapCard 的「标题 + 图集网格 + 未登录占位遮罩 + 大图/副图灯箱」
* 逐块同构,差异只有几处声明式开关:
* - aspect 主图与灯箱缩略图比例(当前两页都 2/3 —— 后端素材统一 2:3,见下方 prop 说明)
* - showCaptions 图注:街拍有(图片名),走秀无
* - countLabel 工具栏计数文案:LOOKS / PHOTOS
* - defaultDensity 默认每行张数(走秀与街拍均 5,都可在工具栏切到 3)
* - kind 决定已登录补全量用哪个取数函数(走秀/街拍各一个接口)
* - slot after-grid 图集之后的内容(走秀的系列描述;街拍没有)
*
* 故把「不变的标记」收口在本组件,把上面这些差异用 props / 插槽注入;
* 页面级差异(SSR 取数、TDK、meta.type、标题里的 \t 归一)仍留在两个详情视图与两个薄壳里。
*
* 与灯箱的关系:大图灯箱 GalleryLightbox / 副图灯箱 GalleryDetailLightbox / 交互 lib/gallery.ts
* 早就共享;本组件只是把它们的装配(含 details 插槽)也一并收口。
*
* ⚠️ Alpine 脚本注意:脚本是打包模块,读不到 props,故 rootId / alpineName / kind / favType /
* defaultDensity 全部经根节点 data-* 传下去,脚本在 alpine:init 时读一次。
*/
import GalleryLightbox from "@/components/GalleryLightbox.astro"
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
import DetailCountBadge from "@/components/DetailCountBadge.astro"
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
import { getI18n } from "@/lib/i18n"
interface GalleryImage {
id: string
url: string
thumb: string
raw: string
name: string
lookIndex: number
}
interface Props {
/** 模块身份:决定已登录补全量走哪个详情接口(runway=图集接口 / street=街拍接口) */
kind: "runway" | "street"
/** 根节点 id(灯箱交互靠它查根元素上的 data-*) */
rootId: string
/** Alpine 组件注册名(两个页面各一个,避免同名互相覆盖) */
alpineName: string
/** 图集网格 id(灯箱按它查 [data-image-index] / [data-fav-id]) */
galleryId: string
/** 单图收藏类型:runway_image | snap_image */
favType: string
/**
* 主图与灯箱缩略图比例。
* 后端素材统一 2:3(实测 S4 原图 240×360、high 样式 720×1080,宽高比均 0.6667),
* 故走秀与街拍都传 "2/3" —— 此时 object-cover 零裁切;写成 3/4 会把 2:3 的图
* 强行按 0.75 裁掉上下各约 11%(一行 5 张、格子变小时尤其明显)。
* 保留本 prop 只是为将来某模块素材比例真的不同时能单独覆盖,不是"两页各一套"。
*/
aspect: "2/3" | "3/4"
/** 是否取到了实体(false 时渲染「缺 id / 取数失败」空态)。不用 title 代替:标题允许为空 */
hasItem: boolean
/** H1 文案(调用方已处理好多语言 / \t 归一) */
title: string
/** SSR 首屏图片(调用方已切到 PREVIEW_LIMIT 张) */
previewImages: GalleryImage[]
/** 工具栏计数(真实总数优先) */
totalCount: number
/** 计数单位文案:LOOKS / PHOTOS */
countLabel: string
/** 后端截断了图集(未登录) */
preview: boolean
/** 占位遮罩格数(补足「剩余 N 张」) */
lockedCount: number
/** 递给客户端的副图投影(look_index + detail),与登录后完整 images 同构解析 */
detailPayload: unknown
/** 图集元信息:写进 data-*,供收藏 / 灯箱使用 */
galleryUid: string
galleryTitle: string
galleryBrand: string
/** 是否渲染图片名图注(街拍有、走秀无) */
showCaptions?: boolean
/** 默认每行张数(移动端固定 2 列,此值只影响 md+) */
defaultDensity?: 3 | 5
/** 取数失败 / 缺 id 时的提示文案(调用方已按语言算好) */
errorText: string
/** 空态返回首页链接 */
homeHref: string
}
const {
kind,
rootId,
alpineName,
galleryId,
favType,
aspect,
hasItem,
title,
previewImages,
totalCount,
countLabel,
preview,
lockedCount,
detailPayload,
galleryUid,
galleryTitle,
galleryBrand,
showCaptions = false,
defaultDensity = 5,
errorText,
homeHref,
} = Astro.props
const { t } = getI18n(Astro)
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
const imgAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
const hasImages = previewImages.length > 0
---
<div
id={rootId}
x-data={`${alpineName}()`}
data-detail-gallery
data-alpine-name={alpineName}
data-gallery-id={galleryId}
data-gallery-kind={kind}
data-default-density={String(defaultDensity)}
data-fav-type={favType}
data-gallery-uid={galleryUid}
data-gallery-title={galleryTitle}
data-gallery-brand={galleryBrand}
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"
>
{
hasItem ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{title}
</h1>
{hasImages && (
<section class="mt-16">
{/* 工具栏:图集计数 + 每行张数切换(移动端固定 2 列,density 只影响 md+) */}
<div class="flex items-center justify-between mb-5 border-b border-black/10 pb-3">
<span class="text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">
{totalCount} {countLabel}
</span>
<div class="flex items-center gap-1" role="group" aria-label="Images per row">
<span class="text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-600)] mr-2 hidden sm:inline">PER ROW</span>
{/* 只有「3 张 / 5 张」两档,用竖条图标直观表达列数(避免文字数字按钮) */}
<button
type="button"
@click="density = 3"
:class="density === 3 ? 'bg-black text-white' : 'bg-transparent text-black hover:bg-black/5'"
class="w-8 h-8 flex items-center justify-center border border-black/15 transition-colors cursor-pointer"
:aria-pressed="density === 3"
aria-label="3 per row"
title="3 per row"
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1.5" y="2" width="3" height="12" />
<rect x="6.5" y="2" width="3" height="12" />
<rect x="11.5" y="2" width="3" height="12" />
</svg>
</button>
<button
type="button"
@click="density = 5"
:class="density === 5 ? 'bg-black text-white' : 'bg-transparent text-black hover:bg-black/5'"
class="w-8 h-8 flex items-center justify-center border border-black/15 transition-colors cursor-pointer"
:aria-pressed="density === 5"
aria-label="5 per row"
title="5 per row"
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1" y="2" width="2" height="12" />
<rect x="4" y="2" width="2" height="12" />
<rect x="7" y="2" width="2" height="12" />
<rect x="10" y="2" width="2" height="12" />
<rect x="13" y="2" width="2" height="12" />
</svg>
</button>
</div>
</div>
<div id={galleryId} class="grid gap-4" :class="gridClass()">
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-look-index={im.lookIndex}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="cursor-zoom-in group"
>
{/* 图片与覆盖层(收藏 / 副图角标)必须包在同一个 relative 盒里:
角标是 absolute bottom-2,若锚点落在含图注的 figure 上,就会压在图注上而不是图片左下角。 */}
<div class="relative bg-[var(--c-gray-100)] overflow-hidden">
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class={`w-full ${imgAspect} object-cover group-hover:opacity-90 transition-opacity`}
/>
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它直接开副图灯箱 */}
<DetailCountBadge look={String(im.lookIndex)} />
</div>
{showCaptions && im.name && (
<figcaption class="mt-2 h-[15px] text-[11px] leading-none uppercase tracking-widest text-[var(--c-gray-600)] truncate">
{im.name}
</figcaption>
)}
</figure>
))}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="cursor-pointer group"
>
{/* 与真实图片等高的图位:锁图标 + 提示,点击跳登录 */}
<div class={`w-full ${imgAspect} bg-[var(--c-gray-100)] flex items-center justify-center text-[var(--c-gray-600)] group-hover:text-[var(--c-gray-700)] transition-colors pointer-events-none`}>
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图注等高的空白衬垫,保证遮罩格与真实图格总高一致。
这里不放「+N photos」文案:lockedCount 是剩余总数,逐格重复显示会误导。 */}
{showCaptions && <div class="mt-2 h-[15px]" aria-hidden="true"></div>}
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-look-index="im.lookIndex"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="cursor-zoom-in group"
>
<div class="relative bg-[var(--c-gray-100)] overflow-hidden">
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class={`w-full ${imgAspect} object-cover group-hover:opacity-90 transition-opacity`}
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<DetailCountBadge look="im.lookIndex" />
</div>
{showCaptions && (
<template x-if="im.name">
<figcaption class="mt-2 h-[15px] text-[11px] leading-none uppercase tracking-widest text-[var(--c-gray-600)] truncate" x-text="im.name"></figcaption>
</template>
)}
</figure>
</template>
</div>
</section>
)}
{/* 图集之后的页面专属内容(走秀的系列描述;街拍不传) */}
<slot name="after-grid" />
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-widest mb-4">
{errorText}
</p>
<a
href={homeHref}
class="text-[12px] uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
>
← {t('Back to Index')}
</a>
</div>
)
}
{/* 大图灯箱(共享):当前主图的副图经 details 插槽注入。 */}
{hasImages && (
<GalleryLightbox images={previewImages} aspect={aspect}>
<LbDetailsStrip slot="details" aspect={aspect} />
</GalleryLightbox>
)}
{/* 副图灯箱(共享):点大图灯箱右下角的副图缩略图、或网格左下角角标打开 */}
{hasImages && <GalleryDetailLightbox aspect={aspect} />}
</div>
<script>
import { getArticleDetailAuthed, getStreetSnapDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { createDetailGallery } from "@/lib/galleryDetail"
// 模块身份 → 已登录补全量的取数函数。脚本读不到 props,故身份走根节点的 data-gallery-kind。
const FETCHERS = {
runway: getArticleDetailAuthed,
street: getStreetSnapDetailAuthed,
} as const
document.addEventListener("alpine:init", () => {
// 一个页面只有一个详情图集(网格 + 两个灯箱都在同一根节点内)。
const root = document.querySelector<HTMLElement>("[data-detail-gallery]")
if (!root) return
const kind = root.dataset.galleryKind === "street" ? "street" : "runway"
const name = root.dataset.alpineName || "articleView"
const density = Number(root.dataset.defaultDensity) === 3 ? 3 : 5
Alpine.data(name, () => ({
...createDetailGallery(
createGalleryView({
rootSelector: `#${root.id}`,
gallerySelector: `#${root.dataset.galleryId}`,
favType: root.dataset.favType || "runway_image",
fetchDetail: FETCHERS[kind],
}),
),
// 每行张数(应对图集图片很多):3 / 5 两档,移动端固定 2 列。
density,
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
gridClass(): string {
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
},
}))
})
</script>

View File

@ -3,7 +3,14 @@
// 走秀(RunwayLookCard)与街拍(StreetSnapCard)共用;交互在 lib/galleryDetail.ts。 // 走秀(RunwayLookCard)与街拍(StreetSnapCard)共用;交互在 lib/galleryDetail.ts。
import { getI18n } from "@/lib/i18n" import { getI18n } from "@/lib/i18n"
interface Props {
/** 左栏副图缩略条比例,与主图一致(后端素材统一 2:3,默认值即可) */
aspect?: "2/3" | "3/4"
}
const { aspect = "2/3" } = Astro.props
const { t } = getI18n(Astro) const { t } = getI18n(Astro)
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
--- ---
<div <div
@ -23,8 +30,8 @@ const { t } = getI18n(Astro)
<button <button
login-required login-required
type="button" type="button"
@click.stop="toggleFav(currentDetailFavId, currentDetailFavUrl)" @click.stop="toggleFav(currentDetailFavId(), currentDetailFavUrl())"
:class="isFav(currentDetailFavId) ? 'is-fav' : ''" :class="isFav(currentDetailFavId()) ? 'is-fav' : ''"
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer" 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')} aria-label={t('save')}
> >
@ -54,7 +61,7 @@ const { t } = getI18n(Astro)
<button <button
type="button" type="button"
@click="showDetailLb(i)" @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" class={`relative block w-full ${thumbAspect} 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}')`" :style="`background-image:url('${im.thumb}')`"
x-bind:class="detailLbIndex === i ? 'opacity-100 ring-1 ring-black' : ''" x-bind:class="detailLbIndex === i ? 'opacity-100 ring-1 ring-black' : ''"
:aria-label="String(i + 1)" :aria-label="String(i + 1)"

View File

@ -10,7 +10,7 @@ import { getI18n } from "@/lib/i18n"
interface Props { interface Props {
/** SSR 首屏图片(前 PREVIEW_LIMIT 张),需带 thumb 供左栏缩略图条使用 */ /** SSR 首屏图片(前 PREVIEW_LIMIT 张),需带 thumb 供左栏缩略图条使用 */
images: { thumb: string }[] images: { thumb: string }[]
/** 缩略图比例:走秀 look 为 2/3,街拍为 3/4 */ /** 缩略图比例。后端素材统一 2:3(说明见 DetailGalleryView 的 aspect prop),默认值即可 */
aspect?: "2/3" | "3/4" aspect?: "2/3" | "3/4"
} }
const { images, aspect = "2/3" } = Astro.props const { images, aspect = "2/3" } = Astro.props

View File

@ -1,22 +1,29 @@
--- ---
// 大图灯箱右下角的「当前主图的细节图 / 副图」缩略簇,注入 GalleryLightbox 的 details 具名插槽。 // 大图灯箱右下角的「当前主图的细节图 / 副图」缩略簇,注入 GalleryLightbox 的 details 具名插槽。
// 主图放大(lbZoomed)时整簇隐藏;点其中一张进细节灯箱看大图。 // 主图放大(lbZoomed)时整簇隐藏;点其中一张进细节灯箱看大图。
// 走秀与街拍共用,故不放任何页面专有逻辑(只读 lib/galleryDetail.ts 的 currentDetailList)。 // 走秀与街拍共用,故不放任何页面专有逻辑(只读 lib/galleryDetail.ts 的 currentDetailList())。
interface Props {
/** 副图缩略图比例,与主图一致(后端素材统一 2:3,默认值即可) */
aspect?: "2/3" | "3/4"
}
const { aspect = "2/3" } = Astro.props
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
--- ---
<div <div
id="lb-details" id="lb-details"
x-show="!lbZoomed && currentDetailList.length > 0" x-show="!lbZoomed && currentDetailList().length > 0"
x-cloak x-cloak
class="absolute right-4 sm:right-6 bottom-[72px] z-30 flex flex-col items-end gap-2" class="absolute right-4 sm:right-6 bottom-[72px] z-30 flex flex-col items-end gap-2"
> >
<span class="text-[10px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">Details</span> <span class="text-[10px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">Details</span>
<div class="flex max-w-[min(70vw,440px)] gap-2 overflow-x-auto no-scrollbar"> <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"> <template x-for="(d, i) in currentDetailList()" :key="d.url">
<button <button
type="button" type="button"
@click.stop="openDetailLbAt(i)" @click.stop="openDetailLbAt(i)"
class="relative flex-none w-[52px] sm:w-[68px] aspect-[2/3] overflow-hidden border border-black/15 bg-[var(--c-gray-200)] cursor-zoom-in transition-colors hover:border-black" class={`relative flex-none w-[52px] sm:w-[68px] ${thumbAspect} overflow-hidden border border-black/15 bg-[var(--c-gray-200)] cursor-zoom-in transition-colors hover:border-black`}
:aria-label="`Detail ${i + 1}`" :aria-label="`Detail ${i + 1}`"
> >
<img :src="d.thumb" :alt="d.name || ''" loading="lazy" decoding="async" class="w-full h-full object-cover" /> <img :src="d.thumb" :alt="d.name || ''" loading="lazy" decoding="async" class="w-full h-full object-cover" />

View File

@ -59,4 +59,4 @@ const { groups = [] } = Astro.props as { groups: Group[] }
)) ))
} }
<button login-required class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button> <button login-required class="showsclear" type="button" @click="clearAll()" x-show="pills().length > 0" x-cloak>Clear all ×</button>

View File

@ -1,9 +1,12 @@
--- ---
/**
* 走秀详情卡片(薄壳)。
*
* 只负责走秀特有的事:把后端 RawArticle 映射成图集数据、算未登录占位格数、
* 组装系列描述。标记与交互全部交给共享的 DetailGalleryView(与街拍同一套)。
*/
import type { RawArticle } from "@/lib/api" import type { RawArticle } from "@/lib/api"
import GalleryLightbox from "@/components/GalleryLightbox.astro" import DetailGalleryView from "@/components/DetailGalleryView.astro"
import DetailCountBadge from "@/components/DetailCountBadge.astro"
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
import { getI18n } from "@/lib/i18n" import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { ssrAbs } from "@/lib/request" import { ssrAbs } from "@/lib/request"
@ -22,7 +25,7 @@ const { locale, t } = getI18n(Astro)
// 故此处 images 即为主图集合,默认只渲染它们;detail 随主图一起带上,供「细节入口」展开。 // 故此处 images 即为主图集合,默认只渲染它们;detail 随主图一起带上,供「细节入口」展开。
const images = (article?.images ?? []) const images = (article?.images ?? [])
.map((im) => ({ .map((im) => ({
id: im.id, id: im.id ?? "",
url: ssrAbs(im.image || ""), url: ssrAbs(im.image || ""),
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可, // 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。 // 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
@ -30,25 +33,12 @@ const images = (article?.images ?? [])
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库 raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
name: im.name || "", name: im.name || "",
lookIndex: Number(im.look_index ?? 0), lookIndex: Number(im.look_index ?? 0),
// 该主图下的细节图:与主图同一套字段映射(递归同构)
detail: (im.detail ?? []).map((d) => ({
id: d.id || "",
url: ssrAbs(d.image || ""),
thumb: ssrAbs(d.thumb || d.image || ""),
raw: d.image || "",
name: d.name || "",
})),
})) }))
.filter((im) => im.url) .filter((im) => im.url)
// 网格左下角的「细节图张数」角标交给 Alpine 的 detailCount() 响应式控制(而非 SSR 编译期条件):
// 首屏前 PREVIEW_LIMIT 张由下面的 data-details 载荷构建映射,登录后由 gallery:full 用完整图集重建,
// 未登录 / 已登录共用同一条渲染路径。
// 递给浏览器端 Alpine 的细节图数据:只序列化「带细节图的主图」投影(不带主图自身 URL, // 递给浏览器端 Alpine 的细节图数据:只序列化「带细节图的主图」投影(不带主图自身 URL,
// 避免把整份 images 重复塞进 HTML)。元素形状与 images 元素一致(look_index + detail), // 避免把整份 images 重复塞进 HTML)。元素形状与 images 元素一致(look_index + detail),
// 故 Alpine 的 buildDetailMapFrom() 能与「登录后拿回的完整 images」共用同一套解析逻辑。 // 故 Alpine 的 buildDetailMapFrom() 能与「登录后拿回的完整 images」共用同一套解析逻辑。
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图(细节图随所属主图一起返回),此处即为这几张的细节子树。
const detailPayload = (article?.images ?? []) const detailPayload = (article?.images ?? [])
.filter((im) => (im.detail?.length ?? 0) > 0) .filter((im) => (im.detail?.length ?? 0) > 0)
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail })) .map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
@ -68,248 +58,38 @@ const descParas = (article?.description || "")
.filter(Boolean) .filter(Boolean)
const homeHref = href(locale, ROUTES.home) const homeHref = href(locale, ROUTES.home)
const errorText = error === "missing"
? t('No article ID specified')
: t('Failed to load article. Please refresh or try later.')
--- ---
<div <DetailGalleryView
id="article-view" kind="runway"
x-data="articleView()" rootId="article-view"
data-fav-type="runway_image" alpineName="articleView"
data-gallery-uid={article?.id ?? ""} galleryId="article-gallery"
data-gallery-title={article?.title ?? ""} favType="runway_image"
data-gallery-brand={article?.brand_name ?? ""} aspect="2/3"
data-preview={preview ? "true" : "false"} hasItem={!!article}
data-details={JSON.stringify(detailPayload)} title={article?.title ?? ""}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white" previewImages={previewImages}
totalCount={imageTotal || images.length}
countLabel="LOOKS"
preview={preview}
lockedCount={lockedCount}
detailPayload={detailPayload}
galleryUid={String(article?.id ?? "")}
galleryTitle={article?.title ?? ""}
galleryBrand={article?.brand_name ?? ""}
defaultDensity={5}
errorText={errorText}
homeHref={homeHref}
> >
{
article ? (
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{article.title}
</h1>
{previewImages.length > 0 && (
<section class="mt-16">
{/* 每行张数切换(应对图集图片很多的情况):2/3/4/5 列,移动端固定 2 列 */}
<div class="flex items-center justify-between mb-5 border-b border-black/10 pb-3">
<span class="text-[11px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">
{(imageTotal || images.length)} LOOKS
</span>
<div class="flex items-center gap-1" role="group" aria-label="Images per row">
<span class="text-[11px] uppercase tracking-[0.18em] text-[var(--c-gray-600)] mr-2 hidden sm:inline">PER ROW</span>
{/* 每行张数切换:只有「3 张 / 5 张」两档,用竖条图标直观表达列数(避免文字数字按钮)。
density 只影响 md+;移动端固定 2 列(5 列在手机上不可用)。 */}
<button
type="button"
@click="density = 3"
:class="density === 3 ? 'bg-black text-white' : 'bg-transparent text-black hover:bg-black/5'"
class="w-8 h-8 flex items-center justify-center border border-black/15 transition-colors cursor-pointer"
:aria-pressed="density === 3"
aria-label="3 per row"
title="3 per row"
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1.5" y="2" width="3" height="12" />
<rect x="6.5" y="2" width="3" height="12" />
<rect x="11.5" y="2" width="3" height="12" />
</svg>
</button>
<button
type="button"
@click="density = 5"
:class="density === 5 ? 'bg-black text-white' : 'bg-transparent text-black hover:bg-black/5'"
class="w-8 h-8 flex items-center justify-center border border-black/15 transition-colors cursor-pointer"
:aria-pressed="density === 5"
aria-label="5 per row"
title="5 per row"
>
<svg class="w-[18px] h-[18px]" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1" y="2" width="2" height="12" />
<rect x="4" y="2" width="2" height="12" />
<rect x="7" y="2" width="2" height="12" />
<rect x="10" y="2" width="2" height="12" />
<rect x="13" y="2" width="2" height="12" />
</svg>
</button>
</div>
</div>
<div
id="article-gallery"
class="grid gap-4"
:class="gridClass()"
>
{previewImages.map((im, i) => {
return (
<figure
data-image-index={i}
data-look-index={im.lookIndex}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class="w-full aspect-[2/3] object-cover group-hover:opacity-90 transition-opacity"
/>
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{/* 细节图数量角标(共享组件):有细节图的主图左下角标「张数」,点它开细节灯箱。 */}
<DetailCountBadge look={String(im.lookIndex)} />
</figure>
)
})}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[var(--c-gray-100)] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[2/3] flex items-center justify-center text-black group-hover:text-[var(--c-gray-700)] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 真实图已无 look 序号 caption,占位格也改为绝对定位的 +N photos,
不撑高格子,保证遮罩格与真实图片格总高一致 */}
{/* <span class="absolute left-2 bottom-2 z-10 text-[10px] uppercase tracking-[0.14em] text-[#b8b8b8] pointer-events-none">+{lockedCount} {t('photos')}</span> */}
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
:data-look-index="im.lookIndex"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[2/3] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{/* 细节图数量角标(共享组件,客户端补拉图,与首屏一致) */}
<DetailCountBadge look="im.lookIndex" />
</figure>
</template>
</div>
</section>
)}
{descParas.length > 0 && ( {descParas.length > 0 && (
<section class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]"> <section slot="after-grid" class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
{descParas.map((p) => ( {descParas.map((p) => (
<p>{p}</p> <p>{p}</p>
))} ))}
</section> </section>
)} )}
</article> </DetailGalleryView>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-widest mb-4">
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
>
← {t('Back to Index')}
</a>
</div>
)
}
{/* 大图灯箱:与街拍详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。 走秀独有的「当前 look 的细节图簇」经 details 插槽注入。 */}
{article && previewImages.length > 0 && (
<GalleryLightbox images={previewImages} aspect="2/3">
{/* 当前主图的细节图缩略簇(共享组件) */}
<LbDetailsStrip slot="details" />
</GalleryLightbox>
)}
{/* 细节图灯箱(共享组件):点大图灯箱里的细节缩略图打开,单张细节图放大查看 */}
{article && previewImages.length > 0 && <GalleryDetailLightbox />}
</div>
<script>
import { getArticleDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { createDetailGallery } from "@/lib/galleryDetail"
// 走秀详情:与街拍共用「图集 + 大图灯箱 + 细节图灯箱」
// (lib/gallery.ts + lib/galleryDetail.ts);本组件只叠加走秀独有的「每行张数」。
document.addEventListener("alpine:init", () => {
Alpine.data("articleView", () => ({
...createDetailGallery(
createGalleryView({
rootSelector: "#article-view",
gallerySelector: "#article-gallery",
favType: "runway_image",
fetchDetail: getArticleDetailAuthed,
}),
),
// 每行张数(应对图集图片很多):只有 3 / 5 两档(工具栏图标按钮切换),移动端固定 2 列。
density: 5,
// 基础 grid-cols-2 与两个 md:grid-cols-* 字面量都出现在源码里,确保 Tailwind v4 JIT 能扫描到。
gridClass(): string {
return "grid-cols-2 " + (this.density === 5 ? "md:grid-cols-5" : "md:grid-cols-3")
},
}))
})
</script>

View File

@ -1,9 +1,13 @@
--- ---
/**
* 街拍详情卡片(薄壳)。
*
* 只负责街拍特有的事:把后端 RawStreetSnap 映射成图集数据、算未登录占位格数、
* 把标题里的字面 \t(地点 \t 场次)归一成 " · "。标记与交互全部交给共享的
* DetailGalleryView(与走秀同一套),差异经 props 声明:带图片名图注等。
*/
import type { RawStreetSnap } from "@/lib/api" import type { RawStreetSnap } from "@/lib/api"
import GalleryLightbox from "@/components/GalleryLightbox.astro" import DetailGalleryView from "@/components/DetailGalleryView.astro"
import DetailCountBadge from "@/components/DetailCountBadge.astro"
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
import { getI18n } from "@/lib/i18n" import { getI18n } from "@/lib/i18n"
import { href, ROUTES } from '@/lib/routes' import { href, ROUTES } from '@/lib/routes'
import { ssrAbs } from "@/lib/request" import { ssrAbs } from "@/lib/request"
@ -20,7 +24,7 @@ const { locale, t } = getI18n(Astro)
const images = (snap?.images ?? []) const images = (snap?.images ?? [])
.map((im) => ({ .map((im) => ({
id: im.id, id: im.id ?? "",
url: ssrAbs(im.image || ""), url: ssrAbs(im.image || ""),
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。 // 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。
thumb: ssrAbs(im.thumb || im.image || ""), thumb: ssrAbs(im.thumb || im.image || ""),
@ -30,6 +34,12 @@ const images = (snap?.images ?? [])
})) }))
.filter((im) => im.url) .filter((im) => im.url)
// 递给浏览器端 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 }))
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示, // 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。 // 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5 const PREVIEW_LIMIT = 5
@ -39,204 +49,36 @@ const previewImages = images.slice(0, PREVIEW_LIMIT)
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。 // 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0 const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(look_index + detail),
// 供 lib/galleryDetail.ts 构建 look_index → 副图 映射(与登录后完整 images 同构解析)。
const detailPayload = (snap?.images ?? [])
.filter((im) => (im.detail?.length ?? 0) > 0)
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
const homeHref = href(locale, ROUTES.home) const homeHref = href(locale, ROUTES.home)
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现 // theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
const displayTitle = (snap?.title || "").replace(/\t/g, " · ") const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
const errorText = error === "missing"
? t('No street snap ID specified')
: t('Failed to load street snap. Please refresh or try later.')
--- ---
<div <DetailGalleryView
id="street-snap-view" kind="street"
x-data="streetSnapView()" rootId="street-snap-view"
data-fav-type="snap_image" alpineName="streetSnapView"
data-gallery-uid={snap?.id ?? ""} galleryId="snap-gallery"
data-gallery-title={displayTitle} favType="snap_image"
data-gallery-brand="" aspect="2/3"
data-preview={preview ? "true" : "false"} hasItem={!!snap}
data-details={JSON.stringify(detailPayload)} title={displayTitle}
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white" previewImages={previewImages}
> totalCount={imageTotal || images.length}
{ countLabel="PHOTOS"
snap ? ( preview={preview}
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16"> lockedCount={lockedCount}
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8"> detailPayload={detailPayload}
{displayTitle} galleryUid={snap?.id ?? ""}
</h1> galleryTitle={displayTitle}
galleryBrand=""
{previewImages.length > 0 && ( showCaptions
<section class="mt-16"> defaultDensity={5}
<div errorText={errorText}
id="snap-gallery" homeHref={homeHref}
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
{previewImages.map((im, i) => (
<figure
data-image-index={i}
data-look-index={im.lookIndex}
data-image-url={im.url}
data-image-name={im.name}
data-fav-id={im.id}
data-fav-url={im.raw}
@click={`openLb(${i})`}
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
src={im.url}
alt={im.name}
loading={i < 6 ? "eager" : "lazy"}
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/> />
{/* 单图收藏:hover 显示,已收藏常驻实心 */}
<button
login-required
type="button"
data-fav-id={im.id}
data-fav-url={im.raw}
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
{/* 副图数量角标(共享组件):有副图的主图左下角标「张数」,点它开副图灯箱 */}
<DetailCountBadge look={String(im.lookIndex)} />
{im.name && (
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">
{im.name}
</figcaption>
)}
</figure>
) )}
{/* 未登录预览占位遮罩:补满「剩余 N 张」空图,提示用户登录后可见,
避免误以为图集只有预览上限那几张。已登录(x-show false)自动隐藏,由 extraImages 真实图替补。 */}
{lockedCount > 0 &&
Array.from({ length: lockedCount }).map(() => (
<figure
x-show="!isAuthed && preview"
x-cloak
login-redirect
class="relative bg-[var(--c-gray-100)] cursor-pointer group overflow-hidden"
>
{/* 与真实图片等高的 3:4 图位:锁图标 + 提示,点击跳登录 */}
<div class="w-full aspect-[3/4] flex items-center justify-center text-[var(--c-gray-600)] group-hover:text-[var(--c-gray-700)] transition-colors pointer-events-none">
<div class="flex flex-col items-center gap-2">
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
<span class="text-[10px] uppercase tracking-[0.18em]">{t('login to view')}</span>
</div>
</div>
{/* 与真实图 figcaption 同高的 caption 行(正常图显示 img01 等小字):
占位显示 +N photos,保证遮罩格子与真实图片格子总高一致 */}
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2">+{lockedCount} {t('photos')}</figcaption>
</figure>
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url" >
<figure
:data-image-index="5 + i"
:data-look-index="im.lookIndex"
:data-image-url="im.url"
:data-image-name="im.name"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click="openLb(5 + i)"
class="relative bg-[var(--c-gray-100)] cursor-zoom-in group"
>
<img
:src="im.url"
:alt="im.name"
loading="lazy"
decoding="async"
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
/>
<button
login-required
type="button"
:data-fav-id="im.id"
:data-fav-url="im.raw"
@click.stop="toggleFav($el.dataset.favId, $el.dataset.favUrl)"
:class="isFav($el.dataset.favId) ? 'is-fav opacity-100' : 'opacity-0 group-hover:opacity-100'"
class="fav-btn absolute right-2 top-2 z-10 w-8 h-8 rounded-full border border-black/15 bg-white/80 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('save')}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/>
</svg>
</button>
<DetailCountBadge look="im.lookIndex" />
<template x-if="im.name">
<figcaption class="text-[11px] uppercase tracking-widest text-[var(--c-gray-600)] mt-2" x-text="im.name"></figcaption>
</template>
</figure>
</template>
</div>
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-widest mb-4">
{error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
>
← {t('Back to Index')}
</a>
</div>
)
}
{/* 大图灯箱:与走秀详情共用 components/GalleryLightbox.astro(标记)+ lib/gallery.ts(交互)。
当前主图的副图经 details 插槽注入(与走秀一致)。 */}
{snap && previewImages.length > 0 && (
<GalleryLightbox images={previewImages} aspect="3/4">
<LbDetailsStrip slot="details" />
</GalleryLightbox>
)}
{/* 副图(细节)灯箱:点大图灯箱右下角的副图缩略图打开 */}
{snap && previewImages.length > 0 && <GalleryDetailLightbox />}
</div>
<script>
import { getStreetSnapDetailAuthed } from "@/lib/api"
import { createGalleryView } from "@/lib/gallery"
import { createDetailGallery } from "@/lib/galleryDetail"
// 街拍详情:与走秀共用「图集 + 大图灯箱 + 细节(副图)灯箱」
// (lib/gallery.ts + lib/galleryDetail.ts),无额外扩展。
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () =>
createDetailGallery(
createGalleryView({
rootSelector: "#street-snap-view",
gallerySelector: "#snap-gallery",
favType: "snap_image",
fetchDetail: getStreetSnapDetailAuthed,
}),
),
)
})
</script>

View File

@ -1,12 +1,11 @@
--- ---
import BrandModal from "@/components/BrandModal.astro" import BrandModal from "@/components/BrandModal.astro"
import CollectionCard from "@/components/CollectionCard.astro" import CollectionListView from "@/components/CollectionListView.astro"
import RefineSidebar from "@/components/RefineSidebar.astro"
import { getSsgHotBrands } from "@/lib/ssg" import { getSsgHotBrands } from "@/lib/ssg"
import { getI18n } from '@/lib/i18n' import { getI18n } from '@/lib/i18n'
import "@/styles/look-grid.css" import { buildYearOptions } from "@/lib/filters"
const { locale, t } = getI18n(Astro) const { t } = getI18n(Astro)
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。 // 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。 // 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
@ -33,15 +32,8 @@ try {
} catch {} } catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
// 年份筛选:写死,按需增删(数据最早约 2005 年,如需更老年份往数组里加即可) // 年份筛选:近 10 年(含当年),末项加"X年前"兜底(与街拍共用 lib/filters.ts)
// 年份:近 10 年(含当年),末项加"X年前"兜底 const yearOptions = buildYearOptions()
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 9
const yearOptions: { value: number | string; label: string }[] = []
for (let y = currentYear; y >= yearFloor; y--) {
yearOptions.push({ value: y, label: String(y) })
}
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
// 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可) // 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可)
const collectionOptions: { value: string; label: string }[] = [ const collectionOptions: { value: string; label: string }[] = [
@ -69,34 +61,16 @@ const runwayGroups = [
] ]
--- ---
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}> <CollectionListView
<!-- 顶部:极简眉头 --> prefix="shows"
<header class="showshead"> alpineName="showsPage"
<div> title={t('all runway looks')}
<h1 class="showsh1">{t('all runway looks')}</h1> groups={runwayGroups}
</div> skeletonLines={3}
</header> emptyText="No shows match the current filters"
>
<!-- 已激活筛选 pills -->
<div class="showspills" x-show="pills.length > 0" x-cloak>
<template x-for="(p, i) in pills" :key="i">
<span class="showspill">
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
<div class="showsbody">
<!-- LEFT SIDEBAR -->
<aside class="showsaside">
<div class="showsaside-head">
<!-- <h2 class="showsaside-h">REFINE</h2> -->
</div>
<div class="showsaside-body">
<!-- Designer 多选(走秀专属:热门品牌单选 + 全品牌弹窗) --> <!-- Designer 多选(走秀专属:热门品牌单选 + 全品牌弹窗) -->
<Fragment slot="sidebar-extra">
<section class="showsg"> <section class="showsg">
<header class="showsg-h" @click="toggleGroup('designers')"> <header class="showsg-h" @click="toggleGroup('designers')">
<span>{t('brands')}</span> <span>{t('brands')}</span>
@ -120,69 +94,11 @@ const runwayGroups = [
<a login-required class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a> <a login-required class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
</div> </div>
</section> </section>
</Fragment>
<!-- 通用分组(collection/season/year) -->
<RefineSidebar groups={runwayGroups} />
</div>
</aside>
<!-- MAIN GRID -->
<main class="showsmain">
<div class="showsgrid" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
<template x-if="loading && items.length === 0">
<template x-for="i in skeletonCount" :key="'skel-'+i">
<div class="showscard showscard--skel" aria-hidden="true">
<div class="showscard-left">
<div class="showscard-img"></div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
<div class="showsline h-[38px] rounded-[2px]"></div>
</div>
<div class="showscard-strip">
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
</div>
</div>
</div>
</template>
</template>
<!-- Empty State -->
<template x-if="!loading && items.length === 0">
<div class="showsempty">No shows match the current filters</div>
</template>
<!-- Card List -->
<template x-for="it in items" :key="it.id">
<CollectionCard />
</template>
</div>
<nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) --> <!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
<BrandModal /> <BrandModal slot="modals" />
</CollectionListView>
</div>
<script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script> <script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
@ -328,8 +244,9 @@ const runwayGroups = [
this.refreshFilter() this.refreshFilter()
}, },
// 已筛选选项 Pills 视图数据 // 已筛选选项 Pills 视图数据(必须是方法:createCollectionPage 用对象展开返回,
get pills() { // getter 会在展开时被求值冻结成静态值,模板里写 pills())
pills() {
const list: { label: string; clear: () => void }[] = [] const list: { label: string; clear: () => void }[] = []
if (this.selectedBrand) { if (this.selectedBrand) {
const findName = (id: any) => this.hotBrands.find((b: { id: string | number; name?: string }) => b.id === id)?.name ?? this.modalResults.find((b: { id: string | number; name?: string }) => b.id === id)?.name ?? `#${id}` const findName = (id: any) => this.hotBrands.find((b: { id: string | number; name?: string }) => b.id === id)?.name ?? this.modalResults.find((b: { id: string | number; name?: string }) => b.id === id)?.name ?? `#${id}`

View File

@ -1,19 +1,12 @@
--- ---
import CollectionListView from "@/components/CollectionListView.astro"
import { getI18n } from '@/lib/i18n' import { getI18n } from '@/lib/i18n'
import CollectionCard from "@/components/CollectionCard.astro" import { buildYearOptions } from "@/lib/filters"
import RefineSidebar from "@/components/RefineSidebar.astro"
import "@/styles/look-grid.css"
const { locale, t } = getI18n(Astro) const { t } = getI18n(Astro)
// 年份筛选:近 10 年(含当年),末项兜底 // 年份筛选:近 10 年(含当年),末项兜底(与走秀共用 lib/filters.ts)
const currentYear = new Date().getFullYear() const yearOptions = buildYearOptions()
const yearFloor = currentYear - 9
const yearOptions: { value: number | string; label: string }[] = []
for (let y = currentYear; y >= yearFloor; y--) {
yearOptions.push({ value: y, label: String(y) })
}
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
// 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc // 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc
const sortOptions: { value: string; label: string }[] = [ const sortOptions: { value: string; label: string }[] = [
@ -53,95 +46,14 @@ const streetGroups = [
] ]
--- ---
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}> <CollectionListView
<!-- 顶部:极简眉头 --> prefix="snaps"
<header class="snapshead"> alpineName="streetSnapsPage"
<div> title={t('All Street Snaps')}
<h1 class="snapsh1">{t('All Street Snaps')}</h1> groups={streetGroups}
</div> skeletonLines={2}
</header> emptyText="No street snaps match the current filters"
/>
<!-- 已激活筛选 pills -->
<div class="snapspills" x-show="pills.length > 0" x-cloak>
<template x-for="(p, i) in pills" :key="i">
<span class="snapspill">
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar(REFINE 筛选) + 右卡片网格 -->
<div class="snapsbody">
<!-- LEFT SIDEBAR -->
<aside class="snapsaside">
<div class="snapsaside-head">
<!-- <h2 class="snapsaside-h">REFINE</h2> -->
</div>
<div class="snapsaside-body">
<RefineSidebar groups={streetGroups} />
</div>
</aside>
<!-- MAIN:卡片网格(左封面 + 右标题 + 缩略图条带) -->
<main class="snapsmain">
<div class="snapscontent" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝尺寸抖动 -->
<template x-if="loading && items.length === 0">
<div class="snapsgrid" aria-hidden="true">
<template x-for="i in skeletonCount" :key="'skel-'+i">
<div class="snapscard snapscard--skel">
<div class="snapscard-left">
<div class="snapscard-img"></div>
</div>
<div class="snapscard-right">
<div class="snapscard-info">
<div class="snapsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="snapsline h-[29px] mb-[14px] rounded-[2px]"></div>
</div>
<div class="snapscard-strip">
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
<div class="snapscard-thumb"></div>
</div>
</div>
</div>
</template>
</div>
</template>
<!-- Empty -->
<template x-if="!loading && items.length === 0">
<div class="snapsempty">No street snaps match the current filters</div>
</template>
<!-- Card List -->
<template x-if="!loading && items.length > 0">
<div class="snapsgrid">
<template x-for="it in items" :key="it.id">
<CollectionCard />
</template>
</div>
</template>
</div>
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
<button login-required class="pg pg-prev" type="button" :disabled="page <= 1 || loading" @click="prevPage()">‹ Prev</button>
<template x-for="(p, i) in pageList" :key="i">
<span class="pg-wrap">
<span class="pg-ellipsis" x-show="p === '...'" x-text="'…'"></span>
<button login-required class="pg" type="button" x-show="p !== '...'" :class="{ 'is-current': p === page }" :disabled="p === page" @click="goPage(p)" x-text="p"></button>
</span>
</template>
<button login-required class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
</div>
<script> <script>
@ -188,7 +100,8 @@ const streetGroups = [
}, },
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它) // 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
get pills() { // 必须是方法:createCollectionPage 用对象展开返回,getter 会在展开时被求值冻结成静态值,模板里写 pills()
pills() {
const out: { label: string; clear: () => void }[] = [] const out: { label: string; clear: () => void }[] = []
if (this.selectedYear) { if (this.selectedYear) {
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear)) const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))

View File

@ -21,6 +21,7 @@ import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes" import { clientHref, ROUTES } from "@/lib/routes"
import { buildPageList, SPIN_SVG } from "@/lib/ui" import { buildPageList, SPIN_SVG } from "@/lib/ui"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites" import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
import { hydrateFilterOptions } from "@/lib/filters"
export interface CollectionPageOptions { export interface CollectionPageOptions {
/** 收藏类型,写入 favorites 表 */ /** 收藏类型,写入 favorites 表 */
@ -50,10 +51,13 @@ export function createCollectionPage(opts: CollectionPageOptions) {
favMap: {} as Record<string, boolean>, favMap: {} as Record<string, boolean>,
items: [] as any[], items: [] as any[],
get skeletonCount() { // ⚠️ 本工厂用 {...base, ...extra} 展开返回,而**对象展开会对 getter 立即求值**,
return opts.skeletonCount ?? 24 // 把 getter 冻结成「创建那一刻」的静态值。故这里一律写普通属性 / 方法,禁止写 getter:
}, // 曾经的 get pageList() 就被冻结在 lastPage=1/page=1 → 分页按钮永远只显示 1;
get pageList() { // 各页 extra 里的 get pills() 同样被冻结成 [],导致筛选 chips 与侧栏 Clear all 永远不显示。
// (同一坑在 lib/gallery.ts 的 currentFavId/currentFavUrl 上踩过一次,那边改成了方法。)
skeletonCount: opts.skeletonCount ?? 24,
pageList() {
return buildPageList(this.lastPage, this.page) return buildPageList(this.lastPage, this.page)
}, },
@ -137,7 +141,12 @@ export function createCollectionPage(opts: CollectionPageOptions) {
}, },
init() { init() {
this.locale = (this.$el as HTMLElement).dataset.locale || "en" const el = this.$el as HTMLElement
this.locale = el.dataset.locale || "en"
// 把列表壳内嵌的筛选分组(构造期数据)回填到 selectedXxx 对应的 xxxOptions,
// 否则 extra 里的空数组会让 pills 标签回落成原始值、并让页面的深链白名单恒为空。
// 必须在 onInit 之前:各页 onInit 里的深链校验依赖这些选项。
hydrateFilterOptions(this, el.dataset.filterGroups)
if (opts.onInit) opts.onInit.call(this, this) if (opts.onInit) opts.onInit.call(this, this)
this.loadPage(1) this.loadPage(1)
}, },

64
src/lib/filters.ts Normal file
View File

@ -0,0 +1,64 @@
/**
* 列表页筛选选项(从 RunwayLooks / StreetSnaps 的 frontmatter 抽出)。
*
* 只收「两模块逐字相同、且与 i18n 无关」的选项生成逻辑。
* 需要 t() 的选项(街拍的 Sort / City,标签要随语言切换)仍留在各自页面内,
* 因为 t() 依赖 Astro 请求上下文,不适合下沉到纯工具模块。
*/
export interface FilterOption {
value: string | number
label: string
}
export interface FilterGroup {
key: string
title: string
options: FilterOption[]
/** 该分组对应的 Alpine 状态变量名,如 'selectedCity' —— 同时决定 options 回填到哪个字段 */
selected: string
}
/**
* 把列表壳内嵌的筛选分组 JSON 回填到 Alpine 状态。
*
* 为什么需要:筛选选项(尤其街拍的 city/sort,标签要过 t())在页面 frontmatter 里生成,
* 是「构建期」数据;而 Alpine 的 extra 里初始化的是空数组,两者从来不通 —— 后果是
* - pills 里 options.find() 永远 miss,只能显示原始值(中文环境显示 "Paris" 而非「巴黎」);
* - StreetSnaps 的 ?city= 深链校验用的白名单恒为空,深链直接失效。
* 列表壳(CollectionListView)已把 groups 序列化进 data-filter-groups,这里按
* selectedXxx -> xxxOptions 的约定回填(selectedCity -> cityOptions),与页面 extra 的字段名对齐。
*
* 解析失败时静默保持空数组:pill 回落显示原始 value,不影响页面可用性。
*/
export function hydrateFilterOptions(self: any, rawJson?: string | null): void {
if (!rawJson) return
try {
const groups: FilterGroup[] = JSON.parse(rawJson)
if (!Array.isArray(groups)) return
for (const g of groups) {
if (!g?.selected) continue
const rest = g.selected.replace(/^selected/, "")
if (!rest) continue
const field = rest.charAt(0).toLowerCase() + rest.slice(1) + "Options"
self[field] = g.options || []
}
} catch {
/* 保持各页 extra 里的空数组兜底 */
}
}
/**
* 年份筛选:近 10 年(含当年),末项为「X年前」兜底。
*
* 数据最早约 2005 年;如需更老年份,改这里的窗口即可(改完走秀/街拍同时生效)。
* 注意「X年前」是既有硬编码中文,与语言无关,保持原样以免改变现有 UI。
*/
export function buildYearOptions(): FilterOption[] {
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 9
const options: FilterOption[] = []
for (let y = currentYear; y >= yearFloor; y--) options.push({ value: y, label: String(y) })
options.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
return options
}

View File

@ -8,9 +8,14 @@
// 用途:走秀详情(RunwayLookCard)与街拍详情(StreetSnapCard)逐行同构,故抽到此处共用; // 用途:走秀详情(RunwayLookCard)与街拍详情(StreetSnapCard)逐行同构,故抽到此处共用;
// 组件仍可在工厂返回值之上叠加自身独有的部分(如走秀的「每行张数」)。 // 组件仍可在工厂返回值之上叠加自身独有的部分(如走秀的「每行张数」)。
// //
// 注意:与 lib/gallery.ts 同样的坑 —— 返回值会被 `{...createDetailGallery(...)}` 展开进 // 注意(两个坑,别"顺手改回去"):
// Alpine.data 的对象字面量,故这里用**显式返回类型**给对象字面量做上下文类型标注, // 1. 返回值会被 `{...createDetailGallery(...)}` 展开进 Alpine.data 的对象字面量,故这里用
// 让方法 / getter 里的 `this` 拿到完整类型(含 createGalleryView 的成员)。 // **显式返回类型**给对象字面量做上下文类型标注,让方法里的 `this` 拿到完整类型
// (含 createGalleryView 的成员)。
// 2. 展开会对 **getter 立即求值**,把 getter 冻结成创建那一刻的静态值(本文件踩过一次:
// currentDetailList / currentDetailFavId / currentDetailFavUrl 曾写成 getter,导致
// 大图灯箱的 Details 缩略簇永不出现、副图灯箱收藏按钮静默失效)。故暴露给模板的
// 派生值一律写**方法**,模板里带括号调用。同一个坑在 lib/gallery.ts 也踩过。
import { toAbs } from "./request" import { toAbs } from "./request"
import type { createGalleryView } from "./gallery" import type { createGalleryView } from "./gallery"
@ -36,10 +41,13 @@ export interface DetailGalleryExtras {
detailLbFromLb: boolean detailLbFromLb: boolean
lbReturnIndex: number lbReturnIndex: number
/** 当前大图所属主图的细节图列表(供大图灯箱右下角缩略簇渲染)。 */ /**
currentDetailList: DetailImage[] * 当前大图所属主图的细节图列表(供大图灯箱右下角缩略簇渲染)。
currentDetailFavId: string * 是方法而非 getter —— 返回值会被 `{...createDetailGallery(...)}` 展开,getter 会被求值成静态值。
currentDetailFavUrl: string */
currentDetailList(): DetailImage[]
currentDetailFavId(): string
currentDetailFavUrl(): string
init(): void init(): void
getImageLookIndex(index: number): number getImageLookIndex(index: number): number
@ -101,7 +109,10 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
}, },
// 当前大图所属主图的细节图列表;该主图没有细节图时返回空数组(整簇隐藏)。 // 当前大图所属主图的细节图列表;该主图没有细节图时返回空数组(整簇隐藏)。
get currentDetailList(): DetailImage[] { // 必须是方法:本对象会被 `{...createDetailGallery(...)}` 展开进 Alpine.data,
// 而对象展开会对 getter 立即求值 —— 写成 getter 会永久冻结成创建那一刻的空数组,
// 导致大图灯箱右下角的 Details 缩略簇永远不显示(模板写 currentDetailList())。
currentDetailList(): DetailImage[] {
const li = this.getImageLookIndex(this.lbIndex) const li = this.getImageLookIndex(this.lbIndex)
return li ? this.detailImagesByLook[li] || [] : [] return li ? this.detailImagesByLook[li] || [] : []
}, },
@ -223,10 +234,12 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
return this.detailImages[index]?.name || "" return this.detailImages[index]?.name || ""
}, },
get currentDetailFavId() { // 同样是方法而非 getter(原因见 currentDetailList):写成 getter 会被展开冻结成 "",
// 副图灯箱里的收藏按钮会退化成 toggleFav("", "") —— 静默失效。
currentDetailFavId() {
return this.detailImages[this.detailLbIndex]?.id || "" return this.detailImages[this.detailLbIndex]?.id || ""
}, },
get currentDetailFavUrl() { currentDetailFavUrl() {
return this.detailImages[this.detailLbIndex]?.raw || "" return this.detailImages[this.detailLbIndex]?.raw || ""
}, },

View File

@ -6,9 +6,14 @@
@reference "tailwindcss"; @reference "tailwindcss";
/* ====================== 顶部 / 主体布局 ====================== */ /* ====================== 顶部 / 主体布局 ====================== */
/* 顶部留白:pt-36(144px)+ 眉头 .showshead/.snapshead 的 pt-5(20px)= 导航下 164px,
大标题盒顶落在距视口顶 218px。用户指定值,替代原先靠 m-auto 的垂直居中(那会让
这段距离随内容多少浮动:实测 1600×1200 下被顶下去 140px)。
改这一处即可(两页共用同一对选择器,改完仍保持一致)。
横向居中用 mx-auto:只做居中,不引入垂直 auto margin。 */
.showsroot, .showsroot,
.snapsroot { .snapsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a]; @apply mx-auto max-w-[1600px] pt-36 text-[#0a0a0a];
} }
.showshead { .showshead {
@ -118,14 +123,13 @@
/* ====================== 右 网格 / 卡片 ====================== */ /* ====================== 右 网格 / 卡片 ====================== */
.showsmain, .showsmain,
.snapsmain { @apply flex-1 min-w-0; } .snapsmain { @apply flex-1 min-w-0; }
.snapscontent { @apply relative; }
.showsgrid, .showsgrid,
.snapsgrid { .snapsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-sm:grid-cols-1; @apply grid grid-cols-2 gap-[22px] relative max-sm:grid-cols-1;
} }
.showsgrid.is-loading::before, .showsgrid.is-loading::before,
.snapscontent.is-loading::before { .snapsgrid.is-loading::before {
content: ""; content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10; @apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: looksProg 1s ease-in-out infinite; animation: looksProg 1s ease-in-out infinite;
@ -212,7 +216,7 @@
/* ====================== 分页 ====================== */ /* ====================== 分页 ====================== */
.showspager, .showspager,
.snappager { @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; } .snapspager { @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; }
.pg { .pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200; @apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
} }