186 lines
9.7 KiB
TypeScript
186 lines
9.7 KiB
TypeScript
import type {
|
||
AppInfo, Attachment, Dimension, FootprintDetail, FootprintInput, FootprintQuery,
|
||
FootprintRow, FootprintType, FormField, GradeBand, GradesOverview, MetricValue,
|
||
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup
|
||
} 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[]; 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 }) =>
|
||
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 attachmentAdd = (p: { owner_id: number; file_name: string; mime?: string; size: number; data_b64: string }) =>
|
||
call<{ item: Attachment }>('attachment_add', p);
|
||
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 });
|
||
|
||
// ---- 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 adminDeleteInvite = (id: number, baseUrl?: string) =>
|
||
adminCall<{ deleted: number }>('/admin/invites/delete', { id }, baseUrl);
|