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

This commit is contained in:
toom1996
2026-08-16 00:56:59 +08:00
parent 31cb7a125a
commit 781242ede0
26 changed files with 1814 additions and 1214 deletions

View File

@ -0,0 +1,6 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "../../components/ComingSoon.astro"
---
<ComingSoon module="About" moduleZh="关于" />

View File

@ -0,0 +1,6 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "../../components/ComingSoon.astro"
---
<ComingSoon module="Contact" moduleZh="联系" />

View File

@ -0,0 +1,6 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "../../components/ComingSoon.astro"
---
<ComingSoon module="Latest Projects" moduleZh="最新项目" />

View File

@ -0,0 +1,6 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "../../components/ComingSoon.astro"
---
<ComingSoon module="Sign In" moduleZh="登录" note="账户系统正在筹备中,暂未开放注册与登录。" />

View File

@ -0,0 +1,6 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "../../components/ComingSoon.astro"
---
<ComingSoon module="Portfolio" moduleZh="作品集" />

View File

@ -0,0 +1,6 @@
---
// 页面主体(与语言无关的内容),由根页面与 [locale] 页面共用
import ComingSoon from "../../components/ComingSoon.astro"
---
<ComingSoon module="Create Account" moduleZh="注册" note="账户系统正在筹备中,暂未开放注册与登录。" />

View File

@ -0,0 +1,460 @@
---
// 示例数据:字段严格对齐后端 BrandRunway 模型 + 图片集接口
const article = {
id: 1,
title: "Maison Lumen — Paper Light",
description:
"巴黎,2026 秋冬。Maison Lumen 以“Paper Light(纸光)”为题,将薄纱、宣纸与压褶工艺叠合成一种近乎透明的体积感。\n\n系列延续了工作室对“轻”的执念:肩线被刻意放低,廓形在行走中随风摆动,像被光穿透的纸。色彩仅保留骨白、炭灰与一抹雾蓝。\n\n这不是关于廓形的宣言,而是关于 material 的克制——用最少的手段,制造最大的呼吸感。",
created_at: 1711948800,
updated_at: 1711948800,
year: 2026,
source_url: "https://example.com/runway/maison-lumen-paper-light",
brand_id: 12,
brand_name: "Maison Lumen",
images: [
{ id: 1, image: "https://picsum.photos/seed/art1/1200/1500", name: "Look 01", sort_order: 1 },
{ id: 2, image: "https://picsum.photos/seed/art2/1200/1500", name: "Look 02", sort_order: 2 },
{ id: 3, image: "https://picsum.photos/seed/art3/1200/1500", name: "Look 03", sort_order: 3 },
{ id: 4, image: "https://picsum.photos/seed/art4/1200/1500", name: "Look 04", sort_order: 4 },
],
}
const fmt = (ts: number) => {
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}.${m}.${day}`
}
const body = article.description.split("\n\n")
---
<article class="mx-auto max-w-[1100px] px-5 md:px-10 py-12 md:py-16">
<!-- 面包屑 / 品牌 -->
<div class="flex items-center justify-between text-[11px] font-mono uppercase tracking-[0.25em] text-black border-b border-black pb-4">
<a href="/" class="hover:opacity-60 transition-opacity">← Index</a>
<span id="art-brand">Runway · {article.brand_name}</span>
</div>
<!-- 标题 -->
<h1 class="font-serif font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{article.title}
</h1>
<!-- 元信息 -->
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-[12px] font-mono uppercase tracking-widest text-[#666]">
<span id="art-date">{fmt(article.created_at)}</span>
<span id="art-year" class="text-black">{article.year}</span>
<a href={article.source_url} target="_blank" rel="noopener" class="hover:text-black underline underline-offset-4">Source ↗</a>
</div>
<!-- 正文 -->
<div class="mt-12 grid md:grid-cols-12 gap-8">
<div class="md:col-span-2 text-[11px] font-mono uppercase tracking-widest text-[#999] pt-1">
Report
</div>
<div id="art-body" class="md:col-span-10 max-w-[68ch] space-y-5 text-[16px] leading-[1.9] text-[#222]">
{body.map((p) => <p>{p}</p>)}
</div>
</div>
<!-- 图片集 -->
<section class="mt-16">
<div class="flex items-end justify-between border-b border-[#eee] pb-3 mb-6">
<div class="text-[11px] font-mono uppercase tracking-[0.25em] text-[#999]">
Gallery <span id="art-gallery-count" class="ml-2 text-black"></span>
</div>
<button type="button" id="art-viewall" class="text-[11px] font-mono uppercase tracking-widest border border-black px-3 py-1.5 hover:bg-black hover:text-white transition-colors">⤢ 全屏查看</button>
</div>
<!-- 图片网格 -->
<div id="art-gallery" class="grid grid-cols-2 md:grid-cols-3 gap-4">
{article.images.map((im) => (
<figure class="g-item bg-[#f2f2f2] cursor-zoom-in">
<img src={im.image} alt={im.name} loading="lazy" class="w-full aspect-[3/4] object-cover" />
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">{im.name}</figcaption>
</figure>
))}
</div>
</section>
<div class="mt-16 pt-6 border-t border-black">
<a href="/" class="text-[12px] font-mono uppercase tracking-widest hover:opacity-60 transition-opacity">← Back to Index</a>
</div>
</article>
<!-- 灯箱:左侧竖排缩略图条 + 中央大图(点击放大→宽度最大+纵向滚动) -->
<div id="lb" class="lb" aria-hidden="true">
<div class="lb-counter" id="lb-counter"></div>
<button class="lb-btn lb-close" id="lb-close" aria-label="关闭">✕</button>
<div class="lb-stage" id="lb-stage">
<div class="lb-thumbs" id="lb-thumbs"></div>
<button class="lb-tbtn lb-tup" id="lb-tup" type="button" aria-label="上一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 14l6-6 6 6"/></svg>
</button>
<button class="lb-tbtn lb-tdown" id="lb-tdown" type="button" aria-label="下一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 10l6 6 6-6"/></svg>
</button>
<button class="lb-btn lb-prev" id="lb-prev" type="button" aria-label="上一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<button class="lb-btn lb-next" id="lb-next" type="button" aria-label="下一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<div class="lb-main" id="lb-main">
<img id="lb-img" class="lb-img" alt="" />
</div>
</div>
<div class="lb-caption" id="lb-caption"></div>
<div class="lb-hint" id="lb-hint">点击图片放大 · 上下滚动查看 · Esc 关闭</div>
</div>
<style is:global>
@reference "tailwindcss";
/* 灯箱:左侧竖排缩略图条 + 中央大图;点击放大→宽度最大+纵向滚动 */
.lb {
position: fixed; inset: 0; z-index: 100;
@apply bg-white;
display: none; flex-direction: column;
opacity: 0; transition: opacity .2s ease;
-webkit-tap-highlight-color: transparent;
}
.lb.is-open { display: flex; opacity: 1; }
.lb-stage {
flex: 1 1 auto; min-height: 0;
display: flex; align-items: stretch; justify-content: stretch;
overflow: hidden; position: relative;
animation: lbIn .2s ease both;
}
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
.lb-thumbs {
flex: 0 0 118px; overflow-y: auto; overflow-x: hidden; position: relative; scroll-behavior: smooth;
padding: 38px 18px 38px 20px;
border-right: 1px solid rgba(0,0,0,.08);
background: linear-gradient(180deg, rgba(0,0,0,.02), rgba(0,0,0,0));
scrollbar-width: none; -ms-overflow-style: none;
}
.lb-thumbs::-webkit-scrollbar { width: 0; height: 0; }
.lb-thumb {
position: relative; display: block; width: 100%; aspect-ratio: 3 / 4;
padding: 0; margin: 0 0 8px; border: 0;
@apply bg-[#ebebeb] rounded-[2px];
overflow: hidden; cursor: pointer;
opacity: .42; transition: opacity .2s ease, transform .2s ease;
}
.lb-thumb:last-child { margin-bottom: 0; }
.lb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lb-thumb::after {
content: ""; position: absolute; inset: 0; border-radius: 2px;
box-shadow: inset 0 0 0 0 transparent; pointer-events: none;
transition: box-shadow .2s ease;
}
.lb-thumb:hover { opacity: .85; }
.lb-thumb.is-active { opacity: 1; transform: scale(1.03); }
.lb-thumb.is-active::after { box-shadow: inset 0 0 0 2px #111; }
.lb-thumb-idx {
position: absolute; left: 6px; top: 6px; z-index: 1;
font: 600 9px/1 ui-monospace, SFMono-Regular, monospace;
@apply tracking-[.12em] text-white px-[5px] py-[3px] rounded-[3px];
background: rgba(0,0,0,.55);
backdrop-filter: blur(4px); pointer-events: none;
}
.lb-thumb.is-active .lb-thumb-idx { @apply bg-[#111] text-white; }
.lb-main {
flex: 1 1 auto; min-width: 0; min-height: 0;
display: flex; align-items: center; justify-content: center;
position: relative; padding: 0 72px;
}
.lb-img {
max-width: 100%; max-height: 100%; display: block;
object-fit: contain; user-select: none; -webkit-user-drag: none;
transition: opacity .2s ease; cursor: zoom-in;
}
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放、无拖拽、无特效) */
.lb-main.is-zoomed { align-items: stretch; display: block; overflow-y: auto; overflow-x: hidden; }
.lb-main.is-zoomed .lb-img { width: 100%; max-width: 100%; height: auto; max-height: none; cursor: zoom-out; }
.lb-btn {
position: absolute; z-index: 3;
width: 42px; height: 42px;
@apply rounded-full;
border: 1px solid rgba(0,0,0,.14); background: rgba(255,255,255,.72);
@apply text-[#111];
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s, transform .15s, border-color .15s; backdrop-filter: blur(10px);
}
.lb-btn svg, .lb-tbtn svg {
display: block; fill: none; stroke: currentColor;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}
.lb-btn svg { width: 20px; height: 20px; }
.lb-tbtn svg { width: 16px; height: 16px; }
.lb-btn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.24); }
.lb-close { top: 18px; right: 18px; }
.lb-prev { left: 114px; top: 50%; transform: translateY(-50%); }
.lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
.lb-prev:hover, .lb-next:hover { transform: translateY(-50%) scale(1.08); }
/* 左栏上下翻页按钮(浮在缩略图条上、禁手动滚动时的翻页入口) */
.lb-tbtn {
position: absolute; z-index: 4;
left: 20px; width: 72px; height: 34px;
border: 1px solid rgba(0,0,0,.12); background: rgba(255,255,255,.82);
@apply rounded-[6px];
cursor: pointer;
display: flex; align-items: center; justify-content: center;
@apply text-[#111];
backdrop-filter: blur(8px);
transition: background .15s, transform .15s, border-color .15s;
}
.lb-tbtn svg { width: 16px; height: 16px; display: block; }
.lb-tbtn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.22); }
.lb-tup { top: 8px; }
.lb-tdown { bottom: 8px; }
.lb-counter {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%); z-index: 3;
font: 600 12px/1 ui-monospace, monospace;
@apply tracking-[.14em] text-[#111];
background: rgba(255,255,255,.75); border: 1px solid rgba(0,0,0,.1);
@apply px-[11px] py-[7px] rounded-[6px];
backdrop-filter: blur(6px);
}
.lb-caption {
position: absolute; left: 0; right: 0; bottom: 44px; z-index: 3;
text-align: center; color: rgba(0,0,0,.62); pointer-events: none;
font: 11px/1.4 ui-monospace, monospace;
@apply tracking-[.18em] uppercase;
padding: 0 24px; text-shadow: 0 1px 3px rgba(255,255,255,.7);
}
.lb-hint {
position: absolute; left: 0; right: 0; bottom: 16px; z-index: 3;
text-align: center; color: rgba(0,0,0,.45); pointer-events: none;
font: 11px/1 ui-monospace, monospace;
@apply tracking-[.14em];
padding: 0 24px; transition: opacity .2s ease; text-shadow: 0 1px 3px rgba(255,255,255,.7);
}
.lb-hint.is-hidden { opacity: 0; }
@media (max-width: 640px) {
.lb-thumbs { flex: 0 0 76px; padding: 32px 8px 32px 12px; }
.lb-thumb { margin-bottom: 8px; }
.lb-thumb-idx { font-size: 8px; left: 4px; top: 4px; padding: 2px 4px; }
.lb-main { padding: 0 18px; }
.lb-btn { width: 38px; height: 38px; }
.lb-prev { left: 82px; } .lb-next { right: 8px; }
.lb-tbtn { left: 12px; width: 52px; height: 30px; }
.lb-caption { bottom: 40px; }
}
</style>
<script>
// 真实数据接入:按 ?id= 拉取后端接口,失败时保留上方示例布局
const API = import.meta.env.PUBLIC_API_BASE || ""
const toAbs = (url: string) =>
!url ? "" : /^https?:\/\//i.test(url) ? url : API + url
const id = new URLSearchParams(location.search).get("id")
if (id) {
;(async () => {
try {
const res = await fetch(`${API}/api/public/articles/${id}`)
if (!res.ok) return
const d = (await res.json()).data
if (!d) return
const titleEl = document.querySelector("h1")
if (titleEl && d.title) titleEl.textContent = d.title
const bodyEl = document.getElementById("art-body")
if (bodyEl && d.description) {
bodyEl.innerHTML = d.description
.split("\n\n")
.map((p: string) => `<p>${p}</p>`)
.join("")
}
const brandEl = document.getElementById("art-brand")
if (brandEl && d.brand_name) brandEl.textContent = `Runway · ${d.brand_name}`
const yearEl = document.getElementById("art-year")
if (yearEl && d.year) yearEl.textContent = String(d.year)
const dateEl = document.getElementById("art-date")
if (dateEl && d.published_at) {
const dt = new Date(Number(d.published_at) * 1000)
dateEl.textContent = `${dt.getFullYear()}.${String(dt.getMonth() + 1).padStart(2, "0")}.${String(dt.getDate()).padStart(2, "0")}`
}
const imgs = d.images
if (imgs && Array.isArray(imgs)) {
const gal = document.getElementById("art-gallery")
if (gal) {
gal.innerHTML = imgs
.map(
(im: any) =>
`<figure class="bg-[#f2f2f2]"><img src="${toAbs(im.image)}" alt="${im.name || ""}" loading="lazy" class="w-full aspect-[3/4] object-cover" /><figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">${im.name || ""}</figcaption></figure>`
)
.join("")
}
}
} catch {
/* 后端不可达时,使用示例数据 */
}
})()
}
</script>
<script>
(function () {
const gallery = document.getElementById("art-gallery");
const lb = document.getElementById("lb");
const lbStage = document.getElementById("lb-stage");
const lbMain = document.getElementById("lb-main");
const lbImg = document.getElementById("lb-img");
const lbCounter = document.getElementById("lb-counter");
const lbCaption = document.getElementById("lb-caption");
const lbThumbs = document.getElementById("lb-thumbs");
const lbHint = document.getElementById("lb-hint");
const btnClose = document.getElementById("lb-close");
const btnPrev = document.getElementById("lb-prev");
const btnNext = document.getElementById("lb-next");
const btnTup = document.getElementById("lb-tup");
const btnTdown = document.getElementById("lb-tdown");
const viewall = document.getElementById("art-viewall");
let items = [];
let index = 0;
let zoomed = false;
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放 / 无拖拽 / 无特效) */
function zoomIn() {
zoomed = true;
if (lbMain) { lbMain.classList.add("is-zoomed"); lbMain.scrollTop = 0; }
if (lbHint) lbHint.classList.add("is-hidden");
}
function resetZoom() {
zoomed = false;
if (lbMain) { lbMain.classList.remove("is-zoomed"); lbMain.scrollTop = 0; }
if (lbHint) lbHint.classList.remove("is-hidden");
}
function toggleZoom() { zoomed ? resetZoom() : zoomIn(); }
/* 收集画廊图片 */
function collect() {
const list = [];
if (gallery) gallery.querySelectorAll("img").forEach((img) => {
const fig = img.closest("figure");
const cap = fig && fig.querySelector("figcaption");
list.push({ src: img.currentSrc || img.src, name: (cap && cap.textContent || "").trim() });
});
return list;
}
/* 渲染左侧竖排缩略图条 */
function renderStrip() {
if (!lbThumbs) return;
lbThumbs.innerHTML = items
.map((it, i) => {
const n = String(i + 1).padStart(2, "0");
return '<button type="button" class="lb-thumb' + (i === index ? ' is-active' : '') + '" data-i="' + i + '">' +
'<img src="' + it.src + '" alt="" />' +
'<span class="lb-thumb-idx">' + n + '</span>' +
'</button>';
})
.join("");
}
function show(i) {
if (!items.length) return;
index = (i + items.length) % items.length;
const it = items[index];
resetZoom();
lbImg.style.opacity = "0";
const loader = new Image();
loader.onload = function () { lbImg.src = it.src; lbImg.style.opacity = "1"; };
loader.src = it.src;
if (lbCounter) lbCounter.textContent = (index + 1) + " / " + items.length;
if (lbCaption) lbCaption.textContent = it.name || "";
if (lbThumbs) lbThumbs.querySelectorAll(".lb-thumb").forEach((t, k) => {
t.classList.toggle("is-active", k === index);
if (k === index) {
// 自动居中当前张(左栏禁用手动滚动,改用程序化 scrollTo)
const target = t.offsetTop - (lbThumbs.clientHeight - t.clientHeight) / 2;
lbThumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}
});
}
function open(i) {
items = collect();
if (!items.length) return;
if (lb) { lb.classList.add("is-open"); lb.setAttribute("aria-hidden", "false"); }
document.body.style.overflow = "hidden";
renderStrip();
show(i || 0);
}
function close() {
if (lb) { lb.classList.remove("is-open"); lb.setAttribute("aria-hidden", "true"); }
document.body.style.overflow = "";
}
function next() { show(index + 1); }
function prev() { show(index - 1); }
/* 事件绑定 */
if (gallery) gallery.addEventListener("click", (e) => {
const fig = e.target.closest("figure");
if (!fig) return;
const imgs = Array.prototype.slice.call(gallery.querySelectorAll("img"));
open(Math.max(0, imgs.indexOf(fig.querySelector("img"))));
});
if (btnClose) btnClose.addEventListener("click", close);
if (btnNext) btnNext.addEventListener("click", (e) => { e.stopPropagation(); next(); });
if (btnPrev) btnPrev.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
if (btnTup) btnTup.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
if (btnTdown) btnTdown.addEventListener("click", (e) => { e.stopPropagation(); next(); });
if (lb) lb.addEventListener("click", (e) => {
const t = e.target;
if (t === lb || (t.classList && t.classList.contains("lb-stage"))) close();
});
if (lbThumbs) lbThumbs.addEventListener("click", (e) => {
const t = e.target.closest(".lb-thumb");
if (t) show(Number(t.dataset.i));
});
if (lbImg) lbImg.addEventListener("click", toggleZoom);
document.addEventListener("keydown", (e) => {
if (!lb || !lb.classList.contains("is-open")) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
});
if (viewall) viewall.addEventListener("click", () => open(0));
/* 移动端单指滑动切换(仅在未放大时,放大态交给纵向滚动) */
let tx0 = 0, ty0 = 0, two = false;
if (lbStage) {
lbStage.addEventListener("touchstart", (e) => {
if (e.touches.length === 1) { tx0 = e.touches[0].clientX; ty0 = e.touches[0].clientY; }
else if (e.touches.length === 2) two = true;
}, { passive: true });
lbStage.addEventListener("touchend", (e) => {
if (two) { two = false; return; }
if (zoomed) return;
const t = e.changedTouches[0];
const dx = t.clientX - tx0, dy = t.clientY - ty0;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) (dx < 0 ? next() : prev());
}, { passive: true });
}
/* 计数 + 画廊重渲染后同步 */
function syncCount() {
const c = document.getElementById("art-gallery-count");
const n = gallery ? gallery.querySelectorAll("img").length : 0;
if (c) c.textContent = n ? "· 共 " + n + " 张" : "";
}
syncCount();
if (gallery && "MutationObserver" in window) {
new MutationObserver(syncCount).observe(gallery, { childList: true });
}
})();
</script>

