F1: 统一前端 HTTP 客户端 - 新增 frontend/src/lib/http.ts(共享客户端,统一超时/错误/401 处理) - Matches.tsx 的列表/分页/进行中/预测请求改用 http 客户端 - 公开站与 Admin 行为一致 D2: events 采集按联赛分批提交 - _run_bzzoiro 改为 per-league 独立事务,避免超长事务 - 单联赛失败不影响其他联赛 F3: 仪表盘真实计数 - admin_stats 补充 matches/stats/standings 真实聚合 - Dashboard 展示比赛总数/已完赛/统计行/积分榜 F4: LLM 连通性测试不依赖 match_id=1 - 新增 POST /admin/llm/ping 端点(只发一次 chat,不依赖比赛) - testLLMConnection 优先调用 ping,失败回退旧方式 F5: 导航改用 React Router Link - App.tsx 中 <a href> 全部替换为 <Link to> D5: 删除 away_possession 死代码 - bzzoiro.py 中移除计算与注释 D1: 管线基础设施表文档化 - RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
101 lines
3.0 KiB
TypeScript
101 lines
3.0 KiB
TypeScript
/**
|
|
* 共享 HTTP 客户端(公开站 + Admin 统一)
|
|
*
|
|
* 特性:
|
|
* - 统一超时(默认 30s,可覆盖)
|
|
* - 统一错误处理(ApiError)
|
|
* - 401 自动广播(Admin 场景)
|
|
* - JSON/HTML 容错解析
|
|
* - 请求竞态防护(可选 signal)
|
|
*/
|
|
|
|
import { UNAUTHORIZED_EVENT } from '../admin/api'
|
|
|
|
const API_BASE = '/api/v1'
|
|
const DEFAULT_TIMEOUT = 30_000
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public status: number,
|
|
public data?: unknown,
|
|
) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
interface RequestOptions {
|
|
timeoutMs?: number
|
|
skipAuthHandling?: boolean
|
|
signal?: AbortSignal
|
|
method?: string
|
|
body?: string
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
|
|
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
// 如果外部传了 signal,也关联到内部 controller
|
|
if (signal) {
|
|
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const rawText = await res.text()
|
|
let detail: unknown = rawText
|
|
try {
|
|
detail = JSON.parse(rawText)
|
|
} catch {
|
|
// 非 JSON(如 HTML 错误页),保留原始文本
|
|
}
|
|
let message =
|
|
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
|
|
? String((detail as { detail: unknown }).detail)
|
|
: `HTTP ${res.status}: ${res.statusText}`
|
|
|
|
if (res.status === 401 && !skipAuthHandling) {
|
|
message += '\n登录已过期,请重新登录。'
|
|
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
|
}
|
|
throw new ApiError(message, res.status, detail)
|
|
}
|
|
|
|
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 http = {
|
|
get: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts }),
|
|
post: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
|
request<T>(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
|
put: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
|
request<T>(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
|
delete: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts, method: 'DELETE' }),
|
|
}
|