512 lines
22 KiB
Plaintext
512 lines
22 KiB
Plaintext
---
|
||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||
import BrandModal from "@/components/BrandModal.astro"
|
||
import { getSsgHotBrands } from "@/lib/api"
|
||
import { getI18n } from '@/i18n/utils'
|
||
import "@/styles/look-grid.css"
|
||
|
||
|
||
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||
|
||
// 热门品牌:走 SSG 专属接口 /api/v1/ssg/brands/hot,后端固定返回热度前 30 个、按热度顺序排列。
|
||
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
|
||
// 接口不可用时回落假数据先看效果。
|
||
const FAKE_HOT_BRANDS = [
|
||
{ 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 }[] = []
|
||
try {
|
||
hotBrands = (await getSsgHotBrands())
|
||
.filter((b) => b.id && b.name)
|
||
.map((b) => ({ id: String(b.id), name: b.name }))
|
||
} catch {}
|
||
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
|
||
|
||
// 年份筛选:写死,按需增删(数据最早约 2005 年,如需更老年份往数组里加即可)
|
||
// 年份:近 10 年(含当年),末项加"X年前"兜底
|
||
const currentYear = new Date().getFullYear()
|
||
const yearFloor = currentYear - 9
|
||
const yearOptions: { value: number | string; label: string }[] = []
|
||
for (let y = currentYear; y >= yearFloor; y--) {
|
||
yearOptions.push({ value: y, label: String(y) })
|
||
}
|
||
yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` })
|
||
|
||
// 系列类型 / 季节:写死静态数据(先用示例/假数据预览,确认后直接改这两个数组即可)
|
||
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" },
|
||
]
|
||
|
||
|
||
---
|
||
|
||
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
|
||
<!-- 顶部:极简眉头 -->
|
||
<header class="showshead">
|
||
<div>
|
||
<Breadcrumb>
|
||
<span slot="current">{t('Runway looks')}</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>{t('brands')}</span>
|
||
<span class="showsg-right">
|
||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
|
||
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</span>
|
||
</span>
|
||
</header>
|
||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||
<ul class="showschecks">
|
||
<template x-for="b in hotBrands.slice(0, 10)" :key="b.id">
|
||
<li>
|
||
<label class="flex items-center gap-2 w-full cursor-pointer">
|
||
<input type="radio" name="designer" :value="b.id" :checked="selectedBrand === b.id" :class="{ 'is-checked': selectedBrand === b.id }" @click.prevent="toggleBrand(b.id)" />
|
||
<span x-text="b.name"></span>
|
||
</label>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
<a class="showsmore" @click="openBrandModal()" x-text="'ALL BRANDS +'" role="button" tabindex="0"></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="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>
|
||
</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="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>
|
||
</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="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>
|
||
</section>
|
||
|
||
<button class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>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="itemHref(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 }" />
|
||
</div>
|
||
</div>
|
||
<div class="showscard-right">
|
||
<div class="showscard-info">
|
||
<h3 class="showscard-title" x-text="`${it.brand_name} / ${it.title}`"></h3>
|
||
</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>
|
||
|
||
<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>
|
||
|
||
<!-- 品牌筛选弹窗:抽成独立组件 BrandModal.astro(状态由本页 showsPage 提供) -->
|
||
<BrandModal />
|
||
|
||
|
||
|
||
</div>
|
||
|
||
|
||
<script id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||
|
||
|
||
|
||
<script>
|
||
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
|
||
import { clientHref, ROUTES } from "@/lib/routes"
|
||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||
|
||
document.addEventListener("alpine:init", () => {
|
||
Alpine.data("showsPage", () => {
|
||
const hotNode = document.getElementById("showshotbrands-data")
|
||
const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : []
|
||
|
||
return {
|
||
locale: '',
|
||
hotBrands,
|
||
SPIN_SVG,
|
||
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
|
||
collapsedGroups: {
|
||
designers: true,
|
||
collection: true,
|
||
season: true,
|
||
year: true,
|
||
},
|
||
selectedBrand: null as (string | null),
|
||
selectedCt: "" as string,
|
||
selectedSeason: "" as string,
|
||
selectedYear: "" as string,
|
||
|
||
// 品牌筛选弹窗状态
|
||
modalOpen: false,
|
||
modalKeyword: "",
|
||
modalLetter: "",
|
||
modalResults: [] as BrandEntry[],
|
||
modalLoading: false,
|
||
modalSelected: null as (string | null),
|
||
|
||
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
|
||
loading: false,
|
||
page: 1,
|
||
lastPage: 1,
|
||
reqToken: 0,
|
||
|
||
init() {
|
||
this.locale = this.$el.dataset.locale || 'en';
|
||
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
|
||
const ct = new URLSearchParams(window.location.search).get('collection')
|
||
const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
|
||
if (ct && ALLOWED.includes(ct)) this.selectedCt = ct
|
||
this.loadPage(1)
|
||
},
|
||
|
||
// 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
|
||
get skeletonCount() {
|
||
return 24
|
||
},
|
||
|
||
// 已筛选选项 Pills 视图数据
|
||
get pills() {
|
||
const list: { label: string; clear: () => void }[] = []
|
||
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 name = findName(this.selectedBrand)
|
||
list.push({
|
||
label: "DESIGNER · " + name,
|
||
clear: () => { this.selectedBrand = null; this.refreshFilter(); }
|
||
})
|
||
}
|
||
if (this.selectedCt) {
|
||
list.push({
|
||
label: "COLLECTION · " + this.selectedCt.toUpperCase(),
|
||
clear: () => { this.selectedCt = ""; this.refreshFilter(); }
|
||
})
|
||
}
|
||
if (this.selectedSeason) {
|
||
list.push({
|
||
label: "SEASON · " + this.selectedSeason.toUpperCase(),
|
||
clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
|
||
})
|
||
}
|
||
if (this.selectedYear) {
|
||
list.push({
|
||
label: "YEAR · " + this.selectedYear,
|
||
clear: () => { this.selectedYear = ""; this.refreshFilter(); }
|
||
})
|
||
}
|
||
return list
|
||
},
|
||
|
||
// 点击抽屉开关:仅切换内存状态,刷新后回到默认(全部收起)
|
||
toggleGroup(group: "designers" | "collection" | "season" | "year") {
|
||
this.collapsedGroups[group] = !this.collapsedGroups[group]
|
||
},
|
||
|
||
refreshFilter() {
|
||
this.loadPage(1)
|
||
},
|
||
|
||
toggleBrand(id: string) {
|
||
this.selectedBrand = this.selectedBrand === id ? null : id
|
||
this.refreshFilter()
|
||
},
|
||
|
||
// ===== 品牌筛选弹窗方法 =====
|
||
async openBrandModal() {
|
||
this.modalSelected = this.selectedBrand
|
||
this.modalKeyword = ""
|
||
this.modalLetter = "HOT"
|
||
this.modalOpen = true
|
||
// 锁根滚动并补偿滚动条宽度,避免背景页面横向偏移
|
||
// const sbw = window.innerWidth - document.documentElement.clientWidth
|
||
// document.documentElement.style.overflow = "hidden"
|
||
// if (sbw > 0) document.documentElement.style.paddingRight = sbw + "px"
|
||
await this.modalLoadHot()
|
||
},
|
||
closeBrandModal() {
|
||
this.modalOpen = false
|
||
document.documentElement.style.overflow = ""
|
||
document.documentElement.style.paddingRight = ""
|
||
},
|
||
async modalLoadHot() {
|
||
this.modalLetter = "HOT"
|
||
this.modalLoading = true
|
||
try {
|
||
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name }))
|
||
} finally {
|
||
this.modalLoading = false
|
||
}
|
||
},
|
||
async modalSearch() {
|
||
const kw = this.modalKeyword.trim()
|
||
if (kw) {
|
||
this.modalLetter = ""
|
||
this.modalLoading = true
|
||
this.modalResults = []
|
||
try {
|
||
this.modalResults = await getBrands({ keyword: kw })
|
||
} 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
|
||
this.modalResults = []
|
||
try {
|
||
const letter = l === "#" ? "OTHER" : l
|
||
this.modalResults = await getBrands({ letter })
|
||
} catch {
|
||
this.modalResults = []
|
||
} finally {
|
||
this.modalLoading = false
|
||
}
|
||
},
|
||
confirmBrandModal() {
|
||
this.selectedBrand = this.modalSelected
|
||
this.modalOpen = false
|
||
document.documentElement.style.overflow = ""
|
||
document.documentElement.style.paddingRight = ""
|
||
this.refreshFilter()
|
||
},
|
||
|
||
clearAll() {
|
||
this.selectedBrand = null
|
||
this.selectedCt = ""
|
||
this.selectedSeason = ""
|
||
this.selectedYear = ""
|
||
this.refreshFilter()
|
||
},
|
||
|
||
// API 加载(分页:每次整页替换,不追加)
|
||
async loadPage(page: number) {
|
||
const myToken = ++this.reqToken
|
||
this.loading = true
|
||
this.items = []
|
||
|
||
try {
|
||
const json = await getArticles({
|
||
page,
|
||
sort: "newest",
|
||
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.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
|
||
}
|
||
},
|
||
|
||
// ===== 分页导航 =====
|
||
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
|
||
get pageList() {
|
||
return buildPageList(this.lastPage, this.page)
|
||
},
|
||
|
||
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) },
|
||
|
||
// 渲染文章url
|
||
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
|
||
|
||
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
|
||
//
|
||
// 注意参数顺序:makeCardSlots(images, count, opts) —— 第二个参数是「槽位数量」不是图片总数。
|
||
// 曾误把 it.image_count(如 31)传成 count,导致每张卡片生成 31 个槽位(大量灰色占位块)。
|
||
// 图片总数只用于末位遮罩文案,通过 moreLabel 回调传入。
|
||
getCardSlots(it: ArticleItem) {
|
||
const total = it.image_count || 0
|
||
return makeCardSlots(it.gallery || [], 5, {
|
||
moreLabel: () => (total > 30 ? "30+" : `${total}+`),
|
||
})
|
||
}
|
||
}
|
||
})
|
||
})
|
||
</script> |