View File

@ -0,0 +1,471 @@
---
import { getReports, fmtDate } from "../../lib/data"
// 文章展示创意布局页:复用你现有的 Layout 外壳。
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。
const reports = await getReports(30)
const fmt = fmtDate
---
<div class="archive">
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
<header class="archive-head">
<div class="archive-kicker">
<span>Runway Archive</span>
<span>SS23 — AW26</span>
</div>
<h1 class="archive-title">The Reports</h1>
<div class="archive-sub">
<p>
走秀趋势报告档案库。支持网格 / 杂志 / 时间轴三种创意视图自由切换,彩色配图由后端接口动态渲染。
</p>
<a href="/" class="archive-back">← Index</a>
</div>
</header>
<!-- 工具栏:布局切换 + 结果计数 -->
<div class="archive-toolbar">
<div class="viewswitch" role="tablist" aria-label="Layout mode">
<button data-layout="grid" class="on" type="button">Grid</button>
<button data-layout="magazine" type="button">Magazine</button>
<button data-layout="timeline" type="button">Timeline</button>
</div>
<span class="archive-count" id="count">{reports.length} Reports</span>
</div>
<!-- 分类筛选(品牌),由脚本按数据动态生成 -->
<div class="filterbar" id="filterbar"></div>
<!-- 系列类型 / 季节筛选(后端分类字段:collection_type / season) -->
<div class="filterbar" id="typefilter">
<button class="chip on" data-type="">全部系列</button>
<button class="chip" data-type="rtw">成衣 RTW</button>
<button class="chip" data-type="menswear">男装</button>
<button class="chip" data-type="couture">高定</button>
<button class="chip" data-type="resort">度假 / 早春</button>
<button class="chip" data-type="pre_fall">早秋</button>
</div>
<div class="filterbar" id="seasonfilter">
<button class="chip on" data-season="">全部季节</button>
<button class="chip" data-season="spring">Spring 春夏</button>
<button class="chip" data-season="fall">Fall 秋冬</button>
</div>
<!-- 画廊容器:data-layout 决定当前视图;局部切换不产生整页刷新 -->
<section class="gallery" id="gallery" data-layout="grid">
{reports.map((s) => (
<article
class="card"
data-brand={s.brand}
data-featured={s.featured ? "true" : "false"}
data-year={s.year}
data-archive={s.year < 2025 ? "true" : "false"}
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}>
<img src={s.cover} alt={s.title} loading="lazy" />
{s.featured && <span class="card-flag">Featured</span>}
</a>
<div class="card-body">
<span class="card-brand">{s.brand}</span>
<h3 class="card-title">{s.title}</h3>
<p class="card-excerpt">{s.excerpt}</p>
<div class="card-foot">
<span class="card-date2">{fmt(s.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>
))}
</section>
</div>
<style is:global>
@reference "tailwindcss";
/* 仅作用于本页(.archive 前缀),不影响其它页面与你的原有样式 */
.archive { max-width: 1440px; margin: 0 auto; }
/* ---------- 页头 ---------- */
.archive-head { margin-top: clamp(48px, 8vw, 110px); }
.archive-kicker {
@apply font-mono uppercase tracking-[0.25em] text-[11px] text-black;
display: flex; justify-content: space-between;
border-top: 1px solid #000; padding-top: 18px;
}
.archive-title {
@apply font-serif font-black tracking-[-0.02em];
line-height: 0.85;
font-size: clamp(48px, 11vw, 150px); margin: 0.28em 0 0;
}
.archive-sub {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 24px; flex-wrap: wrap; border-bottom: 1px solid #000;
padding-bottom: 22px; margin-top: 22px;
}
.archive-sub p { max-width: 580px; @apply text-[#444] text-[15px]; line-height: 1.6; margin: 0; }
.archive-back {
@apply font-mono uppercase tracking-[0.15em] text-[11px] border border-black px-[16px] py-[10px] hover:bg-black hover:text-white;
white-space: nowrap;
transition: background 0.25s, color 0.25s;
}
/* ---------- 工具栏 ---------- */
.archive-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; flex-wrap: wrap; margin: 28px 0 22px;
}
.viewswitch { display: inline-flex; @apply border border-black; }
.viewswitch button {
@apply font-mono uppercase tracking-[0.15em] text-[11px] px-[18px] py-[10px] bg-white text-black;
border: none; border-right: 1px solid #000;
cursor: pointer; transition: background 0.25s, color 0.25s;
}
.viewswitch button:last-child { border-right: none; }
.viewswitch button.on { @apply bg-black text-white; }
.archive-count {
@apply font-mono text-[11px] tracking-[0.15em] uppercase text-[#666];
}
/* ---------- 筛选 chips ---------- */
.filterbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 34px; }
.chip {
@apply font-mono uppercase tracking-[0.12em] text-[11px] px-[14px] py-[7px] border border-[#ccc] bg-white text-[#333] rounded-full;
cursor: pointer; transition: all 0.2s;
}
.chip:hover { border-color: #000; }
.chip.on { @apply bg-black text-white border-black; }
/* ---------- 画廊基础 + 入场/切换过渡 ---------- */
.gallery { transition: opacity 0.24s ease, transform 0.24s ease; }
.gallery.switching { opacity: 0; transform: translateY(10px); }
.gallery .card { position: relative; @apply bg-white; }
/* 无 JS 时直接可见;有 JS 时做交错淡入 */
.archive.js .gallery .card {
opacity: 0; transform: translateY(16px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.archive.js .gallery .card.in { opacity: 1; transform: none; }
.card-media {
position: relative; display: block; overflow: hidden;
aspect-ratio: var(--ratio, 3 / 4); background: #f2f2f2;
}
.card-media img {
width: 100%; height: 100%; object-fit: cover;
opacity: 0; transform: scale(1.03);
transition: opacity 0.7s ease, transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card-media img.loaded { opacity: 1; transform: none; }
.card-media::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0) 42%, rgba(0,0,0,0.30));
opacity: 0; transition: opacity 0.5s;
}
.card:hover .card-media img { transform: scale(1.06); }
.card:hover .card-media::after { opacity: 1; }
.card-flag {
position: absolute; top: 12px; left: 12px; z-index: 2;
@apply font-mono uppercase tracking-[0.15em] text-[10px] font-bold bg-black text-white px-[9px] py-[4px];
}
.card-body { padding: 14px 2px 0; }
.card-brand {
display: block;
@apply font-mono uppercase tracking-[0.14em] text-[10.5px] text-black font-bold;
}
.card-title {
@apply font-serif font-extrabold;
font-size: clamp(18px, 2vw, 26px); line-height: 1.05; margin: 8px 0 0;
transition: color 0.3s;
}
.card:hover .card-title { color: #cf5a44; }
.card-excerpt { @apply text-[#555] text-[13.5px]; line-height: 1.55; margin: 8px 0 0; max-width: 48ch; }
.card-foot {
display: flex; justify-content: space-between; align-items: center;
margin-top: 12px;
@apply font-mono uppercase tracking-[0.1em] text-[10.5px] text-[#999];
}
.card-read {
@apply text-black;
opacity: 0; transform: translateX(-6px);
transition: opacity 0.35s, transform 0.35s;
}
.card:hover .card-read { opacity: 1; transform: none; }
/* 视觉层次:Featured = 黑色粗边框 + 大标题;Archive(年份<2025) = 虚线灰边框 */
.gallery .card[data-featured="true"] .card-media { border: 2px solid #000; }
.gallery .card[data-featured="true"] .card-title { font-size: clamp(22px, 2.6vw, 34px); }
.gallery .card[data-archive="true"] .card-media { border: 1px dashed #bbb; }
/* ---------- 视图 1:Grid(瀑布流,非对称高低错落) ---------- */
.gallery[data-layout="grid"] { column-count: 3; column-gap: 26px; }
.gallery[data-layout="grid"] .card { break-inside: avoid; display: block; margin: 0 0 30px; }
.gallery[data-layout="grid"] .card-datecol { display: none; }
@media (max-width: 1024px) { .gallery[data-layout="grid"] { column-count: 2; } }
@media (max-width: 640px) { .gallery[data-layout="grid"] { column-count: 1; } }
/* ---------- 视图 2:Magazine(非对称网格,Featured 跨栏放大) ---------- */
.gallery[data-layout="magazine"] {
display: grid; grid-template-columns: repeat(6, 1fr);
gap: 26px; grid-auto-flow: dense;
}
.gallery[data-layout="magazine"] .card { grid-column: span 2; display: block; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; grid-row: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] .card-media { aspect-ratio: 16 / 10; }
.gallery[data-layout="magazine"] .card-datecol { display: none; }
@media (max-width: 1024px) {
.gallery[data-layout="magazine"] { grid-template-columns: repeat(4, 1fr); }
.gallery[data-layout="magazine"] .card { grid-column: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; }
}
@media (max-width: 640px) {
.gallery[data-layout="magazine"] { grid-template-columns: 1fr; }
.gallery[data-layout="magazine"] .card,
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 1; grid-row: auto; }
}
/* ---------- 视图 3:Timeline(时间轴,左侧日期 + 缩略图 + 正文) ---------- */
.gallery[data-layout="timeline"] { display: block; position: relative; }
.gallery[data-layout="timeline"]::before {
content: ""; position: absolute; left: 188px; top: 6px; bottom: 6px; width: 2px; background: #000;
}
.gallery[data-layout="timeline"] .card {
display: grid; grid-template-columns: 170px 220px 1fr; gap: 26px;
align-items: start; margin: 0 0 30px; position: relative;
}
.gallery[data-layout="timeline"] .card::before {
content: ""; position: absolute; left: 182px; top: 8px;
width: 14px; height: 14px; border-radius: 50%;
background: #fff; border: 2px solid #000; z-index: 2;
}
.gallery[data-layout="timeline"] .card-datecol { display: block; }
.gallery[data-layout="timeline"] .card-date {
@apply font-mono text-[13px] tracking-[0.08em] text-black;
}
.gallery[data-layout="timeline"] .card-media { aspect-ratio: auto; height: 150px; }
.gallery[data-layout="timeline"] .card-excerpt { display: none; }
.gallery[data-layout="timeline"] .card-foot { display: none; }
@media (max-width: 768px) {
.gallery[data-layout="timeline"]::before { left: 8px; }
.gallery[data-layout="timeline"] .card { grid-template-columns: 1fr; gap: 12px; padding-left: 34px; }
.gallery[data-layout="timeline"] .card::before { left: 2px; }
.gallery[data-layout="timeline"] .card-media { height: 200px; width: 100%; }
}
/* 筛选隐藏 */
.archive .card[data-hidden="true"] { display: none !important; }
</style>
<script>
const archive = document.querySelector<HTMLElement>(".archive")
const gallery = document.getElementById("gallery") as HTMLElement | null
const countEl = document.getElementById("count")
const filterbar = document.getElementById("filterbar")
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
const toAbs = (url: string) => {
if (!url) return ""
return /^https?:\/\//i.test(url) ? url : API_BASE + url
}
// 来自品牌索引的聚焦筛选(/brands 的品牌链接:?brand_id= 或 ?brand=)
const _urlParams = new URLSearchParams(location.search)
const _brandId = _urlParams.get("brand_id")
const _brandName = _urlParams.get("brand")
if (archive && gallery) archive.classList.add("js")
const fmt = (t: number | string) => {
const n = Number(t)
const d = new Date(n < 1e12 ? n * 1000 : n)
const p = (x: number) => String(x).padStart(2, "0")
return `${d.getUTCFullYear()}.${p(d.getUTCMonth() + 1)}.${p(d.getUTCDate())}`
}
type Item = {
id: number | string
title: string
brand: string
year: number | string
created_at: number | string
cover: string
excerpt: string
image_count: number
featured: boolean
ratio?: string
}
// 卡片模板:SSR 示例与后端渲染共用同一套结构,保证三种视图样式一致
const cardTemplate = (it: Item) => {
const archiveFlag = Number(it.year) < 2025 ? "true" : "false"
const ratio = it.ratio ?? "3 / 4"
return `
<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}">
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
</a>
<div class="card-body">
<span class="card-brand">${it.brand}</span>
<h3 class="card-title">${it.title}</h3>
<p class="card-excerpt">${it.excerpt}</p>
<div class="card-foot">
<span class="card-date2">${fmt(it.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>`
}
const ratios = ["3 / 4", "4 / 5", "1 / 1", "5 / 4", "3 / 4", "4 / 3"]
const setupCards = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
// 配图渐显
cards.forEach((c) => {
c.querySelectorAll("img").forEach((img) => {
if (img.complete) img.classList.add("loaded")
else img.addEventListener("load", () => img.classList.add("loaded"), { once: true })
})
})
// 交错入场
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("in")
io.unobserve(e.target)
}
})
},
{ threshold: 0.08 }
)
cards.forEach((c, i) => {
c.style.transitionDelay = (i % 8) * 0.04 + "s"
io.observe(c)
})
buildChips(cards)
}
// 品牌筛选 chips(按当前数据动态生成)
let currentFilter: string | null = null
const buildChips = (cards: HTMLElement[]) => {
if (!filterbar) return
const brands = Array.from(new Set(cards.map((c) => c.dataset.brand).filter(Boolean))) as string[]
filterbar.innerHTML = ""
const mk = (label: string, brand: string | null) => {
const b = document.createElement("button")
b.className = "chip" + (brand === currentFilter ? " on" : "")
b.textContent = label
b.dataset.brand = brand ?? ""
b.addEventListener("click", () => {
currentFilter = currentFilter === brand ? null : brand
filterbar!.querySelectorAll(".chip").forEach((x) => x.classList.toggle("on", (x as HTMLElement).dataset.brand === (currentFilter ?? "")))
applyFilter()
})
return b
}
filterbar.appendChild(mk("All", null))
brands.forEach((br) => filterbar.appendChild(mk(br, br)))
}
const applyFilter = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
let visible = 0
cards.forEach((c) => {
const show = !currentFilter || c.dataset.brand === currentFilter
c.toggleAttribute("data-hidden", !show)
if (show) visible++
})
if (countEl) countEl.textContent = `${visible} Reports`
}
// 布局切换:局部更新,带淡入淡出过渡,不刷新整页
viewBtns.forEach((btn) => {
btn.addEventListener("click", () => {
if (!gallery) return
const mode = btn.dataset.layout as string
if (gallery.dataset.layout === mode) return
viewBtns.forEach((x) => x.classList.toggle("on", x === btn))
gallery.classList.add("switching")
window.setTimeout(() => {
gallery!.dataset.layout = mode
gallery!.classList.remove("switching")
}, 240)
})
})
// 后端数据接入:拉取真实列表,失败则保留示例(布局不变)
const render = (items: Item[]) => {
if (!gallery) return
gallery.dataset.layout = gallery.dataset.layout || "grid"
gallery.innerHTML = items.map(cardTemplate).join("")
if (countEl) countEl.textContent = `${items.length} Reports`
setupCards()
}
const loadBackend = async () => {
if (!API_BASE) return
try {
const size = _brandId ? 100 : 30
const p = new URLSearchParams({ page: "1", size: String(size) })
if (fType) p.set("collection_type", fType)
if (fSeason) p.set("season", fSeason)
if (_brandId) p.set("brand_id", _brandId)
const url = `${API_BASE}/api/public/articles?${p.toString()}`
const res = await fetch(url)
if (!res.ok) throw new Error("bad status")
const json = await res.json()
const rows = json?.data ?? []
if (!Array.isArray(rows) || rows.length === 0) return
let items: Item[] = rows.map((r: any, i: number) => ({
id: r.id,
title: r.title || "Untitled",
brand: r.brand_name || "Unknown",
year: r.year || "",
created_at: r.published_at,
cover: toAbs(r.cover || ""),
excerpt: r.summary || "",
image_count: Number(r.image_count || 0),
featured: false,
ratio: ratios[i % ratios.length],
}))
// 以图片数量最高的前 3 篇标记为 Featured(视觉层次)
const top = [...items].sort((a, b) => b.image_count - a.image_count).slice(0, 3).map((x) => x.id)
items.forEach((it) => (it.featured = top.includes(it.id as any)))
// 来自品牌索引的聚焦:高亮该品牌、改写页头标题
const focus = _brandName || (_brandId && items.length ? items[0].brand : null)
if (focus) {
currentFilter = focus
render(items)
filterbar?.querySelectorAll(".chip").forEach((x) =>
x.classList.toggle("on", (x as HTMLElement).dataset.brand === focus)
)
const t = document.querySelector(".archive-title")
if (t) t.textContent = focus
const k = document.querySelector(".archive-kicker span")
if (k) k.textContent = "Brand Archive"
if (countEl) countEl.textContent = `${items.length} Reports`
} else {
render(items)
}
} catch {
/* 保持示例数据 */
}
}
setupCards()
loadBackend()
</script>

View File

@ -0,0 +1,255 @@
---
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
// - 风格:极简编辑风(黑白 + 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
}
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={`/articles?brand_id=${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>

36
src/i18n/config.ts Normal file
View File

@ -0,0 +1,36 @@
// i18n 核心配置:语言列表、默认语言、URL 前缀规则
// 改这里即可增减语言;新增语言记得同步 src/i18n/dictionary.ts 里的每个 key(单文件聚合多语言)。
/** 默认语言(无前缀时使用的语言,如 /shows)。 */
export const DEFAULT_LOCALE = 'en' as const;
/** 支持的语言列表。URL 前缀用的就是这些值,例如 /cn/shows 的 `cn`。 */
export const LOCALES = ['en', 'cn'] as const;
export type Locale = (typeof LOCALES)[number];
/**
* 默认语言是否带 URL 前缀:
* - false → 英文在根路径(/shows),中文在 /cn/shows(本项目采用)
* - true → 所有语言都带前缀(/en/shows、/cn/shows)
*/
export const PREFIX_DEFAULT_LOCALE = false;
/** 语言显示名(用于语言切换器等 UI)。 */
export const LOCALE_NAMES: Record<Locale, string> = {
en: 'English',
cn: '中文',
};
/** 浏览器 Accept-Language / navigator.language → 本项目支持的语言。 */
export const BROWSER_LOCALE_MAP: Record<string, Locale> = {
'zh': 'cn',
'zh-cn': 'cn',
'zh-tw': 'cn',
'zh-hk': 'cn',
'en': 'en',
};
/** 判断一个路径段是不是已支持的语言前缀。 */
export function isLocaleSegment(seg: string | undefined): seg is Locale {
return !!seg && (LOCALES as readonly string[]).includes(seg);
}

56
src/i18n/dictionary.ts Normal file
View File

@ -0,0 +1,56 @@
import { DEFAULT_LOCALE, type Locale } from './config';
/**
* 单文件翻译字典。
*
* 设计(按你的要求):
* - key 就是「英文原文」,直接当文案用,无需额外维护英文副本;
* - value 只存「非默认语言」的翻译(本项目默认语言是 en,所以只存 cn 等);
* - 英文环境调用 t(key) 直接返回 key 本身;
* - 其它语言从 value 里取对应翻译,取不到就回退英文(开发环境会告警,方便发现漏翻)。
*
* 新增一条:直接加一行 `'英文原文': { cn: '中文' }` 即可;
* 当前是英文时根本不需要在字典里登记,写了 t('Nice day!') 就能渲染 "Nice day!"。
*/
// 除默认语言外的所有语言(如 'cn')。加新语言(ja 等)时这里会自动包含。
type OtherLocale = Exclude<Locale, typeof DEFAULT_LOCALE>;
type TranslationEntry = Partial<Record<OtherLocale, string>>;
export const dict: Record<string, TranslationEntry> = {
Portfolio: { cn: '作品集' },
'All Shows': { cn: '全部走秀' },
'Latest Projects': { cn: '最新项目' },
About: { cn: '关于' },
'Contact Us': { cn: '联系我们' },
Login: { cn: '登录' },
Signup: { cn: '注册' },
Contact: { cn: '联系' },
'Back to top': { cn: '回到顶部' },
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
Previous: { cn: '上一组' },
Next: { cn: '下一组' },
};
/** 把 {name} 形式的占位符替换成参数值。 */
function interpolate(text: string, params?: Record<string, string | number>): string {
if (!params) return text;
return text.replace(/\{(\w+)\}/g, (_, k) => (k in params ? String(params[k]) : `{${k}}`));
}
/**
* 取翻译:
* - 当前是默认语言(en)→ 直接返回 key(英文原文);
* - 其它语言 → 优先取字典里的翻译,缺失则回退英文并返回 key(不会白屏)。
*/
export function translate(key: string, locale: Locale, params?: Record<string, string | number>): string {
if (locale === DEFAULT_LOCALE) return interpolate(key, params);
const localized = dict[key]?.[locale as OtherLocale];
if (localized) return interpolate(localized, params);
if (import.meta.env.DEV) {
console.warn(`[i18n] 缺少 "${locale}" 翻译:"${key}",已回退英文`);
}
return interpolate(key, params);
}

49
src/i18n/utils.ts Normal file
View File

@ -0,0 +1,49 @@
import {
DEFAULT_LOCALE,
LOCALES,
PREFIX_DEFAULT_LOCALE,
isLocaleSegment,
type Locale,
} from './config';
import { translate } from './dictionary';
/**
* 从当前 URL 解析语言:
* - 路径首段若是支持的语言前缀(如 /cn/shows → cn)则用之;
* - 否则回退到默认语言(en)。
*/
export function getLocaleFromUrl(url: URL | string): Locale {
const pathname = typeof url === 'string' ? url : url.pathname;
const seg = pathname.split('/').filter(Boolean)[0];
return isLocaleSegment(seg) ? seg : DEFAULT_LOCALE;
}
/**
* 生成带语言前缀的链接。
* - en(默认且 PREFIX_DEFAULT_LOCALE=false)→ /shows
* - cn → /cn/shows
* 第二个参数可指定目标语言,方便做语言切换器。
*/
export function useTranslatedPath(locale: Locale) {
return (path: string, targetLocale: Locale = locale): string => {
const clean = path.startsWith('/') ? path : `/${path}`;
if (targetLocale === DEFAULT_LOCALE && !PREFIX_DEFAULT_LOCALE) return clean;
return `/${targetLocale}${clean}`;
};
}
/**
* 取翻译函数 t():key 即英文原文,按当前语言自动匹配翻译。
* - 英文环境:t('All Shows') 直接返回 'All Shows'(key 本身)
* - 中文环境:t('All Shows') 返回字典里的 '全部走秀'(取不到则回退英文)
* - 支持 {name} 形式的插值参数。
*/
export function useTranslations(locale: Locale) {
function t(key: string, params?: Record<string, string | number>): string {
return translate(key, locale, params);
}
return { t, locale };
}
export { LOCALES, DEFAULT_LOCALE };
export type { Locale };

View File

@ -1,5 +1,7 @@
---
import '../styles/global.css'
import { getLocaleFromUrl, useTranslations, useTranslatedPath } from '../i18n/utils'
import { LOCALES, LOCALE_NAMES, isLocaleSegment } from '../i18n/config'
const {
title = 'Portfolio Studio',
@ -8,6 +10,17 @@ const {
activeThemePalette = null,
} = Astro.props;
const locale = getLocaleFromUrl(Astro.url)
const { t } = useTranslations(locale)
const translatePath = useTranslatedPath(locale)
// 当前路径去掉语言前缀,用于语言切换器拼出「同页面、另一语言」的链接
const _segs = Astro.url.pathname.split('/').filter(Boolean);
const _stripped = _segs.length > 0 && isLocaleSegment(_segs[0])
? '/' + _segs.slice(1).join('/')
: Astro.url.pathname;
const currentPath = _stripped === '' ? '/' : _stripped;
const categories: { label: string; value: PortfolioCategory | 'Trending Colors' }[] = [
{ label: 'x', value: 'x' },
{ label: 'Backstage', value: 'Backstage' },
@ -17,7 +30,7 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
];
---
<!doctype html>
<html lang="en">
<html lang={locale === 'cn' ? 'zh-CN' : 'en'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
@ -25,25 +38,55 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<!-- 语言自动检测:无前缀且浏览器为中文环境时,跳转到 /cn 前缀 -->
<script is:inline>
(function () {
try {
var SUPPORTED = ['en', 'cn'];
var path = location.pathname;
var seg = path.split('/').filter(Boolean)[0];
if (SUPPORTED.indexOf(seg) !== -1) return; // 已有语言前缀
var lang = (navigator.language || 'en').toLowerCase();
var target = lang.indexOf('zh') === 0 ? 'cn' : 'en';
if (target === 'en') return; // 默认英文无需跳转
if (sessionStorage.getItem('lang-redirect')) return;
sessionStorage.setItem('lang-redirect', '1');
location.pathname = '/' + target + path;
} catch (e) {}
})();
</script>
</head>
<body class="bg-white text-black min-h-screen flex flex-col p-[30px_50px] font-sans">
<!-- 顶部主导航栏 -->
<nav class="flex justify-between items-center text-[13px] font-bold tracking-[0.5px] uppercase">
<div class="flex flex-wrap items-center gap-6 md:gap-8">
<a href="/portfolio" class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">Portfolio<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
<a href={translatePath('/portfolio')} class="relative pb-[2px] cursor-pointer transition-opacity duration-200 flex items-center gap-1.5 hover:opacity-60 opacity-80 border-b-2 border-transparent">{t('Portfolio')}<span class="text-[10px] bg-black text-white px-1.5 py-0.5 rounded-full font-mono tracking-tight leading-none">
2026
</span></a>
<a href="/shows" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">All Shows</a>
<a href="/latest-projects" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">Latest Projects</a>
<a href="/about" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">About</a>
<a href="/contact" class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">Contact Us</a>
<a href={translatePath('/shows')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('All Shows')}</a>
<a href={translatePath('/latest-projects')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
<a href={translatePath('/about')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
<a href={translatePath('/contact')} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
</div>
<div class="flex items-center gap-3" id="auth-slot">
<a href="/login" class="hover:opacity-60 transition-opacity duration-200">Login</a>
<a href="/register" class="hover:opacity-60 transition-opacity duration-200">Signup</a>
<div class="flex items-center gap-3" id="auth-slot">
<div class="flex items-center gap-2 normal-case" aria-label="Language">
{LOCALES.map((l) => (
<a
href={translatePath(currentPath, l)}
aria-current={l === locale ? 'true' : undefined}
class:list={[
'cursor-pointer hover:opacity-60 transition-opacity duration-200',
l === locale ? 'opacity-100 underline underline-offset-4' : 'opacity-40',
]}
>{LOCALE_NAMES[l]}</a>
))}
</div>
<span class="opacity-25">/</span>
<a href={translatePath('/login')} class="hover:opacity-60 transition-opacity duration-200">{t('Login')}</a>
<a href={translatePath('/register')} class="hover:opacity-60 transition-opacity duration-200">{t('Signup')}</a>
</div>
</nav>
<div class="flex-1 flex flex-col">
@ -196,26 +239,30 @@ const categories: { label: string; value: PortfolioCategory | 'Trending Colors'
<div class="flex items-center gap-6">
<a
href="/about"
href={translatePath('/about')}
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
>
About
{t('About')}
</a>
<a
href="/contact"
href={translatePath('/contact')}
class="nav-btn hover:text-black cursor-pointer uppercase font-bold"
>
Contact
{t('Contact')}
</a>
<button
id="back-to-top"
class="flex items-center gap-1 font-bold text-black hover:opacity-60 transition-opacity cursor-pointer uppercase"
>
<span>Back to top</span>
<span>{t('Back to top')}</span>
<!-- <ArrowUp class="w-3.5 h-3.5" /> -->
</button>
</div>
</div>
<div class="mt-6 text-[11px] font-mono text-zinc-400 uppercase tracking-widest">
{t('© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.')}
</div>
</footer>
</body>
</html>

View File

@ -1,8 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import About from "../components/pages/About.astro"
---
<Layout title="About — 暂未上线">
<ComingSoon module="About" moduleZh="关于" />
<About />
</Layout>

View File

@ -1,463 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
// 示例数据:字段严格对齐后端 BrandRunway 模型 + 图片集接口
const article = {
id: 1,
title: "Maison Lumen — Paper Light",
description:
"巴黎,2026 秋冬。Maison Lumen 以“Paper Light(纸光)”为题,将薄纱、宣纸与压褶工艺叠合成一种近乎透明的体积感。\n\n系列延续了工作室对“轻”的执念:肩线被刻意放低,廓形在行走中随风摆动,像被光穿透的纸。色彩仅保留骨白、炭灰与一抹雾蓝。\n\n这不是关于廓形的宣言,而是关于 material 的克制——用最少的手段,制造最大的呼吸感。",
created_at: 1711948800,
updated_at: 1711948800,
year: 2026,
source_url: "https://example.com/runway/maison-lumen-paper-light",
brand_id: 12,
brand_name: "Maison Lumen",
images: [
{ id: 1, image: "https://picsum.photos/seed/art1/1200/1500", name: "Look 01", sort_order: 1 },
{ id: 2, image: "https://picsum.photos/seed/art2/1200/1500", name: "Look 02", sort_order: 2 },
{ id: 3, image: "https://picsum.photos/seed/art3/1200/1500", name: "Look 03", sort_order: 3 },
{ id: 4, image: "https://picsum.photos/seed/art4/1200/1500", name: "Look 04", sort_order: 4 },
],
}
const fmt = (ts: number) => {
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}.${m}.${day}`
}
const body = article.description.split("\n\n")
import Article from "../components/pages/article.astro"
---
<Layout title={`${article.title} — Runway Report`}>
<article class="mx-auto max-w-[1100px] px-5 md:px-10 py-12 md:py-16">
<!-- 面包屑 / 品牌 -->
<div class="flex items-center justify-between text-[11px] font-mono uppercase tracking-[0.25em] text-black border-b border-black pb-4">
<a href="/" class="hover:opacity-60 transition-opacity">← Index</a>
<span id="art-brand">Runway · {article.brand_name}</span>
</div>
<!-- 标题 -->
<h1 class="font-serif font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
{article.title}
</h1>
<!-- 元信息 -->
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-[12px] font-mono uppercase tracking-widest text-[#666]">
<span id="art-date">{fmt(article.created_at)}</span>
<span id="art-year" class="text-black">{article.year}</span>
<a href={article.source_url} target="_blank" rel="noopener" class="hover:text-black underline underline-offset-4">Source ↗</a>
</div>
<!-- 正文 -->
<div class="mt-12 grid md:grid-cols-12 gap-8">
<div class="md:col-span-2 text-[11px] font-mono uppercase tracking-widest text-[#999] pt-1">
Report
</div>
<div id="art-body" class="md:col-span-10 max-w-[68ch] space-y-5 text-[16px] leading-[1.9] text-[#222]">
{body.map((p) => <p>{p}</p>)}
</div>
</div>
<!-- 图片集 -->
<section class="mt-16">
<div class="flex items-end justify-between border-b border-[#eee] pb-3 mb-6">
<div class="text-[11px] font-mono uppercase tracking-[0.25em] text-[#999]">
Gallery <span id="art-gallery-count" class="ml-2 text-black"></span>
</div>
<button type="button" id="art-viewall" class="text-[11px] font-mono uppercase tracking-widest border border-black px-3 py-1.5 hover:bg-black hover:text-white transition-colors">⤢ 全屏查看</button>
</div>
<!-- 图片网格 -->
<div id="art-gallery" class="grid grid-cols-2 md:grid-cols-3 gap-4">
{article.images.map((im) => (
<figure class="g-item bg-[#f2f2f2] cursor-zoom-in">
<img src={im.image} alt={im.name} loading="lazy" class="w-full aspect-[3/4] object-cover" />
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">{im.name}</figcaption>
</figure>
))}
</div>
</section>
<div class="mt-16 pt-6 border-t border-black">
<a href="/" class="text-[12px] font-mono uppercase tracking-widest hover:opacity-60 transition-opacity">← Back to Index</a>
</div>
</article>
<!-- 灯箱:左侧竖排缩略图条 + 中央大图(点击放大→宽度最大+纵向滚动) -->
<div id="lb" class="lb" aria-hidden="true">
<div class="lb-counter" id="lb-counter"></div>
<button class="lb-btn lb-close" id="lb-close" aria-label="关闭">✕</button>
<div class="lb-stage" id="lb-stage">
<div class="lb-thumbs" id="lb-thumbs"></div>
<button class="lb-tbtn lb-tup" id="lb-tup" type="button" aria-label="上一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 14l6-6 6 6"/></svg>
</button>
<button class="lb-tbtn lb-tdown" id="lb-tdown" type="button" aria-label="下一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 10l6 6 6-6"/></svg>
</button>
<button class="lb-btn lb-prev" id="lb-prev" type="button" aria-label="上一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<button class="lb-btn lb-next" id="lb-next" type="button" aria-label="下一张">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<div class="lb-main" id="lb-main">
<img id="lb-img" class="lb-img" alt="" />
</div>
</div>
<div class="lb-caption" id="lb-caption"></div>
<div class="lb-hint" id="lb-hint">点击图片放大 · 上下滚动查看 · Esc 关闭</div>
</div>
<Layout>
<Article />
</Layout>
<style is:global>
@reference "tailwindcss";
/* 灯箱:左侧竖排缩略图条 + 中央大图;点击放大→宽度最大+纵向滚动 */
.lb {
position: fixed; inset: 0; z-index: 100;
@apply bg-white;
display: none; flex-direction: column;
opacity: 0; transition: opacity .2s ease;
-webkit-tap-highlight-color: transparent;
}
.lb.is-open { display: flex; opacity: 1; }
.lb-stage {
flex: 1 1 auto; min-height: 0;
display: flex; align-items: stretch; justify-content: stretch;
overflow: hidden; position: relative;
animation: lbIn .2s ease both;
}
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
.lb-thumbs {
flex: 0 0 118px; overflow-y: auto; overflow-x: hidden; position: relative; scroll-behavior: smooth;
padding: 38px 18px 38px 20px;
border-right: 1px solid rgba(0,0,0,.08);
background: linear-gradient(180deg, rgba(0,0,0,.02), rgba(0,0,0,0));
scrollbar-width: none; -ms-overflow-style: none;
}
.lb-thumbs::-webkit-scrollbar { width: 0; height: 0; }
.lb-thumb {
position: relative; display: block; width: 100%; aspect-ratio: 3 / 4;
padding: 0; margin: 0 0 8px; border: 0;
@apply bg-[#ebebeb] rounded-[2px];
overflow: hidden; cursor: pointer;
opacity: .42; transition: opacity .2s ease, transform .2s ease;
}
.lb-thumb:last-child { margin-bottom: 0; }
.lb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lb-thumb::after {
content: ""; position: absolute; inset: 0; border-radius: 2px;
box-shadow: inset 0 0 0 0 transparent; pointer-events: none;
transition: box-shadow .2s ease;
}
.lb-thumb:hover { opacity: .85; }
.lb-thumb.is-active { opacity: 1; transform: scale(1.03); }
.lb-thumb.is-active::after { box-shadow: inset 0 0 0 2px #111; }
.lb-thumb-idx {
position: absolute; left: 6px; top: 6px; z-index: 1;
font: 600 9px/1 ui-monospace, SFMono-Regular, monospace;
@apply tracking-[.12em] text-white px-[5px] py-[3px] rounded-[3px];
background: rgba(0,0,0,.55);
backdrop-filter: blur(4px); pointer-events: none;
}
.lb-thumb.is-active .lb-thumb-idx { @apply bg-[#111] text-white; }
.lb-main {
flex: 1 1 auto; min-width: 0; min-height: 0;
display: flex; align-items: center; justify-content: center;
position: relative; padding: 0 72px;
}
.lb-img {
max-width: 100%; max-height: 100%; display: block;
object-fit: contain; user-select: none; -webkit-user-drag: none;
transition: opacity .2s ease; cursor: zoom-in;
}
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放、无拖拽、无特效) */
.lb-main.is-zoomed { align-items: stretch; display: block; overflow-y: auto; overflow-x: hidden; }
.lb-main.is-zoomed .lb-img { width: 100%; max-width: 100%; height: auto; max-height: none; cursor: zoom-out; }
.lb-btn {
position: absolute; z-index: 3;
width: 42px; height: 42px;
@apply rounded-full;
border: 1px solid rgba(0,0,0,.14); background: rgba(255,255,255,.72);
@apply text-[#111];
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s, transform .15s, border-color .15s; backdrop-filter: blur(10px);
}
.lb-btn svg, .lb-tbtn svg {
display: block; fill: none; stroke: currentColor;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}
.lb-btn svg { width: 20px; height: 20px; }
.lb-tbtn svg { width: 16px; height: 16px; }
.lb-btn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.24); }
.lb-close { top: 18px; right: 18px; }
.lb-prev { left: 114px; top: 50%; transform: translateY(-50%); }
.lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
.lb-prev:hover, .lb-next:hover { transform: translateY(-50%) scale(1.08); }
/* 左栏上下翻页按钮(浮在缩略图条上、禁手动滚动时的翻页入口) */
.lb-tbtn {
position: absolute; z-index: 4;
left: 20px; width: 72px; height: 34px;
border: 1px solid rgba(0,0,0,.12); background: rgba(255,255,255,.82);
@apply rounded-[6px];
cursor: pointer;
display: flex; align-items: center; justify-content: center;
@apply text-[#111];
backdrop-filter: blur(8px);
transition: background .15s, transform .15s, border-color .15s;
}
.lb-tbtn svg { width: 16px; height: 16px; display: block; }
.lb-tbtn:hover { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.22); }
.lb-tup { top: 8px; }
.lb-tdown { bottom: 8px; }
.lb-counter {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%); z-index: 3;
font: 600 12px/1 ui-monospace, monospace;
@apply tracking-[.14em] text-[#111];
background: rgba(255,255,255,.75); border: 1px solid rgba(0,0,0,.1);
@apply px-[11px] py-[7px] rounded-[6px];
backdrop-filter: blur(6px);
}
.lb-caption {
position: absolute; left: 0; right: 0; bottom: 44px; z-index: 3;
text-align: center; color: rgba(0,0,0,.62); pointer-events: none;
font: 11px/1.4 ui-monospace, monospace;
@apply tracking-[.18em] uppercase;
padding: 0 24px; text-shadow: 0 1px 3px rgba(255,255,255,.7);
}
.lb-hint {
position: absolute; left: 0; right: 0; bottom: 16px; z-index: 3;
text-align: center; color: rgba(0,0,0,.45); pointer-events: none;
font: 11px/1 ui-monospace, monospace;
@apply tracking-[.14em];
padding: 0 24px; transition: opacity .2s ease; text-shadow: 0 1px 3px rgba(255,255,255,.7);
}
.lb-hint.is-hidden { opacity: 0; }
@media (max-width: 640px) {
.lb-thumbs { flex: 0 0 76px; padding: 32px 8px 32px 12px; }
.lb-thumb { margin-bottom: 8px; }
.lb-thumb-idx { font-size: 8px; left: 4px; top: 4px; padding: 2px 4px; }
.lb-main { padding: 0 18px; }
.lb-btn { width: 38px; height: 38px; }
.lb-prev { left: 82px; } .lb-next { right: 8px; }
.lb-tbtn { left: 12px; width: 52px; height: 30px; }
.lb-caption { bottom: 40px; }
}
</style>
<script>
// 真实数据接入:按 ?id= 拉取后端接口,失败时保留上方示例布局
const API = import.meta.env.PUBLIC_API_BASE || ""
const toAbs = (url: string) =>
!url ? "" : /^https?:\/\//i.test(url) ? url : API + url
const id = new URLSearchParams(location.search).get("id")
if (id) {
;(async () => {
try {
const res = await fetch(`${API}/api/public/articles/${id}`)
if (!res.ok) return
const d = (await res.json()).data
if (!d) return
const titleEl = document.querySelector("h1")
if (titleEl && d.title) titleEl.textContent = d.title
const bodyEl = document.getElementById("art-body")
if (bodyEl && d.description) {
bodyEl.innerHTML = d.description
.split("\n\n")
.map((p: string) => `<p>${p}</p>`)
.join("")
}
const brandEl = document.getElementById("art-brand")
if (brandEl && d.brand_name) brandEl.textContent = `Runway · ${d.brand_name}`
const yearEl = document.getElementById("art-year")
if (yearEl && d.year) yearEl.textContent = String(d.year)
const dateEl = document.getElementById("art-date")
if (dateEl && d.published_at) {
const dt = new Date(Number(d.published_at) * 1000)
dateEl.textContent = `${dt.getFullYear()}.${String(dt.getMonth() + 1).padStart(2, "0")}.${String(dt.getDate()).padStart(2, "0")}`
}
const imgs = d.images
if (imgs && Array.isArray(imgs)) {
const gal = document.getElementById("art-gallery")
if (gal) {
gal.innerHTML = imgs
.map(
(im: any) =>
`<figure class="bg-[#f2f2f2]"><img src="${toAbs(im.image)}" alt="${im.name || ""}" loading="lazy" class="w-full aspect-[3/4] object-cover" /><figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">${im.name || ""}</figcaption></figure>`
)
.join("")
}
}
} catch {
/* 后端不可达时,使用示例数据 */
}
})()
}
</script>
<script>
(function () {
const gallery = document.getElementById("art-gallery");
const lb = document.getElementById("lb");
const lbStage = document.getElementById("lb-stage");
const lbMain = document.getElementById("lb-main");
const lbImg = document.getElementById("lb-img");
const lbCounter = document.getElementById("lb-counter");
const lbCaption = document.getElementById("lb-caption");
const lbThumbs = document.getElementById("lb-thumbs");
const lbHint = document.getElementById("lb-hint");
const btnClose = document.getElementById("lb-close");
const btnPrev = document.getElementById("lb-prev");
const btnNext = document.getElementById("lb-next");
const btnTup = document.getElementById("lb-tup");
const btnTdown = document.getElementById("lb-tdown");
const viewall = document.getElementById("art-viewall");
let items = [];
let index = 0;
let zoomed = false;
/* 放大态:宽度最大化 + 纵向滚动(无滚轮缩放 / 无拖拽 / 无特效) */
function zoomIn() {
zoomed = true;
if (lbMain) { lbMain.classList.add("is-zoomed"); lbMain.scrollTop = 0; }
if (lbHint) lbHint.classList.add("is-hidden");
}
function resetZoom() {
zoomed = false;
if (lbMain) { lbMain.classList.remove("is-zoomed"); lbMain.scrollTop = 0; }
if (lbHint) lbHint.classList.remove("is-hidden");
}
function toggleZoom() { zoomed ? resetZoom() : zoomIn(); }
/* 收集画廊图片 */
function collect() {
const list = [];
if (gallery) gallery.querySelectorAll("img").forEach((img) => {
const fig = img.closest("figure");
const cap = fig && fig.querySelector("figcaption");
list.push({ src: img.currentSrc || img.src, name: (cap && cap.textContent || "").trim() });
});
return list;
}
/* 渲染左侧竖排缩略图条 */
function renderStrip() {
if (!lbThumbs) return;
lbThumbs.innerHTML = items
.map((it, i) => {
const n = String(i + 1).padStart(2, "0");
return '<button type="button" class="lb-thumb' + (i === index ? ' is-active' : '') + '" data-i="' + i + '">' +
'<img src="' + it.src + '" alt="" />' +
'<span class="lb-thumb-idx">' + n + '</span>' +
'</button>';
})
.join("");
}
function show(i) {
if (!items.length) return;
index = (i + items.length) % items.length;
const it = items[index];
resetZoom();
lbImg.style.opacity = "0";
const loader = new Image();
loader.onload = function () { lbImg.src = it.src; lbImg.style.opacity = "1"; };
loader.src = it.src;
if (lbCounter) lbCounter.textContent = (index + 1) + " / " + items.length;
if (lbCaption) lbCaption.textContent = it.name || "";
if (lbThumbs) lbThumbs.querySelectorAll(".lb-thumb").forEach((t, k) => {
t.classList.toggle("is-active", k === index);
if (k === index) {
// 自动居中当前张(左栏禁用手动滚动,改用程序化 scrollTo)
const target = t.offsetTop - (lbThumbs.clientHeight - t.clientHeight) / 2;
lbThumbs.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
}
});
}
function open(i) {
items = collect();
if (!items.length) return;
if (lb) { lb.classList.add("is-open"); lb.setAttribute("aria-hidden", "false"); }
document.body.style.overflow = "hidden";
renderStrip();
show(i || 0);
}
function close() {
if (lb) { lb.classList.remove("is-open"); lb.setAttribute("aria-hidden", "true"); }
document.body.style.overflow = "";
}
function next() { show(index + 1); }
function prev() { show(index - 1); }
/* 事件绑定 */
if (gallery) gallery.addEventListener("click", (e) => {
const fig = e.target.closest("figure");
if (!fig) return;
const imgs = Array.prototype.slice.call(gallery.querySelectorAll("img"));
open(Math.max(0, imgs.indexOf(fig.querySelector("img"))));
});
if (btnClose) btnClose.addEventListener("click", close);
if (btnNext) btnNext.addEventListener("click", (e) => { e.stopPropagation(); next(); });
if (btnPrev) btnPrev.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
if (btnTup) btnTup.addEventListener("click", (e) => { e.stopPropagation(); prev(); });
if (btnTdown) btnTdown.addEventListener("click", (e) => { e.stopPropagation(); next(); });
if (lb) lb.addEventListener("click", (e) => {
const t = e.target;
if (t === lb || (t.classList && t.classList.contains("lb-stage"))) close();
});
if (lbThumbs) lbThumbs.addEventListener("click", (e) => {
const t = e.target.closest(".lb-thumb");
if (t) show(Number(t.dataset.i));
});
if (lbImg) lbImg.addEventListener("click", toggleZoom);
document.addEventListener("keydown", (e) => {
if (!lb || !lb.classList.contains("is-open")) return;
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") next();
else if (e.key === "ArrowLeft") prev();
});
if (viewall) viewall.addEventListener("click", () => open(0));
/* 移动端单指滑动切换(仅在未放大时,放大态交给纵向滚动) */
let tx0 = 0, ty0 = 0, two = false;
if (lbStage) {
lbStage.addEventListener("touchstart", (e) => {
if (e.touches.length === 1) { tx0 = e.touches[0].clientX; ty0 = e.touches[0].clientY; }
else if (e.touches.length === 2) two = true;
}, { passive: true });
lbStage.addEventListener("touchend", (e) => {
if (two) { two = false; return; }
if (zoomed) return;
const t = e.changedTouches[0];
const dx = t.clientX - tx0, dy = t.clientY - ty0;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) (dx < 0 ? next() : prev());
}, { passive: true });
}
/* 计数 + 画廊重渲染后同步 */
function syncCount() {
const c = document.getElementById("art-gallery-count");
const n = gallery ? gallery.querySelectorAll("img").length : 0;
if (c) c.textContent = n ? "· 共 " + n + " 张" : "";
}
syncCount();
if (gallery && "MutationObserver" in window) {
new MutationObserver(syncCount).observe(gallery, { childList: true });
}
})();
</script>

View File

@ -1,474 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import { getReports, fmtDate } from "../lib/data"
// 文章展示创意布局页:复用你现有的 Layout 外壳。
// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts);
// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。
const reports = await getReports(30)
const fmt = fmtDate
import Articles from "../components/pages/articles.astro"
---
<Layout>
<div class="archive">
<!-- 页头(极简黑白编辑风,与首页 Hero 一致的排版语言) -->
<header class="archive-head">
<div class="archive-kicker">
<span>Runway Archive</span>
<span>SS23 — AW26</span>
</div>
<h1 class="archive-title">The Reports</h1>
<div class="archive-sub">
<p>
走秀趋势报告档案库。支持网格 / 杂志 / 时间轴三种创意视图自由切换,彩色配图由后端接口动态渲染。
</p>
<a href="/" class="archive-back">← Index</a>
</div>
</header>
<!-- 工具栏:布局切换 + 结果计数 -->
<div class="archive-toolbar">
<div class="viewswitch" role="tablist" aria-label="Layout mode">
<button data-layout="grid" class="on" type="button">Grid</button>
<button data-layout="magazine" type="button">Magazine</button>
<button data-layout="timeline" type="button">Timeline</button>
</div>
<span class="archive-count" id="count">{reports.length} Reports</span>
</div>
<!-- 分类筛选(品牌),由脚本按数据动态生成 -->
<div class="filterbar" id="filterbar"></div>
<!-- 系列类型 / 季节筛选(后端分类字段:collection_type / season) -->
<div class="filterbar" id="typefilter">
<button class="chip on" data-type="">全部系列</button>
<button class="chip" data-type="rtw">成衣 RTW</button>
<button class="chip" data-type="menswear">男装</button>
<button class="chip" data-type="couture">高定</button>
<button class="chip" data-type="resort">度假 / 早春</button>
<button class="chip" data-type="pre_fall">早秋</button>
</div>
<div class="filterbar" id="seasonfilter">
<button class="chip on" data-season="">全部季节</button>
<button class="chip" data-season="spring">Spring 春夏</button>
<button class="chip" data-season="fall">Fall 秋冬</button>
</div>
<!-- 画廊容器:data-layout 决定当前视图;局部切换不产生整页刷新 -->
<section class="gallery" id="gallery" data-layout="grid">
{reports.map((s) => (
<article
class="card"
data-brand={s.brand}
data-featured={s.featured ? "true" : "false"}
data-year={s.year}
data-archive={s.year < 2025 ? "true" : "false"}
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}>
<img src={s.cover} alt={s.title} loading="lazy" />
{s.featured && <span class="card-flag">Featured</span>}
</a>
<div class="card-body">
<span class="card-brand">{s.brand}</span>
<h3 class="card-title">{s.title}</h3>
<p class="card-excerpt">{s.excerpt}</p>
<div class="card-foot">
<span class="card-date2">{fmt(s.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>
))}
</section>
</div>
<Articles />
</Layout>
<style is:global>
@reference "tailwindcss";
/* 仅作用于本页(.archive 前缀),不影响其它页面与你的原有样式 */
.archive { max-width: 1440px; margin: 0 auto; }
/* ---------- 页头 ---------- */
.archive-head { margin-top: clamp(48px, 8vw, 110px); }
.archive-kicker {
@apply font-mono uppercase tracking-[0.25em] text-[11px] text-black;
display: flex; justify-content: space-between;
border-top: 1px solid #000; padding-top: 18px;
}
.archive-title {
@apply font-serif font-black tracking-[-0.02em];
line-height: 0.85;
font-size: clamp(48px, 11vw, 150px); margin: 0.28em 0 0;
}
.archive-sub {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 24px; flex-wrap: wrap; border-bottom: 1px solid #000;
padding-bottom: 22px; margin-top: 22px;
}
.archive-sub p { max-width: 580px; @apply text-[#444] text-[15px]; line-height: 1.6; margin: 0; }
.archive-back {
@apply font-mono uppercase tracking-[0.15em] text-[11px] border border-black px-[16px] py-[10px] hover:bg-black hover:text-white;
white-space: nowrap;
transition: background 0.25s, color 0.25s;
}
/* ---------- 工具栏 ---------- */
.archive-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; flex-wrap: wrap; margin: 28px 0 22px;
}
.viewswitch { display: inline-flex; @apply border border-black; }
.viewswitch button {
@apply font-mono uppercase tracking-[0.15em] text-[11px] px-[18px] py-[10px] bg-white text-black;
border: none; border-right: 1px solid #000;
cursor: pointer; transition: background 0.25s, color 0.25s;
}
.viewswitch button:last-child { border-right: none; }
.viewswitch button.on { @apply bg-black text-white; }
.archive-count {
@apply font-mono text-[11px] tracking-[0.15em] uppercase text-[#666];
}
/* ---------- 筛选 chips ---------- */
.filterbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 34px; }
.chip {
@apply font-mono uppercase tracking-[0.12em] text-[11px] px-[14px] py-[7px] border border-[#ccc] bg-white text-[#333] rounded-full;
cursor: pointer; transition: all 0.2s;
}
.chip:hover { border-color: #000; }
.chip.on { @apply bg-black text-white border-black; }
/* ---------- 画廊基础 + 入场/切换过渡 ---------- */
.gallery { transition: opacity 0.24s ease, transform 0.24s ease; }
.gallery.switching { opacity: 0; transform: translateY(10px); }
.gallery .card { position: relative; @apply bg-white; }
/* 无 JS 时直接可见;有 JS 时做交错淡入 */
.archive.js .gallery .card {
opacity: 0; transform: translateY(16px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.archive.js .gallery .card.in { opacity: 1; transform: none; }
.card-media {
position: relative; display: block; overflow: hidden;
aspect-ratio: var(--ratio, 3 / 4); background: #f2f2f2;
}
.card-media img {
width: 100%; height: 100%; object-fit: cover;
opacity: 0; transform: scale(1.03);
transition: opacity 0.7s ease, transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card-media img.loaded { opacity: 1; transform: none; }
.card-media::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0) 42%, rgba(0,0,0,0.30));
opacity: 0; transition: opacity 0.5s;
}
.card:hover .card-media img { transform: scale(1.06); }
.card:hover .card-media::after { opacity: 1; }
.card-flag {
position: absolute; top: 12px; left: 12px; z-index: 2;
@apply font-mono uppercase tracking-[0.15em] text-[10px] font-bold bg-black text-white px-[9px] py-[4px];
}
.card-body { padding: 14px 2px 0; }
.card-brand {
display: block;
@apply font-mono uppercase tracking-[0.14em] text-[10.5px] text-black font-bold;
}
.card-title {
@apply font-serif font-extrabold;
font-size: clamp(18px, 2vw, 26px); line-height: 1.05; margin: 8px 0 0;
transition: color 0.3s;
}
.card:hover .card-title { color: #cf5a44; }
.card-excerpt { @apply text-[#555] text-[13.5px]; line-height: 1.55; margin: 8px 0 0; max-width: 48ch; }
.card-foot {
display: flex; justify-content: space-between; align-items: center;
margin-top: 12px;
@apply font-mono uppercase tracking-[0.1em] text-[10.5px] text-[#999];
}
.card-read {
@apply text-black;
opacity: 0; transform: translateX(-6px);
transition: opacity 0.35s, transform 0.35s;
}
.card:hover .card-read { opacity: 1; transform: none; }
/* 视觉层次:Featured = 黑色粗边框 + 大标题;Archive(年份<2025) = 虚线灰边框 */
.gallery .card[data-featured="true"] .card-media { border: 2px solid #000; }
.gallery .card[data-featured="true"] .card-title { font-size: clamp(22px, 2.6vw, 34px); }
.gallery .card[data-archive="true"] .card-media { border: 1px dashed #bbb; }
/* ---------- 视图 1:Grid(瀑布流,非对称高低错落) ---------- */
.gallery[data-layout="grid"] { column-count: 3; column-gap: 26px; }
.gallery[data-layout="grid"] .card { break-inside: avoid; display: block; margin: 0 0 30px; }
.gallery[data-layout="grid"] .card-datecol { display: none; }
@media (max-width: 1024px) { .gallery[data-layout="grid"] { column-count: 2; } }
@media (max-width: 640px) { .gallery[data-layout="grid"] { column-count: 1; } }
/* ---------- 视图 2:Magazine(非对称网格,Featured 跨栏放大) ---------- */
.gallery[data-layout="magazine"] {
display: grid; grid-template-columns: repeat(6, 1fr);
gap: 26px; grid-auto-flow: dense;
}
.gallery[data-layout="magazine"] .card { grid-column: span 2; display: block; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; grid-row: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] .card-media { aspect-ratio: 16 / 10; }
.gallery[data-layout="magazine"] .card-datecol { display: none; }
@media (max-width: 1024px) {
.gallery[data-layout="magazine"] { grid-template-columns: repeat(4, 1fr); }
.gallery[data-layout="magazine"] .card { grid-column: span 2; }
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 4; }
}
@media (max-width: 640px) {
.gallery[data-layout="magazine"] { grid-template-columns: 1fr; }
.gallery[data-layout="magazine"] .card,
.gallery[data-layout="magazine"] .card[data-featured="true"] { grid-column: span 1; grid-row: auto; }
}
/* ---------- 视图 3:Timeline(时间轴,左侧日期 + 缩略图 + 正文) ---------- */
.gallery[data-layout="timeline"] { display: block; position: relative; }
.gallery[data-layout="timeline"]::before {
content: ""; position: absolute; left: 188px; top: 6px; bottom: 6px; width: 2px; background: #000;
}
.gallery[data-layout="timeline"] .card {
display: grid; grid-template-columns: 170px 220px 1fr; gap: 26px;
align-items: start; margin: 0 0 30px; position: relative;
}
.gallery[data-layout="timeline"] .card::before {
content: ""; position: absolute; left: 182px; top: 8px;
width: 14px; height: 14px; border-radius: 50%;
background: #fff; border: 2px solid #000; z-index: 2;
}
.gallery[data-layout="timeline"] .card-datecol { display: block; }
.gallery[data-layout="timeline"] .card-date {
@apply font-mono text-[13px] tracking-[0.08em] text-black;
}
.gallery[data-layout="timeline"] .card-media { aspect-ratio: auto; height: 150px; }
.gallery[data-layout="timeline"] .card-excerpt { display: none; }
.gallery[data-layout="timeline"] .card-foot { display: none; }
@media (max-width: 768px) {
.gallery[data-layout="timeline"]::before { left: 8px; }
.gallery[data-layout="timeline"] .card { grid-template-columns: 1fr; gap: 12px; padding-left: 34px; }
.gallery[data-layout="timeline"] .card::before { left: 2px; }
.gallery[data-layout="timeline"] .card-media { height: 200px; width: 100%; }
}
/* 筛选隐藏 */
.archive .card[data-hidden="true"] { display: none !important; }
</style>
<script>
const archive = document.querySelector<HTMLElement>(".archive")
const gallery = document.getElementById("gallery") as HTMLElement | null
const countEl = document.getElementById("count")
const filterbar = document.getElementById("filterbar")
const viewBtns = Array.from(document.querySelectorAll<HTMLButtonElement>(".viewswitch button"))
const API_BASE = (import.meta.env.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
// 封面可能是完整外链(https://...),也可能是相对路径(/uploads/...),统一处理
const toAbs = (url: string) => {
if (!url) return ""
return /^https?:\/\//i.test(url) ? url : API_BASE + url
}
// 来自品牌索引的聚焦筛选(/brands 的品牌链接:?brand_id= 或 ?brand=)
const _urlParams = new URLSearchParams(location.search)
const _brandId = _urlParams.get("brand_id")
const _brandName = _urlParams.get("brand")
if (archive && gallery) archive.classList.add("js")
const fmt = (t: number | string) => {
const n = Number(t)
const d = new Date(n < 1e12 ? n * 1000 : n)
const p = (x: number) => String(x).padStart(2, "0")
return `${d.getUTCFullYear()}.${p(d.getUTCMonth() + 1)}.${p(d.getUTCDate())}`
}
type Item = {
id: number | string
title: string
brand: string
year: number | string
created_at: number | string
cover: string
excerpt: string
image_count: number
featured: boolean
ratio?: string
}
// 卡片模板:SSR 示例与后端渲染共用同一套结构,保证三种视图样式一致
const cardTemplate = (it: Item) => {
const archiveFlag = Number(it.year) < 2025 ? "true" : "false"
const ratio = it.ratio ?? "3 / 4"
return `
<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}">
<img src="${it.cover}" alt="${it.title}" loading="lazy" />
${it.featured ? '<span class="card-flag">Featured</span>' : ""}
</a>
<div class="card-body">
<span class="card-brand">${it.brand}</span>
<h3 class="card-title">${it.title}</h3>
<p class="card-excerpt">${it.excerpt}</p>
<div class="card-foot">
<span class="card-date2">${fmt(it.created_at)}</span>
<span class="card-read">Read →</span>
</div>
</div>
</article>`
}
const ratios = ["3 / 4", "4 / 5", "1 / 1", "5 / 4", "3 / 4", "4 / 3"]
const setupCards = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
// 配图渐显
cards.forEach((c) => {
c.querySelectorAll("img").forEach((img) => {
if (img.complete) img.classList.add("loaded")
else img.addEventListener("load", () => img.classList.add("loaded"), { once: true })
})
})
// 交错入场
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("in")
io.unobserve(e.target)
}
})
},
{ threshold: 0.08 }
)
cards.forEach((c, i) => {
c.style.transitionDelay = (i % 8) * 0.04 + "s"
io.observe(c)
})
buildChips(cards)
}
// 品牌筛选 chips(按当前数据动态生成)
let currentFilter: string | null = null
const buildChips = (cards: HTMLElement[]) => {
if (!filterbar) return
const brands = Array.from(new Set(cards.map((c) => c.dataset.brand).filter(Boolean))) as string[]
filterbar.innerHTML = ""
const mk = (label: string, brand: string | null) => {
const b = document.createElement("button")
b.className = "chip" + (brand === currentFilter ? " on" : "")
b.textContent = label
b.dataset.brand = brand ?? ""
b.addEventListener("click", () => {
currentFilter = currentFilter === brand ? null : brand
filterbar!.querySelectorAll(".chip").forEach((x) => x.classList.toggle("on", (x as HTMLElement).dataset.brand === (currentFilter ?? "")))
applyFilter()
})
return b
}
filterbar.appendChild(mk("All", null))
brands.forEach((br) => filterbar.appendChild(mk(br, br)))
}
const applyFilter = () => {
if (!gallery) return
const cards = Array.from(gallery.querySelectorAll<HTMLElement>(".card"))
let visible = 0
cards.forEach((c) => {
const show = !currentFilter || c.dataset.brand === currentFilter
c.toggleAttribute("data-hidden", !show)
if (show) visible++
})
if (countEl) countEl.textContent = `${visible} Reports`
}
// 布局切换:局部更新,带淡入淡出过渡,不刷新整页
viewBtns.forEach((btn) => {
btn.addEventListener("click", () => {
if (!gallery) return
const mode = btn.dataset.layout as string
if (gallery.dataset.layout === mode) return
viewBtns.forEach((x) => x.classList.toggle("on", x === btn))
gallery.classList.add("switching")
window.setTimeout(() => {
gallery!.dataset.layout = mode
gallery!.classList.remove("switching")
}, 240)
})
})
// 后端数据接入:拉取真实列表,失败则保留示例(布局不变)
const render = (items: Item[]) => {
if (!gallery) return
gallery.dataset.layout = gallery.dataset.layout || "grid"
gallery.innerHTML = items.map(cardTemplate).join("")
if (countEl) countEl.textContent = `${items.length} Reports`
setupCards()
}
const loadBackend = async () => {
if (!API_BASE) return
try {
const size = _brandId ? 100 : 30
const p = new URLSearchParams({ page: "1", size: String(size) })
if (fType) p.set("collection_type", fType)
if (fSeason) p.set("season", fSeason)
if (_brandId) p.set("brand_id", _brandId)
const url = `${API_BASE}/api/public/articles?${p.toString()}`
const res = await fetch(url)
if (!res.ok) throw new Error("bad status")
const json = await res.json()
const rows = json?.data ?? []
if (!Array.isArray(rows) || rows.length === 0) return
let items: Item[] = rows.map((r: any, i: number) => ({
id: r.id,
title: r.title || "Untitled",
brand: r.brand_name || "Unknown",
year: r.year || "",
created_at: r.published_at,
cover: toAbs(r.cover || ""),
excerpt: r.summary || "",
image_count: Number(r.image_count || 0),
featured: false,
ratio: ratios[i % ratios.length],
}))
// 以图片数量最高的前 3 篇标记为 Featured(视觉层次)
const top = [...items].sort((a, b) => b.image_count - a.image_count).slice(0, 3).map((x) => x.id)
items.forEach((it) => (it.featured = top.includes(it.id as any)))
// 来自品牌索引的聚焦:高亮该品牌、改写页头标题
const focus = _brandName || (_brandId && items.length ? items[0].brand : null)
if (focus) {
currentFilter = focus
render(items)
filterbar?.querySelectorAll(".chip").forEach((x) =>
x.classList.toggle("on", (x as HTMLElement).dataset.brand === focus)
)
const t = document.querySelector(".archive-title")
if (t) t.textContent = focus
const k = document.querySelector(".archive-kicker span")
if (k) k.textContent = "Brand Archive"
if (countEl) countEl.textContent = `${items.length} Reports`
} else {
render(items)
}
} catch {
/* 保持示例数据 */
}
}
setupCards()
loadBackend()
</script>

View File

@ -1,258 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
// 品牌索引页(HBX 风格:每个字母一行,品牌名横向展开)
// - 数据:一次 SSR 拉完全部精选集合(?size=300&only_with_articles=1&featured=1),
// 前端按 name 首字母分桶渲染;198 个品牌数据量小,无需懒加载。
// - 风格:极简编辑风(黑白 + 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
}
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)
import Brands from "../components/pages/brands.astro"
---
<Layout title="Brands — HBX">
<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={`/articles?brand_id=${b.id}`}>{primaryName(b)}</a>
</li>
))}
</ul>
</section>
))}
</div>
)}
</main>
</div>
<Layout>
<Brands />
</Layout>
<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>

