713 lines
30 KiB
TypeScript
713 lines
30 KiB
TypeScript
// 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: [],
|
||
}))
|
||
}
|
||
|
||
|
||
|