155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
export type ApiSession = {
|
|
baseUrl: string
|
|
token: string
|
|
}
|
|
|
|
export type CurrentUser = {
|
|
email: string
|
|
displayName: string
|
|
}
|
|
|
|
type RequestOptions = {
|
|
method?: string
|
|
body?: unknown
|
|
token?: string
|
|
responseType?: 'json' | 'void'
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
type ErrorEnvelope = {
|
|
error: {
|
|
code: string
|
|
message: string
|
|
}
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number
|
|
code: string
|
|
|
|
constructor(status: number, code: string, message: string) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
|
|
|
|
export function setApiBaseUrl(baseUrl: string) {
|
|
configuredBaseUrl = normalizeBaseUrl(baseUrl)
|
|
}
|
|
|
|
export function getApiBaseUrl() {
|
|
return configuredBaseUrl
|
|
}
|
|
|
|
export async function login(email: string, password: string): Promise<ApiSession & { user: CurrentUser }> {
|
|
const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', {
|
|
method: 'POST',
|
|
body: { email, password },
|
|
})
|
|
return { baseUrl: configuredBaseUrl, token: response.token, user: response.user }
|
|
}
|
|
|
|
export function updateCurrentUser(
|
|
session: ApiSession,
|
|
input: { displayName: string; currentPassword?: string; newPassword?: string },
|
|
) {
|
|
return apiRequest<CurrentUser>('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
|
|
}
|
|
|
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
|
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
|
|
let response: Response
|
|
try {
|
|
response = await fetch(`${configuredBaseUrl}${path}`, {
|
|
method: options.method ?? 'GET',
|
|
headers: {
|
|
...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}),
|
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
|
},
|
|
body: requestBody as BodyInit | undefined,
|
|
signal: options.signal,
|
|
})
|
|
} catch {
|
|
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let envelope: ErrorEnvelope | null = null
|
|
try {
|
|
const payload: unknown = await response.json()
|
|
if (isErrorEnvelope(payload)) envelope = payload
|
|
} catch {
|
|
// 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。
|
|
}
|
|
if (envelope) throw new ApiError(response.status, envelope.error.code, envelope.error.message)
|
|
throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status))
|
|
}
|
|
|
|
if (response.status === 204 || options.responseType === 'void') {
|
|
return undefined as T
|
|
}
|
|
|
|
let text: string
|
|
try {
|
|
text = await response.text()
|
|
} catch {
|
|
throw invalidResponse(response.status)
|
|
}
|
|
if (text.trim() === '') {
|
|
throw invalidResponse(response.status)
|
|
}
|
|
try {
|
|
return JSON.parse(text) as T
|
|
} catch {
|
|
throw invalidResponse(response.status)
|
|
}
|
|
}
|
|
|
|
function invalidResponse(status: number) {
|
|
return new ApiError(status, 'invalid_response', '服务器返回了无法识别的数据')
|
|
}
|
|
|
|
function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
|
|
if (!value || typeof value !== 'object' || !('error' in value)) return false
|
|
const error = value.error
|
|
return (
|
|
!!error &&
|
|
typeof error === 'object' &&
|
|
'code' in error &&
|
|
typeof error.code === 'string' &&
|
|
error.code.trim() !== '' &&
|
|
'message' in error &&
|
|
typeof error.message === 'string' &&
|
|
error.message.trim() !== ''
|
|
)
|
|
}
|
|
|
|
function fallbackErrorCode(status: number) {
|
|
if (status === 401) return 'unauthorized'
|
|
if (status === 403) return 'forbidden'
|
|
if (status === 404) return 'not_found'
|
|
if (status === 409) return 'conflict'
|
|
if (status >= 500) return 'internal_error'
|
|
return 'request_failed'
|
|
}
|
|
|
|
function fallbackErrorMessage(status: number) {
|
|
if (status === 401) return '登录已失效,请重新登录'
|
|
if (status === 403) return '没有权限执行此操作'
|
|
if (status === 404) return '请求的内容不存在'
|
|
if (status === 409) return '操作冲突,请刷新后重试'
|
|
if (status >= 500) return '服务器暂时无法处理请求,请稍后重试'
|
|
return '请求失败,请稍后重试'
|
|
}
|
|
|
|
export function normalizeBaseUrl(value: string) {
|
|
const normalized = value.trim().replace(/\/+$/, '')
|
|
if (!normalized || /^https?:\/\//i.test(normalized)) return normalized
|
|
return `http://${normalized}`
|
|
}
|