fix: 项目结构优化及后端同步适配 (#11)

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-09-30 12:11:32 +08:00
parent 0af0256c97
commit f31691c300
34 changed files with 1137 additions and 1623 deletions
+62
View File
@@ -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
View File
@@ -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,
}
}
+16
View File
@@ -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
}
}