113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
/**
|
|
* Admin 后台管理系统 - 统一 API 客户端
|
|
*
|
|
* 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护:
|
|
* 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带。
|
|
*/
|
|
|
|
const API_BASE = '/api/v1'
|
|
const TIMEOUT_MS = 30_000
|
|
|
|
const ADMIN_KEY_STORAGE = 'profeto_admin_key'
|
|
|
|
/** 读取本机保存的管理员密钥 */
|
|
export function getAdminKey(): string {
|
|
try {
|
|
return localStorage.getItem(ADMIN_KEY_STORAGE) ?? ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** 保存/清除管理员密钥(传空字符串即清除) */
|
|
export function setAdminKey(key: string): void {
|
|
try {
|
|
if (key) localStorage.setItem(ADMIN_KEY_STORAGE, key)
|
|
else localStorage.removeItem(ADMIN_KEY_STORAGE)
|
|
} catch {
|
|
/* 隐私模式等场景下不可用,静默忽略 */
|
|
}
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public status: number,
|
|
public data?: unknown,
|
|
) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
// 修复: 正确拼接 API_BASE
|
|
const url = path.startsWith('http')
|
|
? path
|
|
: path.startsWith('/')
|
|
? path // 已经是绝对路径(如 /health)
|
|
: `${API_BASE}${path}`
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
|
|
try {
|
|
const adminKey = getAdminKey()
|
|
const res = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(adminKey ? { 'X-API-Key': adminKey } : {}),
|
|
...options.headers,
|
|
},
|
|
})
|
|
|
|
if (!res.ok) {
|
|
let detail: unknown
|
|
try {
|
|
detail = await res.json()
|
|
} catch {
|
|
detail = await res.text()
|
|
}
|
|
let message =
|
|
detail && typeof detail === 'object' && 'detail' in detail
|
|
? String((detail as { detail: unknown }).detail)
|
|
: `HTTP ${res.status}: ${res.statusText}`
|
|
if (res.status === 401) {
|
|
message += '\n请在「系统配置」页填写管理员密钥后重试。'
|
|
}
|
|
throw new ApiError(message, res.status, detail)
|
|
}
|
|
|
|
// 修复: 正确判断 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T
|
|
}
|
|
|
|
return res.json() as Promise<T>
|
|
} catch (err) {
|
|
if (err instanceof ApiError) throw err
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
throw new ApiError('请求超时,请稍后重试', 0)
|
|
}
|
|
throw new ApiError(
|
|
err instanceof Error ? err.message : '网络错误,请检查连接',
|
|
0,
|
|
)
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string) => request<T>(path),
|
|
post: <T>(path: string, body?: unknown) =>
|
|
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
|
put: <T>(path: string, body?: unknown) =>
|
|
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
}
|
|
|
|
export { API_BASE }
|