/** * 后端接口的客户端。 * * 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1 * (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。 */ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' const TOKEN_KEY = 'patchbay.token' export function getToken(): string { return localStorage.getItem(TOKEN_KEY) ?? '' } export function setToken(value: string): void { localStorage.setItem(TOKEN_KEY, value.trim()) } export function clearToken(): void { localStorage.removeItem(TOKEN_KEY) } /** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */ export class ApiError extends Error { constructor( readonly status: number, readonly payload: Record, ) { super(typeof payload.message === 'string' ? payload.message : `请求失败(${String(status)})`) this.name = 'ApiError' } get unauthorized(): boolean { return this.status === 401 } } async function request(method: string, path: string, body?: unknown): Promise { const res = await fetch(path, { method, headers: { Authorization: `Bearer ${getToken()}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await res.text() const payload = text ? (JSON.parse(text) as Record) : {} if (!res.ok) throw new ApiError(res.status, payload) return payload as T } export interface ConfigSnapshot { data: ConfigObject yaml: string revision: string diagnostics: ConfigDiagnostic[] } export interface ApplySuccess { ok: true snapshot: ConfigSnapshot reloaded: boolean reloadMessage: string } export interface ApplyFailure { ok: false stage: 'stale' | 'self-check' | 'kernel-check' | 'write' | 'reload' message: string hint: string restored: boolean } export const api = { readConfig: () => request('GET', '/api/config'), applyConfig: (data: ConfigObject, baseRevision: string) => request('PUT', '/api/config', { data, baseRevision }), listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), restoreBackup: (name: string) => request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), /** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */ providers: () => request<{ providers: Record }>( 'GET', '/api/kernel/providers/proxies', ), }