update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-08-23 00:58:33 +08:00
parent c8d9735751
commit b274ccdfec
21 changed files with 612 additions and 380 deletions

View File

@ -4,14 +4,14 @@ import ComingSoon from "@/components/ComingSoon.astro"
import { toAbs } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import { href, PATH } from '@/lib/routes'
import { getSsgPopularBrands } from "@/lib/ssg"
import { getSsgPopularBrands } from "@/lib/api"
// 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro)
// 首页「热门品牌」区块:构建期(SSG 内部接口,server-only)拉取 20 个热门品牌,
// 封面/标题取自每个品牌代表走秀,直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
// 具体 base 地址 + token 拼接已在 @/lib/ssg 内封装。
// 具体 base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。
const popular = await getSsgPopularBrands(20)
type GalleryItem = {

View File

@ -3,7 +3,7 @@ import type { RawArticle } from "@/lib/data"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getI18n } from "@/i18n/utils"
import { href, PATH } from '@/lib/routes'
import { ssrBase } from "@/lib/ssr"
import { ssrBase } from "@/lib/api"
interface Props {
article: RawArticle | null

View File

@ -1,7 +1,7 @@
---
import type { RawArticle } from "@/lib/data"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { ssrBase } from "@/lib/ssr"
import { ssrBase } from "@/lib/api"
interface Props {
article: RawArticle | null

View File

@ -1,5 +1,7 @@
---
import { getSsgBrands, type SsgBrand } from "@/lib/ssg"
import { getSsgBrands, type SsgBrand } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import { href, PATH } from "@/lib/routes"
type Brand = SsgBrand
@ -11,7 +13,7 @@ type Brand = SsgBrand
// · 每个字母一行:左侧字母标签 + 右侧该字母下品牌名横向铺成多列
// · 无副标题、无数字 badge、无大字标题、无悬停黑底、无搜索框
// 构建期 SSR 拉数据走 SSG 内部接口 /api/ssg/brands(不对外暴露,位于独立内部端口 8091);
// base 地址 + token 拼接已在 @/lib/ssg 内封装。后端不可达则回落空数组(页面显示空索引)。
// base 地址 + token 拼接已在 @/lib/api 的 SSG 函数内封装。后端不可达则回落空数组(页面显示空索引)。
const { locale } = getI18n(Astro)
const brands = await getSsgBrands({ size: 300, only_with_articles: 1, featured: 1 })
const loadError = brands.length === 0

View File

@ -112,7 +112,7 @@ const queryString = Astro.url.search;
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
<script>
import { getUser, fetchMe, clearSession } from "@/lib/auth.ts"
import { getUser, fetchMe, clearSession } from "@/lib/api"
const slot = document.getElementById("auth-slot")
function render(user) {
if (!user) return // 未登录:保留 SSR 渲染的 Login/Signup(已带语言前缀,无需重写)

View File

@ -1,10 +1,29 @@
// src/lib/api.ts — 后端 API 统一客户端
// 集中所有 /api 调用:base URL 解析、错误处理、端点封装。
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
//
// base URL 解析规则:
// - DEV(astro dev,前端 4345 + 后端 8090):一律回落 http://localhost:8090(CORS=*)。
// - SSR(构建/预渲染期):走 server-only 的 API_SSR,回落 PUBLIC_API_BASE。
// - 客户端(浏览器运行期):走 PUBLIC_API_BASE,置空时同源相对 /api(由 nginx 反代到 backend)。
// 集中封装全部 /api 调用:公开查询(/api/public/*)、账号体系(/api/auth/*)、
// 以及构建期专用的 SSG 接口(/api/ssg/*,仅在 Node 构建期使用)。
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
//
// base URL 解析规则(全项目统一两个基准地址,命名见下):
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
// BASE_API_SSG —— 构建期 Node 调 /api/ssg/* 的「内部端口」(默认 8091),
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
//
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
// - publicBase():客户端运行期 base,仅 BASE_API(空 → 同源相对 /api)。
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
import "@/lib/http"
// 公开/运行期基址 BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,
// 浏览器端须从 astro:env/client 读取(不会像 PUBLIC_ 前缀那样自动进 import.meta.env)。
// 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。
import { BASE_API } from "astro:env/client"
// ===================== 类型 =====================
export interface BrandEntry {
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
@ -44,36 +63,186 @@ export interface ArticlePage {
last_page: number
}
export function getBase(): string {
const env = import.meta.env as any
if (env.DEV) return "https://seo.23cm.cn"
if (env.SSR) return (env.API_SSR || env.PUBLIC_API_BASE || "").replace(/\/$/, "")
return (env.PUBLIC_API_BASE || "").replace(/\/$/, "")
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
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 }[]
}
// 把相对图片路径补成绝对(仅客户端渲染卡片时用;空 base 时保留相对路径走同源)
export interface AuthUser {
id: number
username: string
email: string
}
// SSG 构建专用类型
export interface SsgPopularBrand {
id: string
article_id: string
brand: string
cover: string
title: string
}
export interface SsgBrand {
id: string
name: string
show_name: string
article_count: number
}
// ===================== base 解析 =====================
export function getBase(): string {
if (import.meta.env.DEV) return "https://seo.23cm.cn"
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
return (BASE_API || "").replace(/\/$/, "")
}
// 相对图片路径补成绝对(基于 getBase())。
export function toAbs(url: string): string {
if (!url) return ""
return /^https?:\/\//i.test(url) ? url : getBase() + url
}
// 客户端运行期 base:仅 PUBLIC_API_BASE(空 → 同源相对 /api),
// 与 getBase()(含 SSR / DEV 覆盖,用于 RunwayLooks 等)区分,保持 articles 页既有行为。
// 客户端运行期 base:仅 BASE_API(空 → 同源相对 /api)。
export function publicBase(): string {
return ((import.meta.env as any).PUBLIC_API_BASE ?? "").replace(/\/$/, "")
return (BASE_API || "").replace(/\/$/, "")
}
// 相对路径补成绝对(基于 PUBLIC_API_BASE);供 articles.astro 客户端脚本复用,
// 避免在每个 .astro 文件的 <script> 里重复写 base URL 解析。
export function toAbsPublic(url: string): string {
if (!url) return ""
if (/^https?:\/\//i.test(url)) return url
return publicBase() + url
}
// 走秀档案列表(articles.astro 客户端按需拉取):保留该页独有的
// 「单值 brand_id / collection_type / season」参数形态与 summary 字段,
// 与 getArticles(RunwayLooks 用,参数形态不同)分开维护。
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
export function ssrBase(): string {
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
}
// ===================== 内部请求原语 =====================
// 通用请求原语:走 getBase() + /api + path(公开接口)。GET-only。
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const qs = params && params.toString() ? "?" + params.toString() : ""
const url = getBase() + "/api" + path + qs
const res = await fetch(url)
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T
}
// ===================== 文章(/api/public/articles) =====================
// 接口:GET /api/public/articles
// 原始文章列表(不分页映射),供数据层与各类 getter 复用。
export async function getArticlesRaw(opts?: {
page?: number
size?: number
sort?: string
brand_ids?: string[]
brand_id?: string
collection_type?: string[]
season?: string[]
year?: string[]
with_images?: number
keyword?: string
}): Promise<{ data: RawArticle[]; total: number; last_page: number }> {
const p = new URLSearchParams()
p.set("page", String(opts?.page ?? 1))
p.set("size", String(opts?.size ?? 24))
if (opts?.sort) p.set("sort", opts.sort)
if (opts?.keyword) p.set("keyword", opts.keyword)
if (opts?.with_images) p.set("with_images", String(opts.with_images))
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
if (opts?.brand_ids?.length) p.set("brand_ids", opts.brand_ids.join(","))
for (const v of opts?.collection_type ?? []) p.append("collection_type", v)
for (const v of opts?.season ?? []) p.append("season", v)
for (const v of opts?.year ?? []) p.append("year", v)
const j = await request<{ data?: any[]; total?: number; last_page?: number }>(
"/public/articles",
p
)
return {
data: (j.data ?? []) as RawArticle[],
total: Number(j.total ?? (j.data?.length ?? 0)),
last_page: Number(j.last_page ?? 1),
}
}
// 接口:GET /api/public/articles/:id
// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
export async function fetchArticleById(
id: number | string,
base: string = ssrBase()
): Promise<RawArticle | null> {
const b = base.replace(/\/$/, "")
if (!b) return null
try {
const res = await fetch(`${b}/api/public/articles/${id}`)
if (!res.ok) return null
const json: any = await res.json()
return (json?.data ?? null) as RawArticle | null
} catch {
return null
}
}
// 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
export type SsrArticleResult = {
article: RawArticle | null
error: "missing" | "failed" | null
}
export async function getSsrArticle(
id: string | null | undefined
): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing" }
try {
const article = await fetchArticleById(id)
return { article, error: article ? null : "failed" }
} catch {
return { article: null, error: "failed" }
}
}
// 接口:GET /api/public/articles(经 getArticlesRaw 透传)
// 文章列表(RunwayLooks / shows 用):多选用 repeated params,并把 images 映射成 gallery。
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
const { data, total, last_page } = await getArticlesRaw({
page: q.page,
size: q.size,
sort: q.sort,
with_images: q.with_images,
brand_ids: q.brand_ids,
collection_type: q.collection_type,
season: q.season,
year: q.year,
})
const mapped: ArticleItem[] = data.map((it: any) => ({
...it,
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
}))
return { data: mapped, total: Number(total ?? mapped.length), last_page: Number(last_page ?? 1) }
}
// 接口:GET /api/public/articles(客户端运行期,base 走 BASE_API)
// articles.astro 客户端按需拉取:保留该页独有的单值参数形态。
export async function fetchArticlesList(opts?: {
page?: number
size?: number
@ -93,17 +262,35 @@ export async function fetchArticlesList(opts?: {
return json?.data ?? []
}
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const qs = params && params.toString() ? "?" + params.toString() : ""
const url = getBase() + "/api" + path + qs
const res = await fetch(url)
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T
// 接口:GET /api/public/articles(经 getArticlesRaw,sort=year_desc)
// 最近若干条秀场中提取的独特年份(降序去重,最多 6 个),用于筛选年份 chips。
export async function getYears(sample = 30): Promise<number[]> {
const { data } = await getArticlesRaw({ size: sample, sort: "year_desc" })
const ys = Array.from(
new Set(data.map((it: any) => Number(it.year)).filter((y: number) => y > 0))
) as number[]
return ys.slice(0, 6)
}
// 侧边栏品牌清单(含 article_count 计数)
// 支持 keyword(模糊搜索 name/show_name)/ letter(A-Z 或 OTHER=中文)/ featured(精选)参数,
// 用于品牌筛选弹窗的「搜索 + A-Z 跳转 + 热门推荐」三种按需加载,避免一次拉全量。
// 接口:GET /api/public/colors(客户端运行期,base 走 BASE_API)
// 颜色接口(后端暂无独立颜色接口时返回 null,由调用方回落)。
export async function fetchColors(): Promise<any[] | null> {
const base = publicBase()
if (!base) return null
try {
const res = await fetch(`${base}/api/public/colors`)
if (!res.ok) return null
const json: any = await res.json()
const data: any[] = json?.data ?? []
return data.length ? data : null
} catch {
return null
}
}
// ===================== 品牌(/api/public/brands) =====================
// 接口:GET /api/public/brands
export async function getBrands(opts?: {
size?: number
only_with_articles?: number
@ -125,33 +312,110 @@ export async function getBrands(opts?: {
}))
}
// 最近若干条秀场中提取的独特年份(降序去重,最多 6 个),用于筛选年份 chips
export async function getYears(sample = 30): Promise<number[]> {
const qs = new URLSearchParams({ size: String(sample), sort: "year_desc" })
const j = await request<{ data?: any[] }>("/public/articles", qs)
const ys = Array.from(
new Set((j.data ?? []).map((it: any) => Number(it.year)).filter((y: number) => y > 0))
) as number[]
return ys.slice(0, 6)
// ===================== 账号体系(/api/auth/*) =====================
// 说明:当前前端只用「取当前用户 + 登出」两个能力(顶栏已登录态)。
// 登录/注册的表单提交暂未接入,相关接口函数已移除,避免无人调用的死代码。
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
const TOKEN_KEY = "fa_token"
const USER_KEY = "fa_user"
// 注意:getUser / clearSession 只读写 localStorage,不发网络请求。
export function getUser(): AuthUser | null {
if (typeof localStorage === "undefined") return null
try {
const raw = localStorage.getItem(USER_KEY)
return raw ? (JSON.parse(raw) as AuthUser) : null
} catch {
return null
}
}
// 文章/秀场列表:统一构造 QueryArray 参数(多选用 repeated params),
// 并把后端 images[] 映射成 gallery 字符串数组(缩略图条用)。
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
p.set("size", String(q.size ?? 24))
if (q.sort) p.set("sort", q.sort)
if (q.with_images) p.set("with_images", String(q.with_images))
if (q.brand_ids?.length) p.set("brand_ids", q.brand_ids.join(","))
// 多选:同一字段用 repeated params(后端 QueryArray 收到数组后 1 个走 = / 多个走 IN)
for (const v of q.collection_type ?? []) p.append("collection_type", v)
for (const v of q.season ?? []) p.append("season", v)
for (const v of q.year ?? []) p.append("year", v)
const j = await request<{ data?: any[]; total?: number; last_page?: number }>("/public/articles", p)
const data: ArticleItem[] = (j.data ?? []).map((it: any) => ({
...it,
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
}))
return { data, total: Number(j.total ?? data.length), last_page: Number(j.last_page ?? 1) }
export function clearSession(): void {
if (typeof localStorage === "undefined") return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return null
try {
// 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API)
const res = await fetch(`${BASE_API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
clearSession()
return null
}
const json: any = await res.json()
const user = json?.data?.user ?? json?.user
return (user as AuthUser) ?? null
} catch {
return null
}
}
// ===================== SSG 构建专用(/api/ssg/*,仅 Node 构建期) =====================
function ssgEnv(name: string): string | undefined {
const e = import.meta.env as any
if (e[name] != null && e[name] !== "") return e[name] as string
if (typeof process !== "undefined" && process.env[name]) return process.env[name] as string
return undefined
}
// SSG 内部接口 base:只认 BASE_API_SSG 专用端口(默认 8091),不回落 BASE_API/运行期端口。
// 绝不可回落到 BASE_API(8090)——那会把内部全量接口暴露到公网端口。
function ssgBase(): string {
return (ssgEnv("BASE_API_SSG") || "http://localhost:8091").replace(/\/$/, "")
}
function ssgToken(): string {
return (ssgEnv("SSG_TOKEN") || "").toString().trim()
}
function withToken(path: string): string {
const t = ssgToken()
if (!t) return path
return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
}
async function ssgRequest<T>(path: string): Promise<T | null> {
const url = ssgBase() + withToken(path)
try {
const res = await fetch(url)
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
}
}
// 接口:GET /api/ssg/brands/popular?limit=N(SSG 内部端口,构建期 Node 调用)
export async function getSsgPopularBrands(limit = 20): Promise<SsgPopularBrand[]> {
const j = await ssgRequest<{ data?: SsgPopularBrand[] }>(`/api/ssg/brands/popular?limit=${limit}`)
return j?.data ?? []
}
// 接口:GET /api/ssg/brands(SSG 内部端口,构建期 Node 调用)
export async function getSsgBrands(opts?: {
size?: number
only_with_articles?: number
featured?: number
}): Promise<SsgBrand[]> {
const qs = new URLSearchParams()
if (opts?.size) qs.set("size", String(opts.size))
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles))
if (opts?.featured != null) qs.set("featured", String(opts.featured))
const j = await ssgRequest<{ data?: SsgBrand[] }>(`/api/ssg/brands?${qs.toString()}`)
return j?.data ?? []
}
// 接口:GET /api/ssg/articles/ids(SSG 内部端口,构建期 Node 调用,供 getStaticPaths 枚举详情路由)
export async function getSsgArticleIds(): Promise<string[]> {
const j = await ssgRequest<{ data?: string[] }>(`/api/ssg/articles/ids`)
return j?.data ?? []
}

View File

@ -1,87 +0,0 @@
// 账号体系前端封装:token 存储 + 注册/登录/当前用户
// 与后端 /api/auth/* 交互(JWT 无状态,token 存 localStorage)
const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
const TOKEN_KEY = "fa_token"
const USER_KEY = "fa_user"
export type AuthUser = { id: number; username: string; email: string }
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function getUser(): AuthUser | null {
const raw = localStorage.getItem(USER_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AuthUser
} catch {
return null
}
}
export function setSession(token: string, user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
export async function login(account: string, password: string): Promise<AuthUser> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account, password }),
})
const json: any = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(json?.error || "登录失败")
setSession(json.token, json.user)
return json.user as AuthUser
}
export async function register(
username: string,
email: string,
password: string
): Promise<AuthUser> {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password }),
})
const json: any = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(json?.error || "注册失败")
setSession(json.token, json.user)
return json.user as AuthUser
}
// 用本地 token 拉取最新用户信息(校验有效性);无效则清空会话
export async function fetchMe(): Promise<AuthUser | null> {
const token = getToken()
if (!token) return null
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
clearSession()
return null
}
const json: any = await res.json()
const user = json?.user as AuthUser
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
return user
} catch {
return getUser()
}
}
// 带鉴权头的请求头(供将来受保护接口使用)
export function authHeaders(): Record<string, string> {
const t = getToken()
return t ? { Authorization: `Bearer ${t}` } : {}
}

View File

@ -1,5 +1,5 @@
// 网站统一数据层
// 从后端公开只读接口 (/api/public/...) 拉取真实数据,并适配为前端各模块
// 复用 api.ts 的公开接口拉取真实数据,并适配为前端各模块
// (site / shows / trends / colors / street,以及首页 backstage 面板、文章画廊 reports) 所需结构。
//
// 设计原则:
@ -7,33 +7,20 @@
// 2. 优雅回落:后端不可达或返回空时,回落到内置示例,保证 SSG 构建与离线预览不报错。
// 3. 颜色模块:后端暂无独立颜色接口,getColors() 先尝试 /api/public/colors,
// 失败则从 runway 文章封面派生占位(接入颜色接口后即可自动生效)。
//
// 所有 fetch 都已收敛到 @/lib/api,本文件只做数据映射与缓存。
const API_BASE = ((import.meta as any).env?.PUBLIC_API_BASE ?? "").replace(/\/$/, "")
import {
getArticlesRaw,
fetchArticleById,
fetchColors,
toAbs,
type RawArticle,
} from "@/lib/api"
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 type { RawArticle }
// 供仍从 data 取详情的函数(ssr.ts 等)透传
export { fetchArticleById }
export function seasonLabel(y: number | string): string {
const n = Number(y)
@ -100,14 +87,10 @@ function fromSample(): Loaded {
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))
const { data, total } = await getArticlesRaw({ page: 1, size: 60 })
if (!data.length) return fromSample()
return compute(data, Number(total || data.length))
} catch {
return fromSample()
}
@ -115,23 +98,7 @@ export function loadArticles(): Promise<Loaded> {
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
@ -186,13 +153,9 @@ 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 { data } = await getArticlesRaw({ page: 1, size, with_images: 5 })
return data.map((r) => {
const base = mapArticle(r)
base.gallery = (r.images || [])
.map((im: any) => toAbs(im.image || ""))
@ -209,7 +172,9 @@ export function loadArticlesWithImages(size = 40): Promise<ArticleCard[]> {
// attachGallery 在构建期(SSG)为每个展示文章预取其真实多图(前 3 张),
// 供首页卡片直接渲染"图片堆",无需等到悬停才拉取。后端不可达时 gallery 为空。
async function attachGallery<T extends { id: string | number; gallery: string[] }>(items: T[]): Promise<T[]> {
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[] = []
@ -234,25 +199,15 @@ export type ColorItem = {
}
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 colors = await fetchColors()
if (colors && colors.length) {
return colors.slice(0, limit).map((c: any) => ({
id: c.id ?? 0,
name: c.name || c.title || "",
brand: c.brand_name || "",
season: seasonLabel(c.year),
img: toAbs(c.cover || c.image || ""),
}))
}
const { items } = await loadArticles()
return items.slice(0, limit).map((r) => ({
@ -272,7 +227,6 @@ export type SiteInfo = {
}
export async function getSite(): Promise<SiteInfo> {
const loaded = await loadArticles()
// 落回到示例时,沿用与首页原本一致的示例展示值
if (loaded.sampled) {
return {
totalReports: 12,

208
src/lib/http.ts Normal file
View File

@ -0,0 +1,208 @@
// src/lib/http.ts — 统一 API 调试日志
//
// 拦截全局 fetch,记录「请求了哪些接口 / 发送了哪些数据 / 响应了哪些数据」,
// 解决前后端联调时接口黑盒的问题。使用方式:在任意会被加载到的模块里
// `import "@/lib/http"`(本文件会在加载时自动 install),或直接调用 installApiLogger()。
//
// 日志去向:
// - 浏览器(DEV 环境):console.debug 彩色输出,并收集到 window.__apiLog,
// 可用 downloadApiLog() 一键导出为 api-debug.log 下载。
// - Node(SSR / SSG 构建期):追加写入项目根 logs/api-debug.log。
//
// 开关:默认在 DEV 或 SSR(含 astro build)开启;生产环境客户端默认关闭,
// 可在 .env 设 DEBUG_API=true 强制开启。所有日志都会对 URL 里的 token 脱敏。
const g = globalThis as any
const ENABLED =
(import.meta as any).env?.DEV ||
(import.meta as any).env?.SSR ||
(typeof g.process !== "undefined" && g.process.env?.DEBUG_API === "true")
const nativeFetch: any = g.fetch ? g.fetch.bind(g) : undefined
function isNode(): boolean {
return (
typeof g.process !== "undefined" &&
!!g.process.versions?.node &&
typeof window === "undefined"
)
}
function redact(u: string): string {
try {
const url = new URL(u)
if (url.searchParams.has("token")) url.searchParams.set("token", "***")
return url.toString()
} catch {
return u
}
}
function truncate(s: string, n = 2000): string {
return s.length > n ? s.slice(0, n) + ` …(${s.length - n} more chars)` : s
}
function bodyToText(body: unknown): string {
if (body == null) return ""
if (typeof body === "string") return body
try {
return JSON.stringify(body)
} catch {
return String(body)
}
}
type LogEntry = {
ts: string
method: string
url: string
status: number
elapsedMs?: number
req?: string
resp?: string
error?: string
}
function formatLine(e: LogEntry): string {
let s = `${e.ts} ${e.method} ${e.url} -> ${e.status}`
if (e.elapsedMs != null) s += ` (${e.elapsedMs}ms)`
if (e.error) s += `\n ERROR: ${e.error}`
if (e.req) s += `\n REQ: ${e.req}`
if (e.resp) s += `\n RESP: ${e.resp}`
return s
}
async function writeLog(line: string): Promise<void> {
if (!ENABLED) return
if (isNode()) {
try {
const fsMod = await import(/* @vite-ignore */ "node:fs/promises")
const pathMod = await import(/* @vite-ignore */ "node:path")
const cwd = (g.process && g.process.cwd && g.process.cwd()) || "."
const logDir = pathMod.resolve(cwd, "logs")
await fsMod.mkdir(logDir, { recursive: true })
await fsMod.appendFile(pathMod.join(logDir, "api-debug.log"), line + "\n", "utf8")
} catch {
// 文件写入失败不应影响主流程
}
}
}
function pushBrowserLog(entry: {
method: string
url: string
status: number
req?: string
resp?: string
}): void {
try {
g.__apiLog = g.__apiLog || []
g.__apiLog.push(entry)
if (g.__apiLog.length > 300) g.__apiLog.shift()
} catch {
// ignore
}
}
export async function loggedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url
const method = init?.method || (input instanceof Request ? input.method : "GET")
const reqText = bodyToText(init?.body)
const started = Date.now()
const doFetch = nativeFetch || g.fetch
if (!isNode()) {
console.debug(`%c[API] → ${method} ${redact(url)}`, "color:#0a7d3b;font-weight:bold")
if (reqText) console.debug(`[API] req:`, truncate(reqText, 1000))
}
let res: Response
try {
res = await doFetch(input, init)
} catch (err) {
const line = formatLine({
ts: new Date().toISOString(),
method,
url: redact(url),
status: 0,
error: String(err),
})
await writeLog(line)
throw err
}
const elapsed = Date.now() - started
const cloned = res.clone()
let respText = ""
try {
respText = await cloned.text()
} catch {
// 某些响应体不可读,忽略
}
const line = formatLine({
ts: new Date().toISOString(),
method,
url: redact(url),
status: res.status,
elapsedMs: elapsed,
req: reqText || undefined,
resp: truncate(respText, 2000),
})
await writeLog(line)
if (!isNode()) {
const color = res.ok ? "#0a7d3b" : "#b00020"
console.debug(
`%c[API] ← ${res.status} ${method} ${redact(url)} (${elapsed}ms)`,
`color:${color};font-weight:bold`,
truncate(respText, 500)
)
pushBrowserLog({
method,
url: redact(url),
status: res.status,
req: reqText || undefined,
resp: truncate(respText, 2000),
})
}
return res
}
let installed = false
export function installApiLogger(): void {
if (installed || !ENABLED) return
if (nativeFetch) {
g.fetch = (input: any, init?: any) => loggedFetch(input, init)
installed = true
}
}
// 加载即自动安装:任何 import "@/lib/http" 的模块都会触发全局 fetch 拦截。
installApiLogger()
// 浏览器端:把已收集的日志导出为 .log 文件下载(仅在浏览器可用)。
export function downloadApiLog(): void {
if (typeof document === "undefined") return
const logs: any[] = g.__apiLog || []
const text = logs
.map(
(e) =>
`${e.method} ${e.url} -> ${e.status}\nREQ: ${e.req || ""}\nRESP: ${e.resp || ""}`
)
.join("\n\n")
const blob = new Blob([text || "(暂无记录)"], { type: "text/plain" })
const a = document.createElement("a")
a.href = URL.createObjectURL(blob)
a.download = "api-debug.log"
a.click()
URL.revokeObjectURL(a.href)
}

View File

@ -1,96 +0,0 @@
// src/lib/ssg.ts — SSG 构建专用接口客户端(集中封装 /api/ssg/*)
//
// SSG 接口只在「构建 / 预渲染期」由 Astro 后端(Node)fetch 调用,
// 永远不进入浏览器 bundle。它们位于独立的内部端口(默认 8091),不对外暴露、
// nginx 绝不反代。本文件与运行期公共 API 客户端(api.ts)完全分离:
// - api.ts → 运行期浏览器调用 /api/public/*,base = PUBLIC_API_BASE(同源 /api)
// - ssg.ts → 构建期 Node 调用 /api/ssg/*,base = SSG_API(独立内部端口 8091)
//
// base 解析优先级:
// 1) SSG_API —— 构建专用内部地址(部署由 build arg 注入,如 http://localhost:8091)
// 2) API_SSR —— 兼容旧命名
// 3) http://localhost:8091 —— 本地回落(SSG 引擎默认端口,绝不是 8090)
//
// token:SSG_TOKEN(server-only,绝不加 PUBLIC_ 前缀,不会进入 bundle);
// 为空则后端不校验(依赖回环绑定即可)。
// 同时读 import.meta.env 与 process.env:
// 构建/预渲染在 Node 中运行,.env 里的 server-only 变量两者皆可能承载,合并最稳妥。
function env(name: string): string | undefined {
const e = import.meta.env as any
if (e[name] != null && e[name] !== "") return e[name] as string
if (typeof process !== "undefined" && process.env[name]) return process.env[name] as string
return undefined
}
export function ssgBase(): string {
return (env("SSG_API") || env("API_SSR") || "http://localhost:8091").replace(/\/$/, "")
}
function ssgToken(): string {
return (env("SSG_TOKEN") || "").toString().trim()
}
// 若后端 SSG 端口启用了 SSG_TOKEN,则给每个请求附上 ?token=。
function withToken(path: string): string {
const t = ssgToken()
if (!t) return path
return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
}
async function ssgRequest<T>(path: string): Promise<T | null> {
const url = ssgBase() + withToken(path)
try {
const res = await fetch(url)
if (!res.ok) return null
return (await res.json()) as T
} catch {
// 后端不可达 / 超时:返回 null,由各 getter 回落到空数组(构建期不会崩)。
return null
}
}
// ---------- 类型 ----------
// 注意:id / brand_id 是后端 hashid 编码串(string),不是自增数字主键。
export interface SsgPopularBrand {
id: string
article_id: string
brand: string
cover: string
title: string
}
export interface SsgBrand {
id: string
name: string
show_name: string
article_count: number
}
// ---------- getter ----------
// 首页「热门品牌」区块:按图片数排名前 limit 个品牌,每个附带代表走秀的封面与标题。
export async function getSsgPopularBrands(limit = 20): Promise<SsgPopularBrand[]> {
const j = await ssgRequest<{ data?: SsgPopularBrand[] }>(`/api/ssg/brands/popular?limit=${limit}`)
return j?.data ?? []
}
// 品牌索引页全量:一次拉齐精选集合,前端按首字母分桶渲染。
export async function getSsgBrands(opts?: {
size?: number
only_with_articles?: number
featured?: number
}): Promise<SsgBrand[]> {
const qs = new URLSearchParams()
if (opts?.size) qs.set("size", String(opts.size))
if (opts?.only_with_articles != null) qs.set("only_with_articles", String(opts.only_with_articles))
if (opts?.featured != null) qs.set("featured", String(opts.featured))
const j = await ssgRequest<{ data?: SsgBrand[] }>(`/api/ssg/brands?${qs.toString()}`)
return j?.data ?? []
}
// getStaticPaths 用的轻量枚举:只回文章编码 uid,不带回 cover/description,省带宽。
export async function getSsgArticleIds(): Promise<string[]> {
const j = await ssgRequest<{ data?: string[] }>(`/api/ssg/articles/ids`)
return j?.data ?? []
}

View File

@ -1,34 +0,0 @@
// src/lib/ssr.ts — 服务端(按需 SSR / on-demand render)数据客户端
//
// 集中封装「服务端取数 + base URL 解析」,让 .astro 页面只调用业务函数,
// 不再自己拼 API_SSR / PUBLIC_API_BASE / http://localhost:8090 这类 base URL。
//
// 与 ssg.ts 的区别:
// - ssg.ts → 构建期 Node 调 /api/ssg/*(内部端口 8091,不对外暴露)
// - ssr.ts → 运行期(按需渲染)调 /api/public/*,base = API_SSR(回落 PUBLIC_API_BASE)
import { fetchArticleById, type RawArticle } from "./data"
// SSR base 解析:API_SSR(部署注入)> PUBLIC_API_BASE > 本地后端回落
export function ssrBase(): string {
const e = import.meta.env as any
return (e.API_SSR || e.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
}
export type SsrArticleResult = {
article: RawArticle | null
error: "missing" | "failed" | null
}
// 按 id 取文章:包揽「缺 id / 取数失败 / 成功」三态,astro 页面只调这一行
export async function getSsrArticle(
id: string | null | undefined
): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing" }
try {
const article = await fetchArticleById(id, ssrBase())
return { article, error: article ? null : "failed" }
} catch {
return { article: null, error: "failed" }
}
}

View File

@ -1,7 +1,7 @@
---
import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro"
import { getSsrArticle } from "@/lib/ssr"
import { getSsrArticle } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /cn/article?id= 链接,无需改动其他页面)
export const prerender = false

View File

@ -1,7 +1,7 @@
---
import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro"
import { getSsrArticle } from "@/lib/ssr"
import { getSsrArticle } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
export const prerender = false

View File

@ -1,7 +1,7 @@
---
import Layout from "@/layouts/Layout.astro"
import RunwayLookDetail from "@/components/RunwayLooksDetail.astro"
import { getSsrArticle } from "@/lib/ssr"
import Item from "@/components/Item.astro"
import { getSsrArticle } from "@/lib/api"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/items/:id 链接,无需改动其他页面)
export const prerender = false
@ -11,5 +11,5 @@ const { article, error } = await getSsrArticle(id)
---
<Layout>
<RunwayLookDetail article={article} error={error} />
<Item article={article} error={error} />
</Layout>