227 lines
7.3 KiB
TypeScript
227 lines
7.3 KiB
TypeScript
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, unknown>): 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<string, string> {
|
||
const t = getToken();
|
||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||
}
|
||
|
||
async function getJson<T>(path: string): Promise<T> {
|
||
const res = await fetch(`${BASE}${path}`, { headers: { ...authHeaders() } });
|
||
if (!res.ok) throw new Error(`请求失败 ${res.status}: ${path}`);
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||
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<T>(path: string): Promise<T> {
|
||
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<T>(path: string, body: unknown): Promise<T> {
|
||
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<T>(path: string, form: FormData): Promise<T> {
|
||
// 不手动设置 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<Category[]>('/api/categories'),
|
||
|
||
models: (q: ModelQuery = {}) =>
|
||
getJson<Paged<ModelListItem>>(
|
||
`/api/models${buildQuery(q as Record<string, unknown>)}`,
|
||
),
|
||
|
||
model: (id: number) => getJson<ModelDetail>(`/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<AuthResult>('/api/auth/register', { email, password, displayName }),
|
||
login: (email: string, password: string) =>
|
||
postJson<AuthResult>('/api/auth/login', { email, password }),
|
||
me: () => getJson<UserStats>('/api/auth/me'),
|
||
},
|
||
|
||
editableFields: () => getJson<EditableField[]>('/api/editable-fields'),
|
||
modelRevisions: (id: number) => getJson<Revision[]>(`/api/models/${id}/revisions`),
|
||
submitRevision: (id: number, changes: Record<string, string>, note: string) =>
|
||
postJson<Revision>(`/api/models/${id}/revisions`, { changes, note }),
|
||
pendingRevisions: () => getJson<Revision[]>('/api/revisions/pending'),
|
||
approveRevision: (rid: number) =>
|
||
postJson<Revision>(`/api/revisions/${rid}/approve`, {}),
|
||
rejectRevision: (rid: number) =>
|
||
postJson<Revision>(`/api/revisions/${rid}/reject`, {}),
|
||
|
||
leaderboard: (limit = 20) =>
|
||
getJson<LeaderboardEntry[]>(`/api/leaderboard${buildQuery({ limit })}`),
|
||
maintainers: (id: number) => getJson<Maintainer[]>(`/api/models/${id}/maintainers`),
|
||
claimMaintainer: (id: number) =>
|
||
postJson<Maintainer[]>(`/api/models/${id}/maintainers`, {}),
|
||
unclaimMaintainer: (id: number) =>
|
||
deleteJson<Maintainer[]>(`/api/models/${id}/maintainers`),
|
||
|
||
boards: () => getJson<Board[]>('/api/boards'),
|
||
threads: (params: { board?: string; modelId?: number } = {}) =>
|
||
getJson<Thread[]>(`/api/threads${buildQuery(params)}`),
|
||
thread: (id: number) => getJson<Thread>(`/api/threads/${id}`),
|
||
createThread: (data: {
|
||
board: string;
|
||
modelId?: number;
|
||
title: string;
|
||
body: string;
|
||
}) => postJson<Thread>('/api/threads', data),
|
||
addReply: (id: number, body: string) =>
|
||
postJson<Thread>(`/api/threads/${id}/replies`, { body }),
|
||
|
||
modelSightings: (id: number) =>
|
||
getJson<Sighting[]>(`/api/models/${id}/sightings`),
|
||
createSighting: (
|
||
id: number,
|
||
data: {
|
||
lat: number;
|
||
lng: number;
|
||
station?: string;
|
||
carNumber?: string;
|
||
spottedAt?: string;
|
||
description?: string;
|
||
},
|
||
) => postJson<Sighting>(`/api/models/${id}/sightings`, data),
|
||
recentSightings: (limit = 30) =>
|
||
getJson<Sighting[]>(`/api/sightings/recent${buildQuery({ limit })}`),
|
||
sightingsMap: () => getJson<Sighting[]>('/api/sightings/map'),
|
||
spots: () => getJson<Spot[]>('/api/sightings/spots'),
|
||
families: (category?: string) =>
|
||
getJson<Family[]>(`/api/models/families${buildQuery({ category: category ?? '' })}`),
|
||
stats: () => getJson<Stats>('/api/stats'),
|
||
|
||
modelPhotos: (id: number) => getJson<Photo[]>(`/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<Photo>(`/api/models/${id}/photos`, fd);
|
||
},
|
||
deletePhoto: (pid: number) => deleteJson<{ ok: boolean }>(`/api/photos/${pid}`),
|
||
confirmPhoto: (pid: number) => postJson<Photo>(`/api/photos/${pid}/confirm`, {}),
|
||
featurePhoto: (pid: number) => postJson<Photo>(`/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<Identification>('/api/identify', fd);
|
||
},
|
||
identifications: () => getJson<Identification[]>('/api/identifications'),
|
||
updateIdentification: (id: number, note: string) =>
|
||
patchJson<Identification>(`/api/identifications/${id}`, { note }),
|
||
deleteIdentification: (id: number) =>
|
||
deleteJson<{ ok: boolean }>(`/api/identifications/${id}`),
|
||
};
|