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 { 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 { 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 { 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(cmd: string, payload?: unknown): Promise { 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('app_info'); export const syncStatus = () => call('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('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('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 { 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; sort?: Record; is_default: boolean; } export const viewList = () => call<{ items: SavedView[] }>('view_list'); export const viewUpsert = (p: { id?: number; name: string; filter: Record; sort?: Record; 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(path: string, body?: unknown, baseUrl?: string): Promise { 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(path: string, baseUrl?: string): Promise { 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('/admin/invites', baseUrl); export const adminListUsers = (baseUrl?: string) => adminGet('/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 }>('gpa_settings_get', {}); export const gpaSettingsUpsert = (weights: Record) => call<{ ok: true; weights: Record }>('gpa_settings_upsert', { weights }); // ---- 个人信息 / 简历 ---- export const profileGet = () => call<{ content: { info?: Partial; resume?: ResumeConfig } }>('profile_get', {}); export const profileUpsert = (content: { info?: Partial; resume?: ResumeConfig }) => call<{ ok: true }>('profile_upsert', { content }); export async function profilePhotoUpload(file: File): Promise { const dataB64 = await new Promise((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; }