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

This commit is contained in:
toom1996
2026-08-26 19:42:10 +08:00
parent d64a046f82
commit eee2265e15
29 changed files with 337 additions and 2506 deletions

View File

@ -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 ?? []
}