/** * 运营后台接口(/ops/v1,docs/architecture/ops-api.md)。内部工具,类型在这里手写;cookie 会话 + CSRF 头。 */ export class OpsError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); } } /** 页面上做完操作(开通、退回……)发这个事件,菜单上的待办数跟着刷新 */ export const CHANGED = "ops:changed"; export const notifyChanged = () => window.dispatchEvent(new Event(CHANGED)); let onUnauthorized: () => void = () => {}; export function setUnauthorized(fn: () => void) { onUnauthorized = fn; } function csrf(): string { return document.cookie.split("; ").find((c) => c.startsWith("bd_ops_csrf="))?.split("=")[1] ?? ""; } export async function ops(path: string, init: { method?: string; body?: unknown; query?: Record } = {}): Promise { const qs = new URLSearchParams(Object.entries(init.query ?? {}).filter((e): e is [string, string] => !!e[1])).toString(); const res = await fetch(`/ops/v1${path}${qs ? `?${qs}` : ""}`, { method: init.method ?? "GET", credentials: "include", headers: { "Content-Type": "application/json", ...(init.method && init.method !== "GET" ? { "X-CSRF-Token": csrf() } : {}) }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); if (res.status === 204) return undefined as T; const data = await res.json().catch(() => ({})); if (!res.ok) { if (res.status === 401 && path !== "/auth/login") onUnauthorized(); throw new OpsError(res.status, data.code ?? "error", data.detail ?? data.title ?? "请求失败"); } return data as T; } /** 时间一律按北京时间显示:「2026-11-05 14:45」 */ const BJ = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); export const fmt = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)) : "—"); export const day = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)).slice(0, 10) : "—"); export type Tenant = { id: string; name: string; status: string; created_at: string; plan: { code: string | null; expired: boolean; ends_at: string | null }; balance: number | null; projects: number; phone_hint: string | null; open_requests: number; }; export type TenantDetail = Tenant & { members: { display_name: string; role: string; status: string; phone_hint: string | null; last_login_at: string | null }[]; subscriptions: { plan_code: string; status: string; source: string; starts_at: string; ends_at: string | null; request_no: string | null }[]; quota_entries: { kind: string; amount: number; reason: string | null; ref_type: string | null; created_at: string; unit: string; by_ops: boolean }[]; jobs: Record>; }; export type ActivationRequest = { id: string; request_no: string; tenant: { id: string; name: string }; plan_code: string; status: "submitted" | "paid" | "activated" | "rejected" | "cancelled"; invoice_info: Record; ops_note: string | null; invoice_no: string | null; applicant_phone_hint: string | null; created_at: string; handled_at: string | null; }; export type Feedback = { id: string; kind: string; source: string; note: string | null; object_type: string; tenant_name: string; title: string | null; severity: string | null; rule_code: string | null; detector: string | null; finding_type: string | null; rule_set_version: string | null; prompt_versions: Record | null; pages: number[]; created_at: string; }; export type FailedJob = { kind: string; id: string; tenant: { id: string; name: string }; stage: string | null; error_class: string | null; error_code: string | null; error_detail: string | null; created_at: string; duration_s: number | null; ai_calls: number; }; export type AuditEvent = { id: string; tenant: { id: string; name: string }; actor_kind: string; actor_name: string | null; action: string; object_type: string; object_id: string | null; project_id: string | null; scrubbed: boolean; created_at: string; }; export type Operator = { id: string; username: string; display_name: string; role: string; role_label: string; permissions: string[]; env: "dev" | "test" | "prod"; }; export type Summary = { open_requests: number; failed_jobs_7d: number; feedback_7d: number }; export const REQUEST_STATUS: Record = { submitted: "等待转账", paid: "已到账", activated: "已开通", rejected: "已退回", cancelled: "已撤回", }; export const QUOTA_KIND: Record = { grant: "发放", hold: "预扣", settle: "结算", release: "退回", expire: "过期", adjust: "调整", waive: "免扣", }; export const JOB_KIND: Record = { parse_job: "解析", check_run: "检查", report_job: "报告" };