184
src/lib/api.ts
184
src/lib/api.ts
@ -1,7 +1,7 @@
|
||||
// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
|
||||
//
|
||||
// 集中封装全部 /api 调用:公开查询(/api/public/*)、账号体系(/api/auth/*)、
|
||||
// 以及构建期专用的 SSG 接口(/api/ssg/*,仅在 Node 构建期使用)。
|
||||
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
|
||||
// 以及构建期专用的 SSG 接口(/api/v1/ssg/*,仅在 Node 构建期使用)。
|
||||
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
|
||||
//
|
||||
// base URL 解析规则(全项目统一两个基准地址,命名见下):
|
||||
@ -18,6 +18,10 @@
|
||||
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
||||
import "@/lib/http"
|
||||
|
||||
// i18n:把"当前语言"透传到后端,让 /api 按 locale 选 *_en / *_cn 列。
|
||||
import { getLocaleFromUrl } from "@/i18n/utils"
|
||||
import { DEFAULT_LOCALE } from "@/i18n/config"
|
||||
|
||||
// 公开/运行期基址 BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,
|
||||
// 浏览器端须从 astro:env/client 读取(不会像 PUBLIC_ 前缀那样自动进 import.meta.env)。
|
||||
// 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。
|
||||
@ -27,7 +31,9 @@ import { BASE_API } from "astro:env/client"
|
||||
|
||||
export interface BrandEntry {
|
||||
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
|
||||
name: string
|
||||
name: string // 英文名(name_en 回落 name)
|
||||
nameCn?: string // 中文名(name_cn,可能为空)
|
||||
display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
|
||||
article_count: number
|
||||
}
|
||||
|
||||
@ -97,17 +103,13 @@ export interface SsgPopularBrand {
|
||||
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"
|
||||
// if (import.meta.env.DEV) return "https://seo.23cm.cn"
|
||||
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
|
||||
return (BASE_API || "").replace(/\/$/, "")
|
||||
}
|
||||
@ -134,12 +136,44 @@ export function ssrBase(): string {
|
||||
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
// ===================== i18n:请求语言透传 =====================
|
||||
// 后端 /api/v1/public/* 与 /api/v1/ssg/* 都通过 ?locale=cn|en(优先级最高,高于 Accept-Language)
|
||||
// 决定 name / title / brand_name 选用 *_en 还是 *_cn 列(service.pickLocale 选列并缺翻译回落),
|
||||
// 出参字段名保持不变,故前端契约无需改动,仅需在请求上带上当前语言。
|
||||
//
|
||||
// 当前语言来源分两端:
|
||||
// - 浏览器端(<script> 里的 fetch):从 URL path 推断(/cn/ → cn,其余 → en),调用方无需关心。
|
||||
// - 服务端(SSG 构建 / 按需渲染):由各页面/布局在前置 frontmatter 调 setApiLocale() 注入,
|
||||
// 因绝大多数页面是 SSG 单线程构建,模块级 _ssrLocale 安全;本文件也允许按需渲染时显式传参。
|
||||
let _ssrLocale: string | null = null
|
||||
export function setApiLocale(locale: string | null | undefined): void {
|
||||
_ssrLocale = locale || DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
// 解析当前语言:浏览器端走 URL path,服务端回落注入值或默认语言。
|
||||
function currentLocale(): string {
|
||||
if (typeof window !== "undefined" && window.location) {
|
||||
return getLocaleFromUrl(window.location.pathname)
|
||||
}
|
||||
return _ssrLocale || DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
// 把当前语言追加进 query(locale 非空才加,避免污染 URL;DEFAULT_LOCALE 也显式带,语义清晰)。
|
||||
function withLocale(params?: URLSearchParams, override?: string): URLSearchParams {
|
||||
const p = params ? new URLSearchParams(params) : new URLSearchParams()
|
||||
const loc = override || currentLocale()
|
||||
if (loc) p.set("locale", loc)
|
||||
return p
|
||||
}
|
||||
|
||||
// ===================== 内部请求原语 =====================
|
||||
|
||||
// 通用请求原语:走 getBase() + /api + path(公开接口)。GET-only。
|
||||
// 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。
|
||||
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。
|
||||
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
|
||||
const qs = params && params.toString() ? "?" + params.toString() : ""
|
||||
const url = getBase() + "/api" + path + qs
|
||||
const p = withLocale(params)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
const url = getBase() + "/api/v1" + path + qs
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
|
||||
return (await res.json()) as T
|
||||
@ -185,14 +219,18 @@ export async function getArticlesRaw(opts?: {
|
||||
|
||||
// 接口:GET /api/public/articles/:id
|
||||
// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
|
||||
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
|
||||
export async function fetchArticleById(
|
||||
id: number | string,
|
||||
base: string = ssrBase()
|
||||
base: string = ssrBase(),
|
||||
locale?: string
|
||||
): Promise<RawArticle | null> {
|
||||
const b = base.replace(/\/$/, "")
|
||||
if (!b) return null
|
||||
const p = withLocale(undefined, locale)
|
||||
const qs = p.toString() ? "?" + p.toString() : ""
|
||||
try {
|
||||
const res = await fetch(`${b}/api/public/articles/${id}`)
|
||||
const res = await fetch(`${b}/api/v1/public/articles/${id}${qs}`)
|
||||
if (!res.ok) return null
|
||||
const json: any = await res.json()
|
||||
return (json?.data ?? null) as RawArticle | null
|
||||
@ -204,17 +242,19 @@ export async function fetchArticleById(
|
||||
// 服务端按需渲染(on-demand)取文章详情:在 fetchArticleById 之上包揽
|
||||
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
|
||||
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
|
||||
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
|
||||
export type SsrArticleResult = {
|
||||
article: RawArticle | null
|
||||
error: "missing" | "failed" | null
|
||||
}
|
||||
|
||||
export async function getSsrArticle(
|
||||
id: string | null | undefined
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
): Promise<SsrArticleResult> {
|
||||
if (!id) return { article: null, error: "missing" }
|
||||
try {
|
||||
const article = await fetchArticleById(id)
|
||||
const article = await fetchArticleById(id, ssrBase(), locale)
|
||||
return { article, error: article ? null : "failed" }
|
||||
} catch {
|
||||
return { article: null, error: "failed" }
|
||||
@ -256,38 +296,14 @@ export async function fetchArticlesList(opts?: {
|
||||
if (opts?.collection_type) p.set("collection_type", opts.collection_type)
|
||||
if (opts?.season) p.set("season", opts.season)
|
||||
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
|
||||
const res = await fetch(`${base}/api/public/articles?${p.toString()}`)
|
||||
// 浏览器端按 URL path 自动推断当前语言并追加 ?locale=
|
||||
p.set("locale", currentLocale())
|
||||
const res = await fetch(`${base}/api/v1/public/articles?${p.toString()}`)
|
||||
if (!res.ok) throw new Error(`list ${res.status}`)
|
||||
const json = await res.json()
|
||||
return json?.data ?? []
|
||||
}
|
||||
|
||||
// 接口: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)
|
||||
}
|
||||
|
||||
// 接口: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
|
||||
@ -300,16 +316,27 @@ export async function getBrands(opts?: {
|
||||
}): Promise<BrandEntry[]> {
|
||||
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))
|
||||
// 关键词搜索:用户按名字找品牌,不应被"必须有走秀档案"过滤。
|
||||
// 后端已对 keyword 跳过该限制,这里再显式置 0 双保险,使正在运行的后端也立即生效。
|
||||
const onlyWA = opts?.keyword ? 0 : (opts?.only_with_articles ?? 1)
|
||||
qs.set("only_with_articles", String(onlyWA))
|
||||
if (opts?.featured != null) qs.set("featured", String(opts.featured))
|
||||
if (opts?.keyword) qs.set("keyword", opts.keyword)
|
||||
if (opts?.letter) qs.set("letter", opts.letter)
|
||||
const j = await request<{ data?: any[] }>("/public/brands", qs)
|
||||
return (j.data ?? []).map((b: any) => ({
|
||||
id: String(b.id),
|
||||
name: (b.name || b.show_name || "").toString(),
|
||||
article_count: Number(b.article_count ?? 0),
|
||||
}))
|
||||
return (j.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
const nameCn = (b.name_cn || "").toString()
|
||||
// 品牌名始终展示英文(即便是中文语境),不再拼接中文名,保持英文观感。
|
||||
const display = nameEn
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn,
|
||||
display,
|
||||
article_count: Number(b.article_count ?? 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 账号体系(/api/auth/*) =====================
|
||||
@ -343,7 +370,7 @@ export async function fetchMe(): Promise<AuthUser | null> {
|
||||
if (!token) return null
|
||||
try {
|
||||
// 接口:GET /api/auth/me(需 Bearer token,客户端运行期 base 走 BASE_API)
|
||||
const res = await fetch(`${BASE_API_URL}/api/auth/me`, {
|
||||
const res = await fetch(`${BASE_API_URL}/api/v1/auth/me?locale=${encodeURIComponent(currentLocale())}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@ -384,14 +411,14 @@ 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)
|
||||
// 组合 SSG_TOKEN 与 ?locale=(构建期按当前语言选列,后端 parseLocale 已支持)。
|
||||
const t = ssgToken()
|
||||
const parts: string[] = []
|
||||
if (t) parts.push("token=" + encodeURIComponent(t))
|
||||
const loc = currentLocale()
|
||||
if (loc) parts.push("locale=" + encodeURIComponent(loc))
|
||||
const url = ssgBase() + path + (parts.length ? (path.includes("?") ? "&" : "?") + parts.join("&") : "")
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
@ -401,28 +428,29 @@ async function ssgRequest<T>(path: string): Promise<T | 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/v1/ssg/index/runway(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
|
||||
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
|
||||
const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/v1/ssg/index/runway`)
|
||||
return j?.data ?? { hotBrand: [] }
|
||||
}
|
||||
|
||||
// 接口: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 ?? []
|
||||
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
|
||||
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
|
||||
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
||||
const j = await ssgRequest<{ data?: any[] }>("/api/v1/ssg/brands/hot")
|
||||
return (j?.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn: (b.name_cn || "").toString(),
|
||||
display: nameEn,
|
||||
article_count: Number(b.article_count ?? 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接口: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 ?? []
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user