This commit is contained in:
toom1996
2026-08-29 15:47:58 +08:00
parent eee2265e15
commit 3c7c5d8855
15 changed files with 1369 additions and 733 deletions

View File

@ -5,6 +5,7 @@ import { toAbs } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes'
import { getSsgIndexRunway } from "@/lib/api"
import { getSsgStreetSnapPopular } from "@/lib/api"
// 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro)
@ -29,6 +30,19 @@ const galleryItems: GalleryItem[] = popular
articleId: b.article_id,
title: b.title,
}))
// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/v1/ssg/street-snaps/popular(server-only),
// 返回按图片数前 N 的街拍(含封面),直接烧入静态 HTML;后端不可达则静默留空(区块回退 ComingSoon)。
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {
const parts = (b.title || "").split("\t")
return {
src: toAbs(b.cover || ""),
loc: parts[0] || b.title || "",
event: parts.slice(1).join(" ").trim(),
id: b.id,
}
})
---
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
@ -44,8 +58,8 @@ const galleryItems: GalleryItem[] = popular
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }"
@click="activeTab = 'shows'"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
Street Style
</li>
@ -136,9 +150,54 @@ const galleryItems: GalleryItem[] = popular
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板 -->
<!-- 街拍 面板(构建期 SSG 拉取热门街拍封面;去掉 swiper,改为 HBX 大图网格) -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
{
streetItems.length > 0 ? (
<section class="ss-section">
<!-- 顶部 banner:第 1 条最热街拍 -->
{
streetItems[0] && (
<a class="ss-banner" href={href(locale, ROUTES.streetSnap(streetItems[0].id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" /></svg>
</span>
<img src={streetItems[0].src} alt={streetItems[0].loc} loading="eager" decoding="async" :class="{ 'is-error': error }" @load="loaded = true; $dispatch('img-ready')" @error="error = true; $dispatch('img-ready')" />
<span class="ss-cap">
<span class="ss-cap-loc">{streetItems[0].loc}</span>
<span class="ss-cap-event">{streetItems[0].event}</span>
</span>
</div>
</a>
)
}
<!-- 2 列大图卡片网格:其余街拍 -->
<div class="ss-grid">
{
streetItems.slice(1).map((g) => (
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" /></svg>
</span>
<img src={g.src} alt={g.loc} loading="lazy" decoding="async" :class="{ 'is-error': error }" @load="loaded = true; $dispatch('img-ready')" @error="error = true; $dispatch('img-ready')" />
<span class="ss-cap">
<span class="ss-cap-loc">{g.loc}</span>
<span class="ss-cap-event">{g.event}</span>
</span>
</div>
</a>
))
}
</div>
</section>
) : (
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
)
}
</div>
</main>
</div>
@ -310,6 +369,78 @@ const galleryItems: GalleryItem[] = popular
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */
.ss-section { @apply mb-9; }
.ss-banner {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit mb-7;
aspect-ratio: 16 / 9;
}
.ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 gap-[14px] md:gap-[18px];
}
.ss-card {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
aspect-ratio: 3 / 4;
}
.ss-img-box {
@apply absolute inset-0 w-full h-full overflow-hidden;
background: #f0f0f0;
}
.ss-img-box > img {
@apply absolute inset-0 w-full h-full object-cover;
opacity: 0;
transition: opacity 0.45s ease, transform 0.7s ease;
}
.ss-img-box.is-loaded > img { opacity: 1; }
.ss-img-box > img.is-error { opacity: 0 !important; }
.ss-card:hover .ss-img-box > img,
.ss-banner:hover .ss-img-box > img { transform: scale(1.04); }
/* 骨架屏 + spinner(与首页 hs-* 平行) */
.ss-skel {
position: absolute; inset: 0; z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.ss-img-box.is-loaded .ss-skel { opacity: 0; }
.ss-spin {
position: absolute; inset: 0; z-index: 2;
display: flex; align-items: center; justify-content: center;
color: #bdbdbd; pointer-events: none;
transition: opacity 0.3s ease;
}
.ss-img-box.is-loaded .ss-spin { opacity: 0; }
.ss-spin svg { display: block; }
.ss-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
/* 底部叠层白字(loc / event) */
.ss-cap {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 28px 20px 18px;
background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.34) 55%, transparent 100%);
color: white; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end;
gap: 5px; z-index: 3; pointer-events: none;
}
.ss-cap-loc {
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(18px, 2vw, 26px);
font-weight: 700; line-height: 1.1; letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.ss-cap-event {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500; text-transform: uppercase; letter-spacing: 0.16em; line-height: 1.4;
opacity: 0.85; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
.ss-banner .ss-cap { padding: 44px 28px 26px; }
.ss-banner .ss-cap-loc { font-size: clamp(26px, 4vw, 48px); }
@media (min-width: 768px) {
.ss-banner .ss-cap { padding: 56px 36px 30px; }
}
</style>
<script>
@ -319,9 +450,9 @@ const galleryItems: GalleryItem[] = popular
import "swiper/css/navigation";
import "swiper/css/free-mode";
// 初始化 Swiper 轮播
const el = document.getElementById("hsSwiper");
if (el) {
// 初始化 Swiper 轮播(首页可能有多个:Hot Brand / Street Style)
const swipers: Swiper[] = []
document.querySelectorAll<HTMLElement>(".hs-swiper").forEach((el) => {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
@ -335,12 +466,21 @@ const galleryItems: GalleryItem[] = popular
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true,
observeParents: true,
observeSlideChildren: true,
resizeObserver: true,
});
swipers.push(swiper);
});
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", () => {
requestAnimationFrame(() => swiper.update());
});
}
// Street Style 等面板默认 x-show 隐藏,Swiper 在 display:none 下初始化会把 slide 算成 0 宽,
// 切到对应 tab 后必须 update() 一次重算尺寸,否则图片虽在但宽度 0 不可见。
const updateAllSwipers = () =>
requestAnimationFrame(() => swipers.forEach((s) => s.update()));
document.querySelectorAll<HTMLElement>(".tab").forEach((tab) => {
tab.addEventListener("click", () => setTimeout(updateAllSwipers, 60));
});
window.addEventListener("resize", updateAllSwipers);
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", updateAllSwipers);
</script>

View File

@ -1,5 +1,5 @@
---
import type { RawArticle } from "@/lib/data"
import type { RawArticle } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes'

View File

