Files
Train/apps/web/src/api/client.ts
T
2026-06-16 00:55:20 +08:00

227 lines
7.3 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 {
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}`),
};