refactor(web): consume api v1 contracts

This commit is contained in:
2026-07-21 16:29:42 +08:00
parent 80bec26839
commit c56ada3708
8 changed files with 311 additions and 149 deletions

View File

@@ -9,6 +9,25 @@ type RequestOptions = {
token?: string
}
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://127.0.0.1:18080')
export function setApiBaseUrl(baseUrl: string) {
@@ -20,7 +39,7 @@ export function getApiBaseUrl() {
}
export async function login(email: string, password: string): Promise<ApiSession> {
const response = await apiRequest<{ token: string }>('/api/auth/login', {
const response = await apiRequest<{ token: string }>('/api/v1/auth/login', {
method: 'POST',
body: { email, password },
})
@@ -30,24 +49,30 @@ export async function login(email: string, password: string): Promise<ApiSession
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)
const response = await fetch(`${configuredBaseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: requestBody as BodyInit | undefined,
})
let response: Response
try {
response = await fetch(`${configuredBaseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: requestBody as BodyInit | undefined,
})
} catch {
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
}
if (!response.ok) {
let message = `请求失败:${response.status}`
let envelope: ErrorEnvelope | null = null
try {
const payload = await response.json()
if (typeof payload.error === 'string') message = payload.error
const payload: unknown = await response.json()
if (isErrorEnvelope(payload)) envelope = payload
} catch {
// Keep the status-based message when the server does not return JSON.
// 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。
}
throw new Error(message)
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) {
@@ -61,6 +86,39 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
return JSON.parse(text) as T
}
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 '请求失败,请稍后重试'
}
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed