245 lines
13 KiB
TypeScript
245 lines
13 KiB
TypeScript
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;
|
||
}
|