336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
// 网站统一数据层
|
||
// 从后端公开只读接口 (/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: string | number // 后端为 hashid 编码串(string);示例数据回落为数字
|
||
title: string
|
||
summary?: string
|
||
description?: string
|
||
cover: string
|
||
brand_name: string
|
||
brand_id?: string | number // hashid 编码串
|
||
year: number
|
||
image_count: number
|
||
published_at: number
|
||
created_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: String(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, apiBase: string = API_BASE): Promise<RawArticle | null> {
|
||
const base = apiBase.replace(/\/$/, "")
|
||
if (!base) return null
|
||
try {
|
||
const res = await fetch(`${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: string | 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: string | 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: string | 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: string | 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,
|
||
}))
|
||
}
|