// 网站统一数据层 // 从后端公开只读接口 (/api/public/...) 拉取真实数据,并适配为前端各模块 // (site / shows / trends / colors / street,以及首页 backstage 面板、文章画廊 reports) 所需结构。 // // 设计原则: // 1. 单一数据源:所有 getter 共享一次分页拉取 (loadArticles),避免重复请求。 // 2. 优雅回落:后端不可达或返回空时,回落到内置示例,保证 SSG 构建与离线预览不报错。 // 3. 颜色模块:后端暂无独立颜色接口,getColors() 先尝试 /api/public/colors, // 失败则从 runway 文章封面派生占位(接入颜色接口后即可自动生效)。 const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "") export type RawArticle = { id: number title: string summary?: string description?: string cover: string brand_name: string year: number image_count: number published_at: number source_url?: string collection_type?: string season?: string season_code?: string images?: { id: number; image: string; name?: string }[] } // ---------- 通用辅助 ---------- export function toAbs(url: string): string { if (!url) return "" return /^https?:\/\//i.test(url) ? url : API_BASE + url } export function seasonLabel(y: number | string): string { const n = Number(y) return n ? `AW${String(n).slice(2)}` : "" } // 系列类型 → 展示名(用于首页/列表的系列标签与筛选) export const SERIES_LABELS: Record = { rtw: "成衣 RTW", menswear: "男装", couture: "高定", resort: "度假", pre_fall: "早秋", } export function seriesLabel(ct?: string): string { return (ct && SERIES_LABELS[ct]) || "成衣 RTW" } export function fmtDate(t: number | string): 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())}` } // ---------- 内置示例(后端不可达时回落) ---------- const BRANDS = ["MAISON LUMEN", "STUDIO VANTA", "ATELIER NOIR", "KÔ", "CÉLESTE", "RIVE GAUCHE"] const SAMPLE: RawArticle[] = Array.from({ length: 24 }, (_, i) => ({ id: i + 1, title: `Collection ${String(i + 1).padStart(2, "0")}`, brand_name: BRANDS[i % BRANDS.length], year: 2023 + (i % 4), cover: `https://picsum.photos/seed/s${i + 1}/800/1000`, image_count: 3 + (i % 12), published_at: Math.floor(Date.UTC(2026, 0, 1) / 1000) - i * 86400, })) // ---------- 底层:一次性拉取并缓存 ---------- type Loaded = { items: RawArticle[] total: number brandCount: number yearMin: number yearMax: number latest: RawArticle | null sampled: boolean } let _loaded: Promise | null = null function compute(items: RawArticle[], total: number): Loaded { const brandCount = new Set(items.map((r) => r.brand_name).filter(Boolean)).size const years = items.map((r) => Number(r.year)).filter((y) => y > 0) const yearMin = years.length ? Math.min(...years) : 0 const yearMax = years.length ? Math.max(...years) : 0 const latest = [...items].sort((a, b) => Number(b.published_at) - Number(a.published_at))[0] || null return { items, total, brandCount, yearMin, yearMax, latest, sampled: false } } function fromSample(): Loaded { return { ...compute(SAMPLE, SAMPLE.length), sampled: true } } export function loadArticles(): Promise { if (_loaded) return _loaded _loaded = (async () => { if (!API_BASE) return fromSample() try { const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=60`) if (!res.ok) return fromSample() const json: any = await res.json() const items: RawArticle[] = json?.data ?? [] if (!items.length) return fromSample() return compute(items, Number(json?.total ?? items.length)) } catch { return fromSample() } })() return _loaded } export async function fetchArticleById(id: number | string): Promise { if (!API_BASE) return null try { const res = await fetch(`${API_BASE}/api/public/articles/${id}`) if (!res.ok) return null const json: any = await res.json() return json?.data ?? null } catch { return null } } // ---------- 模块 getter ---------- // 统一映射:四块面板共用同一真实文章结构,字段含义清晰、不再错配 // (接口仅暴露 runway 文章一类数据,四块面板取真实列表的不同区段,避免重复同一批内容) export type ArticleCard = { id: number brand: string title: string season: string // 季节码,如 AW16 year: number img: string count: number gallery: string[] collection_type?: string // rtw/menswear/couture/resort/pre_fall season_code?: string // SS26/FW25/RES26/PF25 } function mapArticle(r: RawArticle): ArticleCard { return { id: r.id, brand: r.brand_name || "Unknown", title: r.title || "", season: seasonLabel(r.year), year: Number(r.year) || 0, img: toAbs(r.cover || ""), count: r.image_count || 0, gallery: [], collection_type: r.collection_type, season_code: r.season_code, } } export async function getShows(limit = 8): Promise { const items = await loadArticlesWithImages(40) return items.slice(0, limit) } export async function getBackstage(limit = 8): Promise { const items = await loadArticlesWithImages(40) return items.slice(8, 8 + limit) } export async function getTrends(limit = 9): Promise { const items = await loadArticlesWithImages(40) return items.slice(16, 16 + limit) } export async function getStreet(limit = 8): Promise { const items = await loadArticlesWithImages(40) return items.slice(24, 24 + limit) } // 列表带图拉取:一次请求即带回每篇文章前 5 张图(后端 with_images 参数), // 用于首页卡片缩略图,避免此前逐篇详情请求导致的缓慢。 let _loadedWithImages: Promise | null = null export function loadArticlesWithImages(size = 40): Promise { if (_loadedWithImages) return _loadedWithImages _loadedWithImages = (async () => { if (!API_BASE) return [] try { const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=${size}&with_images=5`) console.log(res) if (!res.ok) return [] const json: any = await res.json() const items: RawArticle[] = json?.data ?? [] return items.map((r: RawArticle) => { const base = mapArticle(r) base.gallery = (r.images || []) .map((im: any) => toAbs(im.image || "")) .filter(Boolean) .slice(0, 5) return base }) } catch { return [] } })() return _loadedWithImages } // attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张), // 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。 async function attachGallery(items: T[]): Promise { return await Promise.all( items.map(async (it) => { let gallery: string[] = [] const art = await fetchArticleById(it.id) if (art?.images?.length) { gallery = art.images .slice(0, 5) .map((im: any) => toAbs(im.image || "")) .filter(Boolean) } return { ...it, gallery } }) ) } export type ColorItem = { id: number name: string brand: string season: string img: string } export async function getColors(limit = 12): Promise { // 后端暂无独立颜色接口:优先尝试 /api/public/colors,失败则从 runway 文章封面派生占位。 if (API_BASE) { try { const res = await fetch(`${API_BASE}/api/public/colors`) if (res.ok) { const json: any = await res.json() const data: any[] = json?.data ?? [] if (data.length) { return data.slice(0, limit).map((c) => ({ id: c.id ?? 0, name: c.name || c.title || "", brand: c.brand_name || "", season: seasonLabel(c.year), img: toAbs(c.cover || c.image || ""), })) } } } catch { /* 回落到封面派生 */ } } const { items } = await loadArticles() return items.slice(0, limit).map((r) => ({ id: r.id, name: r.title, brand: r.brand_name, season: seasonLabel(r.year), img: toAbs(r.cover || ""), })) } export type SiteInfo = { totalReports: number brandCount: number seasonRange: string latest: { title: string; brand: string; date: string; href: string } } export async function getSite(): Promise { const loaded = await loadArticles() // 落回到示例时,沿用与首页原本一致的示例展示值 if (loaded.sampled) { return { totalReports: 12, brandCount: 6, seasonRange: "2023–26", latest: { title: "Paper Light", brand: "MAISON LUMEN", date: "2026.03.14", href: "/article?id=1", }, } } const { total, brandCount, yearMin, yearMax, latest } = loaded const seasonRange = yearMin && yearMax ? `${yearMin}–${String(yearMax).slice(2)}` : "2023–26" return { totalReports: total || 12, brandCount: brandCount || 6, seasonRange, latest: latest ? { title: latest.title, brand: latest.brand_name, date: fmtDate(latest.published_at), href: `/article?id=${latest.id}`, } : { title: "Paper Light", brand: "MAISON LUMEN", date: "2026.03.14", href: "/article?id=1" }, } } // ---------- 文章画廊(/articles)所需结构 ---------- export type ReportItem = { id: number brand: string title: string year: number cover: string excerpt: string image_count: number featured: boolean ratio: string created_at: number } const RATIOS = ["3 / 4", "4 / 5", "1 / 1", "5 / 4"] export async function getReports(limit = 30): Promise { const { items } = await loadArticles() return items.slice(0, limit).map((r, i) => ({ id: r.id, brand: r.brand_name || "Unknown", title: r.title || "", year: Number(r.year) || 0, cover: toAbs(r.cover || ""), excerpt: (r.summary || r.description || r.title || "").slice(0, 120), image_count: r.image_count || 0, featured: i % 4 === 0, ratio: RATIOS[i % RATIOS.length], created_at: Number(r.published_at) || 0, })) }