344
src/pages/cn/index.astro Normal file
View File

@ -0,0 +1,344 @@
---
import Layout from "../../layouts/Layout.astro"
import ComingSoon from "../../components/ComingSoon.astro"
import { getShows, seriesLabel } from "../../lib/data"
import { getLocaleFromUrl, useTranslations } from "../../i18n/utils"
// SSR 预取走秀数据
const shows = await getShows(20)
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
const locale = getLocaleFromUrl(Astro.url)
const { t } = useTranslations(locale)
type GalleryItem = {
src: string;
brand: string;
articleId: number;
year: number;
title: string;
collection_type?: 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,
})
}
---
<Layout>
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
<div id="tabbar" class="mt-16 md:mt-24">
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'backstage' }"
@click="activeTab = 'backstage'"
>
A
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }"
@click="activeTab = 'shows'"
>
B
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
C
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
</ul>
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- 横向滚动画廊 -->
{
galleryItems.length > 0 && (
<section class="hs-section">
<div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper">
{
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 -->
<div
class="hs-img-box"
x-data="{ loaded: false, error: false }"
:class="{ 'is-loaded': loaded || error }"
>
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
</svg>
</span>
<img
src={g.src}
alt={g.brand}
loading={i < 4 ? "eager" : "lazy"}
decoding="async"
:class="{ 'is-error': error }"
@load="loaded = true; $dispatch('img-ready')"
@error="error = true; $dispatch('img-ready')"
/>
<span class="hs-caption">
<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>
</div>
</a>
</div>
))
}
</div>
</div>
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Previous')}</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
</div>
</div>
</section>
)
}
</div>
<!-- 灵感 面板 -->
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板 -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
</div>
</Layout>
<style>
@reference "tailwindcss";
:root { --brand: #cf5a44; }
/* 标签基础与激活样式 */
.tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
}
.tab:hover { @apply text-[#666]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active { @apply text-black; }
.tab.is-active::after { @apply scale-x-100; }
/* 防闪烁标签 */
[x-cloak] { display: none !important; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 横向画廊样式 */
.hs-section { @apply mb-9; }
.hs-stage { @apply relative; }
.hs-controls {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
z-index: 6;
padding: 0 8px;
pointer-events: none;
mix-blend-mode: difference;
color: #fff;
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; }
.hs-arrow.swiper-button-disabled { pointer-events: none; }
/* Swiper 尺寸规整 */
.hs-swiper {
width: 100%;
height: clamp(500px, 80vh, 900px);
padding: 0 4px;
}
.hs-swiper .swiper-wrapper { align-items: center; }
.hs-swiper .hs-slide {
width: auto;
height: auto;
}
.hs-item {
display: block;
height: auto;
width: max-content;
text-decoration: none;
color: inherit;
}
.hs-img-box {
position: relative;
display: block;
height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 4; */
background: #f0f0f0;
overflow: hidden;
}
.hs-img-box > img {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
opacity: 0;
transition: opacity 0.4s ease;
}
.hs-img-box.is-loaded > img { opacity: 1; }
.hs-img-box > img.is-error { opacity: 0 !important; }
/* 加载骨架屏与旋转 Spinner */
.hs-skel {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
.hs-spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
.hs-spin svg { display: block; }
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
@keyframes hs-rotate { to { transform: rotate(360deg); } }
@keyframes hs-shimmer {
from { background-position: 200% 0; }
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);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
}
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
</style>
<script>
import Swiper from "swiper";
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
// 初始化 Swiper 轮播
const el = document.getElementById("hsSwiper");
if (el) {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
const swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto",
spaceBetween: 4,
grabCursor: true,
speed: 220,
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true,
observeSlideChildren: true,
});
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", () => {
requestAnimationFrame(() => swiper.update());
});
}
</script>

View File

@ -1,8 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import Contact from "../components/pages/Contact.astro"
---
<Layout title="Contact — 暂未上线">
<ComingSoon module="Contact" moduleZh="联系" />
<Contact />
</Layout>

View File

@ -2,10 +2,15 @@
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import { getShows, seriesLabel } from "../lib/data"
import { getLocaleFromUrl, useTranslations } from "../i18n/utils"
// SSR 预取走秀数据
const shows = await getShows(20)
// 国际化:根据 URL 前缀(/cn/...)取当前语言与翻译函数
const locale = getLocaleFromUrl(Astro.url)
const { t } = useTranslations(locale)
type GalleryItem = {
src: string;
brand: string;
@ -74,13 +79,7 @@ for (const s of shows) {
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="sec-head">
<div>
<h2 class="sec-title">RUNWAY ARCHIVE</h2>
<p class="sec-sub">走秀档案 · 精选最新上架的秀场</p>
</div>
<a href="/shows" class="sec-more">全部走秀 →</a>
</div>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- 横向滚动画廊 -->
{
@ -129,8 +128,8 @@ for (const s of shows) {
</div>
</div>
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">Previous</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">Next</div>
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Previous')}</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
</div>
</div>
</section>

View File

@ -1,8 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import LatestProjects from "../components/pages/LatestProjects.astro"
---
<Layout title="Latest Projects — 暂未上线">
<ComingSoon module="Latest Projects" moduleZh="最新项目" />
<LatestProjects />
</Layout>

View File

@ -1,8 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import Login from "../components/pages/Login.astro"
---
<Layout title="Sign In — 暂未上线">
<ComingSoon module="Sign In" moduleZh="登录" note="账户系统正在筹备中,暂未开放注册与登录。" />
<Login />
</Layout>

View File

@ -1,8 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import Portfolio from "../components/pages/Portfolio.astro"
---
<Layout title="Portfolio — 暂未上线">
<ComingSoon module="Portfolio" moduleZh="作品集" />
<Portfolio />
</Layout>

View File

@ -1,8 +1,8 @@
---
import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import Register from "../components/pages/Register.astro"
---
<Layout title="Create Account — 暂未上线">
<ComingSoon module="Create Account" moduleZh="注册" note="账户系统正在筹备中,暂未开放注册与登录。" />
<Register />
</Layout>