Files
FootPrint/app/src/lib/api.ts
T

245 lines
13 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 {
AppInfo, Attachment, Dimension, FootprintDetail, FootprintInput, FootprintQuery,
FootprintRow, FootprintType, FormField, GradeBand, GradesOverview, MetricValue,
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup,
ProfileInfo, ResumeConfig,
} from './types';
const TOKEN_KEY = 'fp-token';
export function getToken() { return localStorage.getItem(TOKEN_KEY); }
export function setToken(t: string) { localStorage.setItem(TOKEN_KEY, t); }
export function clearToken() { localStorage.removeItem(TOKEN_KEY); }
export interface ServerSession { token: string; user_id: string; device_id: string; device_name: string; }
export async function serverLogin(username: string, password: string, baseUrl?: string): Promise<ServerSession> {
const base = baseUrl || getServerUrl();
const res = await fetch(base + '/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, device_name: '桌面应用' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '登录失败');
setToken(json.data.token);
return json.data;
}
export async function serverRegister(username: string, password: string, invite_code: string, baseUrl?: string): Promise<ServerSession> {
const base = baseUrl || getServerUrl();
const res = await fetch(base + '/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, invite_code, device_name: '桌面应用' })
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || '注册失败');
setToken(json.data.token);
return json.data;
}
export interface MeInfo { user_id: string; username: string; is_admin?: boolean; device_id: string; }
export async function serverMe(baseUrl?: string): Promise<MeInfo> {
const base = baseUrl || getServerUrl();
const res = await fetch(base + '/auth/me', { headers: { Authorization: 'Bearer ' + getToken() } });
const json = await res.json();
if (!json.ok) throw new Error(json.error || '会话失效');
return json.data;
}
// ---------- 云端传输层:所有数据存服务器 ----------
const SERVER_KEY = 'fp-server-url';
export function getServerUrl() { return localStorage.getItem(SERVER_KEY) || 'https://footpr.linyux.cn'; }
export function setServerUrl(url: string) { localStorage.setItem(SERVER_KEY, url.replace(/\/+$/, '')); }
export class AuthError extends Error {
constructor(msg: string) { super(msg); this.name = 'AuthError'; }
}
async function call<T>(cmd: string, payload?: unknown): Promise<T> {
const token = getToken();
if (!token) throw new AuthError('请先登录');
const base = getServerUrl();
const res = await fetch(base + '/api/' + cmd, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token
},
body: JSON.stringify(payload ?? {})
});
if (res.status === 401) {
clearToken();
window.dispatchEvent(new CustomEvent('fp-auth-required'));
throw new AuthError('登录已过期,请重新登录');
}
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error || ('HTTP ' + res.status));
return json.data as T;
}
// ---- app ----
export const appInfo = () => call<AppInfo>('app_info');
export const syncStatus = () => call<SyncStatus>('sync_status');
// ---- footprint type ----
export const listTypes = () => call<{ items: FootprintType[] }>('type_list');
export const upsertType = (p: { id?: number; name: string; icon: string; color: string; sort: number; form_template: FormField[]; profile?: string; closed?: boolean }) =>
call<{ item: FootprintType }>('type_upsert', p);
export const deleteType = (id: number) => call<{ ok: true }>('type_delete', { id });
// ---- taxonomy ----
export const listTaxonomy = (kind?: string) => call<{ items: TaxonomyItem[] }>('taxonomy_list', { kind });
export const upsertTaxonomy = (p: { id?: number; kind: string; value: string; color?: string | null; sort?: number }) =>
call<{ item: TaxonomyItem }>('taxonomy_upsert', p);
export const deleteTaxonomy = (id: number) => call<{ ok: true }>('taxonomy_delete', { id });
// ---- dimension ----
export const listDimensions = () => call<{ items: Dimension[] }>('dimension_list');
export const upsertDimension = (p: { id?: number; name: string; color?: string | null; sort?: number }) =>
call<{ item: Dimension }>('dimension_upsert', p);
export const deleteDimension = (id: number) => call<{ ok: true }>('dimension_delete', { id });
// ---- footprint ----
export const footprintList = (query: FootprintQuery = {}) =>
call<{ items: FootprintRow[]; total: number; page: number; page_size: number }>('footprint_list', { query });
export const footprintGet = (id: number) => call<{ item: FootprintDetail }>('footprint_get', { id });
export const footprintUpsert = (item: FootprintInput) => call<{ id: number }>('footprint_upsert', { item });
export const footprintDelete = (id: number) => call<{ ok: true }>('footprint_delete', { id });
export const timeline = (query: FootprintQuery = {}) => call<{ groups: TimelineGroup[]; total: number }>('timeline', { query });
// ---- stats ----
export const statsOverview = (year?: number) => call<StatsOverview>('stats_overview', { year });
export const metricList = () => call<{ items: MetricValue[] }>('metric_list');
export const metricUpsert = (p: { key: string; label: string; unit?: string; agg?: string; section?: string }) =>
call<{ item: MetricValue }>('metric_upsert', p);
export const metricDelete = (key: string) => call<{ ok: true }>('metric_delete', { key });
// ---- grades ----
export const gradesOverview = () => call<GradesOverview>('grades_overview');
export const semesterUpsert = (p: { id?: number; term: string; gpa?: number | null; rank?: number | null; total_students?: number | null }) =>
call<{ item: { id: number } }>('semester_upsert', p);
export const semesterDelete = (id: number) => call<{ ok: true }>('semester_delete', { id });
export const courseUpsert = (p: { id?: number; semester_id: number; course_name: string; credit: number; score?: number | null; grade_point?: number | null; category?: string | null }) =>
call<{ item: { id: number } }>('course_upsert', p);
export const courseDelete = (id: number) => call<{ ok: true }>('course_delete', { id });
export const bandUpsert = (p: GradeBand) => call<{ item: { id: number } }>('band_upsert', p);
export const bandDelete = (id: number) => call<{ ok: true }>('band_delete', { id });
// ---- attachment ----
export const attachmentList = (owner_id: number) => call<{ items: Attachment[] }>('attachment_list', { owner_id });
export const attachmentDelete = (id: number) => call<{ ok: true }>('attachment_delete', { id });
export const attachmentRead = (id: number) =>
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_read', { id });
export async function attachmentUploadRaw(ownerId: number, file: File, thumbB64?: string): Promise<Attachment> {
const q = new URLSearchParams({ owner: String(ownerId), name: file.name, mime: file.type || 'application/octet-stream' });
const r = await fetch(getServerUrl() + '/sync/attachment/raw?' + q, {
method: 'PUT',
headers: { authorization: 'Bearer ' + getToken(), 'content-type': file.type || 'application/octet-stream' },
body: file,
});
const j = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status }));
if (!j.ok) throw new Error(j.error || '上传失败');
const item = j.data.item as Attachment;
if (thumbB64) { try { await call('attachment_set_thumb', { id: item.id, thumb_b64: thumbB64 }); } catch {} }
return item;
}
export async function attachmentDownload(id: number): Promise<{ blob: Blob; file_name: string; mime?: string }> {
const r = await fetch(getServerUrl() + '/api/attachment_download', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ id }),
});
if (!r.ok) {
const j = await r.json().catch(() => null);
throw new Error(j?.error || '下载失败');
}
const file_name = decodeURIComponent(r.headers.get('x-file-name') || (r.headers.get('content-disposition') || '').match(/UTF-8''([^;]+)/)?.[1] || 'file');
const mime = r.headers.get('content-type') || undefined;
return { blob: await r.blob(), file_name, mime };
}
export const attachmentThumb = (id: number) =>
call<{ data_b64: string; mime?: string; file_name: string }>('attachment_thumb', { id });
// ---- saved view ----
export interface SavedView { id: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default: boolean; }
export const viewList = () => call<{ items: SavedView[] }>('view_list');
export const viewUpsert = (p: { id?: number; name: string; filter: Record<string, unknown>; sort?: Record<string, unknown>; is_default?: boolean }) =>
call<{ item: SavedView }>('view_upsert', p);
export const viewDelete = (id: number) => call<{ ok: true }>('view_delete', { id });
// ---- 管理端(is_admin) ----
async function adminCall<T>(path: string, body?: unknown, baseUrl?: string): Promise<T> {
const res = await fetch((baseUrl || getServerUrl()) + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify(body ?? {}),
});
const json = await res.json().catch(() => ({ ok: false, error: 'HTTP ' + res.status }));
if (!json.ok) throw new Error(json.error || '请求失败');
return json.data as T;
}
async function adminGet<T>(path: string, baseUrl?: string): Promise<T> {
const res = await fetch((baseUrl || getServerUrl()) + path, { headers: { Authorization: 'Bearer ' + getToken() } });
const json = await res.json().catch(() => ({ ok: false, error: 'HTTP ' + res.status }));
if (!json.ok) throw new Error(json.error || '请求失败');
return json.data as T;
}
export interface InviteRow {
id: number; code: string; remark: string | null; created_at: string;
used_by: string | null; used_at: string | null; valid: boolean;
used_by_name?: string | null; created_by_name?: string | null;
}
export interface AdminUserRow {
id: string; username: string; is_admin: boolean; created_at: string;
devices: number; records: number; attachments: number; attachment_bytes: number;
}
export interface InviteList { total: number; invites: InviteRow[]; }
export interface UserList { total: number; users: AdminUserRow[]; }
export const adminListInvites = (baseUrl?: string) => adminGet<InviteList>('/admin/invites', baseUrl);
export const adminListUsers = (baseUrl?: string) => adminGet<UserList>('/admin/users', baseUrl);
export const adminGenerateInvites = (count: number, baseUrl?: string) =>
adminCall<{ codes: string[] }>('/admin/invites/generate', { count }, baseUrl);
export const adminCreateInvite = (code: string, remark: string, baseUrl?: string) =>
adminCall<{ code: string }>('/admin/invites/create', { code, remark }, baseUrl);
export const adminUpdateInvite = (id: number, patch: { remark?: string; valid?: boolean }, baseUrl?: string) =>
adminCall<{ id: number }>('/admin/invites/update', { id, ...patch }, baseUrl);
export const adminDemoRebuild = (baseUrl?: string) =>
adminCall<{ ok: true; seeded: { seeded: boolean; footprints: number; semesters: number } }>('/admin/demo-rebuild', {}, baseUrl);
export const adminDeleteUser = (userId: number | string, baseUrl?: string) =>
adminCall<{ ok: true; username: string; remote_deleted: number }>('/admin/users/delete', { user_id: userId }, baseUrl);
export const adminDeleteInvite = (id: number, baseUrl?: string) =>
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
// ---- 绩点公式设置 ----
export const gpaSettingsGet = () => call<{ weights: Record<string, number> }>('gpa_settings_get', {});
export const gpaSettingsUpsert = (weights: Record<string, number>) => call<{ ok: true; weights: Record<string, number> }>('gpa_settings_upsert', { weights });
// ---- 个人信息 / 简历 ----
export const profileGet = () => call<{ content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig } }>('profile_get', {});
export const profileUpsert = (content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig }) =>
call<{ ok: true }>('profile_upsert', { content });
export async function profilePhotoUpload(file: File): Promise<string> {
const dataB64 = await new Promise<string>((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result || '').split(',')[1] || '');
fr.onerror = () => reject(new Error('读取文件失败'));
fr.readAsDataURL(file);
});
const r = await fetch(getServerUrl() + '/api/profile_photo', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ data_b64: dataB64, file_name: file.name, mime: file.type || 'image/png' }),
});
const j = await r.json().catch(() => null);
if (!j?.ok) throw new Error(j?.error || '证件照上传失败');
return j.data.url as string;
}