update
This commit is contained in:
@ -103,3 +103,18 @@
|
||||
### 待办(未做)
|
||||
- **运行期烟测**:起 pgvector Docker 实连,验证 GORM 把 `vector(64)` 列读进 `sql.NullString`(pgx text 格式扫描)无报错。尚未跑。
|
||||
- 汉明阈值 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` 临时诊断目录。
|
||||
|
||||
151
src/components/CollectionListView.astro
Normal file
151
src/components/CollectionListView.astro
Normal 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>
|
||||
378
src/components/DetailGalleryView.astro
Normal file
378
src/components/DetailGalleryView.astro
Normal 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>
|
||||
@ -3,7 +3,14 @@
|
||||
// 走秀(RunwayLookCard)与街拍(StreetSnapCard)共用;交互在 lib/galleryDetail.ts。
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
|
||||
interface Props {
|
||||
/** 左栏副图缩略条比例,与主图一致(后端素材统一 2:3,默认值即可) */
|
||||
aspect?: "2/3" | "3/4"
|
||||
}
|
||||
const { aspect = "2/3" } = Astro.props
|
||||
const { t } = getI18n(Astro)
|
||||
// 两个字面量都出现在本文件源码里,保证 Tailwind v4 JIT 能扫描到
|
||||
const thumbAspect = aspect === "3/4" ? "aspect-[3/4]" : "aspect-[2/3]"
|
||||
---
|
||||
|
||||
<div
|
||||
@ -23,8 +30,8 @@ const { t } = getI18n(Astro)
|
||||
<button
|
||||
login-required
|
||||
type="button"
|
||||
@click.stop="toggleFav(currentDetailFavId, currentDetailFavUrl)"
|
||||
:class="isFav(currentDetailFavId) ? 'is-fav' : ''"
|
||||
@click.stop="toggleFav(currentDetailFavId(), currentDetailFavUrl())"
|
||||
:class="isFav(currentDetailFavId()) ? 'is-fav' : ''"
|
||||
class="fav-btn absolute top-5 right-[68px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/90 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black hover:text-white transition-colors cursor-pointer"
|
||||
aria-label={t('save')}
|
||||
>
|
||||
@ -54,7 +61,7 @@ const { t } = getI18n(Astro)
|
||||
<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"
|
||||
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}')`"
|
||||
x-bind:class="detailLbIndex === i ? 'opacity-100 ring-1 ring-black' : ''"
|
||||
:aria-label="String(i + 1)"
|
||||
|
||||
@ -10,7 +10,7 @@ import { getI18n } from "@/lib/i18n"
|
||||
interface Props {
|
||||
/** SSR 首屏图片(前 PREVIEW_LIMIT 张),需带 thumb 供左栏缩略图条使用 */
|
||||
images: { thumb: string }[]
|
||||
/** 缩略图比例:走秀 look 为 2/3,街拍为 3/4 */
|
||||
/** 缩略图比例。后端素材统一 2:3(说明见 DetailGalleryView 的 aspect prop),默认值即可 */
|
||||
aspect?: "2/3" | "3/4"
|
||||
}
|
||||
const { images, aspect = "2/3" } = Astro.props
|
||||
|
||||
@ -1,22 +1,29 @@
|
||||
---
|
||||
// 大图灯箱右下角的「当前主图的细节图 / 副图」缩略簇,注入 GalleryLightbox 的 details 具名插槽。
|
||||
// 主图放大(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
|
||||
id="lb-details"
|
||||
x-show="!lbZoomed && currentDetailList.length > 0"
|
||||
x-show="!lbZoomed && currentDetailList().length > 0"
|
||||
x-cloak
|
||||
class="absolute right-4 sm:right-6 bottom-[72px] z-30 flex flex-col items-end gap-2"
|
||||
>
|
||||
<span class="text-[10px] uppercase tracking-[0.22em] text-[var(--c-gray-600)]">Details</span>
|
||||
<div class="flex max-w-[min(70vw,440px)] gap-2 overflow-x-auto no-scrollbar">
|
||||
<template x-for="(d, i) in currentDetailList" :key="d.url">
|
||||
<template x-for="(d, i) in currentDetailList()" :key="d.url">
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="openDetailLbAt(i)"
|
||||
class="relative flex-none w-[52px] sm:w-[68px] aspect-[2/3] overflow-hidden border border-black/15 bg-[var(--c-gray-200)] cursor-zoom-in transition-colors hover:border-black"
|
||||
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}`"
|
||||
>
|
||||
<img :src="d.thumb" :alt="d.name || ''" loading="lazy" decoding="async" class="w-full h-full object-cover" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
---
|
||||
/**
|
||||
* 走秀详情卡片(薄壳)。
|
||||
*
|
||||
* 只负责走秀特有的事:把后端 RawArticle 映射成图集数据、算未登录占位格数、
|
||||
* 组装系列描述。标记与交互全部交给共享的 DetailGalleryView(与街拍同一套)。
|
||||
*/
|
||||
import type { RawArticle } from "@/lib/api"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import DetailCountBadge from "@/components/DetailCountBadge.astro"
|
||||
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
|
||||
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
|
||||
import DetailGalleryView from "@/components/DetailGalleryView.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
@ -22,7 +25,7 @@ const { locale, t } = getI18n(Astro)
|
||||
// 故此处 images 即为主图集合,默认只渲染它们;detail 随主图一起带上,供「细节入口」展开。
|
||||
const images = (article?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
id: im.id ?? "",
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb(240px 样式)即可,
|
||||
// 不必拿 720px 的展示图当缩略图(一场 63 个 look 能省约 1MB)。后端未返回 thumb 时回落展示图。
|
||||
@ -30,25 +33,12 @@ const images = (article?.images ?? [])
|
||||
raw: im.image || "", // 相对路径:收藏快照存相对地址,不把宿主/端口写死进库
|
||||
name: im.name || "",
|
||||
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)
|
||||
|
||||
// 网格左下角的「细节图张数」角标交给 Alpine 的 detailCount() 响应式控制(而非 SSR 编译期条件):
|
||||
// 首屏前 PREVIEW_LIMIT 张由下面的 data-details 载荷构建映射,登录后由 gallery:full 用完整图集重建,
|
||||
// 未登录 / 已登录共用同一条渲染路径。
|
||||
|
||||
// 递给浏览器端 Alpine 的细节图数据:只序列化「带细节图的主图」投影(不带主图自身 URL,
|
||||
// 避免把整份 images 重复塞进 HTML)。元素形状与 images 元素一致(look_index + detail),
|
||||
// 故 Alpine 的 buildDetailMapFrom() 能与「登录后拿回的完整 images」共用同一套解析逻辑。
|
||||
// 未登录时后端只返回前 PREVIEW_LIMIT 张主图(细节图随所属主图一起返回),此处即为这几张的细节子树。
|
||||
const detailPayload = (article?.images ?? [])
|
||||
.filter((im) => (im.detail?.length ?? 0) > 0)
|
||||
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
|
||||
@ -68,248 +58,38 @@ const descParas = (article?.description || "")
|
||||
.filter(Boolean)
|
||||
|
||||
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
|
||||
id="article-view"
|
||||
x-data="articleView()"
|
||||
data-fav-type="runway_image"
|
||||
data-gallery-uid={article?.id ?? ""}
|
||||
data-gallery-title={article?.title ?? ""}
|
||||
data-gallery-brand={article?.brand_name ?? ""}
|
||||
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"
|
||||
<DetailGalleryView
|
||||
kind="runway"
|
||||
rootId="article-view"
|
||||
alpineName="articleView"
|
||||
galleryId="article-gallery"
|
||||
favType="runway_image"
|
||||
aspect="2/3"
|
||||
hasItem={!!article}
|
||||
title={article?.title ?? ""}
|
||||
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 && (
|
||||
<section class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
|
||||
{descParas.map((p) => (
|
||||
<p>{p}</p>
|
||||
))}
|
||||
</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 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>
|
||||
{descParas.length > 0 && (
|
||||
<section slot="after-grid" class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
|
||||
{descParas.map((p) => (
|
||||
<p>{p}</p>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 细节图灯箱(共享组件):点大图灯箱里的细节缩略图打开,单张细节图放大查看 */}
|
||||
{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>
|
||||
</DetailGalleryView>
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
---
|
||||
/**
|
||||
* 街拍详情卡片(薄壳)。
|
||||
*
|
||||
* 只负责街拍特有的事:把后端 RawStreetSnap 映射成图集数据、算未登录占位格数、
|
||||
* 把标题里的字面 \t(地点 \t 场次)归一成 " · "。标记与交互全部交给共享的
|
||||
* DetailGalleryView(与走秀同一套),差异经 props 声明:带图片名图注等。
|
||||
*/
|
||||
import type { RawStreetSnap } from "@/lib/api"
|
||||
import GalleryLightbox from "@/components/GalleryLightbox.astro"
|
||||
import DetailCountBadge from "@/components/DetailCountBadge.astro"
|
||||
import GalleryDetailLightbox from "@/components/GalleryDetailLightbox.astro"
|
||||
import LbDetailsStrip from "@/components/LbDetailsStrip.astro"
|
||||
import DetailGalleryView from "@/components/DetailGalleryView.astro"
|
||||
import { getI18n } from "@/lib/i18n"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrAbs } from "@/lib/request"
|
||||
@ -20,7 +24,7 @@ const { locale, t } = getI18n(Astro)
|
||||
|
||||
const images = (snap?.images ?? [])
|
||||
.map((im) => ({
|
||||
id: im.id,
|
||||
id: im.id ?? "",
|
||||
url: ssrAbs(im.image || ""),
|
||||
// 缩略图:灯箱左栏缩略图条只显示 76~118px,用后端给的 thumb 即可;未返回时回落展示图。
|
||||
thumb: ssrAbs(im.thumb || im.image || ""),
|
||||
@ -30,6 +34,12 @@ const images = (snap?.images ?? [])
|
||||
}))
|
||||
.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 张图片;其余由登录门禁提示,
|
||||
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
|
||||
const PREVIEW_LIMIT = 5
|
||||
@ -39,204 +49,36 @@ const previewImages = images.slice(0, PREVIEW_LIMIT)
|
||||
// 已登录(preview=false)或图片本就不超上限时 lockedCount=0,不渲染占位。
|
||||
const lockedCount = preview ? Math.max(0, (imageTotal || 0) - previewImages.length) : 0
|
||||
|
||||
// 递给浏览器端 Alpine 的副图数据:只序列化「带副图的主图」投影(look_index + detail),
|
||||
// 供 lib/galleryDetail.ts 构建 look_index → 副图 映射(与登录后完整 images 同构解析)。
|
||||
const detailPayload = (snap?.images ?? [])
|
||||
.filter((im) => (im.detail?.length ?? 0) > 0)
|
||||
.map((im) => ({ look_index: Number(im.look_index ?? 0), detail: im.detail }))
|
||||
|
||||
const homeHref = href(locale, ROUTES.home)
|
||||
|
||||
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
|
||||
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
|
||||
id="street-snap-view"
|
||||
x-data="streetSnapView()"
|
||||
data-fav-type="snap_image"
|
||||
data-gallery-uid={snap?.id ?? ""}
|
||||
data-gallery-title={displayTitle}
|
||||
data-gallery-brand=""
|
||||
data-preview={preview ? "true" : "false"}
|
||||
data-details={JSON.stringify(detailPayload)}
|
||||
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||
>
|
||||
{
|
||||
snap ? (
|
||||
<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">
|
||||
{displayTitle}
|
||||
</h1>
|
||||
|
||||
{previewImages.length > 0 && (
|
||||
<section class="mt-16">
|
||||
<div
|
||||
id="snap-gallery"
|
||||
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>
|
||||
<DetailGalleryView
|
||||
kind="street"
|
||||
rootId="street-snap-view"
|
||||
alpineName="streetSnapView"
|
||||
galleryId="snap-gallery"
|
||||
favType="snap_image"
|
||||
aspect="2/3"
|
||||
hasItem={!!snap}
|
||||
title={displayTitle}
|
||||
previewImages={previewImages}
|
||||
totalCount={imageTotal || images.length}
|
||||
countLabel="PHOTOS"
|
||||
preview={preview}
|
||||
lockedCount={lockedCount}
|
||||
detailPayload={detailPayload}
|
||||
galleryUid={snap?.id ?? ""}
|
||||
galleryTitle={displayTitle}
|
||||
galleryBrand=""
|
||||
showCaptions
|
||||
defaultDensity={5}
|
||||
errorText={errorText}
|
||||
homeHref={homeHref}
|
||||
/>
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
---
|
||||
import BrandModal from "@/components/BrandModal.astro"
|
||||
import CollectionCard from "@/components/CollectionCard.astro"
|
||||
import RefineSidebar from "@/components/RefineSidebar.astro"
|
||||
import CollectionListView from "@/components/CollectionListView.astro"
|
||||
import { getSsgHotBrands } from "@/lib/ssg"
|
||||
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 个、按热度顺序排列。
|
||||
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
|
||||
@ -33,15 +32,8 @@ try {
|
||||
} catch {}
|
||||
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
|
||||
|
||||
// 年份筛选:写死,按需增删(数据最早约 2005 年,如需更老年份往数组里加即可)
|
||||
// 年份:近 10 年(含当年),末项加"X年前"兜底
|
||||
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}年前` })
|
||||
// 年份筛选:近 10 年(含当年),末项加"X年前"兜底(与街拍共用 lib/filters.ts)
|
||||
const yearOptions = buildYearOptions()
|
||||
|
||||
// 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可)
|
||||
const collectionOptions: { value: string; label: string }[] = [
|
||||
@ -69,120 +61,44 @@ const runwayGroups = [
|
||||
]
|
||||
---
|
||||
|
||||
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
|
||||
<!-- 顶部:极简眉头 -->
|
||||
<header class="showshead">
|
||||
<div>
|
||||
<h1 class="showsh1">{t('all runway looks')}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 已激活筛选 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>
|
||||
<CollectionListView
|
||||
prefix="shows"
|
||||
alpineName="showsPage"
|
||||
title={t('all runway looks')}
|
||||
groups={runwayGroups}
|
||||
skeletonLines={3}
|
||||
emptyText="No shows match the current filters"
|
||||
>
|
||||
<!-- Designer 多选(走秀专属:热门品牌单选 + 全品牌弹窗) -->
|
||||
<Fragment slot="sidebar-extra">
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||
<span>{t('brands')}</span>
|
||||
<span class="showsg-right">
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label login-required class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input type="radio" name="designer" :value="b.id" :checked="selectedBrand === b.id" :class="{ 'is-checked': selectedBrand === b.id }" @click.prevent="toggleBrand(b.id)" />
|
||||
<span x-text="b.name"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<a login-required class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
|
||||
</div>
|
||||
</section>
|
||||
</Fragment>
|
||||
|
||||
<!-- 主体:左 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 多选(走秀专属:热门品牌单选 + 全品牌弹窗) -->
|
||||
<section class="showsg">
|
||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||
<span>{t('brands')}</span>
|
||||
<span class="showsg-right">
|
||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<ul class="showschecks">
|
||||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||||
<li>
|
||||
<label login-required class="flex items-center gap-2 w-full cursor-pointer">
|
||||
<input type="radio" name="designer" :value="b.id" :checked="selectedBrand === b.id" :class="{ 'is-checked': selectedBrand === b.id }" @click.prevent="toggleBrand(b.id)" />
|
||||
<span x-text="b.name"></span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<a login-required class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 通用分组(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 />
|
||||
|
||||
</div>
|
||||
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
|
||||
<BrandModal slot="modals" />
|
||||
</CollectionListView>
|
||||
|
||||
|
||||
<script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||||
@ -328,8 +244,9 @@ const runwayGroups = [
|
||||
this.refreshFilter()
|
||||
},
|
||||
|
||||
// 已筛选选项 Pills 视图数据
|
||||
get pills() {
|
||||
// 已筛选选项 Pills 视图数据(必须是方法:createCollectionPage 用对象展开返回,
|
||||
// getter 会在展开时被求值冻结成静态值,模板里写 pills())
|
||||
pills() {
|
||||
const list: { label: string; clear: () => void }[] = []
|
||||
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}`
|
||||
|
||||
@ -1,19 +1,12 @@
|
||||
---
|
||||
import CollectionListView from "@/components/CollectionListView.astro"
|
||||
import { getI18n } from '@/lib/i18n'
|
||||
import CollectionCard from "@/components/CollectionCard.astro"
|
||||
import RefineSidebar from "@/components/RefineSidebar.astro"
|
||||
import "@/styles/look-grid.css"
|
||||
import { buildYearOptions } from "@/lib/filters"
|
||||
|
||||
const { locale, t } = getI18n(Astro)
|
||||
const { t } = getI18n(Astro)
|
||||
|
||||
// 年份筛选:近 10 年(含当年),末项兜底
|
||||
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}年前` })
|
||||
// 年份筛选:近 10 年(含当年),末项兜底(与走秀共用 lib/filters.ts)
|
||||
const yearOptions = buildYearOptions()
|
||||
|
||||
// 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc
|
||||
const sortOptions: { value: string; label: string }[] = [
|
||||
@ -53,95 +46,14 @@ const streetGroups = [
|
||||
]
|
||||
---
|
||||
|
||||
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
|
||||
<!-- 顶部:极简眉头 -->
|
||||
<header class="snapshead">
|
||||
<div>
|
||||
<h1 class="snapsh1">{t('All Street Snaps')}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 已激活筛选 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>
|
||||
<CollectionListView
|
||||
prefix="snaps"
|
||||
alpineName="streetSnapsPage"
|
||||
title={t('All Street Snaps')}
|
||||
groups={streetGroups}
|
||||
skeletonLines={2}
|
||||
emptyText="No street snaps match the current filters"
|
||||
/>
|
||||
|
||||
|
||||
<script>
|
||||
@ -188,7 +100,8 @@ const streetGroups = [
|
||||
},
|
||||
|
||||
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
|
||||
get pills() {
|
||||
// 必须是方法:createCollectionPage 用对象展开返回,getter 会在展开时被求值冻结成静态值,模板里写 pills()
|
||||
pills() {
|
||||
const out: { label: string; clear: () => void }[] = []
|
||||
if (this.selectedYear) {
|
||||
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))
|
||||
|
||||
@ -21,6 +21,7 @@ import { getUser } from "@/lib/auth"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
import { buildPageList, SPIN_SVG } from "@/lib/ui"
|
||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||||
import { hydrateFilterOptions } from "@/lib/filters"
|
||||
|
||||
export interface CollectionPageOptions {
|
||||
/** 收藏类型,写入 favorites 表 */
|
||||
@ -50,10 +51,13 @@ export function createCollectionPage(opts: CollectionPageOptions) {
|
||||
favMap: {} as Record<string, boolean>,
|
||||
items: [] as any[],
|
||||
|
||||
get skeletonCount() {
|
||||
return opts.skeletonCount ?? 24
|
||||
},
|
||||
get pageList() {
|
||||
// ⚠️ 本工厂用 {...base, ...extra} 展开返回,而**对象展开会对 getter 立即求值**,
|
||||
// 把 getter 冻结成「创建那一刻」的静态值。故这里一律写普通属性 / 方法,禁止写 getter:
|
||||
// 曾经的 get pageList() 就被冻结在 lastPage=1/page=1 → 分页按钮永远只显示 1;
|
||||
// 各页 extra 里的 get pills() 同样被冻结成 [],导致筛选 chips 与侧栏 Clear all 永远不显示。
|
||||
// (同一坑在 lib/gallery.ts 的 currentFavId/currentFavUrl 上踩过一次,那边改成了方法。)
|
||||
skeletonCount: opts.skeletonCount ?? 24,
|
||||
pageList() {
|
||||
return buildPageList(this.lastPage, this.page)
|
||||
},
|
||||
|
||||
@ -137,7 +141,12 @@ export function createCollectionPage(opts: CollectionPageOptions) {
|
||||
},
|
||||
|
||||
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)
|
||||
this.loadPage(1)
|
||||
},
|
||||
|
||||
64
src/lib/filters.ts
Normal file
64
src/lib/filters.ts
Normal 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
|
||||
}
|
||||
@ -8,9 +8,14 @@
|
||||
// 用途:走秀详情(RunwayLookCard)与街拍详情(StreetSnapCard)逐行同构,故抽到此处共用;
|
||||
// 组件仍可在工厂返回值之上叠加自身独有的部分(如走秀的「每行张数」)。
|
||||
//
|
||||
// 注意:与 lib/gallery.ts 同样的坑 —— 返回值会被 `{...createDetailGallery(...)}` 展开进
|
||||
// Alpine.data 的对象字面量,故这里用**显式返回类型**给对象字面量做上下文类型标注,
|
||||
// 让方法 / getter 里的 `this` 拿到完整类型(含 createGalleryView 的成员)。
|
||||
// 注意(两个坑,别"顺手改回去"):
|
||||
// 1. 返回值会被 `{...createDetailGallery(...)}` 展开进 Alpine.data 的对象字面量,故这里用
|
||||
// **显式返回类型**给对象字面量做上下文类型标注,让方法里的 `this` 拿到完整类型
|
||||
// (含 createGalleryView 的成员)。
|
||||
// 2. 展开会对 **getter 立即求值**,把 getter 冻结成创建那一刻的静态值(本文件踩过一次:
|
||||
// currentDetailList / currentDetailFavId / currentDetailFavUrl 曾写成 getter,导致
|
||||
// 大图灯箱的 Details 缩略簇永不出现、副图灯箱收藏按钮静默失效)。故暴露给模板的
|
||||
// 派生值一律写**方法**,模板里带括号调用。同一个坑在 lib/gallery.ts 也踩过。
|
||||
|
||||
import { toAbs } from "./request"
|
||||
import type { createGalleryView } from "./gallery"
|
||||
@ -36,10 +41,13 @@ export interface DetailGalleryExtras {
|
||||
detailLbFromLb: boolean
|
||||
lbReturnIndex: number
|
||||
|
||||
/** 当前大图所属主图的细节图列表(供大图灯箱右下角缩略簇渲染)。 */
|
||||
currentDetailList: DetailImage[]
|
||||
currentDetailFavId: string
|
||||
currentDetailFavUrl: string
|
||||
/**
|
||||
* 当前大图所属主图的细节图列表(供大图灯箱右下角缩略簇渲染)。
|
||||
* 是方法而非 getter —— 返回值会被 `{...createDetailGallery(...)}` 展开,getter 会被求值成静态值。
|
||||
*/
|
||||
currentDetailList(): DetailImage[]
|
||||
currentDetailFavId(): string
|
||||
currentDetailFavUrl(): string
|
||||
|
||||
init(): void
|
||||
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)
|
||||
return li ? this.detailImagesByLook[li] || [] : []
|
||||
},
|
||||
@ -223,10 +234,12 @@ export function createDetailGallery<T extends ReturnType<typeof createGalleryVie
|
||||
return this.detailImages[index]?.name || ""
|
||||
},
|
||||
|
||||
get currentDetailFavId() {
|
||||
// 同样是方法而非 getter(原因见 currentDetailList):写成 getter 会被展开冻结成 "",
|
||||
// 副图灯箱里的收藏按钮会退化成 toggleFav("", "") —— 静默失效。
|
||||
currentDetailFavId() {
|
||||
return this.detailImages[this.detailLbIndex]?.id || ""
|
||||
},
|
||||
get currentDetailFavUrl() {
|
||||
currentDetailFavUrl() {
|
||||
return this.detailImages[this.detailLbIndex]?.raw || ""
|
||||
},
|
||||
|
||||
|
||||
@ -6,9 +6,14 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* ====================== 顶部 / 主体布局 ====================== */
|
||||
/* 顶部留白:pt-36(144px)+ 眉头 .showshead/.snapshead 的 pt-5(20px)= 导航下 164px,
|
||||
大标题盒顶落在距视口顶 218px。用户指定值,替代原先靠 m-auto 的垂直居中(那会让
|
||||
这段距离随内容多少浮动:实测 1600×1200 下被顶下去 140px)。
|
||||
改这一处即可(两页共用同一对选择器,改完仍保持一致)。
|
||||
横向居中用 mx-auto:只做居中,不引入垂直 auto margin。 */
|
||||
.showsroot,
|
||||
.snapsroot {
|
||||
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
|
||||
@apply mx-auto max-w-[1600px] pt-36 text-[#0a0a0a];
|
||||
}
|
||||
|
||||
.showshead {
|
||||
@ -118,14 +123,13 @@
|
||||
/* ====================== 右 网格 / 卡片 ====================== */
|
||||
.showsmain,
|
||||
.snapsmain { @apply flex-1 min-w-0; }
|
||||
.snapscontent { @apply relative; }
|
||||
|
||||
.showsgrid,
|
||||
.snapsgrid {
|
||||
@apply grid grid-cols-2 gap-[22px] relative max-sm:grid-cols-1;
|
||||
}
|
||||
.showsgrid.is-loading::before,
|
||||
.snapscontent.is-loading::before {
|
||||
.snapsgrid.is-loading::before {
|
||||
content: "";
|
||||
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
|
||||
animation: looksProg 1s ease-in-out infinite;
|
||||
@ -212,7 +216,7 @@
|
||||
|
||||
/* ====================== 分页 ====================== */
|
||||
.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 {
|
||||
@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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user