init: AI培训与智能巡检系统

This commit is contained in:
selfrelease
2026-06-16 00:55:20 +08:00
commit c55598494b
201 changed files with 53131 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
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}`),
};