import type { AuthResult, Board, Category, EditableField, Family, Identification, LeaderboardEntry, Maintainer, ModelDetail, ModelListItem, ModelQuery, Paged, Photo, PublicUser, Revision, SearchResult, Sighting, Spot, Stats, Thread, UserStats, } from '../types'; const BASE = import.meta.env?.VITE_API_BASE ?? ''; const TOKEN_KEY = 'train.token'; export function getToken(): string | null { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } } export function setToken(t: string) { try { localStorage.setItem(TOKEN_KEY, t); } catch { /* ignore */ } } export function clearToken() { try { localStorage.removeItem(TOKEN_KEY); } catch { /* ignore */ } } /** 把查询对象转为 URLSearchParams 字符串(剔除 null/undefined/空串)。*/ export function buildQuery(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === '') continue; sp.set(k, String(v)); } const s = sp.toString(); return s ? `?${s}` : ''; } function authHeaders(): Record { const t = getToken(); return t ? { Authorization: `Bearer ${t}` } : {}; } async function getJson(path: string): Promise { const res = await fetch(`${BASE}${path}`, { headers: { ...authHeaders() } }); if (!res.ok) throw new Error(`请求失败 ${res.status}: ${path}`); return res.json() as Promise; } async function postJson(path: string, body: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = Array.isArray((data as any)?.message) ? (data as any).message.join(';') : (data as any)?.message || `请求失败 ${res.status}`; throw new Error(msg); } return data as T; } async function deleteJson(path: string): Promise { const res = await fetch(`${BASE}${path}`, { method: 'DELETE', headers: { ...authHeaders() }, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as any)?.message || `请求失败 ${res.status}`); return data as T; } async function patchJson(path: string, body: unknown): Promise { const res = await fetch(`${BASE}${path}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as any)?.message || `请求失败 ${res.status}`); return data as T; } async function postForm(path: string, form: FormData): Promise { // 不手动设置 Content-Type,让浏览器带上 multipart boundary const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { ...authHeaders() }, body: form, }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = Array.isArray((data as any)?.message) ? (data as any).message.join(';') : (data as any)?.message || `上传失败 ${res.status}`; throw new Error(msg); } return data as T; } export const api = { categories: () => getJson('/api/categories'), models: (q: ModelQuery = {}) => getJson>( `/api/models${buildQuery(q as Record)}`, ), model: (id: number) => getJson(`/api/models/${id}`), search: (q: string, limit = 10) => getJson<{ query: string; results: SearchResult[] }>( `/api/search${buildQuery({ q, limit })}`, ), auth: { register: (email: string, password: string, displayName: string) => postJson('/api/auth/register', { email, password, displayName }), login: (email: string, password: string) => postJson('/api/auth/login', { email, password }), me: () => getJson('/api/auth/me'), }, editableFields: () => getJson('/api/editable-fields'), modelRevisions: (id: number) => getJson(`/api/models/${id}/revisions`), submitRevision: (id: number, changes: Record, note: string) => postJson(`/api/models/${id}/revisions`, { changes, note }), pendingRevisions: () => getJson('/api/revisions/pending'), approveRevision: (rid: number) => postJson(`/api/revisions/${rid}/approve`, {}), rejectRevision: (rid: number) => postJson(`/api/revisions/${rid}/reject`, {}), leaderboard: (limit = 20) => getJson(`/api/leaderboard${buildQuery({ limit })}`), maintainers: (id: number) => getJson(`/api/models/${id}/maintainers`), claimMaintainer: (id: number) => postJson(`/api/models/${id}/maintainers`, {}), unclaimMaintainer: (id: number) => deleteJson(`/api/models/${id}/maintainers`), boards: () => getJson('/api/boards'), threads: (params: { board?: string; modelId?: number } = {}) => getJson(`/api/threads${buildQuery(params)}`), thread: (id: number) => getJson(`/api/threads/${id}`), createThread: (data: { board: string; modelId?: number; title: string; body: string; }) => postJson('/api/threads', data), addReply: (id: number, body: string) => postJson(`/api/threads/${id}/replies`, { body }), modelSightings: (id: number) => getJson(`/api/models/${id}/sightings`), createSighting: ( id: number, data: { lat: number; lng: number; station?: string; carNumber?: string; spottedAt?: string; description?: string; }, ) => postJson(`/api/models/${id}/sightings`, data), recentSightings: (limit = 30) => getJson(`/api/sightings/recent${buildQuery({ limit })}`), sightingsMap: () => getJson('/api/sightings/map'), spots: () => getJson('/api/sightings/spots'), families: (category?: string) => getJson(`/api/models/families${buildQuery({ category: category ?? '' })}`), stats: () => getJson('/api/stats'), modelPhotos: (id: number) => getJson(`/api/models/${id}/photos`), uploadPhoto: (id: number, file: File, caption = '') => { const fd = new FormData(); fd.append('file', file); if (caption) fd.append('caption', caption); return postForm(`/api/models/${id}/photos`, fd); }, deletePhoto: (pid: number) => deleteJson<{ ok: boolean }>(`/api/photos/${pid}`), confirmPhoto: (pid: number) => postJson(`/api/photos/${pid}/confirm`, {}), featurePhoto: (pid: number) => postJson(`/api/photos/${pid}/feature`, {}), candidatePhotos: () => getJson<(Photo & { modelCode: string; category: string })[]>( '/api/photos/candidates', ), identify: (file: File) => { const fd = new FormData(); fd.append('file', file); return postForm('/api/identify', fd); }, identifications: () => getJson('/api/identifications'), updateIdentification: (id: number, note: string) => patchJson(`/api/identifications/${id}`, { note }), deleteIdentification: (id: number) => deleteJson<{ ok: boolean }>(`/api/identifications/${id}`), };