Files
Nexus/frontend/src/api/index.ts
T
Your Name e084d90c61 fix: add Bing wallpaper proxy endpoint, make it public (no JWT required)
- Added GET /api/settings/bing-wallpaper — proxies cn.bing.com HPImageArchive
  to avoid CORS issues, returns {url} for the daily wallpaper
- Added /api/settings/bing-wallpaper to PUBLIC_PREFIXES in auth_jwt.py
  so the login page can fetch it without authentication
- Login page now fetches wallpaper via backend proxy instead of direct CORS-blocked fetch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:12:09 +08:00

131 lines
3.8 KiB
TypeScript

/**
* api/index.ts
*
* Axios-free HTTP client for Nexus backend.
* - JWT Bearer token auto-attach
* - 401 → auto-logout via auth store
* - 202 → TOTP required (not an error)
* - Base URL from VITE_API_BASE or relative /api
*/
import { useAuthStore } from '@/stores/auth'
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
/** Raw fetch wrapper with JSON + JWT */
export async function api<T = any>(
path: string,
opts: RequestInit & { params?: Record<string, any> } = {},
): Promise<T> {
const { params, ...init } = opts
// Build URL with query params
let url = `${BASE_URL}${path}`
if (params) {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) sp.set(k, String(v))
}
const qs = sp.toString()
if (qs) url += `?${qs}`
}
// Attach JWT
const auth = useAuthStore()
const headers: Record<string, string> = {
...(init.headers as Record<string, string> || {}),
}
// Only set Content-Type for non-FormData bodies (FormData needs browser-generated boundary)
if (!(init.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
if (auth.token) {
headers['Authorization'] = `Bearer ${auth.token}`
}
const res = await fetch(url, { ...init, headers })
// 401 → force logout
if (res.status === 401) {
auth.forceLogout()
throw new ApiError(401, '登录已过期,请重新登录')
}
// 202 → TOTP required (not an error, return the response)
if (res.status === 202) {
const text = await res.text()
let data: any
try { data = JSON.parse(text) } catch { data = { message: text } }
throw new TotpRequiredError(data.detail || data.message || '请输入 TOTP 验证码')
}
// 429 → Account locked
if (res.status === 429) {
throw new ApiError(429, '登录尝试过多,账户已锁定 15 分钟')
}
// 204 No Content
if (res.status === 204) return undefined as T
// Try JSON
const text = await res.text()
let data: any
try {
data = JSON.parse(text)
} catch {
data = text
}
if (!res.ok) {
const msg = data?.detail || data?.message || res.statusText
throw new ApiError(res.status, msg)
}
return data as T
}
/** Convenience helpers */
export const http = {
get: <T = any>(path: string, params?: Record<string, any>) =>
api<T>(path, { method: 'GET', params }),
/** GET a list endpoint that returns a bare array (not {items,total}).
* Wraps the result in {items, total} so callers don't need to care. */
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const arr = await api<T[]>(path, { method: 'GET', params })
return { items: (arr || []) as T[], total: (arr || []).length }
},
post: <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T = any>(path: string) =>
api<T>(path, { method: 'DELETE' }),
/** Upload FormData (multipart, auto Content-Type with boundary) */
upload: <T = any>(path: string, formData: FormData) =>
api<T>(path, { method: 'POST', body: formData }),
/** GET a list that returns a bare array — wraps in {items, total} */
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const arr = await api<T[]>(path, { method: 'GET', params })
return { items: (arr || []) as T[], total: (arr || []).length }
},
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
export class TotpRequiredError extends Error {
constructor(message: string) {
super(message)
this.name = 'TotpRequiredError'
}
}