Files
frontend_v2/src/lib/api.ts
toom1996 c85c561a99 update
2026-09-13 00:49:42 +08:00

713 lines
30 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/internal/ssg/*,内部契约,仅 Node 构建期使用)。
// 任何模块需要后端数据,都只从这里取,不要再自己写 fetch。
//
// base URL 解析规则(全项目统一两个基准地址,命名见下):
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
// BASE_API_SSG —— 构建期 Node 调 /api/internal/ssg/* 的「内部端口」(默认 8091),
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
//
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
// - ssgBase():构建期 Node 调 /api/internal/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 // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
}
export interface ArticleItem {
id: string // hashid 编码串
brand_id: string // hashid 编码串
title: string
cover: string
brand_name: string
image_count: number // 该走秀图片总数(列表卡片 "N+" 角标用)
gallery: string[] // 由 images 映射而来的附图 URL 列表
}
export interface ArticleQuery {
page?: number
sort?: string
brandId?: string // 编码串;传入则附加 ?brand_id 查询参数
collection?: string // 单值:rtw/menswear/couture/resort/pre_fall
season?: string // 单值:spring/fall
year?: string | number
}
export interface ArticlePage {
data: ArticleItem[]
last_page: number
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
}
// 后端单张图片(与 PublicArticleImage 对齐):主图或细节图。
export interface RawArticleImage {
id?: string // 图片自身编码串(i/j 前缀),单图收藏用
image: string // 该接口场景的主用地址(详情=展示样式 !style:high;列表=缩略图 !style:thumb)
thumb?: string // 缩略图地址(!style:thumb),仅详情接口返回:供大图灯箱左栏缩略图条 / 右下角细节缩略图等小尺寸场景用,省带宽
name?: string
is_detail?: number // 0=主图 1=细节图(详情接口 images 仅含主图;细节在 details 分组)
look_index?: number // 细节图归属的主图序号(与 images 主图的 look_index 一致)
}
// 某个主图(look)对应的细节图集合(与 RunwayLookDetails 对齐),详情页「细节入口」按需展开。
export interface RawLookDetails {
look_index: number // 主图序号
images: RawArticleImage[] // 该主图的细节图列表
}
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
export type RawArticle = {
id: string | number // 后端为 hashid 编码串(string)
title: string
description?: string // 详情正文(仅详情接口返回)
cover: string
brand_name: string
brand_id?: string | number // hashid 编码串
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回);改造后仅计主图数
// 详情接口 images 仅含主图(look 图),默认展示;细节图在 details 分组,需经「细节入口」展开。
images?: RawArticleImage[]
// 按主图分组的细节图(「细节入口」展开用);无细节时为空数组或不返回。
details?: RawLookDetails[]
}
export interface AuthUser {
id: number
username: string
email: string
/** 用户等级:free(普通)| vip(高级,图集详情页可见高清原图)。 */
tier: 'free' | 'vip'
}
// SSG 构建专用类型
export interface SsgPopularBrand {
id: string
article_id: string
brand: string
cover: string
title: string
}
// ===================== base 解析 =====================
// 仅本模块内部使用(toAbs / request 等),不对外导出。
function getBase(): string {
// 运行期/预渲染统一走 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
}
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
export function ssrBase(): string {
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
}
// ===================== i18n:请求语言透传 =====================
// 后端 /api/v1/public/* 与 /api/internal/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
}
// ===================== 前端 JS 请求签名(反爬一层) =====================
// 注意:这是「提高成本」而非「加密」。CLIENT_SIGN_SECRET 必须出现在前端 bundle 才能签名,
// 对浏览器可见;有决心的爬虫可反编译复刻。它用于拖慢 casual 爬虫,配合后端按 IP 限流才有效。
// 必须与后端 client_sign.secret 完全一致。生产请改用随机长串并同步两端。
const CLIENT_SIGN_SECRET = "dev-client-sign-secret-change-me"
const CLIENT_SIGN_TTL = 30
// 计算 HMAC-SHA256 签名。canonical = METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce
// 与后端 middleware.ClientSign 的拼接方式严格一致(换行符分隔,顺序固定)。
async function clientSign(method: string, fullPath: string, rawQuery: string): Promise<{ sig: string; ts: string; nonce: string }> {
const ts = String(Math.floor(Date.now() / 1000))
const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36)
const msg = [method, fullPath, rawQuery, ts, nonce].join("\n")
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
enc.encode(CLIENT_SIGN_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
)
const buf = await crypto.subtle.sign("HMAC", key, enc.encode(msg))
const sig = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return { sig, ts, nonce }
}
// ===================== 内部请求原语 =====================
// 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。
// 每个请求附带前端 JS 签名(X-Sign / X-Sign-Ts / X-Sign-Nonce),供后端 ClientSign 中间件校验。
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const p = withLocale(params)
const qs = p.toString() ? "?" + p.toString() : ""
const fullPath = "/api/v1" + path
const { sig, ts, nonce } = await clientSign("GET", fullPath, p.toString())
const url = getBase() + fullPath + qs
const headers: Record<string, string> = {
"X-Sign": sig,
"X-Sign-Ts": ts,
"X-Sign-Nonce": nonce,
}
// 已登录则自动附带 Bearer(翻页/筛选等需鉴权的列表请求才能通过后端校验)。
// 未登录时不带,首页(page<=1)仍可直接公开访问。
const token = getAccessToken()
if (token) headers["Authorization"] = `Bearer ${token}`
let res = await fetch(url, { headers })
// 登录态下 access 过期:用 refresh 续期一次后重试(与 authedFetch 行为一致)。
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers["Authorization"] = `Bearer ${newToken}`
res = await fetch(url, { headers })
} catch {
/* 刷新失败:保留原 401,下面统一抛出,由调用方处理 */
}
}
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T
}
// ===================== 走秀(/api/public/runway-looks) =====================
// 接口:GET /api/public/runway-looks/:id
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
// 内部 base 走 ssrBase()(运行期取数,BASE_API > 本地 8090)。
// locale 显式传入(如 Astro.currentLocale)以绑定当前语言,避免并发串 locale。
export type SsrArticleResult = {
article: RawArticle | null
error: "missing" | "failed" | null
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
}
export async function getSsrArticle(
id: string | null | undefined,
locale?: string
): Promise<SsrArticleResult> {
if (!id) return { article: null, error: "missing", preview: false, imageTotal: 0 }
const b = ssrBase().replace(/\/$/, "")
if (!b) return { article: null, error: "failed", preview: false, imageTotal: 0 }
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
if (!res.ok) return { article: null, error: "failed", preview: false, imageTotal: 0 }
const json: any = await res.json()
return {
article: (json?.data ?? null) as RawArticle | null,
preview: !!json?.preview,
imageTotal: Number(json?.image_total ?? 0),
error: null,
}
} catch {
return { article: null, error: "failed", preview: false, imageTotal: 0 }
}
}
// 已登录用户详情取数:走 request()(自动附带 Bearer + locale 签名),
// 后端据此返回完整图片集(未登录截断到 PreviewLimit 张)。详情页未登录→登录后客户端补拉全量用。
export async function getArticleDetailAuthed(
id: string,
locale?: string
): Promise<RawArticle | null> {
try {
const json: any = await request(`/public/runway-looks/${encodeURIComponent(id)}`)
return (json?.data ?? null) as RawArticle | null
} catch {
return null
}
}
// 接口:GET /api/public/runway-looks
// 走秀列表(RunwayLooks / shows 用):单值筛选,brandId 走 ?brand_id 查询参数;
// 把后端 images[{image,name}] 映射成前端卡片用的 gallery(纯 URL 数组)。
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
if (q.sort) p.set("sort", q.sort)
if (q.brandId) p.set("brand_id", q.brandId)
if (q.collection) p.set("collection", q.collection)
if (q.season) p.set("season", q.season)
if (q.year) p.set("year", String(q.year))
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/runway-looks", p)
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
...it,
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
}))
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
}
// ===================== 品牌(/api/public/brands) =====================
// 接口:GET /api/public/brands
// size 已写死 30(后端 brandDefaultSize),only_with_articles 已删除(字母索引始终只返回有档案品牌),
// 前端均不传;关键词搜索仍不过滤"必须有走秀档案"(后端对 keyword 跳过该限制)。
export async function getBrands(opts?: {
featured?: number
keyword?: string
letter?: string
}): Promise<BrandEntry[]> {
const qs = new URLSearchParams()
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,
}
})
}
// ===================== 账号体系(/api/auth/*) =====================
// 双令牌:access_token(短命,请求鉴权)+ refresh_token(长命,落库可吊销)。
// 登录写两者;fetchMe 等受保护请求遇 401 自动用 refresh 续期一次;登出吊销 refresh。
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
const TOKEN_KEY = "fa_token" // access token
const REFRESH_KEY = "fa_refresh" // refresh 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 getAccessToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(TOKEN_KEY)
}
export function getRefreshToken(): string | null {
if (typeof localStorage === "undefined") return null
return localStorage.getItem(REFRESH_KEY)
}
export function clearSession(): void {
if (typeof localStorage === "undefined") return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
}
// 写登录态:登录成功后把 access + refresh + user 写入 localStorage。
export function saveSession(access: string, refresh: string, user: AuthUser): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(TOKEN_KEY, access)
localStorage.setItem(REFRESH_KEY, refresh)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
// 刷新 token:用 refresh_token 换新的 access_token(refresh 复用、不轮换)。
export async function refreshSession(): Promise<string> {
const refresh = getRefreshToken()
if (!refresh) throw new Error("no refresh token")
const res = await fetch(`${BASE_API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) {
clearSession()
throw new Error("refresh failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
if (!access) throw new Error("refresh failed")
// refresh 复用:仅更新 access,保留原 refresh。
const user = getUser()
if (user) saveSession(access, refresh, user)
return access
}
// 带鉴权的请求:自动附带 Bearer,遇 401 静默刷新一次后重试。
async function authedFetch(url: string, opts: RequestInit = {}): Promise<Response> {
const headers = new Headers(opts.headers)
const token = getAccessToken()
if (token) headers.set("Authorization", `Bearer ${token}`)
let res = await fetch(url, { ...opts, headers })
if (res.status === 401 && getRefreshToken()) {
try {
await refreshSession()
const newToken = getAccessToken()
if (newToken) headers.set("Authorization", `Bearer ${newToken}`)
res = await fetch(url, { ...opts, headers })
} catch {
/* 刷新失败:返回原始 401,由调用方处理 */
}
}
return res
}
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
// 收藏/个人中心等需要登录的写操作统一走这里。
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${BASE_API_URL}/api/v1${path}`
const headers: Record<string, string> = { "Content-Type": "application/json" }
const res = await authedFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let msg = `API ${res.status} ${method} ${path}`
try {
const j = await res.json()
if (j?.message) msg = String(j.message)
} catch {
/* 响应体非 JSON,沿用默认错误信息 */
}
throw new Error(msg)
}
const json: any = await res.json()
return (json?.data ?? null) as T
}
export async function fetchMe(): Promise<AuthUser | null> {
if (typeof localStorage === "undefined") return null
if (!getAccessToken()) return null
try {
const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
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
}
}
// 登录:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
const res = await fetch(`${BASE_API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
if (!res.ok) {
clearSession()
throw new Error("login failed")
}
const json: any = await res.json()
const access: string | undefined = json?.access_token
const refresh: string | undefined = json?.refresh_token
const user: any = json?.user
if (!access || !refresh || !user) throw new Error("login failed")
saveSession(access, refresh, user)
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:login"))
}
return user as AuthUser
}
// 登出:吊销当前 refresh_token,再清本地态。
export async function logout(): Promise<void> {
const refresh = getRefreshToken()
if (refresh) {
try {
// 会话即 refresh token:单设备登出 = 吊销当前会话
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
})
} catch {
/* 忽略网络错误,仍清本地态 */
}
}
clearSession()
}
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
// 历史只存文章对外编码串,展示时逐篇回查公开详情接口取 title/cover/brand。
// 编码串首字符即类型:s=… 实为 s 开头(如 s000zAwwQ)代表街拍,其余(r 开头等)为走秀。
// 走 request():自动带 locale 与前端签名,登录态附带 Bearer。
export interface HistoryMeta {
title: string
cover: string
brand: string
type: "street" | "runway"
// 后端回传的**规范 id**(带类型前缀,如 r0008CZYI)。历史/收藏里可能存在早期无前缀的 id,
// 后端对无前缀串也能解析并回传规范 id,故用它做「同一篇文章」的判据与链接目标。
id: string
}
// 单篇回查:按编码串首字符判断类型(s 开头 = 街拍,其余 = 走秀)。失败返回 null,由调用方容忍。
export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
const sid = String(id)
// 编码串类型前缀是单个字符(如 s000zAwwQ),与 /item/[id] 路由判断一致(r= 走秀 / s= 街拍)
const type = sid.charAt(0) === "s" ? ("street" as const) : ("runway" as const)
const path =
type === "street"
? `/public/street-snaps/${encodeURIComponent(sid)}`
: `/public/runway-looks/${encodeURIComponent(sid)}`
try {
const j = await request<{ data?: any }>(path)
const d = j?.data
if (!d) return null
return {
title: (d.title || "").toString(),
cover: (d.cover || "").toString(),
brand: (d.brand_name || d.brand || "").toString(),
type,
id: (d.id || sid).toString(),
}
} catch {
return null
}
}
// ===================== SSG 构建专用(/api/internal/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/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
// 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/internal/ssg/home/runway`)
return j?.data ?? { hotBrand: [] }
}
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
const j = await ssgRequest<{ data?: any[] }>("/api/internal/ssg/brands/popular")
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,
}
})
}
// ===================== 街拍(/api/public/street-snaps) =====================
export interface StreetSnapItem {
id: string // hashid 编码串
title: string // 含字面 \t 分隔:地点 \t 场次
cover: string
image_count: number
images: { id?: string; image: string; name?: string }[] // 列表附带的每篇前 N 张缩略图(id 为图片自身的编码串)
}
// 列表响应(与走秀 ArticlePage 同构)
export interface StreetSnapPage {
data: StreetSnapItem[]
last_page: number
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
}
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
export interface RawStreetSnap {
id: string
title: string
cover: string
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
}
// 接口:GET /api/public/street-snaps
// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)+ 地区(city 精确匹配)。
// 分页 size 服务端固定 24,images 服务端固定 6,前端均不传。
export async function getStreetSnaps(q: {
page?: number
year?: number | string
city?: string
sort?: string
} = {}): Promise<StreetSnapPage> {
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
if (q.sort) p.set("sort", q.sort)
if (q.year) p.set("year", String(q.year))
if (q.city) p.set("city", q.city)
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/street-snaps", p)
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
id: String(it.id),
title: it.title ?? "",
cover: it.cover ?? "",
image_count: Number(it.image_count ?? 0),
// 保留每张图的 id(图片编码串),单图收藏需要用它定位到具体某一张
images: Array.isArray(it.images)
? it.images.map((im: any) => ({ id: im.id || "", image: im.image || im, name: im.name || "" }))
: [],
}))
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
}
// 接口:GET /api/public/street-snaps/:id
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
// 对外导出:详情取数 / 个人中心逐篇回查。
export type SsrStreetSnapResult = {
snap: RawStreetSnap | null
error: "missing" | "failed" | null
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
}
// 服务端按需渲染(on-demand)取街拍详情:直接 fetch 详情接口并透传 preview 标记。
export async function getSsrStreetSnap(
id: string | null | undefined,
locale?: string
): Promise<SsrStreetSnapResult> {
if (!id) return { snap: null, error: "missing", preview: false, imageTotal: 0 }
const b = ssrBase().replace(/\/$/, "")
if (!b) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
if (!res.ok) return { snap: null, error: "failed", preview: false, imageTotal: 0 }
const json: any = await res.json()
return {
snap: (json?.data ?? null) as RawStreetSnap | null,
preview: !!json?.preview,
imageTotal: Number(json?.image_total ?? 0),
error: null,
}
} catch {
return { snap: null, error: "failed", preview: false, imageTotal: 0 }
}
}
// 已登录用户街拍详情取数:走 request()(自动附带 Bearer + locale 签名),返回完整图片集。
export async function getStreetSnapDetailAuthed(
id: string,
locale?: string
): Promise<RawStreetSnap | null> {
try {
const json: any = await request(`/public/street-snaps/${encodeURIComponent(id)}`)
return (json?.data ?? null) as RawStreetSnap | null
} catch {
return null
}
}
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
const j = await ssgRequest<{ data?: any[] }>(`/api/internal/ssg/street-snaps/popular?limit=${limit}`)
return (j?.data ?? []).map((b: any) => ({
id: String(b.id),
title: b.title ?? "",
cover: b.cover ?? "",
image_count: Number(b.image_count ?? 0),
images: [],
}))
}