Files
frontend_v2/src/lib/api.ts
toom1996 eee2265e15
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-08-26 19:42:10 +08:00

457 lines
17 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.

// src/lib/api.ts — 后端 API 统一客户端(所有接口的唯一出入口)
//
// 集中封装全部 /api/v1 调用:公开查询(/api/v1/public/*)、账号体系(/api/v1/auth/*)、
// 以及构建期专用的 SSG 接口(/api/v1/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"
// 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,因此服务端/客户端共用同一份值。
import { BASE_API } from "astro:env/client"
// ===================== 类型 =====================
export interface BrandEntry {
id: string // 后端返回 hashid 编码串(非自增主键),防爬虫顺序枚举
name: string // 英文名(name_en 回落 name)
nameCn?: string // 中文名(name_cn,可能为空)
display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
article_count: number
}
export interface ArticleItem {
id: string // hashid 编码串
brand_id: string // hashid 编码串
title: string
cover: string
brand_name: string
year: number
image_count: number
published_at: number
collection_type?: string
season?: string
season_code?: string
gallery: string[]
}
export interface ArticleQuery {
page?: number
size?: number
sort?: string
brand_ids?: string[] // 编码串数组,透传给后端解码
collection_type?: string[]
season?: string[]
year?: string[]
with_images?: number
}
export interface ArticlePage {
data: ArticleItem[]
total: number
last_page: number
}
// 后端文章原始结构(与 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 }[]
}
export interface AuthUser {
id: number
username: string
email: string
}
// SSG 构建专用类型
export interface SsgPopularBrand {
id: string
article_id: string
brand: string
cover: string
title: string
}
// ===================== 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:仅 BASE_API(空 → 同源相对 /api)。
export function publicBase(): string {
return (BASE_API || "").replace(/\/$/, "")
}
export function toAbsPublic(url: string): string {
if (!url) return ""
if (/^https?:\/\//i.test(url)) return url
return publicBase() + url
}
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
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/v1 + path(公开接口)。GET-only。
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
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
}
// ===================== 文章(/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()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
export async function fetchArticleById(
id: number | string,
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/v1/public/articles/${id}${qs}`)
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)。
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
export type SsrArticleResult = {
article: RawArticle | null
error: "missing" | "failed" | null
}
export async function getSsrArticle(
id: string | null | undefined,
locale?: string
): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing" }
try {
const article = await fetchArticleById(id, ssrBase(), locale)
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
collection_type?: string
season?: string
brand_id?: string
}): Promise<any[]> {
const base = publicBase()
if (!base) return []
const p = new URLSearchParams({ page: String(opts?.page ?? 1), size: String(opts?.size ?? 30) })
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)
// 浏览器端按 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 ?? []
}
// ===================== 品牌(/api/public/brands) =====================
// 接口:GET /api/public/brands
export async function getBrands(opts?: {
size?: number
only_with_articles?: number
featured?: number
keyword?: string
letter?: string
}): Promise<BrandEntry[]> {
const qs = new URLSearchParams()
if (opts?.size) qs.set("size", String(opts.size))
// 关键词搜索:用户按名字找品牌,不应被"必须有走秀档案"过滤。
// 后端已对 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) => {
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/*) =====================
// 说明:当前前端只用「取当前用户 + 登出」两个能力(顶栏已登录态)。
// 登录/注册的表单提交暂未接入,相关接口函数已移除,避免无人调用的死代码。
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
}
}
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/v1/auth/me?locale=${encodeURIComponent(currentLocale())}`, {
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
}
}
// 登录/注册成功后写入本地会话(token + user),供 Layout 顶部已登录态与 fetchMe 读取。
export function saveSession(token: string, user: AuthUser): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
// ===================== 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()
}
async function ssgRequest<T>(path: string): Promise<T | null> {
// 组合 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
return (await res.json()) as T
} catch {
return null
}
}
// 接口: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 调用)
// 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),
}
})
}