Files
frontend_v2/src/lib/data.ts
toom1996 58f9f95f8e
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-08-15 01:07:33 +08:00

334 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 网站统一数据层
// 从后端公开只读接口 (/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`)
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<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,
}))
}