Files
frontend_v2/src/pages/cn/shows.astro
toom1996 fbbac26903
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-08-24 21:52:47 +08:00

939 lines
38 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import Layout from "@/layouts/Layout.astro"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getBrands, getSsgPopularBrands, type BrandEntry } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
import { clientHref, ROUTES, href } from '@/lib/routes'
const { locale } = getI18n(Astro)
let sidebarBrands: BrandEntry[] = []
try {
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
.filter((b) => b.id && b.name)
.sort((a, b) => a.name.localeCompare(b.name))
} catch {}
// 热门品牌:构建期走 SSG 内部接口(/api/ssg/brands/popular),不可用时回落假数据先看效果
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 },
]
let hotBrands: { id: string; name: string; article_count: number }[] = []
try {
const ssg = await getSsgPopularBrands(12)
if (ssg.length) hotBrands = ssg.map((b) => ({ id: String(b.id), name: b.brand || "", article_count: 0 }))
} catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
// 年份筛选:写死,按需增删(数据最早约 2005 年,如需更老年份往数组里加即可)
// 年份:从当今年份往前推 15 年(含),末项加"X年前"兜底
const currentYear = new Date().getFullYear()
const yearFloor = currentYear - 15
const yearOptions: { value: number | string; label: string }[] = []
for (let y = currentYear; y >= yearFloor; y--) {
yearOptions.push({ value: y, label: String(y) })
}
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
// 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可)
const collectionOptions: { value: string; label: string }[] = [
{ value: "rtw", label: "Ready-to-Wear" },
{ value: "menswear", label: "Menswear" },
{ value: "womenswear", label: "Womenswear" },
{ value: "couture", label: "Couture" },
{ value: "resort", label: "Resort" },
{ value: "pre_fall", label: "Pre-Fall" },
{ value: "bridal", label: "Bridal" },
{ value: "swimwear", label: "Swimwear" },
]
const seasonOptions: { value: string; label: string }[] = [
{ value: "spring", label: "Spring / Summer" },
{ value: "fall", label: "Fall / Winter" },
{ value: "cruise", label: "Cruise" },
{ value: "holiday", label: "Holiday" },
]
---
<Layout title="全部的 runway — Portfolio Studio">
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="showshead">
<div>
<Breadcrumb
items={[
{ label: 'Home', href: href(locale, ROUTES.home) },
{ label: 'Runway Archive', href: href(locale, ROUTES.shows) },
]}
>
<span slot="current" x-text="crumbYear">2026 Edition</span>
</Breadcrumb>
<h1 class="showsh1">全部的 runway</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>
</span>
</template>
</div>
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
<div class="showsbody">
<!-- LEFT SIDEBAR -->
<aside class="showsaside">
<div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2>
</div>
<div class="showsaside-body">
<!-- Designer 多选 -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('designers')">
<span>DESIGNERS</span>
<span class="showsg-right">
<span class="showsg-count" x-text="selectedBrands.length">0</span>
<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">
<div class="showshot" x-show="hotBrands.length">
<div class="mb-2 text-[10px] uppercase tracking-[0.18em] text-black/45">热门品牌 · HOT</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="b in hotBrands" :key="b.id">
<label class="inline-flex items-center gap-1.5 border border-black/15 px-2 py-1 text-[11px] cursor-pointer transition-colors has-[:checked]:border-black has-[:checked]:bg-black has-[:checked]:text-white">
<input type="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
<span x-text="b.name"></span>
</label>
</template>
</div>
</div>
<ul class="showschecks">
<template x-for="item in visibleBrandItems" :key="item.key">
<li>
<!-- Letter Header -->
<template x-if="item.type === 'letter'">
<div class="letter-head" x-text="item.letter"></div>
</template>
<!-- Brand Item -->
<template x-if="item.type === 'brand'">
<label class="flex items-center gap-2 w-full cursor-pointer">
<input
type="checkbox"
:value="item.id"
:checked="selectedBrands.includes(item.id)"
@change="toggleBrand(item.id)"
>
<span x-text="item.name"></span>
<span class="ct" x-text="item.count ? item.count : ''"></span>
</label>
</template>
</li>
</template>
</ul>
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
</div>
</section>
<!-- Collection type -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('collection')">
<span>COLLECTION</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
<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="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>
))}
</ul>
</div>
</section>
<!-- Season -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('season')">
<span>SEASON</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
<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="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>
))}
</ul>
</div>
</section>
<!-- Year -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('year')">
<span>YEAR</span>
<span class="showsg-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="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>
))}
</ul>
</div>
</section>
<button class="showsclear" type="button" @click="clearAll()">Clear all ×</button>
</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">
<a class="showscard" :href="articleHref(it)" :aria-label="`${it.brand_name} ${it.title}`">
<div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
<img :src="toAbsUrl(it.cover)" :alt="it.title" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
<!-- <span class="showsbadge" x-show="isNewAdded(it.published_at)">New</span> -->
</div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<h3 class="showscard-title" x-text="it.title"></h3>
<p class="showscard-meta" x-text="`${it.season_code || it.year || ''} · ${ctLabel(it.collection_type)} · ${it.brand_name}`"></p>
<div class="showscard-meta-foot">
<span class="showscard-meta-line"><span x-html="ICON_USER"></span><span x-text="it.brand_name"></span></span>
<span class="showscard-meta-line"><span x-html="ICON_DATE"></span><span x-text="fmtDate(it.published_at)"></span></span>
</div>
</div>
<div class="showscard-strip">
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
<span class="showsspin" 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 }" />
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
</div>
</template>
</div>
</div>
</a>
</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>
</main>
</div>
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak>
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && modalLetter === 'HOT'">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="checkbox"
:value="b.id"
:checked="modalSelected.includes(b.id)"
@change="toggleModalBrand(b.id)"
/>
<span class="showsmodal-name" x-text="b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 1 个品牌`"></span>
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
</div>
</Layout>
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* 使用 @apply 精简并重构样式 */
.showsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.showshead {
@apply flex justify-between items-end gap-6 py-5 pb-[26px] ;
}
.showsh1 {
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.showspills {
@apply flex flex-wrap gap-2 pt-3.5;
}
.showspill {
@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;
}
.showspill .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;
}
.showsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.showsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none transition-[flex-basis] duration-250 ease-in-out flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.showsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
}
.showsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed {
@apply -rotate-90;
}
.showsg-count {
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
}
.showschecks {
@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];
}
.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="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showschecks--flat li {
@apply py-[4px];
}
.showschecks--flat li label {
@apply inline-flex items-center gap-2 cursor-pointer w-full;
}
.showsmore {
@apply inline-block mt-[10px] text-[10.5px] uppercase tracking-[0.16em] text-[#555] cursor-pointer hover:text-black;
}
.showsclear {
@apply mt-[18px] w-full border-[1.5px] border-black bg-white text-black p-[11px_12px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-150 hover:bg-black hover:text-white;
}
.showsmain {
@apply flex-1 min-w-0;
}
.showsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.showsgrid.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: hbxProg 1s ease-in-out infinite;
}
.showscard {
@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];
}
.showscard:hover .showscard-img img {
@apply scale-104;
}
.showscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.showscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.showscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.showscard-img img.is-loaded {
@apply opacity-100;
}
.showsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.showscard-thumb.is-loaded .showsspin {
@apply opacity-0;
}
.showsbadge {
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
}
.showsflag-featured {
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
}
.showscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.showscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.showscard-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;
}
.showscard-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;
}
.showscard-meta-foot {
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
}
.showscard-meta-line {
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
}
.showscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.showscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.showscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.showscard-thumb img.is-loaded {
@apply opacity-100;
}
.showscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline {
@apply bg-[#efefef];
animation: hbxShim 1.6s ease infinite;
}
.showsloadmore-wrap {
@apply flex justify-center 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;
}
.showsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes hbxProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes hbxShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
/* ===== 品牌筛选弹窗(语言切换器风格:无 mask、顶部小箭头、干净白底列表) ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-start justify-center pt-[14vh] pointer-events-none;
}
.showsmodal-mask {
@apply absolute inset-0 pointer-events-auto cursor-default;
background: transparent;
}
.showsmodal-panel {
@apply relative w-full max-w-[460px] max-h-[72vh] bg-white flex flex-col border border-black/10 pointer-events-auto;
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
/* 顶部小三角箭头(语言切换器同款:白底 + 浅边框) */
.showsmodal-panel::before {
content: "";
position: absolute;
top: -5px;
left: 50%;
width: 10px;
height: 10px;
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
transform: translateX(-50%) rotate(45deg);
}
.showsmodal-head {
@apply flex items-center justify-between px-4 pt-4 pb-2 shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[12px] tracking-[0.22em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-transparent border-0 w-6 h-6 inline-flex items-center justify-center cursor-pointer text-[14px] leading-none text-[#999] transition-colors hover:text-black;
}
.showsmodal-search {
@apply px-4 pt-1 pb-3 shrink-0;
}
.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-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-black/10 w-6 h-6 text-[10px] cursor-pointer leading-none font-mono transition-colors hover:bg-black/5;
}
.showsmodal-az .az.active {
@apply bg-black text-white border-black;
}
.showsmodal-az .az-hot {
@apply w-auto px-2 font-sans font-bold tracking-[0.12em] text-[9px];
}
.showsmodal-body {
@apply flex-1 min-h-0 overflow-y-auto px-2 py-1;
}
.showsmodal-hint {
@apply text-[10px] tracking-[0.22em] uppercase text-[#999] px-3 py-2;
}
.showsmodal-loading {
@apply flex justify-center py-6 text-[#bdbdbd];
}
.showsmodal-list {
@apply list-none m-0 p-0;
}
.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-name {
@apply flex-1 truncate;
}
.showsmodal-ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showsmodal-empty {
@apply py-6 text-center text-[#999] text-[13px];
}
.showsmodal-foot {
@apply flex items-center justify-between px-4 py-3 border-t border-black/5 shrink-0 text-[11px] text-[#888];
}
.showsmodal-counter {
@apply tracking-[0.06em];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-4 py-1.5 cursor-pointer text-[12px] tracking-[0.1em] transition-colors hover:bg-black/85;
}
</style>
<script>
import Alpine from "alpinejs"
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { clientHref, ROUTES } from "@/lib/routes"
const ICON_USER = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><circle cx="6" cy="4" r="2" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 10c0-2 1.8-3 4-3s4 1 4 3" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const ICON_DATE = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="2" y="3" width="8" height="7" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 6h8M4 2v2M8 2v2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
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>`
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
const node = document.getElementById("showsbrands-data")
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
const hotNode = document.getElementById("showshotbrands-data")
const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : []
return {
allBrands,
hotBrands,
locale: '',
ICON_USER,
ICON_DATE,
SPIN_SVG,
collapsedGroups: {
designers: localStorage.getItem("showsg-designers") === "1",
collection: localStorage.getItem("showsg-collection") === "1",
season: localStorage.getItem("showsg-season") === "1",
year: localStorage.getItem("showsg-year") === "1",
},
selectedBrands: [] as number[],
selectedCt: [] as string[],
selectedSeason: [] as string[],
selectedYear: [] as string[],
brandsExpanded: false,
// 品牌筛选弹窗状态
modalOpen: false,
modalKeyword: "",
modalLetter: "",
modalResults: [] as BrandEntry[],
modalLoading: false,
modalSelected: [] as number[],
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false,
loadingMore: false,
page: 1,
size: 24,
total: 0,
isEnd: false,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'en'
this.loadPage(1, false)
},
// 计算属性:侧边栏设计师列表的分组渲染
get visibleBrandItems() {
const result: any[] = []
const map = new Map<string, BrandEntry[]>()
for (const b of this.allBrands) {
const letter = (b.name[0] || "?").toUpperCase()
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(b)
}
const grouped = [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
let shown = 0
const MAX_INITIAL = 60
for (const [letter, list] of grouped) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "letter", letter, key: `letter-${letter}` })
for (const b of list) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
shown++
}
}
return result
},
get hasMoreBrands() {
return this.allBrands.length > 60
},
// 面包屑末级:未选年份时显示默认的「2026 Edition」,选中后显示对应年份
get crumbYear() {
if (this.selectedYear.length) {
const ys = [...this.selectedYear].sort()
return ys.length === 1 ? `${ys[0]} Edition` : `${ys.join(" / ")}`
}
return "2026 Edition"
},
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
get skeletonCount() {
return this.size
},
// 已筛选选项 Pills 视图数据
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) {
const findName = (id: any) => this.allBrands.find((b) => b.id === id)?.name ?? this.hotBrands.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}` : ""
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
})
}
if (this.selectedCt.length) {
list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); }
})
}
if (this.selectedSeason.length) {
list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
})
}
if (this.selectedYear.length) {
list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
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) {
if (this.selectedBrands.includes(id)) this.selectedBrands = []
else this.selectedBrands = [id]
this.refreshFilter()
},
refreshFilter() {
this.loadPage(1, false)
},
// ===== 品牌筛选弹窗方法 =====
async openBrandModal() {
this.modalSelected = [...this.selectedBrands]
this.modalKeyword = ""
this.modalLetter = "HOT"
this.modalOpen = true
await this.modalLoadHot()
},
closeBrandModal() {
this.modalOpen = false
},
async modalLoadHot() {
this.modalLetter = "HOT"
this.modalLoading = true
try {
this.modalResults = this.hotBrands.length
? this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
: this.allBrands.slice(0, 50)
} finally {
this.modalLoading = false
}
},
async modalSearch() {
const kw = this.modalKeyword.trim()
if (kw) {
this.modalLetter = ""
this.modalLoading = true
try {
this.modalResults = await getBrands({ keyword: kw, size: 60 })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
} else if (this.modalLetter) {
await this.modalPickLetter(this.modalLetter)
} else {
await this.modalLoadHot()
}
},
async modalPickLetter(l: string) {
if (l === "HOT") {
this.modalKeyword = ""
await this.modalLoadHot()
return
}
this.modalLetter = l
this.modalKeyword = ""
this.modalLoading = true
try {
const letter = l === "#" ? "OTHER" : l
this.modalResults = await getBrands({ letter, size: 200 })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
},
toggleModalBrand(id: number) {
if (this.modalSelected.includes(id)) this.modalSelected = []
else this.modalSelected = [id]
},
confirmBrandModal() {
this.selectedBrands = [...this.modalSelected]
this.modalOpen = false
this.refreshFilter()
},
clearAll() {
this.selectedBrands = []
this.selectedCt = []
this.selectedSeason = []
this.selectedYear = []
this.refreshFilter()
},
// API 加载
async loadPage(page: number, append = false) {
const myToken = ++this.reqToken
if (append) {
this.loadingMore = true
} else {
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,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
this.total = json.total
this.page = page
this.items = append ? [...this.items, ...rows] : rows
this.isEnd = page >= json.last_page || rows.length < this.size
} catch {
if (myToken !== this.reqToken) return
if (!append) this.items = []
} finally {
if (myToken === this.reqToken) {
this.loading = false
this.loadingMore = false
}
}
},
// 辅助辅助工具函数
toAbsUrl(url: string) { return toAbs(url) },
articleHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.article(it.id)) },
isNewAdded(pubAt?: number) { return Date.now() / 1000 - Number(pubAt || 0) < 60 * 60 * 24 * 14 },
fmtDate(ts: number) {
if (!ts) return ""
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
},
ctLabel(ct?: string) {
const map: Record<string, string> = { rtw: "Ready-to-Wear", menswear: "Menswear", womenswear: "Womenswear", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall", bridal: "Bridal", swimwear: "Swimwear" }
return ct && map[ct] ? map[ct] : (ct || "")
},
getCardSlots(it: ArticleItem) {
const gallery = it.gallery || []
const totalCount = it.image_count || 0
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
const slots = []
for (let i = 0; i < 5; i++) {
const src = gallery[i]
const isLast = i === 4
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" })
}
return slots
}
}
})
})
</script>