import { getActivePreview } from "./preview"; import { normalizeRole } from "./agentPerms"; const PLATFORM = ""; export const AI = "/ai"; const SESSION_KEY = "ajz_session"; export type Session = { accessToken: string; agentKey: string; tenantId: number; userId: number; username?: string; phone?: string; usernameLoginDisabled?: boolean; displayName?: string; role?: string; orgUnitId?: number; status?: string; // pending | active expiresAt?: number; /** 超管打开某公司管理视图时的公司名(身份仍是超管) */ tenantName?: string; }; export function isPendingMembership(s: Session | null | undefined): boolean { if (!s) return false; const role = normalizeRole(s.role || ""); // 平台超管无租户,绝不能当成「待加入」 if (role === "超级管理员") return false; if (s.username === "ljk_admin") return false; if (role === "待加入" || s.status === "pending") return true; // status=active 且无租户:仍可能是待入驻注册用户 if (!s.tenantId) return true; return false; } function roleFromAccessToken(token: string | undefined): string { if (!token || token.split(".").length < 2) return ""; try { const b64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); const pad = b64 + "=".repeat((4 - (b64.length % 4)) % 4); const bin = atob(pad); const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); const json = JSON.parse(new TextDecoder().decode(bytes)); return typeof json?.role === "string" ? json.role : ""; } catch { return ""; } } /** emit=false:仅清存储(如 loadSession 发现坏 token),不踢路由——避免毁掉 /#/preview */ export function clearSession(opts?: { emit?: boolean }) { try { localStorage.removeItem(SESSION_KEY); } catch { /* ignore */ } if (opts?.emit === false) return; if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("ajz:session-expired")); } } export function loadSession(): Session | null { try { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return null; const s = JSON.parse(raw) as Session; if (!s?.accessToken || typeof s.accessToken !== "string" || s.accessToken.split(".").length !== 3) { clearSession({ emit: false }); return null; } if (s.expiresAt && s.expiresAt * 1000 < Date.now() - 60_000) { clearSession({ emit: false }); return null; } if (!s.role) { s.role = roleFromAccessToken(s.accessToken); } return s; } catch { clearSession({ emit: false }); return null; } } export function saveSession(s: Session) { localStorage.setItem(SESSION_KEY, JSON.stringify(s)); } function toSession(data: any): Session { const accessToken = data.access_token; const s: Session = { accessToken, agentKey: data.agent_key, tenantId: data.tenant_id || 0, userId: data.user_id, username: data.username, phone: data.phone || undefined, usernameLoginDisabled: !!data.username_login_disabled, displayName: data.display_name, role: data.role || roleFromAccessToken(accessToken), orgUnitId: data.org_unit_id || 0, status: data.status, expiresAt: data.expires_at, tenantName: data.tenant_name || undefined, }; if (!s.accessToken) { throw new Error("登录响应缺少 access_token"); } return s; } function networkError(err: unknown): Error { const msg = String((err as any)?.message || err); if (/failed to fetch|networkerror|load failed|network request failed/i.test(msg)) { return new Error("无法连接网关或前端代理,请确认服务已启动后刷新页面"); } return err instanceof Error ? err : new Error(msg); } function authErrorMessage(data: any, status: number): string { const msg = String(data?.message || data?.msg || data?.error || ""); // 登录/注册接口本身的 401 只表示账号密码错误,不能清掉会话事件循环 if (status === 401 || /invalid token|malformed token|expired|missing bearer/i.test(msg)) { if (/invalid username or password|账号已停用|login failed/i.test(msg)) { return msg || "用户名或密码错误"; } clearSession(); return msg || "登录已失效,请重新登录"; } return msg; } async function apiFetch(input: string, init?: RequestInit): Promise { try { return await fetch(input, init); } catch (e) { throw networkError(e); } } async function readJson(res: Response): Promise { const text = await res.text(); if (!text) return {}; try { return JSON.parse(text); } catch { throw new Error(res.ok ? "响应不是 JSON" : `请求失败 HTTP ${res.status}`); } } export async function login(username: string, password: string): Promise { return loginWith({ username: username.trim(), password }); } export async function loginWith(body: { username?: string; phone?: string; password?: string; sms_code?: string; }): Promise { const res = await apiFetch(`${PLATFORM}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await readJson(res); if (!res.ok) { throw new Error(data.message || (res.status === 401 ? "登录失败" : "login failed")); } return toSession(data); } export async function sendLoginSMS(phone: string) { const res = await apiFetch(`${PLATFORM}/api/v1/auth/sms/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: phone.trim(), purpose: "login" }), }); const data = await readJson(res); throwIfBad(res, data, "send sms failed"); return data as { ok: boolean; expires_in: number; retry_after?: number; message?: string; debug_code?: string; }; } export async function register(username: string, password: string, displayName: string): Promise { const res = await apiFetch(`${PLATFORM}/api/v1/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, display_name: displayName }), }); const data = await readJson(res); if (!res.ok) throw new Error(data.message || "register failed"); return toSession(data); } export async function listLlmProviders() { const res = await apiFetch(`${AI}/api/v1/llm/providers`); const data = await readJson(res); if (!res.ok) throw new Error(JSON.stringify(data)); return data as { default: string; providers: { id: string; label: string; configured: boolean; default_model: string; models: { id: string; label: string }[]; supports_vision: boolean; }[]; }; } export async function generateDraft( prompt: string, excel?: File | null, images?: File[], storageMode: "schema_per_app" | "database_per_app" = "schema_per_app", llmProvider = "deepseek", llmModel = "", layoutFiles?: File[] ) { const fd = new FormData(); fd.append("prompt", prompt); fd.append("storage_mode", storageMode); fd.append("llm_provider", llmProvider); if (llmModel) fd.append("llm_model", llmModel); if (excel) fd.append("excel", excel); for (const img of images || []) { fd.append("images", img); } for (const f of layoutFiles || []) { fd.append("layout_files", f); } const res = await apiFetch(`${AI}/api/v1/apps/generate`, { method: "POST", body: fd }); const data = await readJson(res); if (!res.ok) throw new Error(JSON.stringify(data)); return data as { draft: any; warnings: string[]; confidence: number; require_confirm: boolean; llm_provider?: string; llm_model?: string; fidelity?: { skipped?: boolean; reason?: string; target?: number; max_rounds?: number; final_score?: number; passed?: boolean; rounds?: { round: number; score?: number; pass?: boolean; fails?: string[]; passes?: string[] }[]; }; generate_log?: { run_id?: string; started_at?: string; log_file?: string; lines?: { ts?: string; level?: string; message?: string; [k: string]: unknown }[]; }; }; } function throwIfBad(res: Response, data: any, fallback: string): void { if (res.ok) return; const msg = authErrorMessage(data, res.status) || fallback || `请求失败 (HTTP ${res.status})`; throw new Error(msg); } export async function listApps(session: Session) { const res = await apiFetch(`${PLATFORM}/api/v1/apps`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "列出模块失败"); return data as { scope?: string; items: { app_id: string; slug: string; name: string; status: string; status_label?: string; building?: boolean; schema_name?: string; page_count: number; entity_count: number; updated_at: string; created_at: string; }[]; }; } /** Z9e:为本租户已发布模块补齐 import/export(一键开启导入) */ export async function ensureAppsImport(session: Session, dryRun = false) { const q = dryRun ? "?dry_run=1" : ""; const res = await apiFetch(`${PLATFORM}/api/v1/admin/apps/ensure-import${q}`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "一键开启导入失败"); return data as { scanned: number; updated: number; skipped: number; dry_run: boolean; updated_slugs?: string[]; message?: string; }; } export async function saveDraft(session: Session, slug: string, blueprint: any) { const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/draft`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.accessToken}`, }, body: JSON.stringify({ blueprint }), }); const data = await readJson(res); throwIfBad(res, data, "登记在建模块失败"); return data; } export async function publish( session: Session, slug: string, blueprint: any, mode: "auto" | "add_pages" | "create" | "replace" | "merge" = "auto", ) { const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/publish`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.accessToken}`, }, body: JSON.stringify({ blueprint, mode: mode === "merge" ? "add_pages" : mode }), }); const data = await readJson(res); throwIfBad(res, data, "发布失败"); return data; } export async function getBlueprint(session: Session | null | undefined, slug: string) { if (session?.accessToken) { const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/blueprint`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "blueprint failed"); return data; } const res = await apiFetch(`${PLATFORM}/api/v1/public/apps/${slug}/blueprint`); const data = await readJson(res); throwIfBad(res, data, "blueprint failed"); return data; } export async function listRows( session: Session | null | undefined, slug: string, resource: string, filters?: Record, page = 1, pageSize = 500 ) { const q = new URLSearchParams(); q.set("page", String(page)); q.set("page_size", String(pageSize)); if (filters) { for (const [k, v] of Object.entries(filters)) { if (v) q.set(`filter.${k}`, v); } } const qs = q.toString(); const path = session?.accessToken ? `${PLATFORM}/api/v1/apps/${slug}/${resource}?${qs}` : `${PLATFORM}/api/v1/public/apps/${slug}/${resource}?${qs}`; // 草稿预览:优先用预览包内嵌行数据(未发布也可出图) const prev = getActivePreview(); if (prev && (!resource || resource === prev.resource || !prev.resource)) { let items = prev.rows || []; if (filters) { items = items.filter((row) => Object.entries(filters).every(([k, v]) => !v || String(row[k] ?? "") === String(v)) ); } const start = Math.max(0, (page - 1) * pageSize); return { items: items.slice(start, start + pageSize), total: items.length, }; } const headers: Record = {}; if (session?.accessToken) headers.Authorization = `Bearer ${session.accessToken}`; const res = await apiFetch(path, { headers }); const data = await readJson(res); throwIfBad(res, data, "list failed"); return data as { items: Record[]; total: number }; } export async function createRow( session: Session | null | undefined, slug: string, resource: string, body: Record ) { if (!session?.accessToken) throw new Error("预览模式为只读,写操作请先登录控制台"); const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/${resource}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.accessToken}`, }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create failed"); return data as Record; } export async function updateRow( session: Session | null | undefined, slug: string, resource: string, id: string, body: Record ) { if (!session?.accessToken) throw new Error("预览模式为只读,写操作请先登录控制台"); const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/${resource}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.accessToken}`, }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update failed"); return data as Record; } export async function deleteRow(session: Session | null | undefined, slug: string, resource: string, id: string) { if (!session?.accessToken) throw new Error("预览模式为只读,写操作请先登录控制台"); const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/${resource}/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); if (!res.ok && res.status !== 204) { const data = await readJson(res).catch(() => ({})); throwIfBad(res, data, "delete failed"); } } export async function aggregate( session: Session | null | undefined, slug: string, resource: string, groupBy?: string, sum?: string ) { const q = new URLSearchParams(); if (groupBy) q.set("group_by", groupBy); if (sum) q.set("sum", sum); const headers: Record = {}; if (session?.accessToken) headers.Authorization = `Bearer ${session.accessToken}`; const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/${resource}/aggregate?${q}`, { headers, }); const data = await readJson(res); throwIfBad(res, data, "aggregate failed"); return data as { total: number; group_by?: string; sum_field?: string; sum?: number; buckets?: { key: string; count: number; sum?: number }[]; }; } export async function getCapsule(session: Session, slug: string) { const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/agent-capsule`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "capsule failed"); return data as { capsule: string; format: string; hint: string }; } export async function importRows(session: Session | null | undefined, slug: string, resource: string, file: File) { if (!session?.accessToken) throw new Error("预览模式为只读,写操作请先登录控制台"); const fd = new FormData(); fd.append("file", file); const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/${resource}/import`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, body: fd, }); const data = await readJson(res); throwIfBad(res, data, "import failed"); return data; } /** @deprecated use importRows */ export const importCSV = importRows; export async function exportRows( session: Session | null | undefined, slug: string, resource: string, format: "xlsx" | "csv" = "xlsx" ) { if (!session?.accessToken) throw new Error("预览模式为只读,写操作请先登录控制台"); const res = await apiFetch( `${PLATFORM}/api/v1/apps/${slug}/${resource}/export?format=${format}`, { headers: { Authorization: `Bearer ${session.accessToken}` } } ); if (!res.ok) { const data = await readJson(res).catch(() => ({})); throwIfBad(res, data, "export failed"); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${resource}.${format === "csv" ? "csv" : "xlsx"}`; a.click(); URL.revokeObjectURL(url); } export async function listAudit(session: Session, page = 1) { const res = await apiFetch(`/api/v1/audit/logs?page=${page}&page_size=50`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "audit failed"); return data as { items: any[]; total: number }; } export type AgentAccount = { agent_id: number; tenant_id: number; name: string; client_id: string; host_key?: string; role_id?: number; role_code?: string; role_name?: string; status: string; permissions: string[]; app_slugs: string[]; /** Z8b:绑定同步通道 */ channel_id?: string; /** Z8b:绑定线上库 id */ online_db_id?: string; /** Z8b:模块落库名(database_per_app) */ database_name?: string; created_by: number; created_at: string; last_token_at?: string; }; export type Role = { role_id: number; tenant_id: number; code: string; name: string; description: string; permissions: string[]; created_at: string; }; export async function listAgents(session: Session) { const res = await apiFetch(`/api/v1/admin/agents`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list agents failed"); return data as { items: AgentAccount[] }; } export async function createAgent( session: Session, body: { name: string; role_id: number; app_slugs: string[]; permissions?: string[]; channel_id?: string; online_db_id?: string; database_name?: string; } ) { const res = await apiFetch(`/api/v1/admin/agents`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create agent failed"); return data as { account: AgentAccount; client_secret: string }; } export async function updateAgent( session: Session, id: number, body: { name?: string; status?: string; role_id?: number; permissions?: string[]; app_slugs?: string[]; channel_id?: string; online_db_id?: string; database_name?: string; } ) { const res = await apiFetch(`/api/v1/admin/agents/${id}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update agent failed"); return data as AgentAccount; } export async function rotateAgentSecret(session: Session, id: number) { const res = await apiFetch(`/api/v1/admin/agents/${id}/rotate-secret`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "rotate secret failed"); return data as { client_id: string; client_secret: string }; } export async function deleteAgent(session: Session, id: number) { const res = await apiFetch(`/api/v1/admin/agents/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "delete agent failed"); } export async function listRoles(session: Session) { const res = await apiFetch(`/api/v1/admin/roles`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list roles failed"); return data as { items: Role[] }; } export async function createRole( session: Session, body: { code: string; name: string; description?: string; permissions: string[] } ) { const res = await apiFetch(`/api/v1/admin/roles`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create role failed"); return data as Role; } export async function updateRole( session: Session, id: number, body: { name?: string; description?: string; permissions?: string[] } ) { const res = await apiFetch(`/api/v1/admin/roles/${id}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update role failed"); return data as Role; } export async function deleteRole(session: Session, id: number) { const res = await apiFetch(`/api/v1/admin/roles/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "delete role failed"); } export type TenantInvite = { invite_id: number; tenant_id: number; code: string; role: string; org_unit_id?: number; max_uses: number; used_count: number; status: string; expires_at?: string; created_at: string; }; export type OrgUnit = { org_unit_id: number; tenant_id: number; parent_id?: number; name: string; code?: string; depth: number; path: string; created_at: string; }; export async function acceptInvite(session: Session, code: string) { const res = await apiFetch(`/api/v1/auth/invites/accept`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ code }), }); const data = await readJson(res); throwIfBad(res, data, "accept invite failed"); return toSession(data); } export async function createTenant(session: Session, name: string) { const res = await apiFetch(`/api/v1/tenants`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name }), }); const data = await readJson(res); throwIfBad(res, data, "create tenant failed"); return toSession(data); } export async function listInvites(session: Session) { const res = await apiFetch(`/api/v1/admin/invites`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list invites failed"); return data as { items: TenantInvite[] }; } export async function createInvite( session: Session, body: { role?: string; org_unit_id?: number; max_uses?: number; expires_in_hours?: number } ) { const res = await apiFetch(`/api/v1/admin/invites`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create invite failed"); return data as TenantInvite; } export async function revokeInvite(session: Session, id: number) { const res = await apiFetch(`/api/v1/admin/invites/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "revoke invite failed"); } export async function listOrgUnits(session: Session) { const res = await apiFetch(`/api/v1/admin/org-units`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list org units failed"); return data as { items: OrgUnit[]; max_depth: number }; } export async function createOrgUnit( session: Session, body: { parent_id?: number; name: string; code?: string } ) { const res = await apiFetch(`/api/v1/admin/org-units`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create org unit failed"); return data as OrgUnit; } export async function updateOrgUnit( session: Session, id: number, body: { name?: string; code?: string } ) { const res = await apiFetch(`/api/v1/admin/org-units/${id}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update org unit failed"); return data as OrgUnit; } export async function deleteOrgUnit(session: Session, id: number) { const res = await apiFetch(`/api/v1/admin/org-units/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "delete org unit failed"); } export type SyncEndpoint = { driver: string; dsn: string; tables: string[]; }; export type SyncChannel = { id: string; tenant_id?: number; name: string; enabled: boolean; direction: string; conflict_policy: string; poll_interval_ms: number; local: SyncEndpoint; remote: SyncEndpoint; pk_columns: Record; /** 绑定智能体:模块/库归属对照 */ agent_id?: number; /** 公司默认同步通道(Z12) */ is_system_default?: boolean; /** 绑定已发布模块 slug */ app_slug?: string; last_error?: string; last_sync_at?: string; stats?: { pushed_ok: number; pushed_applied?: number; pushed_skipped?: number; pulled_ok: number; conflicts: number; retries: number; last_batch: number; }; }; export type SyncTableInspect = { name: string; row_count: number; columns: string[]; column_count: number; }; export type SyncInspectResult = { ok: boolean; side: string; driver: string; dsn_hint?: string; tables: SyncTableInspect[]; message?: string; }; export type SyncPreviewResult = { ok: boolean; table: string; columns: string[]; rows: Record[]; total: number; limit: number; message?: string; }; export type SyncConflict = { id: string; channel_id: string; table: string; row_pk: string; op: string; source: string; payload: string; message: string; resolved: boolean; }; export async function listSyncChannels(session: Session) { const res = await apiFetch(`/api/v1/admin/sync/channels`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list sync channels failed"); return data as { items: SyncChannel[] }; } export async function createSyncChannel(session: Session, body: Partial) { const res = await apiFetch(`/api/v1/admin/sync/channels`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create sync channel failed"); return data as SyncChannel; } export async function updateSyncChannel(session: Session, id: string, body: Partial) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update sync channel failed"); return data as SyncChannel; } export async function deleteSyncChannel(session: Session, id: string) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "delete sync channel failed"); return data as { ok?: boolean; deleted_id?: string; was_system_default?: boolean; recreated_default?: boolean; channel_id?: string; bindings_fixed?: number; agents_fixed?: number; heal_error?: string; }; } export async function testSyncEndpoints( session: Session, body: { local?: SyncEndpoint; remote?: SyncEndpoint; side?: string } ) { const res = await apiFetch(`/api/v1/admin/sync/test`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "test sync failed"); return data as Record; } export async function startSyncChannel(session: Session, id: string) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/start`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "start sync failed"); return data; } export async function stopSyncChannel(session: Session, id: string) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/stop`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "stop sync failed"); return data; } /** @deprecated 公司侧已 403;请用 listPlatformLwwOverrides */ export async function listSyncConflicts(session: Session, unresolved = true) { const q = unresolved ? "?unresolved=1" : "?unresolved=0"; const res = await apiFetch(`/api/v1/admin/sync/conflicts${q}`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list conflicts failed (公司侧已禁用,请用超管 LWW 审计)"); return data as { items: SyncConflict[] }; } /** @deprecated 公司侧已 403 */ export async function resolveSyncConflict(session: Session, id: string, resolution: string) { const res = await apiFetch(`/api/v1/admin/sync/conflicts/${encodeURIComponent(id)}/resolve`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ resolution }), }); const data = await readJson(res); throwIfBad(res, data, "resolve conflict failed (公司侧已禁用)"); return data; } export async function reconcileSyncChannel(session: Session, id: string) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/reconcile`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "reconcile failed"); return data as { channel_id: string; reports: Array<{ table: string; only_local: string[]; only_remote: string[]; patched_push: number; patched_pull: number; }>; message: string; }; } export type SyncCheckpointSlot = { synced_at?: string; source?: string; table_count?: number; row_count?: number; }; export type SyncCheckpointMeta = { channel_id?: string; latest?: SyncCheckpointSlot | null; previous?: SyncCheckpointSlot | null; }; export async function getSyncCheckpointMeta(session: Session, id: string) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/checkpoint`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "get checkpoint failed"); return data as SyncCheckpointMeta; } export async function restoreSyncChannel( session: Session, id: string, body: { which?: "latest" | "previous"; confirm: boolean } ) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/restore`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "restore failed"); return data as { ok?: boolean; which?: string; synced_at?: string; source?: string; tables?: number; upserted?: number; deleted?: number; restored_at?: string; }; } /** 外部源 C → 写入本地 B,再经 outbox 同步到线上 A */ export async function ingestSyncRows( session: Session, id: string, body: { table: string; source?: string; rows: Record[] } ) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/ingest`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "ingest failed"); return data as { ok: boolean; ingested: number; hint?: string }; } /** 控制台验同步:列出通道线上/本机库的表、行数、字段 */ export async function inspectSyncChannel( session: Session, id: string, opts?: { side?: "remote" | "local"; include_sync_meta?: boolean } ) { const side = opts?.side || "remote"; const q = new URLSearchParams({ side }); if (opts?.include_sync_meta) q.set("include_sync_meta", "1"); const res = await apiFetch( `/api/v1/admin/sync/channels/${encodeURIComponent(id)}/inspect?${q}`, { headers: { Authorization: `Bearer ${session.accessToken}` } } ); const data = await readJson(res); throwIfBad(res, data, "inspect failed"); return data as SyncInspectResult; } /** 预览单表内容 */ export async function previewSyncTable( session: Session, id: string, body: { table: string; side?: "remote" | "local"; limit?: number } ) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/preview`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "preview failed"); return data as SyncPreviewResult; } /** 控制台删业务表(仅当前 side;不同步 DDL 到另一侧) */ export async function dropSyncTable( session: Session, id: string, body: { table: string; side?: "remote" | "local" } ) { const res = await apiFetch(`/api/v1/admin/sync/channels/${encodeURIComponent(id)}/drop-table`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "drop table failed"); return data as { ok: boolean; side: string; table: string; dropped: boolean; meta_cleared?: number; message?: string; }; } export async function ensureSyncBinding( session: Session, body: { local_database_id: string; online_db_id: string; channel_id?: string; database_name?: string; display_name?: string; note?: string; user_id?: number; shared?: boolean; } ) { const res = await apiFetch(`/api/v1/admin/sync/bindings`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "ensure binding failed"); return data as SyncBinding; } export type SyncBinding = { id: string; tenant_id: number; user_id?: number; local_database_id: string; online_db_id: string; channel_id?: string; database_name?: string; display_name?: string; shared?: boolean; note?: string; created_at?: string; updated_at?: string; }; export type BindCode = { code: string; tenant_id: number; channel_id?: string; online_db_id?: string; database_name?: string; max_uses: number; used_count: number; revoked?: boolean; expires_at?: string; created_by?: number; created_at?: string; note?: string; }; export async function listBindCodes(session: Session) { const res = await apiFetch(`/api/v1/admin/bind-codes`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list bind codes failed"); return data as { items: BindCode[] }; } export async function createBindCode( session: Session, body?: { channel_id?: string; online_db_id?: string; database_name?: string; max_uses?: number; expires_hours?: number; note?: string; } ) { const res = await apiFetch(`/api/v1/admin/bind-codes`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body || {}), }); const data = await readJson(res); throwIfBad(res, data, "create bind code failed"); return data as BindCode; } export async function revokeBindCode(session: Session, code: string) { const res = await apiFetch(`/api/v1/admin/bind-codes/${encodeURIComponent(code)}`, { method: "DELETE", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "revoke bind code failed"); return data as { ok: boolean }; } export async function listSyncBindings(session: Session, localDatabaseId?: string) { const q = localDatabaseId ? `?local_database_id=${encodeURIComponent(localDatabaseId)}` : ""; const res = await apiFetch(`/api/v1/admin/sync/bindings${q}`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list bindings failed"); return data as { items: SyncBinding[] }; } export async function listPlatformLwwOverrides( session: Session, opts?: { tenant_id?: number; channel_id?: string; limit?: number } ) { const q = new URLSearchParams(); if (opts?.tenant_id) q.set("tenant_id", String(opts.tenant_id)); if (opts?.channel_id) q.set("channel_id", opts.channel_id); if (opts?.limit) q.set("limit", String(opts.limit)); const qs = q.toString(); const res = await apiFetch(`/api/v1/platform/dbsync/lww-overrides${qs ? `?${qs}` : ""}`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list lww overrides failed"); return data as { items: Array<{ id: string; tenant_id: number; channel_id: string; table: string; row_pk: string; op: string; entry: string; policy: string; outcome: string; loser_payload?: string; winner_payload?: string; target_ver: number; source_ver: number; created_at: string; }>; hint?: string; }; } export async function rollbackPlatformLwwOverride(session: Session, id: string) { const res = await apiFetch( `/api/v1/platform/dbsync/lww-overrides/${encodeURIComponent(id)}/rollback`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, } ); const data = await readJson(res); throwIfBad(res, data, "lww rollback failed"); return data as { ok: boolean; record?: Record; hint?: string }; } export async function listPlatformTenants(session: Session) { const res = await apiFetch(`/api/v1/platform/tenants`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list tenants failed"); return data as { items: Array<{ tenant_id: number; name: string; slug: string; created_at: string; user_count: number; app_count: number; }>; }; } export async function listPlatformPermModules(session: Session) { const res = await apiFetch(`/api/v1/platform/perm-modules`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list perm modules failed"); return data as { modules: { title: string; items: { perm: string; desc: string }[] }[]; catalog: string[]; platform: string[]; }; } export async function getPlatformTenantPerms(session: Session, tenantId: number) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/permissions`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "get tenant perms failed"); return data as { tenant_id: number; permissions: string[]; total: number }; } export async function setPlatformTenantPerms(session: Session, tenantId: number, permissions: string[]) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/permissions`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ permissions }), }); const data = await readJson(res); throwIfBad(res, data, "set tenant perms failed"); return data as { tenant_id: number; permissions: string[]; total: number }; } export async function createPlatformTenant( session: Session, name: string, withAdminInvite = false, slug = "", adminPhone = "" ) { const res = await apiFetch(`/api/v1/platform/tenants`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, slug: slug || undefined, with_admin_invite: withAdminInvite, admin_phone: adminPhone.trim() || undefined, }), }); const data = await readJson(res); throwIfBad(res, data, "create tenant failed"); return data as { tenant: { tenant_id: number; name: string; slug: string }; admin_account?: { user_id: number; username: string; password: string; phone?: string; display_name: string; role: string; }; admin_invite?: { code: string; role: string; expires_at?: string }; }; } export async function updatePlatformTenant( session: Session, tenantId: number, name: string, slug = "" ) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, slug: slug || undefined }), }); const data = await readJson(res); throwIfBad(res, data, "update tenant failed"); return data as { tenant_id: number; name: string; slug: string }; } export async function issuePlatformAdminInvite(session: Session, tenantId: number) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/admin-invite`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "admin invite failed"); return data as { code: string; role: string; expires_at?: string }; } export async function issuePlatformAdminAccount(session: Session, tenantId: number, phone: string) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/admin-account`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ phone: phone.trim() }), }); const data = await readJson(res); throwIfBad(res, data, "admin account failed"); return data as { admin_account: { user_id: number; username: string; password: string; phone?: string; display_name: string; role: string; }; }; } export type PlatformAdminInfo = { user_id: number; username: string; phone: string; username_login_disabled?: boolean; display_name: string; role: string; status: string; }; export async function listPlatformTenantAdmins(session: Session, tenantId: number) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/admins`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list admins failed"); return data as { items: PlatformAdminInfo[] }; } export async function updatePlatformTenantAdmin( session: Session, tenantId: number, userId: number, body: { reset_password?: boolean; password?: string; phone?: string; disable_username_login?: boolean } ) { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/admins/${userId}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update admin failed"); return data as { admin_account: { user_id: number; username: string; password?: string; phone?: string; display_name: string; role: string; }; }; } export async function enterPlatformTenant(session: Session, tenantId: number): Promise { const res = await apiFetch(`/api/v1/platform/tenants/${tenantId}/enter`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "enter tenant failed"); return toSession(data); } export async function exitPlatformTenant(session: Session): Promise { const res = await apiFetch(`/api/v1/platform/exit`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "exit tenant failed"); return toSession(data); } export async function getCompanyEntitlements(session: Session) { const res = await apiFetch(`/api/v1/admin/entitlements`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "get entitlements failed"); return data as { permissions: string[]; modules: { title: string; items: { perm: string; desc: string }[] }[]; }; } export type Member = { user_id: number; username: string; phone?: string; display_name: string; role: string; org_unit_id: number; status: string; created_at?: string; }; export async function listMembers(session: Session) { const res = await apiFetch(`/api/v1/admin/members`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "list members failed"); return data as { items: Member[] }; } export async function createMember( session: Session, body: { password?: string; display_name?: string; role?: string; org_unit_id?: number; } ) { const res = await apiFetch(`/api/v1/admin/members`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "create member failed"); return data as Member & { password: string }; } export async function changePassword(session: Session, oldPassword: string, newPassword: string) { const res = await apiFetch(`/api/v1/auth/password`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), }); const data = await readJson(res); throwIfBad(res, data, "change password failed"); return data as { ok: boolean }; } export async function fetchMe(session: Session) { const res = await apiFetch(`/api/v1/auth/me`, { headers: { Authorization: `Bearer ${session.accessToken}` }, }); const data = await readJson(res); throwIfBad(res, data, "fetch me failed"); return data as { user_id: number; username: string; phone: string; display_name: string; role: string; tenant_id: number; status: string; }; } export async function bindPhone( session: Session, phone: string, disableUsernameLogin?: boolean ) { const body: { phone: string; disable_username_login?: boolean } = { phone }; if (disableUsernameLogin !== undefined) { body.disable_username_login = disableUsernameLogin; } const res = await apiFetch(`/api/v1/auth/phone`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "bind phone failed"); return data as { user_id: number; username: string; phone: string; username_login_disabled: boolean; display_name: string; }; } export async function updateMember( session: Session, id: number, body: { role?: string; org_unit_id?: number; status?: string; phone?: string } ) { const res = await apiFetch(`/api/v1/admin/members/${id}`, { method: "PUT", headers: { Authorization: `Bearer ${session.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const data = await readJson(res); throwIfBad(res, data, "update member failed"); return data as Member; } export async function uploadFile(session: Session, file: File) { const fd = new FormData(); fd.append("file", file); const res = await apiFetch(`/api/v1/storage`, { method: "POST", headers: { Authorization: `Bearer ${session.accessToken}` }, body: fd, }); const data = await readJson(res); throwIfBad(res, data, "upload failed"); return data as { key: string; url: string; filename: string }; }