376
src/lib/api.ts
376
src/lib/api.ts
@ -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 ?? []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user