@ -1,13 +1,12 @@
|
||||
---
|
||||
import Layout from "@/layouts/Layout.astro"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
||||
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[] = []
|
||||
let yearChips: number[] = []
|
||||
|
||||
try {
|
||||
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
|
||||
@ -15,12 +14,55 @@ try {
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
} catch {}
|
||||
|
||||
let yearSeed = [2026, 2025, 2024, 2023, 2022, 2021]
|
||||
// 热门品牌:构建期走 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 ys = await getYears(30)
|
||||
if (ys.length) yearChips = ys
|
||||
const ssg = await getSsgPopularBrands(12)
|
||||
if (ssg.length) hotBrands = ssg.map((b) => ({ id: String(b.id), name: b.brand || "", article_count: 0 }))
|
||||
} catch {}
|
||||
if (!yearChips.length) yearChips = yearSeed
|
||||
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">
|
||||
@ -72,28 +114,40 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
</span>
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.designers">
|
||||
<input class="showssearch" placeholder="Search designer…" autocomplete="off" x-model.debounce.150ms="keyword" />
|
||||
<ul class="showschecks">
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
</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>
|
||||
@ -109,11 +163,9 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.collection">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
<li><label><input type="checkbox" value="rtw" x-model="selectedCt" @change="refreshFilter()" /> <span>Ready-to-Wear</span></label></li>
|
||||
<li><label><input type="checkbox" value="menswear" x-model="selectedCt" @change="refreshFilter()" /> <span>Menswear</span></label></li>
|
||||
<li><label><input type="checkbox" value="couture" x-model="selectedCt" @change="refreshFilter()" /> <span>Couture</span></label></li>
|
||||
<li><label><input type="checkbox" value="resort" x-model="selectedCt" @change="refreshFilter()" /> <span>Resort</span></label></li>
|
||||
<li><label><input type="checkbox" value="pre_fall" x-model="selectedCt" @change="refreshFilter()" /> <span>Pre-Fall</span></label></li>
|
||||
{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>
|
||||
@ -128,8 +180,9 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.season">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
<li><label><input type="checkbox" value="spring" x-model="selectedSeason" @change="refreshFilter()" /> <span>Spring / Summer</span></label></li>
|
||||
<li><label><input type="checkbox" value="fall" x-model="selectedSeason" @change="refreshFilter()" /> <span>Fall / Winter</span></label></li>
|
||||
{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>
|
||||
@ -144,8 +197,8 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
</header>
|
||||
<div class="showsg-b" x-show="!collapsedGroups.year">
|
||||
<ul class="showschecks showschecks--flat">
|
||||
{yearChips.map((y) => (
|
||||
<li><label><input type="checkbox" value={String(y)} x-model="selectedYear" @change="refreshFilter()" /> <span>{y}</span></label></li>
|
||||
{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>
|
||||
@ -247,12 +300,13 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
/>
|
||||
</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">热门推荐</div>
|
||||
<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">
|
||||
@ -262,7 +316,6 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
type="checkbox"
|
||||
:value="b.id"
|
||||
:checked="modalSelected.includes(b.id)"
|
||||
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
|
||||
@change="toggleModalBrand(b.id)"
|
||||
/>
|
||||
<span class="showsmodal-name" x-text="b.name"></span>
|
||||
@ -274,7 +327,7 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
</ul>
|
||||
</div>
|
||||
<footer class="showsmodal-foot">
|
||||
<span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span>
|
||||
<span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 1 个品牌`"></span>
|
||||
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
|
||||
</footer>
|
||||
</div>
|
||||
@ -284,6 +337,7 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
</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";
|
||||
@ -350,9 +404,7 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
|
||||
}
|
||||
|
||||
.showssearch {
|
||||
@apply w-full border border-[#d4d4d4] p-[7px_10px] text-[11px] uppercase tracking-[0.08em] text-black rounded-none outline-none bg-[#fafafa] focus:border-black focus:bg-white;
|
||||
}
|
||||
|
||||
|
||||
.showschecks {
|
||||
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin];
|
||||
@ -542,6 +594,9 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
.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;
|
||||
}
|
||||
@ -582,6 +637,7 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
<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>`
|
||||
@ -591,9 +647,12 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
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,
|
||||
@ -608,7 +667,6 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
selectedCt: [] as string[],
|
||||
selectedSeason: [] as string[],
|
||||
selectedYear: [] as string[],
|
||||
keyword: "",
|
||||
brandsExpanded: false,
|
||||
|
||||
// 品牌筛选弹窗状态
|
||||
@ -618,9 +676,6 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
modalResults: [] as BrandEntry[],
|
||||
modalLoading: false,
|
||||
modalSelected: [] as number[],
|
||||
get modalMaxReached() {
|
||||
return this.modalSelected.length >= 5
|
||||
},
|
||||
|
||||
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
|
||||
loading: false,
|
||||
@ -638,34 +693,24 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
|
||||
// 计算属性:侧边栏设计师列表的分组渲染
|
||||
get visibleBrandItems() {
|
||||
const kw = this.keyword.trim().toLowerCase()
|
||||
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]))
|
||||
|
||||
if (kw) {
|
||||
this.allBrands.forEach((b) => {
|
||||
if (b.name.toLowerCase().includes(kw)) {
|
||||
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
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) {
|
||||
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: "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++
|
||||
}
|
||||
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
|
||||
shown++
|
||||
}
|
||||
}
|
||||
return result
|
||||
@ -693,8 +738,9 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
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) => this.allBrands.find((b) => b.id === id)?.name ?? `#${id}`)
|
||||
.map((id) => findName(id))
|
||||
.slice(0, 4)
|
||||
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
|
||||
list.push({
|
||||
@ -736,9 +782,8 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
},
|
||||
|
||||
toggleBrand(id: number) {
|
||||
const idx = this.selectedBrands.indexOf(id)
|
||||
if (idx >= 0) this.selectedBrands.splice(idx, 1)
|
||||
else this.selectedBrands.push(id)
|
||||
if (this.selectedBrands.includes(id)) this.selectedBrands = []
|
||||
else this.selectedBrands = [id]
|
||||
this.refreshFilter()
|
||||
},
|
||||
|
||||
@ -750,7 +795,7 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
async openBrandModal() {
|
||||
this.modalSelected = [...this.selectedBrands]
|
||||
this.modalKeyword = ""
|
||||
this.modalLetter = ""
|
||||
this.modalLetter = "HOT"
|
||||
this.modalOpen = true
|
||||
await this.modalLoadHot()
|
||||
},
|
||||
@ -758,12 +803,12 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
this.modalOpen = false
|
||||
},
|
||||
async modalLoadHot() {
|
||||
this.modalLetter = "HOT"
|
||||
this.modalLoading = true
|
||||
try {
|
||||
const list = await getBrands({ featured: 1, size: 50 })
|
||||
this.modalResults = list.length ? list : this.allBrands.slice(0, 50)
|
||||
} catch {
|
||||
this.modalResults = this.allBrands.slice(0, 50)
|
||||
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
|
||||
}
|
||||
@ -787,6 +832,11 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
}
|
||||
},
|
||||
async modalPickLetter(l: string) {
|
||||
if (l === "HOT") {
|
||||
this.modalKeyword = ""
|
||||
await this.modalLoadHot()
|
||||
return
|
||||
}
|
||||
this.modalLetter = l
|
||||
this.modalKeyword = ""
|
||||
this.modalLoading = true
|
||||
@ -800,12 +850,8 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
}
|
||||
},
|
||||
toggleModalBrand(id: number) {
|
||||
const idx = this.modalSelected.indexOf(id)
|
||||
if (idx >= 0) this.modalSelected.splice(idx, 1)
|
||||
else {
|
||||
if (this.modalSelected.length >= 5) return
|
||||
this.modalSelected.push(id)
|
||||
}
|
||||
if (this.modalSelected.includes(id)) this.modalSelected = []
|
||||
else this.modalSelected = [id]
|
||||
},
|
||||
confirmBrandModal() {
|
||||
this.selectedBrands = [...this.modalSelected]
|
||||
@ -818,7 +864,6 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
this.selectedCt = []
|
||||
this.selectedSeason = []
|
||||
this.selectedYear = []
|
||||
this.keyword = ""
|
||||
this.refreshFilter()
|
||||
},
|
||||
|
||||
@ -873,7 +918,7 @@ if (!yearChips.length) yearChips = yearSeed
|
||||
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", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall" }
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user