This commit is contained in:
toom1996
2026-08-29 15:47:58 +08:00
parent eee2265e15
commit 3c7c5d8855
15 changed files with 1369 additions and 733 deletions

View File

@ -10,24 +10,24 @@ const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
// 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。
// 接口不可用时回落假数据先看效果。
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 },
{ 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; article_count: number }[] = []
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, article_count: b.article_count }))
.map((b) => ({ id: String(b.id), name: b.name }))
} catch {}
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
@ -108,9 +108,8 @@ const seasonOptions: { value: string; label: string }[] = [
<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="checkbox" :value="b.id" :checked="selectedBrands.includes(b.id)" @change="toggleBrand(b.id)" />
<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>
<span class="ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
@ -130,7 +129,7 @@ const seasonOptions: { value: string; label: string }[] = [
<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>
<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>
@ -147,7 +146,7 @@ const seasonOptions: { value: string; label: string }[] = [
<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>
<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>
@ -164,7 +163,7 @@ const seasonOptions: { value: string; label: string }[] = [
<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>
<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>
@ -235,9 +234,16 @@ const seasonOptions: { value: string; label: string }[] = [
</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>
<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>
@ -258,6 +264,7 @@ const seasonOptions: { value: string; label: string }[] = [
placeholder="搜索品牌名…"
autocomplete="off"
/>
<p class="showsmodal-tip">最多展示 30 条结果,可用搜索或字母筛选缩小范围</p>
</div>
<div class="showsmodal-az">
<button class="az az-hot" type="button" :class="{ active: modalLetter === 'HOT' }" @click="modalPickLetter('HOT')">HOT</button>
@ -273,14 +280,13 @@ const seasonOptions: { value: string; label: string }[] = [
<li>
<label class="showsmodal-row">
<input
type="checkbox"
type="radio"
name="modalDesigner"
:value="b.id"
:checked="modalSelected.includes(b.id)"
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
@change="toggleModalBrand(b.id)"
x-model="modalSelected"
:class="{ 'is-checked': modalSelected === b.id }"
/>
<span class="showsmodal-name" x-text="b.display || b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
@ -368,7 +374,7 @@ const seasonOptions: { value: string; label: string }[] = [
.showschecks {
@apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin] overscroll-contain;
@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];
@ -376,12 +382,14 @@ const seasonOptions: { value: string; label: string }[] = [
.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="radio"] {
@apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showschecks input[type="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
/* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类,
因为 @click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */
.showschecks input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
@ -493,11 +501,26 @@ const seasonOptions: { value: string; label: string }[] = [
animation: hbxShim 1.6s ease infinite;
}
.showsloadmore-wrap {
@apply flex justify-center mt-[38px];
.showspager {
@apply flex flex-wrap items-center justify-center gap-2 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;
.pg {
@apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200;
}
.pg.is-current {
@apply bg-black text-white cursor-default;
}
.pg:disabled {
@apply opacity-30 cursor-default;
}
.pg.enabled:hover {
@apply bg-black text-white;
}
.pg-ellipsis {
@apply px-1 text-[#999] select-none text-[11px];
}
.pg-wrap {
@apply inline-flex items-center;
}
.showsempty {
@ -546,6 +569,9 @@ const seasonOptions: { value: string; label: string }[] = [
.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-tip {
@apply mt-2 text-[11px] leading-snug text-black/45 font-mono;
}
.showsmodal-az {
@apply flex flex-wrap gap-1 px-4 pb-3 shrink-0;
}
@ -573,6 +599,13 @@ const seasonOptions: { value: string; label: string }[] = [
.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-row input[type="radio"] {
@apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full;
}
.showsmodal-row input[type="radio"].is-checked {
@apply bg-white border-black;
background-image: radial-gradient(circle, #000 36%, transparent 40%);
}
.showsmodal-name {
@apply flex-1 truncate;
}
@ -616,10 +649,10 @@ const seasonOptions: { value: string; label: string }[] = [
season: localStorage.getItem("showsg-season") === "1",
year: localStorage.getItem("showsg-year") === "1",
},
selectedBrands: [] as number[],
selectedCt: [] as string[],
selectedSeason: [] as string[],
selectedYear: [] as string[],
selectedBrand: null as (string | null),
selectedCt: "" as string,
selectedSeason: "" as string,
selectedYear: "" as string,
// 品牌筛选弹窗状态
modalOpen: false,
@ -627,91 +660,74 @@ const seasonOptions: { value: string; label: string }[] = [
modalLetter: "",
modalResults: [] as BrandEntry[],
modalLoading: false,
modalSelected: [] as number[],
get modalMaxReached() {
return this.modalSelected.length >= 1
},
modalSelected: null as (string | null),
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false,
loadingMore: false,
page: 1,
size: 24,
total: 0,
isEnd: false,
lastPage: 1,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'en';
this.loadPage(1, false)
this.loadPage(1)
},
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
// 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
get skeletonCount() {
return this.size
return 24
},
// 已筛选选项 Pills 视图数据
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) {
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 labels = this.selectedBrands
.map((id) => findName(id))
.slice(0, 4)
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
const name = findName(this.selectedBrand)
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
label: "DESIGNER · " + name,
clear: () => { this.selectedBrand = null; this.refreshFilter(); }
})
}
if (this.selectedCt.length) {
if (this.selectedCt) {
list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); }
label: "COLLECTION · " + this.selectedCt.toUpperCase(),
clear: () => { this.selectedCt = ""; this.refreshFilter(); }
})
}
if (this.selectedSeason.length) {
if (this.selectedSeason) {
list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
label: "SEASON · " + this.selectedSeason.toUpperCase(),
clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
})
}
if (this.selectedYear.length) {
if (this.selectedYear) {
list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
clear: () => { this.selectedYear = []; this.refreshFilter(); }
label: "YEAR · " + this.selectedYear,
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) {
const idx = this.selectedBrands.indexOf(id)
if (idx >= 0) this.selectedBrands.splice(idx, 1)
else this.selectedBrands.push(id)
this.refreshFilter()
refreshFilter() {
this.loadPage(1)
},
refreshFilter() {
this.loadPage(1, false)
toggleBrand(id: string) {
this.selectedBrand = this.selectedBrand === id ? null : id
this.refreshFilter()
},
// ===== 品牌筛选弹窗方法 =====
async openBrandModal() {
this.modalSelected = [...this.selectedBrands]
this.modalSelected = this.selectedBrand
this.modalKeyword = ""
this.modalLetter = "HOT"
this.modalOpen = true
@ -730,7 +746,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLetter = "HOT"
this.modalLoading = true
try {
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name }))
} finally {
this.modalLoading = false
}
@ -742,7 +758,7 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalLoading = true
this.modalResults = []
try {
this.modalResults = await getBrands({ keyword: kw, size: 60 })
this.modalResults = await getBrands({ keyword: kw })
} catch {
this.modalResults = []
} finally {
@ -766,23 +782,15 @@ const seasonOptions: { value: string; label: string }[] = [
this.modalResults = []
try {
const letter = l === "#" ? "OTHER" : l
this.modalResults = await getBrands({ letter, size: 200 })
this.modalResults = await getBrands({ letter })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
},
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)
}
},
confirmBrandModal() {
this.selectedBrands = [...this.modalSelected]
this.selectedBrand = this.modalSelected
this.modalOpen = false
document.documentElement.style.overflow = ""
document.documentElement.style.paddingRight = ""
@ -790,54 +798,73 @@ const seasonOptions: { value: string; label: string }[] = [
},
clearAll() {
this.selectedBrands = []
this.selectedCt = []
this.selectedSeason = []
this.selectedYear = []
this.selectedBrand = null
this.selectedCt = ""
this.selectedSeason = ""
this.selectedYear = ""
this.refreshFilter()
},
// API 加载
async loadPage(page: number, append = false) {
// API 加载(分页:每次整页替换,不追加)
async loadPage(page: number) {
const myToken = ++this.reqToken
if (append) {
this.loadingMore = true
} else {
this.loading = true
this.items = []
}
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,
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.total = json.total
this.lastPage = json.last_page
this.page = page
this.items = append ? [...this.items, ...rows] : rows
this.isEnd = page >= json.last_page || rows.length < this.size
this.items = rows
} catch {
if (myToken !== this.reqToken) return
if (!append) this.items = []
this.items = []
} finally {
if (myToken === this.reqToken) {
this.loading = false
this.loadingMore = false
}
if (myToken === this.reqToken) this.loading = false
}
},
// ===== 分页导航 =====
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
get pageList() {
const total = this.lastPage
const cur = this.page
if (total <= 7) {
return Array.from({ length: total }, (_: any, i: number) => i + 1)
}
const pages: (number | "...")[] = [1]
const start = Math.max(2, cur - 1)
const end = Math.min(total - 1, cur + 1)
if (start > 2) pages.push("...")
for (let i = start; i <= end; i++) pages.push(i)
if (end < total - 1) pages.push("...")
pages.push(total)
return pages
},
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) },