939 lines
38 KiB
Plaintext
939 lines
38 KiB
Plaintext
---
|
||
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> |