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

This commit is contained in:
toom1996
2026-08-22 01:51:42 +08:00
parent 975c55feb2
commit 0df14036cf
16 changed files with 270 additions and 1023 deletions

View File

@ -1,37 +1,34 @@
---
import ComingSoon from "@/components/ComingSoon.astro"
import { getShows, seriesLabel } from "@/lib/data"
import { toAbs } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import { getRelativeLocaleUrl } from 'astro:i18n'
const shows = await getShows(20)
import { getSsgPopularBrands } from "@/lib/ssg"
// 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro)
type GalleryItem = {
src: string;
brand: string;
articleId: number;
year: number;
title: string;
collection_type?: string
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
// 具体 base 地址 + token 拼接已在 @/lib/ssg 内封装。
const popular = await getSsgPopularBrands(20)
type GalleryItem = {
src: string
brand: string
articleId: string // 代表走秀 hashid 编码串(后端返回 string)
title: string
}
const galleryItems: GalleryItem[] = []
for (const s of shows) {
if (!s.img) continue
galleryItems.push({
src: s.img,
brand: s.brand,
articleId: s.id,
year: s.year,
title: s.title,
collection_type: s.collection_type,
})
}
const galleryItems: GalleryItem[] = popular
.filter((b) => b.cover && b.article_id)
.map((b) => ({
src: toAbs(b.cover || ""),
brand: b.brand,
articleId: b.article_id,
title: b.title,
}))
---
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
@ -89,7 +86,7 @@ for (const s of shows) {
{
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={getRelativeLocaleUrl(locale, `/runway-looks/${g.articleId}`)} data-idx={i}>
<a class="hs-item" href={getRelativeLocaleUrl(locale, `/items/${g.articleId}`)} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 -->
<div
class="hs-img-box"
@ -111,12 +108,9 @@ for (const s of shows) {
@load="loaded = true; $dispatch('img-ready')"
@error="error = true; $dispatch('img-ready')"
/>
<span class="hs-caption">
<span class="hs-caption font-sans">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">
{g.year ? g.year : ""}
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
</span>
<span class="hs-cap-meta">{g.title}</span>
</span>
</div>
</a>
@ -269,36 +263,51 @@ for (const s of shows) {
to { background-position: -200% 0; }
}
/* 悬停品牌遮罩 */
/* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
padding: 48px 24px 22px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 6px;
opacity: 1;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
@apply font-sans;
font-size: 21px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.02em;
line-height: 1.1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
@apply font-sans;
font-size: 13px;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.04em;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 768px) {
.hs-caption { padding: 60px 28px 26px; }
.hs-cap-brand { font-size: 26px; }
.hs-cap-meta { font-size: 15px; }
}
@media (max-width: 768px) {

View File

@ -1,5 +1,4 @@
---
import Layout from "@/layouts/Layout.astro"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
@ -71,26 +70,24 @@ if (!yearChips.length) yearChips = yearSeed
<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>
<!-- <li>
<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 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> -->
</ul>
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
</div>

View File

@ -1,20 +1,16 @@
---
import type { RawArticle } from "@/lib/data"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils"
interface Props {
article: RawArticle | null
locale?: "en" | "cn"
apiBase?: string
error?: "missing" | "failed" | null
}
const {
article,
locale = "en",
apiBase = "",
error = null,
} = Astro.props
const { article, apiBase = "", error = null } = Astro.props
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
const full = (u: string): string => {
if (!u) return ""
@ -35,36 +31,12 @@ const descParas = (article?.description || "")
.map((s) => s.trim())
.filter(Boolean)
const L = {
en: {
back: "← Back to Index",
missing: "No article ID specified",
failed: "Failed to load article. Please refresh or try later.",
close: "Close",
previous: "Previous image",
next: "Next image",
zoomTip: "Click image to zoom · Scroll to view · Esc to close",
},
cn: {
back: "← 返回首页",
missing: "未指定文章 ID",
failed: "加载文章失败,请刷新或稍后再试",
close: "关闭",
previous: "上一张",
next: "下一张",
zoomTip: "点击图片放大 · 上下滚动查看 · Esc 关闭",
},
}
const lbl = L[locale === "cn" ? "cn" : "en"]
const backLabel = lbl.back.replace("← ", "")
const homeHref = locale === "cn" ? "/cn" : "/en/"
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
const homeHref = getRelativeLocaleUrl(locale, "/")
const archiveHref = getRelativeLocaleUrl(locale, "/shows")
const crumbs: { label: string; href: string }[] = [
{
label: "Runway Looks",
label: t("Runway looks"),
href: archiveHref,
},
]
@ -139,14 +111,14 @@ if (article?.brand_id) {
) : (
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
{error === "missing" ? lbl.missing : lbl.failed}
{error === "missing" ? t('No article ID specified') : t('Failed to load article. Please refresh or try later.')}
</p>
<a
href={homeHref}
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
>
← {backLabel}
← {t('Back to Index')}
</a>
</div>
)
@ -171,7 +143,7 @@ if (article?.brand_id) {
type="button"
@click="closeLb()"
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
aria-label={lbl.close}
aria-label={t('Close')}
>
✕
</button>
@ -204,7 +176,7 @@ if (article?.brand_id) {
type="button"
@click.stop="prevLb()"
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
aria-label={lbl.previous}
aria-label={t('Previous image')}
>
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M6 14l6-6 6 6" />
@ -215,7 +187,7 @@ if (article?.brand_id) {
type="button"
@click.stop="nextLb()"
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
aria-label={lbl.next}
aria-label={t('Next image')}
>
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M6 10l6 6 6-6" />
@ -226,7 +198,7 @@ if (article?.brand_id) {
type="button"
@click.stop="prevLb()"
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
aria-label={lbl.previous}
aria-label={t('Previous image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M15 6l-6 6 6 6" />
@ -237,7 +209,7 @@ if (article?.brand_id) {
type="button"
@click.stop="nextLb()"
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
aria-label={lbl.next}
aria-label={t('Next image')}
>
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
<path d="M9 6l6 6 6-6" />
@ -275,7 +247,7 @@ if (article?.brand_id) {
x-show="!lbZoomed"
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
>
{lbl.zoomTip}
{t('Click image to zoom · Scroll to view · Esc to close')}
</div>
</div>
)}
@ -459,12 +431,12 @@ if (article?.brand_id) {
})
},
// TODO 这是干啥的
initThumbLazyLoading() {
const thumbs = document.querySelectorAll<HTMLElement>(
"#lb-thumbs [data-thumb-url]"
)
console.log(thumbs)
if (!thumbs.length) return
if (!("IntersectionObserver" in window)) {

View File

@ -1,5 +1,6 @@
---
import { getReports, fmtDate } from "@/lib/data"
import { getI18n } from "@/i18n/utils"
// 文章展示创意布局页:复用你现有的 Layout 外壳。
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
@ -7,9 +8,11 @@ import { getReports, fmtDate } from "@/lib/data"
const reports = await getReports(30)
const fmt = fmtDate
const { locale, getRelativeLocaleUrl } = getI18n(Astro)
const itemBase = getRelativeLocaleUrl(locale, "/items/").replace(/\/$/, "")
---
<div class="archive">
<div class="archive" data-item-base={itemBase}>
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
<header class="archive-head">
<div class="archive-kicker">
@ -65,7 +68,7 @@ const fmt = fmtDate
style={`--ratio:${s.ratio}`}
>
<div class="card-datecol"><span class="card-date">{fmt(s.created_at)}</span></div>
<a class="card-media" href={`/article?id=${s.id}`} aria-label={s.title}>
<a class="card-media" href={`${itemBase}/${s.id}`} aria-label={s.title}>
<img src={s.cover} alt={s.title} loading="lazy" />
{s.featured && <span class="card-flag">Featured</span>}
</a>
@ -269,6 +272,7 @@ const fmt = fmtDate
const filterbar = document.getElementById("filterbar")
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
const itemBase = archive?.dataset.itemBase || "/items"
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
const toAbs = (url: string) => {
if (!url) return ""
@ -310,7 +314,7 @@ const fmt = fmtDate
<article class="card" data-brand="${it.brand}" data-featured="${it.featured ? "true" : "false"}"
data-year="${it.year}" data-archive="${archiveFlag}" style="--ratio:${ratio}">
<div class="card-datecol"><span class="card-date">${fmt(it.created_at)}</span></div>
<a class="card-media" href="/article?id=${it.id}" aria-label="${it.title}">
<a class="card-media" href="${itemBase}/${it.id}" aria-label="${it.title}">
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
</a>

View File

@ -1,37 +1,19 @@
---
import { getSsgBrands, type SsgBrand } from "@/lib/ssg"
type Brand = SsgBrand
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
// - 数据:一次 SSR 拉完全部精选集合,前端按 name 首字母分桶渲染;数据量小,无需懒加载。
// - 风格:极简编辑风(黑白 + monospace + 细线)。
// · 顶部单行 BRANDS 标题 + 总数
// · sticky A-Z 字母条(点击平滑滚动)
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
// 构建期 SSR 拉数据用 API_SSR(容器内部后端地址,如 http://backend:8090);
// 运行期浏览器走同源 /api(由 nginx 反代),PUBLIC_API_BASE 置空即可。
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
interface Brand {
id: number
name: string
show_name: string
article_count: number
}
let brands: Brand[] = []
let loadError = false
try {
const res = await fetch(`${API}/api/public/brands?size=300&only_with_articles=1&featured=1`)
if (res.ok) {
const j = await res.json()
brands = (j?.data ?? []) as Brand[]
} else {
loadError = true
}
} catch {
loadError = true
}
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
// base 地址 + token 拼接已在 @/lib/ssg 内封装。后端不可达则回落空数组(页面显示空索引)。
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
const loadError = brands.length === 0
const LETTERS = Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ")