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

This commit is contained in:
toom1996
2026-08-11 22:29:47 +08:00
commit 5a8dbf41cb
62 changed files with 9514 additions and 0 deletions

474
src/pages/articles.astro Normal file
View File

@ -0,0 +1,474 @@
---
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
---
<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>
</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>