@ -4,15 +4,15 @@ import ComingSoon from "@/components/ComingSoon.astro"
|
||||
import { toAbs } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { getSsgPopularBrands } from "@/lib/api"
|
||||
import { getSsgIndexRunway } from "@/lib/api"
|
||||
|
||||
// 国际化:一行取当前语言 + 翻译函数
|
||||
const { locale, t } = getI18n(Astro)
|
||||
|
||||
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
|
||||
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||
// 具体 base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。
|
||||
const popular = await getSsgPopularBrands(20)
|
||||
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/v1/ssg/index/runway(server-only),
|
||||
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
|
||||
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
|
||||
const popular = (await getSsgIndexRunway()).hotBrand
|
||||
|
||||
type GalleryItem = {
|
||||
src: string
|
||||
|
||||
@ -1,20 +1,14 @@
|
||||
---
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getBrands, getSsgPopularBrands, type BrandEntry } from "@/lib/api"
|
||||
import { getSsgHotBrands } from "@/lib/api"
|
||||
import { getI18n } from '@/i18n/utils'
|
||||
|
||||
|
||||
const { locale, t, getRelativeLocaleUrl } = 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),不可用时回落假数据先看效果
|
||||
// 热门品牌:走 SSG 专属接口 /api/v1/ssg/brands/hot,后端固定返回热度前 30 个、按热度顺序排列。
|
||||
// 侧栏 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 },
|
||||
@ -31,8 +25,9 @@ const FAKE_HOT_BRANDS = [
|
||||
]
|
||||
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 }))
|
||||
hotBrands = (await getSsgHotBrands())
|
||||
.filter((b) => b.id && b.name)
|
||||
.map((b) => ({ id: String(b.id), name: b.name, article_count: b.article_count }))
|
||||
} catch {}
|
||||
if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS
|
||||
|
||||
@ -284,7 +279,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
|
||||
@change="toggleModalBrand(b.id)"
|
||||
/>
|
||||
<span class="showsmodal-name" x-text="b.name"></span>
|
||||
<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>
|
||||
@ -302,7 +297,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
@ -609,14 +603,11 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
|
||||
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 {
|
||||
locale: '',
|
||||
allBrands,
|
||||
hotBrands,
|
||||
SPIN_SVG,
|
||||
collapsedGroups: {
|
||||
@ -629,7 +620,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
selectedCt: [] as string[],
|
||||
selectedSeason: [] as string[],
|
||||
selectedYear: [] as string[],
|
||||
brandsExpanded: false,
|
||||
|
||||
// 品牌筛选弹窗状态
|
||||
modalOpen: false,
|
||||
@ -656,35 +646,6 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
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
|
||||
},
|
||||
|
||||
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
|
||||
get skeletonCount() {
|
||||
return this.size
|
||||
@ -694,7 +655,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
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 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)
|
||||
@ -755,9 +716,9 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
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"
|
||||
// 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() {
|
||||
@ -769,9 +730,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
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)
|
||||
this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 }))
|
||||
} finally {
|
||||
this.modalLoading = false
|
||||
}
|
||||
|
||||
@ -230,7 +230,7 @@ const registerHref = href(locale, ROUTES.register)
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register"
|
||||
const endpoint = mode === "login" ? "/api/v1/auth/login" : "/api/v1/auth/register"
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@ -1,240 +0,0 @@
|
||||
---
|
||||
import { getSsgBrands, type SsgBrand } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
type Brand = SsgBrand
|
||||
|
||||
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
|
||||
// - 数据:一次 SSR 拉完全部精选集合,前端按 name 首字母分桶渲染;数据量小,无需懒加载。
|
||||
// - 风格:极简编辑风(黑白 + monospace + 细线)。
|
||||
// · 顶部单行 BRANDS 标题 + 总数
|
||||
// · sticky A-Z 字母条(点击平滑滚动)
|
||||
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
|
||||
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
|
||||
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
|
||||
// base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。后端不可达则回落空数组(页面显示空索引)。
|
||||
const { locale } = getI18n(Astro)
|
||||
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
|
||||
const loadError = brands.length === 0
|
||||
|
||||
const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
// 主显示名:拉丁/英文名优先(用于 A-Z 归类与展示),无拉丁名时回退中文
|
||||
const primaryName = (b: Brand): string => {
|
||||
const name = (b.name || "").trim()
|
||||
const show = (b.show_name || "").trim()
|
||||
if (/^[A-Za-z]/.test(name)) return name
|
||||
if (show) {
|
||||
const segs = show.split("/")
|
||||
const lat = segs.find((s) => /^[A-Za-z]/.test(s.trim()))
|
||||
if (lat) return lat.trim()
|
||||
}
|
||||
return name || show || "Unnamed"
|
||||
}
|
||||
|
||||
// 按首字母归类到 A-Z(中文/符号开头的品牌不再单独成桶,直接忽略)
|
||||
type Bucket = Record<string, Brand[]>
|
||||
const buckets: Bucket = {}
|
||||
LETTERS.forEach((l) => (buckets[l] = []))
|
||||
|
||||
for (const b of brands) {
|
||||
const p = primaryName(b)
|
||||
const first = (p[0] || "").toUpperCase()
|
||||
if (first >= "A" && first <= "Z") buckets[first].push(b)
|
||||
}
|
||||
|
||||
// 按字母顺序输出行
|
||||
const orderedColumns: { key: string; label: string; items: Brand[] }[] = []
|
||||
LETTERS.forEach((l) => {
|
||||
if (buckets[l].length > 0) orderedColumns.push({ key: l, label: l, items: buckets[l] })
|
||||
})
|
||||
|
||||
const total = orderedColumns.reduce((s, c) => s + c.items.length, 0)
|
||||
---
|
||||
|
||||
<div class="brands" id="top">
|
||||
<!-- 页头:单行 BRANDS 标题 + 总数 -->
|
||||
<header class="brands-head">
|
||||
<h1 class="brands-title">
|
||||
<span class="brands-mark">Brands</span>
|
||||
<span class="brands-count">{total.toLocaleString()}</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<!-- A-Z 字母 tabbar(sticky 顶部) -->
|
||||
<nav class="azbar" id="azbar" aria-label="Brand index by letter">
|
||||
<div class="azbar-inner">
|
||||
<a href="#top" class="azbar-logo" id="az-all">All</a>
|
||||
<ol class="azbar-letters" id="azbar-letters">
|
||||
{LETTERS.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
href={`#letter-${l}`}
|
||||
class="az-letter"
|
||||
data-letter={l}
|
||||
data-empty={buckets[l].length > 0 ? "false" : "true"}
|
||||
>{l}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 每个字母一行:字母标签 + 品牌名横向铺成多列 -->
|
||||
<main class="brands-main">
|
||||
{loadError ? (
|
||||
<p class="brands-error">Failed to load brands. Please try again later.</p>
|
||||
) : total === 0 ? (
|
||||
<p class="brands-error">No brands indexed yet.</p>
|
||||
) : (
|
||||
<div class="brows">
|
||||
{orderedColumns.map((col) => (
|
||||
<section class="brow" id={`letter-${col.key}`} data-letter={col.key}>
|
||||
<h2 class="brow-letter">{col.label}</h2>
|
||||
<ul class="brow-items">
|
||||
{col.items.map((b) => (
|
||||
<li>
|
||||
<a href={href(locale, ROUTES.articlesByBrand(b.id))}>{primaryName(b)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style is:global>
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* 布局容器 */
|
||||
.brands { @apply max-w-[1440px] mx-auto; }
|
||||
|
||||
/* ---------- 页头:单行 ---------- */
|
||||
.brands-head {
|
||||
margin-top: clamp(48px, 8vw, 110px);
|
||||
border-top: 1px solid #000;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.brands-title {
|
||||
@apply flex items-baseline gap-[18px] mb-[14px] font-serif font-black leading-[0.9] tracking-[-0.02em];
|
||||
margin-top: 0;
|
||||
font-size: clamp(32px, 5vw, 56px);
|
||||
}
|
||||
.brands-mark { @apply text-black; }
|
||||
.brands-count { @apply font-mono text-[12px] font-normal tracking-[0.06em] text-[#999]; }
|
||||
|
||||
/* ---------- A-Z Tabbar(sticky) ---------- */
|
||||
.azbar {
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #000;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.azbar-inner { @apply flex items-center gap-[14px] py-[10px]; }
|
||||
.azbar-logo {
|
||||
@apply font-mono uppercase tracking-[0.18em] text-[11px] font-bold text-black shrink-0 px-[9px] py-[4px] no-underline hover:bg-black hover:text-white;
|
||||
}
|
||||
.azbar-logo.is-active { @apply bg-black text-white; }
|
||||
|
||||
.azbar-letters { @apply flex flex-wrap list-none flex-1; margin: 0; padding: 0; gap: 0; }
|
||||
.az-letter {
|
||||
@apply inline-flex items-center justify-center min-w-[22px] h-[24px] px-[5px] font-mono text-[11px] font-bold tracking-[0.04em] text-black no-underline hover:bg-black hover:text-white;
|
||||
}
|
||||
.az-letter[data-empty="true"] { @apply text-[#cfcfcf] cursor-not-allowed pointer-events-none; }
|
||||
.az-letter.is-active { @apply bg-black text-white; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.azbar-inner { gap: 8px; padding: 8px 0; }
|
||||
.azbar-letters { overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
|
||||
.az-letter { min-width: 20px; height: 22px; font-size: 10px; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
/* ---------- 每个字母一行 ---------- */
|
||||
.brands-main { @apply pt-[10px] pb-[80px]; }
|
||||
.brands-error { @apply font-mono uppercase tracking-[0.15em] text-[11px] text-[#999] py-[60px]; }
|
||||
|
||||
.brows { @apply border-t border-black; }
|
||||
.brow {
|
||||
@apply grid gap-[20px] pt-[14px] pb-[18px] border-b border-[#ececec];
|
||||
grid-template-columns: 48px 1fr;
|
||||
scroll-margin-top: 70px;
|
||||
}
|
||||
.brow-letter { @apply font-mono uppercase tracking-[0.12em] text-[14px] font-bold text-black; margin: 0; }
|
||||
|
||||
.brow-items { @apply list-none grid; margin: 0; padding: 0; grid-template-columns: repeat(4, 1fr); }
|
||||
.brow-items li { @apply leading-[1.5] pl-[14px] pr-[16px] py-[3px] border-l border-[#ececec]; }
|
||||
/* 每列首元素去掉左边线,避免与左侧字母标签粘连 */
|
||||
.brow-items li:nth-child(4n+1) { border-left: none; padding-left: 0; }
|
||||
.brow-items a {
|
||||
@apply font-mono uppercase tracking-[0.05em] text-[12px] text-black no-underline inline-block py-[2px] hover:underline underline-offset-[3px];
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.brow-items { grid-template-columns: repeat(3, 1fr); }
|
||||
.brow-items li:nth-child(4n+1) { border-left: 1px solid #ececec; padding-left: 14px; }
|
||||
.brow-items li:nth-child(3n+1) { border-left: none; padding-left: 0; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.brow { grid-template-columns: 36px 1fr; gap: 12px; }
|
||||
.brow-letter { font-size: 13px; }
|
||||
.brow-items { grid-template-columns: repeat(2, 1fr); }
|
||||
.brow-items li:nth-child(3n+1) { border-left: 1px solid #ececec; padding-left: 14px; }
|
||||
.brow-items li:nth-child(2n+1) { border-left: none; padding-left: 0; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.brow { grid-template-columns: 1fr; gap: 8px; }
|
||||
.brow-letter { font-size: 15px; }
|
||||
.brow-items { grid-template-columns: 1fr; }
|
||||
.brow-items li { border-left: none !important; padding-left: 0 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 字母点击平滑滚动 + 滚动高亮当前字母
|
||||
const bar = document.getElementById("azbar")
|
||||
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(".az-letter"))
|
||||
const sections = Array.from(document.querySelectorAll<HTMLElement>(".brow"))
|
||||
const linkByLetter = new Map(links.map((a) => [a.dataset.letter!, a]))
|
||||
const sectionByLetter = new Map(sections.map((s) => [s.dataset.letter!, s]))
|
||||
const barH = () => (bar ? bar.getBoundingClientRect().height + 10 : 70)
|
||||
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
if (a.dataset.empty === "true") { e.preventDefault(); return }
|
||||
const l = a.dataset.letter
|
||||
if (!l) return
|
||||
e.preventDefault()
|
||||
const target = sectionByLetter.get(l)
|
||||
if (!target) return
|
||||
const y = target.getBoundingClientRect().top + window.scrollY - barH()
|
||||
window.scrollTo({ top: y, behavior: "smooth" })
|
||||
})
|
||||
})
|
||||
|
||||
const allLink = document.getElementById("az-all")
|
||||
if (allLink) {
|
||||
allLink.addEventListener("click", (e) => {
|
||||
e.preventDefault()
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
})
|
||||
}
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
if (visible.length) {
|
||||
const l = (visible[0].target as HTMLElement).dataset.letter
|
||||
if (l) links.forEach((x) => x.classList.toggle("is-active", x.dataset.letter === l))
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-90px 0px -60% 0px", threshold: 0 }
|
||||
)
|
||||
sections.forEach((s) => io.observe(s))
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user