码桶
发现社区成员的开源项目
api.ts4.8 KB
// 统一 API 客户端:携带 cookie 凭证;对后端声明 X-Requested-With 以便 admin 动作返回 JSON
// (后端对 fetch 请求返回 JSON,对浏览器导航重定向,两者互不干扰)
import { logStore } from './logStore'
export interface ApiError extends Error {
status?: number
data?: any
}
function safeBody(body: unknown): unknown {
if (body instanceof FormData) {
const files: string[] = []
body.forEach((_v, k) => files.push(k))
return `[FormData: ${files.join(', ')}]`
}
if (typeof body === 'string' && body.length > 2000) return body.slice(0, 2000) + '…'
return body
}
// 请求超时(毫秒):避免部署环境网络/反代异常时前端永久转圈
const REQUEST_TIMEOUT = 20000
async function request(path: string, opts: { method?: string; body?: any } = {}): Promise<any> {
const method = opts.method || 'POST'
const isForm = opts.body instanceof FormData
const headers: Record<string, string> = { 'X-Requested-With': 'XMLHttpRequest' }
if (!isForm) headers['Content-Type'] = 'application/json'
// 超时控制:超过阈值主动 abort,让 loading 状态能结束并暴露错误
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const t0 = performance.now()
// 记录请求日志
logStore.add({
level: 'info',
type: 'request',
method,
path,
message: `${method} ${path}`,
requestBody: safeBody(opts.body),
})
let res: Response
try {
res = await fetch(path, {
method,
credentials: 'include',
headers,
signal: controller.signal,
body: isForm ? (opts.body as FormData) : opts.body ? JSON.stringify(opts.body) : undefined,
})
} catch (e: any) {
const dur = Math.round(performance.now() - t0)
clearTimeout(timer)
const isTimeout = e?.name === 'AbortError'
logStore.add({
level: 'error',
type: 'error',
method,
path,
duration: dur,
message: isTimeout
? `请求超时(>${REQUEST_TIMEOUT / 1000}s 无响应),请检查服务器/反向代理是否可达`
: `网络错误: ${e.message || '无法连接服务器'}`,
})
const err: ApiError = new Error(isTimeout ? '请求超时,服务器无响应' : '网络连接失败')
throw err
}
clearTimeout(timer)
const dur = Math.round(performance.now() - t0)
const text = await res.text()
let data: any = null
try {
data = text ? JSON.parse(text) : {}
} catch {
data = { ok: false, msg: text || '服务器返回非 JSON 响应' }
}
// 记录响应日志
const respPreview = typeof data === 'object' && data !== null
? { ok: data.ok, msg: data.msg, ...(data.summary ? { summary: data.summary } : {}) }
: data
if (!res.ok || (data && data.ok === false)) {
logStore.add({
level: 'error',
type: 'response',
method,
path,
status: res.status,
duration: dur,
message: data?.msg || `HTTP ${res.status}`,
responseBody: respPreview,
})
const err: ApiError = new Error(data?.msg || `请求失败 (${res.status})`)
err.status = res.status
err.data = data
throw err
}
logStore.add({
level: 'info',
type: 'response',
method,
path,
status: res.status,
duration: dur,
message: `${res.status} ${dur}ms`,
responseBody: respPreview,
})
return data
}
export const api = {
get: (path: string) => request(path, { method: 'GET' }),
post: (path: string, body?: any) => request(path, { method: 'POST', body }),
upload: (path: string, formData: FormData) => request(path, { method: 'POST', body: formData }),
/** bot_media 等二进制接口直接作为 <img src> 使用,无需经过本方法 */
mediaUrl: (botId: number, msgId: number) => `/api/bot_media?bot_id=${botId}&msg_id=${msgId}`,
}
// ---- 类型定义(与后端 routes/api.js 对齐)----
export interface Bot {
id: number
bot_code: string
name: string
login_status: 'none' | 'wait' | 'confirmed' | string
wechat_uin?: string | null
bind_at?: number | null
}
export interface MeResponse {
ok: boolean
id: number
username: string
email: string
created_at: number | null
is_admin: number
user_code: string
ai_persona: string
companion_enabled: number
bots: Bot[]
}
export interface ChatMessage {
id: number
direction: 'in' | 'out'
peer_id: string | null
content: string | null
msg_type: string
status: string
error_msg: string
created_at: number
}
export interface MsgEvent {
id: number
bot_id: number
ts: number
type: 'inbound' | 'processing' | 'outbound_ok' | 'outbound_fail' | 'error' | 'system'
msg: string
detail?: string
}