- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
/**
|
|
* Admin 后台管理系统 - 统一 API 客户端
|
|
*
|
|
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
|
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
|
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
|
*/
|
|
|
|
const API_BASE = '/api/v1'
|
|
const TIMEOUT_MS = 30_000
|
|
|
|
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
|
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
|
|
|
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 & { timeoutMs?: number } = {},
|
|
): Promise<T> {
|
|
// 修复: 正确拼接 API_BASE
|
|
const url = path.startsWith('http')
|
|
? path
|
|
: path.startsWith('/')
|
|
? path // 已经是绝对路径(如 /health)
|
|
: `${API_BASE}${path}`
|
|
|
|
const { timeoutMs = TIMEOUT_MS, ...fetchOptions } = options
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
...fetchOptions,
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...fetchOptions.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登录已过期,请重新登录。'
|
|
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
|
}
|
|
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, opts?: { timeoutMs?: number }) =>
|
|
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
|
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
|
|
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
}
|
|
|
|
// ── 认证 ────────────────────────────────────────────────────────
|
|
|
|
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
|
|
export function login(password: string): Promise<{ ok: boolean }> {
|
|
return api.post(`${API_BASE}/auth/login`, { password })
|
|
}
|
|
|
|
/** 退出登录,清除会话 Cookie */
|
|
export function logout(): Promise<{ ok: boolean }> {
|
|
return api.post(`${API_BASE}/auth/logout`)
|
|
}
|
|
|
|
/** 探测当前登录状态 */
|
|
export function fetchAuthState(): Promise<{
|
|
authenticated: boolean
|
|
enabled: boolean
|
|
password_origin?: 'db' | 'env' | 'none'
|
|
}> {
|
|
return api.get(`${API_BASE}/auth/me`)
|
|
}
|
|
|
|
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
|
|
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
|
|
return api.post(`${API_BASE}/auth/change-password`, {
|
|
current_password: currentPassword,
|
|
new_password: newPassword,
|
|
})
|
|
}
|
|
|
|
export { API_BASE }
|