@ -10,24 +10,24 @@ const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
// 接口不可用时回落假数据先看效果。
const FAKE_HOT_BRANDS = [
{ id: "hot-chanel", name: "Chanel", article_count: 342 },
{ id: "hot-dior", name: "Dior", article_count: 318 },
{ id: "hot-gucci", name: "Gucci", article_count: 287 },
{ id: "hot-prada", name: "Prada", article_count: 254 },
{ id: "hot-louisvuitton", name: "Louis Vuitton", article_count: 241 },
{ id: "hot-saintlaurent", name: "Saint Laurent", article_count: 219 },
{ id: "hot-balenciaga", name: "Balenciaga", article_count: 198 },
{ id: "hot-valentino", name: "Valentino", article_count: 176 },
{ id: "hot-givenchy", name: "Givenchy", article_count: 162 },
{ id: "hot-burberry", name: "Burberry", article_count: 153 },
{ id: "hot-fendi", name: "Fendi", article_count: 141 },
{ id: "hot-bottega", name: "Bottega Veneta", article_count: 128 },
{ id: "hot-chanel", name: "Chanel" },
{ id: "hot-dior", name: "Dior" },
{ id: "hot-gucci", name: "Gucci" },
{ id: "hot-prada", name: "Prada" },
{ id: "hot-louisvuitton", name: "Louis Vuitton" },
{ id: "hot-saintlaurent", name: "Saint Laurent" },
{ id: "hot-balenciaga", name: "Balenciaga" },
{ id: "hot-valentino", name: "Valentino" },
{ id: "hot-givenchy", name: "Givenchy" },
{ id: "hot-burberry", name: "Burberry" },
{ id: "hot-fendi", name: "Fendi" },
{ id: "hot-bottega", name: "Bottega Veneta" },
]
let hotBrands: { id: string; name: string; article_count: number }[] = []
let hotBrands: { id: string; name: string }[] = []
try {
hotBrands = (await getSsgHotBrands())
.filter((b) => b.id && b.name)
.map((b) => ({ id: String(b.id), name: b.name, article_count: b.article_count }))
.map((b) => ({ id: String(b.id), name: b.name }))
} catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
@ -108,9 +108,8 @@ const seasonOptions: { value: string; label: string }[] = [
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
<li>
<label class="flex items-center gap-2 w-full cursor-pointer">
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
<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>
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
@ -130,7 +129,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.collection">
<ul class="showschecks showschecks--flat">
{collectionOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedCt" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
<li><label><input type="radio" name="collection" value={o.value} :checked={'selectedCt === "' + o.value + '"'} :class={`{'is-checked': selectedCt === '${o.value}'}`} @click.prevent={'selectedCt = selectedCt === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -147,7 +146,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.season">
<ul class="showschecks showschecks--flat">
{seasonOptions.map((o) => (
<li><label><input type="checkbox" value={o.value} x-model="selectedSeason" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
<li><label><input type="radio" name="season" value={o.value} :checked={'selectedSeason === "' + o.value + '"'} :class={`{'is-checked': selectedSeason === '${o.value}'}`} @click.prevent={'selectedSeason = selectedSeason === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -164,7 +163,7 @@ const seasonOptions: { value: string; label: string }[] = [
<div class="showsg-b" x-show="!collapsedGroups.year">
<ul class="showschecks showschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="checkbox" value={String(o.value)} x-model="selectedYear" @change="refreshFilter()" /> <span>{o.label}</span></label></li>
<li><label><input type="radio" name="year" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'}`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
@ -235,9 +234,16 @@ const seasonOptions: { value: string; label: string }[] = [
</template>
</div>
<div class="showsloadmore-wrap" x-show="items.length > 0">
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
</div>
<nav class="showspager" x-show="lastPage > 1" aria-label="Pagination">
<button 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 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 class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
@ -258,6 +264,7 @@ const seasonOptions: { value: string; label: string }[] = [
placeholder="搜索品牌名…"
autocomplete="off"
/>
<p class="showsmodal-tip">最多展示 30 条结果,可用搜索或字母筛选缩小范围</p>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
@ -273,14 +280,13 @@ const seasonOptions: { value: string; label: string }[] = [
<li>
<label class="showsmodal-row">
<input
type="checkbox"
type="radio"
name="modalDesigner"
:value="b.id"
:checked="modalSelected.includes(b.id)"
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
@change="toggleModalBrand(b.id)"
x-model="modalSelected"
:class="{ 'is-checked': modalSelected === b.id }"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
@ -368,7 +374,7 @@ const seasonOptions: { value: string; label: string }[] = [
.showschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin] overscroll-contain;
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.showschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
@ -376,12 +382,14 @@ const seasonOptions: { value: string; label: string }[] = [
.showschecks li.letter-head {
@apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1;
}
.showschecks input[type="checkbox"] {
@apply appearance-none w-3 h-3 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-none inline-grid place-content-center checked:bg-black;
.showschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showschecks input[type="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类,
因为 @click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.showschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
@ -493,11 +501,26 @@ const seasonOptions: { value: string; label: string }[] = [
animation: hbxShim 1.6s ease infinite;
}
.showsloadmore-wrap {
@apply flex justify-center mt-[38px];
.showspager {
@apply flex flex-wrap items-center justify-center gap-2 mt-[38px];
}
.showsloadmore {
@apply border-[1.5px] border-black bg-white text-black p-[13px_36px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-200 hover:bg-black hover:text-white disabled:opacity-40 disabled:cursor-default;
.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;
}
.pg.is-current {
@apply bg-black text-white cursor-default;
}
.pg:disabled {
@apply opacity-30 cursor-default;
}
.pg.enabled:hover {
@apply bg-black text-white;
}
.pg-ellipsis {
@apply px-1 text-[#999] select-none text-[11px];
}
.pg-wrap {
@apply inline-flex items-center;
}
.showsempty {
@ -546,6 +569,9 @@ const seasonOptions: { value: string; label: string }[] = [
.showsmodal-input {
@apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black;
}
.showsmodal-tip {
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
@ -573,6 +599,13 @@ const seasonOptions: { value: string; label: string }[] = [
.showsmodal-row {
@apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5;
}
.showsmodal-row input[type="radio"] {
@apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showsmodal-row input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showsmodal-name {
@apply flex-1 truncate;
}
@ -616,10 +649,10 @@ const seasonOptions: { value: string; label: string }[] = [
season: localStorage.getItem("showsg-season") === "1",
year: localStorage.getItem("showsg-year") === "1",
},
selectedBrands: [] as number[],
selectedCt: [] as string[],
selectedSeason: [] as string[],
selectedYear: [] as string[],
selectedBrand: null as (string | null),
selectedCt: "" as string,
selectedSeason: "" as string,
selectedYear: "" as string,
// 品牌筛选弹窗状态
modalOpen: false,
@ -627,91 +660,74 @@ const seasonOptions: { value: string; label: string }[] = [
modalLetter: "",
modalResults: [] as BrandEntry[],
modalLoading: false,
modalSelected: [] as number[],
get modalMaxReached() {
return this.modalSelected.length >= 1
},
modalSelected: null as (string | null),
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false,
loadingMore: false,
page: 1,
size: 24,
total: 0,
isEnd: false,
lastPage: 1,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'en';
this.loadPage(1, false)
this.loadPage(1)
},
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
// 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
get skeletonCount() {
return this.size
return 24
},
// 已筛选选项 Pills 视图数据
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) {
if (this.selectedBrand) {
const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}`
const labels = this.selectedBrands
.map((id) => findName(id))
.slice(0, 4)
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
const name = findName(this.selectedBrand)
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
label: "DESIGNER · " + name,
clear: () => { this.selectedBrand = null; this.refreshFilter(); }
})
}
if (this.selectedCt.length) {
if (this.selectedCt) {
list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); }
label: "COLLECTION · " + this.selectedCt.toUpperCase(),
clear: () => { this.selectedCt = ""; this.refreshFilter(); }
})
}
if (this.selectedSeason.length) {
if (this.selectedSeason) {
list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
label: "SEASON · " + this.selectedSeason.toUpperCase(),
clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
})
}
if (this.selectedYear.length) {
if (this.selectedYear) {
list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
clear: () => { this.selectedYear = []; this.refreshFilter(); }
label: "YEAR · " + this.selectedYear,
clear: () => { this.selectedYear = ""; this.refreshFilter(); }
})
}
return list
},
get loadBtnText() {
if (this.loadingMore) return "Loading…"
if (this.isEnd) return "— End of Archive —"
return "Load More ↓"
},
// 事件处理方法
toggleGroup(group: "designers" | "collection" | "season" | "year") {
this.collapsedGroups[group] = !this.collapsedGroups[group]
localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "")
},
toggleBrand(id: number) {
const idx = this.selectedBrands.indexOf(id)
if (idx >= 0) this.selectedBrands.splice(idx, 1)
else this.selectedBrands.push(id)
this.refreshFilter()
refreshFilter() {
this.loadPage(1)
},
refreshFilter() {
this.loadPage(1, false)
toggleBrand(id: string) {
this.selectedBrand = this.selectedBrand === id ? null : id
this.refreshFilter()
},
// ===== 品牌筛选弹窗方法 =====
async openBrandModal() {
this.modalSelected = [...this.selectedBrands]
this.modalSelected = this.selectedBrand
this.modalKeyword = ""
this.modalLetter = "HOT"
this.modalOpen = true
@ -730,7 +746,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLetter = "HOT"
this.modalLoading = true
try {
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name }))
} finally {
this.modalLoading = false
}
@ -742,7 +758,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLoading = true
this.modalResults = []
try {
this.modalResults = await getBrands({ keyword: kw, size: 60 })
this.modalResults = await getBrands({ keyword: kw })
} catch {
this.modalResults = []
} finally {
@ -766,23 +782,15 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalResults = []
try {
const letter = l === "#" ? "OTHER" : l
this.modalResults = await getBrands({ letter, size: 200 })
this.modalResults = await getBrands({ letter })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
},
toggleModalBrand(id: number) {
const idx = this.modalSelected.indexOf(id)
if (idx >= 0) this.modalSelected.splice(idx, 1)
else {
if (this.modalSelected.length >= 5) return
this.modalSelected.push(id)
}
},
confirmBrandModal() {
this.selectedBrands = [...this.modalSelected]
this.selectedBrand = this.modalSelected
this.modalOpen = false
document.documentElement.style.overflow = ""
document.documentElement.style.paddingRight = ""
@ -790,54 +798,73 @@ const seasonOptions: { value: string; label: string }[] = [
},
clearAll() {
this.selectedBrands = []
this.selectedCt = []
this.selectedSeason = []
this.selectedYear = []
this.selectedBrand = null
this.selectedCt = ""
this.selectedSeason = ""
this.selectedYear = ""
this.refreshFilter()
},
// API 加载
async loadPage(page: number, append = false) {
// API 加载(分页:每次整页替换,不追加)
async loadPage(page: number) {
const myToken = ++this.reqToken
if (append) {
this.loadingMore = true
} else {
this.loading = true
this.items = []
}
this.loading = true
this.items = []
try {
const json = await getArticles({
page,
size: this.size,
sort: "newest",
brand_ids: this.selectedBrands,
collection_type: this.selectedCt,
season: this.selectedSeason,
year: this.selectedYear,
with_images: 6,
brandId: this.selectedBrand || undefined,
collection: this.selectedCt || undefined,
season: this.selectedSeason || undefined,
year: this.selectedYear || undefined,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
this.total = json.total
this.lastPage = json.last_page
this.page = page
this.items = append ? [...this.items, ...rows] : rows
this.isEnd = page >= json.last_page || rows.length < this.size
this.items = rows
} catch {
if (myToken !== this.reqToken) return
if (!append) this.items = []
this.items = []
} finally {
if (myToken === this.reqToken) {
this.loading = false
this.loadingMore = false
}
if (myToken === this.reqToken) this.loading = false
}
},
// ===== 分页导航 =====
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) {
return Array.from({ length: total }, (_: any, i: number) => i + 1)
}
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
},
goPage(p: number) {
if (this.loading) return
if (p < 1 || p > this.lastPage || p === this.page) return
this.loadPage(p)
const main = this.$el.querySelector(".showsmain")
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
else window.scrollTo({ top: 0, behavior: "smooth" })
},
prevPage() { this.goPage(this.page - 1) },
nextPage() { this.goPage(this.page + 1) },
// 辅助辅助工具函数
toAbsUrl(url: string) { return toAbs(url) },

View File

@ -0,0 +1,348 @@
---
import type { RawStreetSnap } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes'
import { ssrBase } from "@/lib/api"
interface Props {
snap: RawStreetSnap | null
error?: "missing" | "failed" | null
}
const { snap, error = null } = Astro.props
const { locale, t } = getI18n(Astro)
const full = (u: string): string => {
if (!u) return ""
if (/^https?:\/\//i.test(u)) return u
return ssrBase().replace(/\/$/, "") + u
}
const images = (snap?.images ?? [])
.map((im) => ({
id: im.id,
url: full(im.image || ""),
name: im.name || "",
}))
.filter((im) => im.url)
const homeHref = href(locale, ROUTES.home)
const archiveHref = href(locale, ROUTES.streetSnaps)
// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现
const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
---
<div
id="street-snap-view"
x-data="streetSnapView()"
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">
<Breadcrumb>
<span slot="current" class="max-w-[60vw] truncate">{displayTitle}</span>
</Breadcrumb>
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{displayTitle}
</h1>
{images.length > 0 && (
<section class="mt-16">
<div
id="snap-gallery"
class="grid grid-cols-2 md:grid-cols-3 gap-4"
>
{images.map((im, i) => (
<figure
data-image-index={i}
data-image-url={im.url}
data-image-name={im.name}
@click={`openLb(${i})`}
class="bg-[#f2f2f2] 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"
/>
{im.name && (
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
{im.name}
</figcaption>
)}
</figure>
))}
</div>
</section>
)}
</article>
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="font-mono text-sm text-[#888] 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] font-mono 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>
)
}
{snap && images.length > 0 && (
<div
x-show="lbOpen"
x-cloak
@keydown.window.escape="closeLb()"
@keydown.window.arrow-right="nextLb()"
@keydown.window.arrow-left="prevLb()"
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
aria-hidden="true"
>
<div
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md"
x-text="`${lbIndex + 1} / ${imageCount}`"
></div>
<button
type="button"
@click="closeLb()"
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('Close')}
>
✕
</button>
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<div
id="lb-thumbs"
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
>
{images.map((im, i) => (
<button
type="button"
data-lb-index={i}
data-thumb-url={im.url}
@click={`showLb(${i})`}
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
: ''"
aria-label={`${i + 1}`}
>
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm">
{String(i + 1).padStart(2, "0")}
</span>
</button>
))}
</div>
<button
type="button"
@click.stop="prevLb()"
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
aria-label={t('Previous image')}
>
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 14l6-6 6 6" /></svg>
</button>
<button
type="button"
@click.stop="nextLb()"
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
aria-label={t('Next image')}
>
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M6 10l6 6 6-6" /></svg>
</button>
<button
type="button"
@click.stop="prevLb()"
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('Previous image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" /></svg>
</button>
<button
type="button"
@click.stop="nextLb()"
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
aria-label={t('Next image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24"><path d="M9 6l6 6 6-6" /></svg>
</button>
<div
id="lb-main"
@click.self="closeLb()"
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
x-bind:class="lbZoomed
? 'overflow-y-auto overflow-x-hidden items-start'
: 'items-center'"
>
<img
id="lb-image"
src=""
alt=""
@click="toggleZoom()"
decoding="async"
class="block select-none transition-opacity duration-200"
x-bind:class="lbZoomed
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
/>
</div>
</div>
<div
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6"
x-text="currentImageName"
></div>
<div
x-show="!lbZoomed"
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
>
{t('Click image to zoom · Scroll to view · Esc to close')}
</div>
</div>
)}
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () => ({
lbOpen: false,
lbIndex: 0,
lbZoomed: false,
touchStartX: 0,
touchStartY: 0,
imageCount: 0,
currentImageName: "",
init() {
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
this.initThumbLazyLoading()
},
getImageElement(index: number) {
return document.querySelector<HTMLElement>(
`#snap-gallery [data-image-index="${index}"]`
)
},
getImageUrl(index: number) {
return this.getImageElement(index)?.dataset.imageUrl || ""
},
getImageName(index: number) {
return this.getImageElement(index)?.dataset.imageName || ""
},
openLb(index = 0) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
this.lbZoomed = false
this.lbOpen = true
document.body.style.overflow = "hidden"
this.updateLightboxImage()
requestAnimationFrame(() => {
this.scrollThumbIntoView()
this.preloadAround()
})
},
closeLb() {
this.lbOpen = false
this.lbZoomed = false
document.body.style.overflow = ""
},
showLb(index: number) {
if (!this.imageCount) return
this.lbIndex = this.normalizeIndex(index)
this.lbZoomed = false
this.updateLightboxImage()
const main = document.getElementById("lb-main")
if (main) main.scrollTop = 0
requestAnimationFrame(() => {
this.scrollThumbIntoView()
this.preloadAround()
})
},
nextLb() { this.showLb(this.lbIndex + 1) },
prevLb() { this.showLb(this.lbIndex - 1) },
normalizeIndex(index: number) {
if (!this.imageCount) return 0
return ((index % this.imageCount) + this.imageCount) % this.imageCount
},
updateLightboxImage() {
const image = document.getElementById("lb-image") as HTMLImageElement | null
if (!image) return
image.src = this.getImageUrl(this.lbIndex)
image.alt = this.getImageName(this.lbIndex)
this.currentImageName = this.getImageName(this.lbIndex)
},
toggleZoom() {
this.lbZoomed = !this.lbZoomed
const main = document.getElementById("lb-main")
if (main) main.scrollTop = 0
},
scrollThumbIntoView() {
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
if (!thumbs) return
const active = thumbs.querySelector<HTMLElement>(`[data-lb-index="${this.lbIndex}"]`)
if (!active) return
const target = active.offsetTop - (thumbs.clientHeight - active.clientHeight) / 2
thumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
},
preloadAround() {
const indexes = [this.lbIndex, this.normalizeIndex(this.lbIndex - 1), this.normalizeIndex(this.lbIndex + 1)]
indexes.forEach((index: number) => {
const url = this.getImageUrl(index)
if (!url) return
const image = new Image()
image.decoding = "async"
image.src = url
})
},
initThumbLazyLoading() {
const thumbs = document.querySelectorAll<HTMLElement>("#lb-thumbs [data-thumb-url]")
if (!thumbs.length) return
if (!("IntersectionObserver" in window)) {
thumbs.forEach((thumb) => { thumb.style.backgroundImage = `url("${thumb.dataset.thumbUrl}")` })
return
}
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const element = entry.target as HTMLElement
const url = element.dataset.thumbUrl
if (url) element.style.backgroundImage = `url("${url}")`
obs.unobserve(element)
})
},
{ root: document.getElementById("lb-thumbs"), rootMargin: "300px" }
)
thumbs.forEach((thumb) => observer.observe(thumb))
},
}))
})
</script>
<style>
[x-cloak] { display: none !important; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@ -0,0 +1,490 @@
---
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES } from '@/lib/routes'
import { toAbs } from '@/lib/api'
const { locale, 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}年前` })
// 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc
const sortOptions: { value: string; label: string }[] = [
{ value: "image_count", label: t('Most photos') },
{ value: "year_desc", label: t('Newest year') },
{ value: "year_asc", label: t('Oldest year') },
]
---
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="snapshead">
<div>
<Breadcrumb>
<span slot="current">{t('Street Style')}</span>
</Breadcrumb>
<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">
<!-- Year -->
<section class="snapsg">
<header class="snapsg-h" @click="toggleGroup('year')">
<span>{t('Year')}</span>
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
<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>
</header>
<div class="snapsg-b" x-show="!collapsedGroups.year">
<ul class="snapschecks snapschecks--flat">
{yearOptions.map((o) => (
<li><label><input type="radio" name="snapYear" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<!-- Sort -->
<section class="snapsg">
<header class="snapsg-h" @click="toggleGroup('sort')">
<span>{t('Sort')}</span>
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.sort }">
<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>
</header>
<div class="snapsg-b" x-show="!collapsedGroups.sort">
<ul class="snapschecks snapschecks--flat">
{sortOptions.map((o) => (
<li><label><input type="radio" name="snapSort" value={o.value} :checked={'selectedSort === "' + o.value + '"'} :class={`{'is-checked': selectedSort === '${o.value}'}`} @click.prevent={'selectedSort = selectedSort === "' + o.value + '" ? "image_count" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="snapsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</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 4" :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">
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)">
<div class="snapscard-left">
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
<img :src="toAbsUrl(it.cover)" :alt="cardTitle(it)" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
</div>
</div>
<div class="snapscard-right">
<div class="snapscard-info">
<h3 class="snapscard-title" x-text="titleParts(it)[0]"></h3>
<p class="snapscard-meta" x-text="titleParts(it)[1]"></p>
</div>
<div class="snapscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
<div class="snapscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
</div>
</template>
</div>
</div>
</a>
</template>
</div>
</template>
</div>
<nav class="snappager" x-show="lastPage > 1" aria-label="Pagination">
<button 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 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 class="pg pg-next" type="button" :disabled="page >= lastPage || loading" @click="nextPage()">Next ›</button>
</nav>
</main>
</div>
</div>
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions })}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
.snapsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.snapshead {
@apply flex justify-between items-end gap-6 py-5 pb-[22px];
}
.snapsh1 {
@apply text-[clamp(34px,5.5vw,64px)] leading-[0.95] font-black tracking-[-0.02em] my-0 mb-[8px];
}
/* 已激活筛选 pills */
.snapspills {
@apply flex flex-wrap gap-2 mb-4;
}
.snapspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.snapspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
/* 主体:左 sidebar + 右网格 */
.snapsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.snapsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.snapsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.snapsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none;
}
.snapsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.snapsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.snapsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.snapsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.snapsg-chev.is-collapsed { @apply -rotate-90; }
.snapschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
}
.snapschecks li {
@apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6];
}
.snapschecks input[type="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类:
@click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.snapschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.snapschecks--flat li { @apply py-[4px]; }
.snapschecks--flat li label { @apply inline-flex items-center gap-2 cursor-pointer w-full; }
.snapsclear {
@apply mt-[22px] block w-full text-left pt-[16px] border-t border-black/10 text-[11px] uppercase tracking-[0.18em] text-[#666] cursor-pointer hover:text-black transition-colors duration-150;
}
/* 主体内容区 */
.snapsmain { @apply flex-1 min-w-0; }
.snapscontent {
@apply relative;
}
.snapscontent.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: snapProg 1s ease-in-out infinite;
}
/* ===== 卡片网格(与 runway-looks 同款) ===== */
.snapsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.snapscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.snapscard:hover .snapscard-img img {
@apply scale-104;
}
.snapscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.snapscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.snapscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.snapscard-img img.is-loaded {
@apply opacity-100;
}
.snapsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.snapscard-img.is-loaded .snapsspin,
.snapscard-thumb.is-loaded .snapsspin {
@apply opacity-0;
}
.snapscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.snapscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.snapscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.snapscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.snapscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.snapscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.snapscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.snapscard-thumb img.is-loaded {
@apply opacity-100;
}
.snapscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.snapscard--skel .snapscard-img,
.snapscard--skel .snapscard-thumb,
.snapscard--skel .snapsline {
@apply bg-[#efefef];
animation: snapShim 1.6s ease infinite;
}
/* ===== 分页 ===== */
.snappager { @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;
}
.pg.is-current { @apply bg-black text-white cursor-default; }
.pg:disabled { @apply opacity-30 cursor-default; }
.pg.enabled:hover { @apply bg-black text-white; }
.pg-ellipsis { @apply px-1 text-[#999] select-none text-[11px]; }
.pg-wrap { @apply inline-flex items-center; }
.snapsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes snapProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes snapShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
</style>
<script>
import { getStreetSnaps, toAbs, type StreetSnapItem } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
const DEFAULT_SORT = "image_count"
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapsPage", () => {
const optNode = document.getElementById("snaps-year-opts")
const opts = optNode?.textContent ? JSON.parse(optNode.textContent) : { yearOptions: [], sortOptions: [] }
return {
locale: '',
SPIN_SVG,
yearOptions: opts.yearOptions || [],
sortOptions: opts.sortOptions || [],
selectedYear: '' as string,
selectedSort: DEFAULT_SORT as string,
// 侧栏分组折叠状态
collapsedGroups: { year: false, sort: false } as Record<string, boolean>,
items: [] as (StreetSnapItem & { _imgLoaded?: boolean })[],
loading: false,
page: 1,
lastPage: 1,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'en'
this.loadPage(1)
},
// ===== 侧栏筛选 =====
toggleGroup(key: string) {
this.collapsedGroups[key] = !this.collapsedGroups[key]
},
clearAll() {
this.selectedYear = ''
this.selectedSort = DEFAULT_SORT
this.loadPage(1)
},
// 已激活筛选 chips(顶部 pills + 侧栏 Clear all 的可见性都依赖它)
get pills() {
const out: { label: string; clear: () => void }[] = []
if (this.selectedYear) {
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))
out.push({
label: "YEAR · " + (hit ? hit.label : this.selectedYear),
clear: () => { this.selectedYear = ''; this.loadPage(1) },
})
}
if (this.selectedSort !== DEFAULT_SORT) {
const hit = this.sortOptions.find((o: any) => o.value === this.selectedSort)
out.push({
label: "SORT · " + (hit ? hit.label : this.selectedSort),
clear: () => { this.selectedSort = DEFAULT_SORT; this.loadPage(1) },
})
}
return out
},
// 卡片右侧缩略图条带:固定 5 个槽位,缺图留 placeholder
// 街拍 images 是 { image, name }[](与 runway 的 string[] gallery 不同)
getCardSlots(it: StreetSnapItem) {
const imgs = it.images || []
const slots: { src: string; loaded: boolean }[] = []
for (let i = 0; i < 5; i++) {
const im: any = imgs[i]
slots.push({ src: im ? (im.image || im) : "", loaded: false })
}
return slots
},
async loadPage(page: number) {
const myToken = ++this.reqToken
this.loading = true
this.items = []
try {
const json = await getStreetSnaps({
page,
year: this.selectedYear || undefined,
sort: this.selectedSort,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: StreetSnapItem) => ({ ...item, _imgLoaded: false }))
this.lastPage = json.last_page
this.page = page
this.items = rows
} catch {
if (myToken !== this.reqToken) return
this.items = []
} finally {
if (myToken === this.reqToken) this.loading = false
}
},
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) return Array.from({ length: total }, (_: any, i: number) => i + 1)
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
},
goPage(p: number) {
if (this.loading) return
if (p < 1 || p > this.lastPage || p === this.page) return
this.loadPage(p)
const main = this.$el.querySelector(".snapsmain")
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
else window.scrollTo({ top: 0, behavior: "smooth" })
},
prevPage() { this.goPage(this.page - 1) },
nextPage() { this.goPage(this.page + 1) },
toAbsUrl(url: string) { return toAbs(url) },
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.streetSnap(it.id)) },
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
titleParts(it: StreetSnapItem): string[] {
const parts = (it.title || "").split("\t")
return [parts[0] || it.title || "", parts.slice(1).join(" ").trim()]
},
cardTitle(it: StreetSnapItem): string {
return (it.title || "").replace(/\t/g, " · ")
},
}
})
})
</script>

View File

@ -1,5 +1,5 @@
---
import type { RawArticle } from "@/lib/data"
import type { RawArticle } from "@/lib/api"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { ssrBase } from "@/lib/api"

View File

@ -1,470 +0,0 @@
---
import { getReports, fmtDate } from "@/lib/data"
import { href, ROUTES } from '@/lib/routes'
import { getI18n } from "@/i18n/utils"
// 文章展示创意布局页:复用你现有的 Layout 外壳。
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。
const reports = await getReports(30)
const fmt = fmtDate
const { locale } = getI18n(Astro)
const itemBase = href(locale, "/items")
---
<div class="archive" data-item-base={itemBase}>
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
<header class="archive-head">
<div class="archive-kicker">
<span>Runway Archive</span>
<span>SS23 — AW26</span>
</div>
<h1 class="archive-title">The Reports</h1>
<div class="archive-sub">
<p>
走秀趋势报告档案库。支持网格 / 杂志 / 时间轴三种创意视图自由切换,彩色配图由后端接口动态渲染。
</p>
<a href={href(locale, ROUTES.home)} class="archive-back">← Index</a>
</div>
</header>
<!-- 工具栏:布局切换 + 结果计数 -->
<div class="archive-toolbar">
<div class="viewswitch" role="tablist" aria-label="Layout mode">
<button data-layout="grid" class="on" type="button">Grid</button>
<button data-layout="magazine" type="button">Magazine</button>
<button data-layout="timeline" type="button">Timeline</button>
</div>
<span class="archive-count" id="count">{reports.length} Reports</span>
</div>
<!-- 分类筛选(品牌),由脚本按数据动态生成 -->
<div class="filterbar" id="filterbar"></div>
<!-- 系列类型 / 季节筛选(后端分类字段:collection_type / season) -->
<div class="filterbar" id="typefilter">
<button class="chip on" data-type="">全部系列</button>
<button class="chip" data-type="rtw">成衣 RTW</button>
<button class="chip" data-type="menswear">男装</button>
<button class="chip" data-type="couture">高定</button>
<button class="chip" data-type="resort">度假 / 早春</button>
<button class="chip" data-type="pre_fall">早秋</button>
</div>
<div class="filterbar" id="seasonfilter">
<button class="chip on" data-season="">全部季节</button>
<button class="chip" data-season="spring">Spring 春夏</button>
<button class="chip" data-season="fall">Fall 秋冬</button>
</div>
<!-- 画廊容器:data-layout 决定当前视图;局部切换不产生整页刷新 -->
<section class="gallery" id="gallery" data-layout="grid">
{reports.map((s) => (
<article
class="card"
data-brand={s.brand}
data-featured={s.featured ? "true" : "false"}
data-year={s.year}
data-archive={s.year < 2025 ? "true" : "false"}
style={`--ratio:${s.ratio}`}
>
<div class="card-datecol"><span class="card-date">{fmt(s.created_at)}</span></div>
<a class="card-media" href={href(locale, ROUTES.item(s.id))} aria-label={s.title}>
<img src={s.cover} alt={s.title} loading="lazy" />
{s.featured && <span class="card-flag">Featured</span>}
</a>
<div class="card-body">
<span class="card-brand">{s.brand}</span>
<h3 class="card-title">{s.title}</h3>
<p class="card-excerpt">{s.excerpt}</p>
<div class="card-foot">
<span class="card-date2">{fmt(s.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>
))}
</section>
</div>
<style is:global>
@reference "tailwindcss";
/* 仅作用于本页(.archive 前缀),不影响其它页面与你的原有样式 */
.archive { max-width: 1440px; margin: 0 auto; }
/* ---------- 页头 ---------- */
.archive-head { margin-top: clamp(48px, 8vw, 110px); }
.archive-kicker {
@apply font-mono uppercase tracking-[0.25em] text-[11px] text-black;
display: flex; justify-content: space-between;
border-top: 1px solid #000; padding-top: 18px;
}
.archive-title {
@apply font-serif font-black tracking-[-0.02em];
line-height: 0.85;
font-size: clamp(48px, 11vw, 150px); margin: 0.28em 0 0;
}
.archive-sub {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 24px; flex-wrap: wrap; border-bottom: 1px solid #000;
padding-bottom: 22px; margin-top: 22px;
}
.archive-sub p { max-width: 580px; @apply text-[#444] text-[15px]; line-height: 1.6; margin: 0; }
.archive-back {
@apply font-mono uppercase tracking-[0.15em] text-[11px] border border-black px-[16px] py-[10px] hover:bg-black hover:text-white;
white-space: nowrap;
transition: background 0.25s, color 0.25s;
}
/* ---------- 工具栏 ---------- */
.archive-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; flex-wrap: wrap; margin: 28px 0 22px;
}
.viewswitch { display: inline-flex; @apply border border-black; }
.viewswitch button {
@apply font-mono uppercase tracking-[0.15em] text-[11px] px-[18px] py-[10px] bg-white text-black;
border: none; border-right: 1px solid #000;
cursor: pointer; transition: background 0.25s, color 0.25s;
}
.viewswitch button:last-child { border-right: none; }
.viewswitch button.on { @apply bg-black text-white; }
.archive-count {
@apply font-mono text-[11px] tracking-[0.15em] uppercase text-[#666];
}
/* ---------- 筛选 chips ---------- */
.filterbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 34px; }
.chip {
@apply font-mono uppercase tracking-[0.12em] text-[11px] px-[14px] py-[7px] border border-[#ccc] bg-white text-[#333] rounded-full;
cursor: pointer; transition: all 0.2s;
}
.chip:hover { border-color: #000; }
.chip.on { @apply bg-black text-white border-black; }
/* ---------- 画廊基础 + 入场/切换过渡 ---------- */
.gallery { transition: opacity 0.24s ease, transform 0.24s ease; }
.gallery.switching { opacity: 0; transform: translateY(10px); }
.gallery .card { position: relative; @apply bg-white; }
/* 无 JS 时直接可见;有 JS 时做交错淡入 */
.archive.js .gallery .card {
opacity: 0; transform: translateY(16px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.archive.js .gallery .card.in { opacity: 1; transform: none; }
.card-media {
position: relative; display: block; overflow: hidden;
aspect-ratio: var(--ratio, 3 / 4); background: #f2f2f2;
}
.card-media img {
width: 100%; height: 100%; object-fit: cover;
opacity: 0; transform: scale(1.03);
transition: opacity 0.7s ease, transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card-media img.loaded { opacity: 1; transform: none; }
.card-media::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0) 42%, rgba(0,0,0,0.30));
opacity: 0; transition: opacity 0.5s;
}
.card:hover .card-media img { transform: scale(1.06); }
.card:hover .card-media::after { opacity: 1; }
.card-flag {
position: absolute; top: 12px; left: 12px; z-index: 2;
@apply font-mono uppercase tracking-[0.15em] text-[10px] font-bold bg-black text-white px-[9px] py-[4px];
}
.card-body { padding: 14px 2px 0; }
.card-brand {
display: block;
@apply font-mono uppercase tracking-[0.14em] text-[10.5px] text-black font-bold;
}
.card-title {
@apply font-serif font-extrabold;
font-size: clamp(18px, 2vw, 26px); line-height: 1.05; margin: 8px 0 0;
transition: color 0.3s;
}
.card:hover .card-title { color: #cf5a44; }
.card-excerpt { @apply text-[#555] text-[13.5px]; line-height: 1.55; margin: 8px 0 0; max-width: 48ch; }
.card-foot {
display: flex; justify-content: space-between; align-items: center;
margin-top: 12px;
@apply font-mono uppercase tracking-[0.1em] text-[10.5px] text-[#999];
}
.card-read {
@apply text-black;
opacity: 0; transform: translateX(-6px);
transition: opacity 0.35s, transform 0.35s;
}
.card:hover .card-read { opacity: 1; transform: none; }
/* 视觉层次:Featured = 黑色粗边框 + 大标题;Archive(年份<2025) = 虚线灰边框 */
.gallery .card[data-featured="true"] .card-media { border: 2px solid #000; }
.gallery .card[data-featured="true"] .card-title { font-size: clamp(22px, 2.6vw, 34px); }
.gallery .card[data-archive="true"] .card-media { border: 1px dashed #bbb; }
/* ---------- 视图 1:Grid(瀑布流,非对称高低错落) ---------- */
.gallery[data-layout="grid"] { column-count: 3; column-gap: 26px; }
.gallery[data-layout="grid"] .card { break-inside: avoid; display: block; margin: 0 0 30px; }
.gallery[data-layout="grid"] .card-datecol { display: none; }
@media (max-width: 1024px) { .gallery[data-layout="grid"] { column-count: 2; } }
@media (max-width: 640px) { .gallery[data-layout="grid"] { column-count: 1; } }
/* ---------- 视图 2:Magazine(非对称网格,Featured 跨栏放大) ---------- */
.gallery[data-layout="magazine"] {
display: grid; grid-template-columns: repeat(6, 1fr);
gap: 26px; grid-auto-flow: dense;
}
.gallery[data-layout="magazine"] .card { grid-column: span 2; display: block; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; grid-row: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] .card-media { aspect-ratio: 16 / 10; }
.gallery[data-layout="magazine"] .card-datecol { display: none; }
@media (max-width: 1024px) {
.gallery[data-layout="magazine"] { grid-template-columns: repeat(4, 1fr); }
.gallery[data-layout="magazine"] .card { grid-column: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; }
}
@media (max-width: 640px) {
.gallery[data-layout="magazine"] { grid-template-columns: 1fr; }
.gallery[data-layout="magazine"] .card,
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 1; grid-row: auto; }
}
/* ---------- 视图 3:Timeline(时间轴,左侧日期 + 缩略图 + 正文) ---------- */
.gallery[data-layout="timeline"] { display: block; position: relative; }
.gallery[data-layout="timeline"]::before {
content: ""; position: absolute; left: 188px; top: 6px; bottom: 6px; width: 2px; background: #000;
}
.gallery[data-layout="timeline"] .card {
display: grid; grid-template-columns: 170px 220px 1fr; gap: 26px;
align-items: start; margin: 0 0 30px; position: relative;
}
.gallery[data-layout="timeline"] .card::before {
content: ""; position: absolute; left: 182px; top: 8px;
width: 14px; height: 14px; border-radius: 50%;
background: #fff; border: 2px solid #000; z-index: 2;
}
.gallery[data-layout="timeline"] .card-datecol { display: block; }
.gallery[data-layout="timeline"] .card-date {
@apply font-mono text-[13px] tracking-[0.08em] text-black;
}
.gallery[data-layout="timeline"] .card-media { aspect-ratio: auto; height: 150px; }
.gallery[data-layout="timeline"] .card-excerpt { display: none; }
.gallery[data-layout="timeline"] .card-foot { display: none; }
@media (max-width: 768px) {
.gallery[data-layout="timeline"]::before { left: 8px; }
.gallery[data-layout="timeline"] .card { grid-template-columns: 1fr; gap: 12px; padding-left: 34px; }
.gallery[data-layout="timeline"] .card::before { left: 2px; }
.gallery[data-layout="timeline"] .card-media { height: 200px; width: 100%; }
}
/* 筛选隐藏 */
.archive .card[data-hidden="true"] { display: none !important; }
</style>
<script>
import { fetchArticlesList, toAbsPublic } from "@/lib/api"
const archive = document.querySelector<HTMLElement>(".archive")
const gallery = document.getElementById("gallery") as HTMLElement | null
const countEl = document.getElementById("count")
const filterbar = document.getElementById("filterbar")
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
// 系列类型 / 季节筛选状态(对应 #typefilter / #seasonfilter 按钮;仅后端接口分支使用)
let fType = ""
let fSeason = ""
const itemBase = archive?.dataset.itemBase || "/items"
// 来自品牌索引的聚焦筛选(/brands 的品牌链接:?brand_id= 或 ?brand=)
const _urlParams = new URLSearchParams(location.search)
const _brandId = _urlParams.get("brand_id")
const _brandName = _urlParams.get("brand")
if (archive && gallery) archive.classList.add("js")
const fmt = (t: number | string) => {
const n = Number(t)
const d = new Date(n < 1e12 ? n * 1000 : n)
const p = (x: number) => String(x).padStart(2, "0")
return `${d.getUTCFullYear()}.${p(d.getUTCMonth() + 1)}.${p(d.getUTCDate())}`
}
type Item = {
id: number | string
title: string
brand: string
year: number | string
created_at: number | string
cover: string
excerpt: string
image_count: number
featured: boolean
ratio?: string
}
// 卡片模板:SSR 示例与后端渲染共用同一套结构,保证三种视图样式一致
const cardTemplate = (it: Item) => {
const archiveFlag = Number(it.year) < 2025 ? "true" : "false"
const ratio = it.ratio ?? "3 / 4"
return `
<article class="card" data-brand="${it.brand}" data-featured="${it.featured ? "true" : "false"}"
data-year="${it.year}" data-archive="${archiveFlag}" style="--ratio:${ratio}">
<div class="card-datecol"><span class="card-date">${fmt(it.created_at)}</span></div>
<a class="card-media" href="${itemBase}/${it.id}" aria-label="${it.title}">
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
</a>
<div class="card-body">
<span class="card-brand">${it.brand}</span>
<h3 class="card-title">${it.title}</h3>
<p class="card-excerpt">${it.excerpt}</p>
<div class="card-foot">
<span class="card-date2">${fmt(it.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>`
}
const ratios = ["3 / 4", "4 / 5", "1 / 1", "5 / 4", "3 / 4", "4 / 3"]
const setupCards = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
// 配图渐显
cards.forEach((c) => {
c.querySelectorAll("img").forEach((img) => {
if (img.complete) img.classList.add("loaded")
else img.addEventListener("load", () => img.classList.add("loaded"), { once: true })
})
})
// 交错入场
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("in")
io.unobserve(e.target)
}
})
},
{ threshold: 0.08 }
)
cards.forEach((c, i) => {
c.style.transitionDelay = (i % 8) * 0.04 + "s"
io.observe(c)
})
buildChips(cards)
}
// 品牌筛选 chips(按当前数据动态生成)
let currentFilter: string | null = null
const buildChips = (cards: HTMLElement[]) => {
if (!filterbar) return
const brands = Array.from(new Set(cards.map((c) => c.dataset.brand).filter(Boolean))) as string[]
filterbar.innerHTML = ""
const mk = (label: string, brand: string | null) => {
const b = document.createElement("button")
b.className = "chip" + (brand === currentFilter ? " on" : "")
b.textContent = label
b.dataset.brand = brand ?? ""
b.addEventListener("click", () => {
currentFilter = currentFilter === brand ? null : brand
filterbar!.querySelectorAll(".chip").forEach((x) => x.classList.toggle("on", (x as HTMLElement).dataset.brand === (currentFilter ?? "")))
applyFilter()
})
return b
}
filterbar.appendChild(mk("All", null))
brands.forEach((br) => filterbar.appendChild(mk(br, br)))
}
const applyFilter = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
let visible = 0
cards.forEach((c) => {
const show = !currentFilter || c.dataset.brand === currentFilter
c.toggleAttribute("data-hidden", !show)
if (show) visible++
})
if (countEl) countEl.textContent = `${visible} Reports`
}
// 布局切换:局部更新,带淡入淡出过渡,不刷新整页
viewBtns.forEach((btn) => {
btn.addEventListener("click", () => {
if (!gallery) return
const mode = btn.dataset.layout as string
if (gallery.dataset.layout === mode) return
viewBtns.forEach((x) => x.classList.toggle("on", x === btn))
gallery.classList.add("switching")
window.setTimeout(() => {
gallery!.dataset.layout = mode
gallery!.classList.remove("switching")
}, 240)
})
})
// 后端数据接入:拉取真实列表,失败则保留示例(布局不变)
const render = (items: Item[]) => {
if (!gallery) return
gallery.dataset.layout = gallery.dataset.layout || "grid"
gallery.innerHTML = items.map(cardTemplate).join("")
if (countEl) countEl.textContent = `${items.length} Reports`
setupCards()
}
const loadBackend = async () => {
try {
const rows = await fetchArticlesList({
size: _brandId ? 100 : 30,
collection_type: fType || undefined,
season: fSeason || undefined,
brand_id: _brandId || undefined,
})
if (!Array.isArray(rows) || rows.length === 0) return
let items: Item[] = rows.map((r: any, i: number) => ({
id: r.id,
title: r.title || "Untitled",
brand: r.brand_name || "Unknown",
year: r.year || "",
created_at: r.published_at,
cover: toAbsPublic(r.cover || ""),
excerpt: r.summary || "",
image_count: Number(r.image_count || 0),
featured: false,
ratio: ratios[i % ratios.length],
}))
// 以图片数量最高的前 3 篇标记为 Featured(视觉层次)
const top = [...items].sort((a, b) => b.image_count - a.image_count).slice(0, 3).map((x) => x.id)
items.forEach((it) => (it.featured = top.includes(it.id as any)))
// 来自品牌索引的聚焦:高亮该品牌、改写页头标题
const focus = _brandName || (_brandId && items.length ? items[0].brand : null)
if (focus) {
currentFilter = focus
render(items)
filterbar?.querySelectorAll(".chip").forEach((x) =>
x.classList.toggle("on", (x as HTMLElement).dataset.brand === focus)
)
const t = document.querySelector(".archive-title")
if (t) t.textContent = focus
const k = document.querySelector(".archive-kicker span")
if (k) k.textContent = "Brand Archive"
if (countEl) countEl.textContent = `${items.length} Reports`
} else {
render(items)
}
} catch {
/* 保持示例数据 */
}
}
setupCards()
loadBackend()
</script>