332
src/lib/data.ts
Normal file
332
src/lib/data.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user