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

332
src/lib/data.ts Normal file
View File

@ -0,0 +1,332 @@
// 网站统一数据层
// 从后端公开只读接口 (/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<string, string> = {
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<Loaded> | 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<Loaded> {
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<RawArticle | null> {
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<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(0, limit)
}
export async function getBackstage(limit = 8): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(8, 8 + limit)
}
export async function getTrends(limit = 9): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(16, 16 + limit)
}
export async function getStreet(limit = 8): Promise<ArticleCard[]> {
const items = await loadArticlesWithImages(40)
return items.slice(24, 24 + limit)
}
// 列表带图拉取:一次请求即带回每篇文章前 5 张图(后端 with_images 参数),
// 用于首页卡片缩略图,避免此前逐篇详情请求导致的缓慢。
let _loadedWithImages: Promise<ArticleCard[]> | null = null
export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
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`)
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<T extends { id: number; gallery: string[] }>(items: T[]): Promise<T[]> {
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<ColorItem[]> {
// 后端暂无独立颜色接口:优先尝试 /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<SiteInfo> {
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<ReportItem[]> {
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,
}))
}