update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-08-26 19:42:10 +08:00
parent d64a046f82
commit eee2265e15
29 changed files with 337 additions and 2506 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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" },

View File

@ -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>