fix: 项目结构优化及后端同步适配 (#11)
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { API_URL } from './api'
|
||||
import type { CloudResponse, CloudTypeResponse, UserProfileResponse } from '@/types/api-contract'
|
||||
import type { CloudDetail, AuthUser } from '@/types/view-models'
|
||||
import type { CloudType } from '@/types/models'
|
||||
|
||||
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
||||
return `${API_URL}/images/${encodeURIComponent(id)}/${variant}`
|
||||
}
|
||||
|
||||
export function toAuthUser(profile: UserProfileResponse): AuthUser {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
username: profile.username,
|
||||
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: CloudTypeResponse): CloudType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genus: row.genus || '',
|
||||
icon_id: row.icon_id,
|
||||
rarity: row.rarity,
|
||||
description: row.description,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toCloudDetail(row: CloudResponse): CloudDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.owner.id,
|
||||
cloud_type_id: row.cloud_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.review_status,
|
||||
is_hidden: row.is_hidden,
|
||||
created_at: row.uploaded_at,
|
||||
updated_at: row.updated_at,
|
||||
cloud_type: {
|
||||
id: row.cloud_type.id,
|
||||
name: row.cloud_type.name,
|
||||
rarity: row.cloud_type.rarity,
|
||||
},
|
||||
owner: { id: row.owner.id, username: row.owner.username },
|
||||
cloud_type_name: row.cloud_type.name,
|
||||
cloud_type_rarity: row.cloud_type.rarity,
|
||||
username: row.owner.username,
|
||||
received_like_count: row.received_like_count,
|
||||
}
|
||||
}
|
||||
+28
-127
@@ -1,15 +1,6 @@
|
||||
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 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 {
|
||||
@@ -26,64 +17,23 @@ export class ApiError extends Error {
|
||||
|
||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
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
|
||||
}
|
||||
if (!body || typeof body !== 'object') return false
|
||||
const prototype = Object.getPrototypeOf(body)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
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
|
||||
return payload &&
|
||||
typeof payload === 'object' &&
|
||||
'message' in payload &&
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
async function readPayload(response: Response) {
|
||||
@@ -111,7 +61,7 @@ async function toApiError(response: Response) {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
const { body, auth = true, ...requestOptions } = options
|
||||
const { body, query, auth = true, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
|
||||
@@ -120,7 +70,12 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value !== undefined) search.set(key, String(value))
|
||||
}
|
||||
const url = `${API_URL}${path}${search.size ? `?${search}` : ''}`
|
||||
const response = await fetch(url, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
headers,
|
||||
@@ -132,72 +87,18 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
}
|
||||
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
return (await readPayload(response)) as T
|
||||
if (response.status === 204) return undefined as T
|
||||
const payload = await readPayload(response)
|
||||
if (
|
||||
!response.headers.get('content-type')?.includes('json') ||
|
||||
typeof payload === 'string' ||
|
||||
payload === undefined
|
||||
) {
|
||||
throw new ApiError('服务器返回了无效的 JSON 响应', response.status, payload)
|
||||
}
|
||||
return payload 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
page_size: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
/** Collect only for screens that need a complete set (heatmaps, likes, admin). */
|
||||
export async function collectPages<T>(load: (page: number) => Promise<Page<T>>): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await load(page)
|
||||
items.push(...result.items)
|
||||
if (!result.has_more) return items
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user