195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
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<RequestInit, 'body'> {
|
||
body?: BodyInit | Record<string, unknown> | null
|
||
auth?: boolean
|
||
}
|
||
|
||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||
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<T>(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<T>(path: string, options: ApiRequestOptions = {}) {
|
||
return request<T>(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,
|
||
}
|
||
}
|