457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
// 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),
|
||
}
|
||
})
|
||
}
|
||
|
||
|
||
|