import type { ApiCloud, AuthUser, BackendCloudInfo, BackendCloudType, BackendUserProfile, } from '@/types/api' import type { CloudType } from '@/types/database' const DEFAULT_API_URL = 'http://localhost:3000' export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '') export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired' export class ApiError extends Error { status: number detail: unknown constructor(message: string, status: number, detail?: unknown) { super(message) this.name = 'ApiError' this.status = status this.detail = detail } } interface ApiRequestOptions extends Omit { body?: BodyInit | Record | null auth?: boolean } function isJsonBody(body: ApiRequestOptions['body']): body is Record { return !!body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob) && !(body instanceof URLSearchParams) && !(body instanceof ArrayBuffer) } function validationMessage(value: unknown) { if (typeof value !== 'string') return '' try { const issues = JSON.parse(value) as unknown if (!Array.isArray(issues)) return value const messages = issues .map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '') .filter(Boolean) return messages.join(';') || value } catch { return value } } function errorMessage(payload: unknown, fallback: string) { if (!payload || typeof payload !== 'object') return fallback if ('detail' in payload) { const detail = payload.detail if (typeof detail === 'string') return detail if (Array.isArray(detail)) { const messages = detail .map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '') .filter(Boolean) if (messages.length) return messages.join(';') } } if ('error' in payload) { const error = payload.error if (typeof error === 'string') return error if (error && typeof error === 'object' && 'message' in error) { return validationMessage(error.message) || fallback } } return fallback } async function readPayload(response: Response) { const text = await response.text() if (!text) return undefined const contentType = response.headers.get('content-type') || '' if (!contentType.includes('json')) return text try { return JSON.parse(text) as unknown } catch { return text } } async function toApiError(response: Response) { const payload = await readPayload(response) const fallback = typeof payload === 'string' && payload.trim() ? payload.trim() : `请求失败(${response.status})` return new ApiError(errorMessage(payload, fallback), response.status, payload) } async function request(path: string, options: ApiRequestOptions = {}) { const { body, auth = true, ...requestOptions } = options const headers = new Headers(requestOptions.headers) let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined if (isJsonBody(body)) { headers.set('Content-Type', 'application/json') requestBody = JSON.stringify(body) } const response = await fetch(`${API_URL}${path}`, { ...requestOptions, body: requestBody, headers, credentials: 'include', }) if (response.status === 401 && auth && typeof window !== 'undefined') { window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) } if (!response.ok) throw await toApiError(response) return await readPayload(response) as T } export function apiRequest(path: string, options: ApiRequestOptions = {}) { return request(path, options) } export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') { return `${API_URL}/image/${encodeURIComponent(id)}/${variant}` } export function toAuthUser(profile: BackendUserProfile): AuthUser { return { id: profile.id, email: profile.email, username: profile.name, image: profile.image, role: profile.role, is_disabled: profile.is_disabled, cloud_count: profile.cloud_count, received_like_count: profile.received_like_count, created_at: profile.created_at, } } export function toCloudType(row: BackendCloudType): CloudType { return { id: row.id, name: row.name, genus: row.genus || '', icon_id: row.icon_id, // Compatibility value for the excluded legacy admin view. Migrated pages // display rarity_value directly because API.md defines no semantic mapping. rarity: 'common', rarity_value: row.rarity, description: row.description, created_at: row.created_at, } } export function toApiCloud(row: BackendCloudInfo): ApiCloud { return { id: row.id, user_id: row.owner.id, cloud_type_id: row.type.id, image_url: imageUrl(row.id, 'original'), thumbnail_url: imageUrl(row.id, 'preview'), latitude: row.latitude, longitude: row.longitude, description: row.description, captured_at: row.captured_at, status: row.status, is_hidden: row.is_hidden, created_at: row.uploaded_at, updated_at: row.updated_at, cloud_type: { id: row.type.id, name: row.type.name, rarity: 'common', rarity_value: row.type.rarity, }, owner: { id: row.owner.id, username: row.owner.name }, cloud_type_name: row.type.name, cloud_type_rarity: 'common', cloud_type_rarity_value: row.type.rarity, username: row.owner.name, received_like_count: row.received_like_count, } }