Capture rolling online DB snapshots after push/drain/reconcile and expose SyncPage 数据恢复 plus checkpoint/restore APIs; mark Z12h and restore done in coop docs. Co-authored-by: Cursor <cursoragent@cursor.com>
1708 lines
50 KiB
TypeScript
1708 lines
50 KiB
TypeScript
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<Response> {
|
||
try {
|
||
return await fetch(input, init);
|
||
} catch (e) {
|
||
throw networkError(e);
|
||
}
|
||
}
|
||
|
||
async function readJson(res: Response): Promise<any> {
|
||
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<Session> {
|
||
return loginWith({ username: username.trim(), password });
|
||
}
|
||
|
||
export async function loginWith(body: {
|
||
username?: string;
|
||
phone?: string;
|
||
password?: string;
|
||
sms_code?: string;
|
||
}): Promise<Session> {
|
||
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<Session> {
|
||
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<string, string>,
|
||
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<string, string> = {};
|
||
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<string, unknown>[]; total: number };
|
||
}
|
||
|
||
export async function createRow(
|
||
session: Session | null | undefined,
|
||
slug: string,
|
||
resource: string,
|
||
body: Record<string, unknown>
|
||
) {
|
||
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<string, unknown>;
|
||
}
|
||
|
||
export async function updateRow(
|
||
session: Session | null | undefined,
|
||
slug: string,
|
||
resource: string,
|
||
id: string,
|
||
body: Record<string, unknown>
|
||
) {
|
||
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<string, unknown>;
|
||
}
|
||
|
||
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<string, string> = {};
|
||
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<string, string>;
|
||
/** 绑定智能体:模块/库归属对照 */
|
||
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<string, unknown>[];
|
||
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<SyncChannel>) {
|
||
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<SyncChannel>) {
|
||
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<string, unknown>;
|
||
}
|
||
|
||
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<string, unknown>[] }
|
||
) {
|
||
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<string, unknown>; 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<Session> {
|
||
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<Session> {
|
||
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 };
|
||
}
|