/** Console API 客户端:统一 JSON 请求、错误处理、401 跳登录 */ /** # 成员变量: 未登录回调(由应用注册,跳转登录页) **/ let onUnauthorized: (() => void) | null = null; /** # 函数 * 注册 401 处理器 */ export function setUnauthorizedHandler(fn: () => void): void { onUnauthorized = fn; } /** # 函数 * 通用请求封装 * 参数: method/url/body(JSON)/query * 返回值: 解析后的 JSON;非 2xx 抛 ApiError */ export async function api( url: string, method: string = "GET", body?: unknown, ): Promise { const resp = await fetch(url, { method, credentials: "include", headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (resp.status === 401) { onUnauthorized?.(); throw new ApiError(401, "未登录或会话已过期"); } const text = await resp.text(); let data: unknown = null; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } if (!resp.ok) { const message = (data as { error?: string } | null)?.error ?? `请求失败 (${resp.status})`; throw new ApiError(resp.status, message, data); } return data as T; } /** # 成员变量: API 错误 **/ export class ApiError extends Error { constructor( public status: number, message: string, public data?: unknown, ) { super(message); } } /** # 函数 * 分页查询串拼接 */ export function qs(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") sp.set(k, String(v)); } const s = sp.toString(); return s ? `?${s}` : ""; } /* ---- 具体接口封装 ---- */ /** 登录 */ export const login = (username: string, password: string) => api<{ ok: boolean; user: { id: string; username: string } }>("/api/auth/login", "POST", { username, password }); /** 登出 */ export const logout = () => api("/api/auth/logout", "POST"); /** 当前用户 */ export const me = () => api<{ user: { id: string; username: string } }>("/api/auth/me"); /** 健康检查(含版本号) */ export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health"); /** Dashboard 统计 */ export const stats = () => api<{ nodes_online: number; nodes_total: number; domains: number; mailboxes: number; emails: number; emails_expired: number; webhooks: number; deliveries_pending: number; oauth_connections: number; recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[]; }>("/api/admin/stats"); /** 手动触发 Cron */ export const runCron = () => api<{ ok: boolean; stats: Record }>("/api/admin/cron/run", "POST"); /* Nodes */ export interface NodeRow { id: string; name: string; cloudflare_account_id: string | null; status: string; version: string | null; last_seen_at: string | null; ingress_url: string; created_at: string; } export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes"); export const createNode = (name: string, ingress_url: string) => api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>( "/api/nodes", "POST", { name, ingress_url }, ); export interface NodeDetail { node: NodeRow & { enrollment_expires_at: string | null }; steps: Record; oauth: { id: string; account_id: string | null; created_at: string } | null; domains: { id: string; name: string; status: string; worker_id: string | null }[]; } export const getNode = (id: string) => api(`/api/nodes/${id}`); export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE"); export const rotateNode = (id: string) => api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST"); export const getNodeIngressSecret = (id: string) => api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`); /* OAuth */ export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode"); export const oauthStart = (nodeId: string) => api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`); export const oauthSimApprove = (state: string) => api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state }); /* Domains */ export interface DomainRow { id: string; name: string; status: string; node_id: string | null; node_name: string | null; worker_id: string | null; mailbox_count: number; created_at: string; } export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains"); export const createDomain = (name: string) => api<{ ok: boolean; id: string }>("/api/domains", "POST", { name }); export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE"); /* Mailboxes */ export interface MailboxRow { id: string; address: string; status: string; is_catch_all: number; retention_seconds: number | null; domain_id: string; domain_name: string; email_count: number; created_at: string; } export const listMailboxes = (domainId?: string) => api<{ items: MailboxRow[]; total: number }>(`/api/mailboxes${qs({ domain_id: domainId })}`); export const createMailbox = (body: { domain_id: string; address: string; retention_seconds?: number; catch_all?: boolean }) => api<{ ok: boolean; id: string; address: string }>("/api/mailboxes", "POST", body); export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) => api(`/api/mailboxes/${id}`, "PATCH", body); export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE"); /* Emails */ export interface EmailRow { id: string; mailbox_id: string | null; mailbox_address: string | null; message_id: string | null; from_address: string | null; to_addresses: string; subject: string | null; size: number; received_at: string; expires_at: string; attachment_count: number; } export const listEmails = (params: { mailbox_id?: string; q?: string; limit?: number; offset?: number }) => api<{ items: EmailRow[]; total: number }>(`/api/emails${qs(params)}`); export interface EmailDetail { email: EmailRow & { text_body: string | null; html_body: string | null; raw_storage_key: string; cc_addresses: string }; attachments: { id: string; filename: string; content_type: string; size: number; content_id: string | null }[]; } export const getEmail = (id: string) => api(`/api/emails/${id}`); export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE"); /* Webhooks */ export interface WebhookRow { id: string; name: string; endpoint: string; enabled: number; delivery_count: number; success_count: number; created_at: string; } export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks"); export const createWebhook = (name: string, endpoint: string) => api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint }); export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) => api(`/api/webhooks/${id}`, "PATCH", body); export const rotateWebhookSecret = (id: string) => api<{ ok: boolean; secret: string }>(`/api/webhooks/${id}/rotate`, "POST"); export const deleteWebhook = (id: string) => api(`/api/webhooks/${id}`, "DELETE"); export const testWebhook = (id: string) => api<{ ok: boolean; status?: number; error?: string }>(`/api/webhooks/${id}/test`, "POST"); export interface DeliveryRow { id: string; webhook_id: string; email_id: string; status: string; attempt_count: number; next_retry_at: string | null; last_error: string | null; delivered_at: string | null; created_at: string; subject: string | null; } export const listDeliveries = (webhookId: string) => api<{ items: DeliveryRow[] }>(`/api/webhooks/${webhookId}/deliveries`); export const redeliver = (deliveryId: string) => api(`/api/webhooks/deliveries/${deliveryId}/redeliver`, "POST"); /* API/MCP Keys */ export interface KeyRow { id: string; kind: "api" | "mcp"; name: string; key_last4: string; created_at: string; revoked_at: string | null; last_used_at: string | null; } export const listKeys = () => api<{ items: KeyRow[] }>("/api/keys"); export const createKey = (kind: "api" | "mcp", name?: string) => api<{ ok: boolean; id: string; kind: string; key: string }>("/api/keys", "POST", { kind, name }); export const revokeKey = (id: string) => api(`/api/keys/${id}/revoke`, "POST"); export const deleteKey = (id: string) => api(`/api/keys/${id}`, "DELETE");