Files
frontend_v2/src/components/RunwayLooks.astro
toom1996 662b12c612
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-08-30 22:49:56 +08:00

512 lines
22 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 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>