Files
opencloud/src/lib/api.ts
T

195 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}