chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)

This commit is contained in:
logan committed 2026-10-14 20:31:17 +08:00
1 parent a9510235bb
commit 456fa6954b
203 files changed
+18787 -48

No files matched your search

+592
View File
@@ -0,0 +1,592 @@
import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store";
import { decodeSecret } from "./secrets";
/** C2:桌面 sidecar 注入的运行时配置(缺省时行为与现状一致) */
declare global {
interface Window {
__OPSXDB_API__?: string;
__OPSXDB_TOKEN__?: string;
}
}
function injectedApi(): string | undefined {
if (typeof window === "undefined") return undefined;
const v = window.__OPSXDB_API__;
return v ? v.replace(/\/$/, "") : undefined;
}
function injectedToken(): string {
if (typeof window === "undefined") return "";
return window.__OPSXDB_TOKEN__ ?? "";
}
/** 开发态走 Vite 代理(空串);桌面生产包优先 sidecar 注入地址,回落默认 8788。 */
function resolveApiBase(): string {
const injected = injectedApi();
if (injected) return injected;
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_OPSXDB_API;
if (fromEnv) return fromEnv.replace(/\/$/, "");
if (typeof window === "undefined") return "";
const { protocol, hostname, port } = window.location;
if (protocol === "tauri:" || hostname === "tauri.localhost" || hostname === "localhost" && port === "") {
return "http://127.0.0.1:8788";
}
return "";
}
const API = resolveApiBase();
/** C2:token 存在时统一带 Authorization 头;token 为空(本机 dev)行为不变 */
function authHeaders(): Record<string, string> {
const token = injectedToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as T;
}
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}${path}`, { headers: { ...authHeaders() }, signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as T;
}
export type ConnBody = {
driver?: string;
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
service?: string;
ssl?: boolean;
sslMode?: string;
sslCA?: string;
sslCert?: string;
sslKey?: string;
sessionId?: string;
sql?: string;
table?: string;
limit?: number;
/** E1:执行请求标识,配合 POST /api/db/cancel 取消在途语句 */
requestId?: string;
/** E2:单语句执行超时(毫秒),0/缺省 = 服务端默认不额外限制 */
timeoutMs?: number;
/** E6:对象搜索关键字 */
keyword?: string;
/** E7:服务端导出格式 */
format?: string;
/** C5:结果分页,跳过前 offset 行 */
offset?: number;
/** C1:前端已完成危险语句确认后重发时置 true */
confirmDanger?: boolean;
key?: string;
pattern?: string;
cursor?: number;
redisMode?: string;
redisNodes?: string;
masterName?: string;
sentinelPassword?: string;
sshEnabled?: boolean;
sshHost?: string;
sshPort?: number;
sshUser?: string;
sshAuth?: string;
sshPassword?: string;
sshPrivateKey?: string;
sshPassphrase?: string;
};
export function fullConnPayload(c: SavedConnection): ConnBody {
const sslOn = !!(c.ssl || (c.sslMode && c.sslMode !== "disable"));
return {
driver: c.driver,
host: c.host,
port: c.port,
user: c.user,
password: decodeSecret(c.password),
database: c.database,
service: c.service,
ssl: sslOn,
sslMode: sslOn ? (c.sslMode && c.sslMode !== "disable" ? c.sslMode : "require") : "disable",
sslCA: c.sslCA || "",
sslCert: c.sslCert || "",
sslKey: c.sslKey || "",
redisMode: c.redisMode || "standalone",
redisNodes: c.redisNodes || "",
masterName: c.masterName || "",
sentinelPassword: c.sentinelPassword ? decodeSecret(c.sentinelPassword) : "",
sshEnabled: !!c.sshEnabled,
sshHost: c.sshHost,
sshPort: c.sshPort,
sshUser: c.sshUser,
sshAuth: c.sshAuth,
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
sshPrivateKey: c.sshPrivateKey,
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
};
}
export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Partial<ConnBody>): ConnBody {
return {
sessionId,
database: extra?.database ?? c.database,
sql: extra?.sql,
table: extra?.table,
limit: extra?.limit,
offset: extra?.offset,
confirmDanger: extra?.confirmDanger,
key: extra?.key,
pattern: extra?.pattern,
service: extra?.service ?? c.service,
requestId: extra?.requestId,
timeoutMs: extra?.timeoutMs,
keyword: extra?.keyword,
format: extra?.format,
};
}
export async function testConnection(c: SavedConnection) {
return post<{ ok: boolean; message?: string }>("/api/db/test-connection", fullConnPayload(c));
}
export async function openSession(c: SavedConnection) {
return post<{ ok: boolean; sessionId: string; message?: string }>("/api/db/open", fullConnPayload(c));
}
export async function closeSession(sessionId: string) {
return post<{ ok: boolean }>("/api/db/close", { sessionId });
}
function body(c: SavedConnection, sessionId: string | undefined, extra?: Partial<ConnBody>): ConnBody {
if (sessionId) return sessionPayload(c, sessionId, extra);
return { ...fullConnPayload(c), ...extra };
}
export async function listDatabases(c: SavedConnection, sessionId?: string) {
return post<{ databases: string[] }>("/api/db/databases", body(c, sessionId));
}
/**
* C8:schema 缓存 —— connId → database → table → columns。
* listTables/listColumns 成功时写入,供 SqlEditor 补全与 AiPanel 上下文注入共用。
*/
const schemaCache = new Map<string, Map<string, Map<string, ColumnInfo[]>>>();
function cacheTables(connId: string, db: string, tables: string[]) {
let dbMap = schemaCache.get(connId);
if (!dbMap) {
dbMap = new Map();
schemaCache.set(connId, dbMap);
}
let tblMap = dbMap.get(db);
if (!tblMap) {
tblMap = new Map();
dbMap.set(db, tblMap);
}
for (const t of tables) {
if (!tblMap.has(t)) tblMap.set(t, []);
}
}
function cacheColumns(connId: string, db: string, table: string, columns: ColumnInfo[]) {
let dbMap = schemaCache.get(connId);
if (!dbMap) {
dbMap = new Map();
schemaCache.set(connId, dbMap);
}
let tblMap = dbMap.get(db);
if (!tblMap) {
tblMap = new Map();
dbMap.set(db, tblMap);
}
tblMap.set(table, columns);
}
/** 连接关闭/删除后丢弃其缓存,避免跨连接串数据 */
export function dropSchemaCache(connId: string) {
schemaCache.delete(connId);
}
/** SqlEditor 补全数据源:已加载的表与列 */
export function getCachedSchema(
connId: string | null | undefined,
): Map<string, Map<string, ColumnInfo[]>> | null {
if (!connId) return null;
return schemaCache.get(connId) ?? null;
}
/**
* AiPanel 上下文注入:把已加载的库表列拼成文本,按 connId 隔离,截断 4000 字符。
* 优先输出当前库,其余库只列库.表名。
*/
export function schemaContextFor(connId: string | null | undefined, currentDb?: string): string {
const dbMap = connId ? schemaCache.get(connId) : undefined;
if (!dbMap || dbMap.size === 0) return "";
const MAX = 4000;
const lines: string[] = [];
const dbs = [...dbMap.keys()].sort((a, b) => {
if (a === currentDb) return -1;
if (b === currentDb) return 1;
return a.localeCompare(b);
});
for (const db of dbs) {
const tblMap = dbMap.get(db)!;
for (const [table, cols] of tblMap) {
const colText = cols.length
? `(${cols.map((c) => `${c.name} ${c.type}`.trim()).join(", ")})`
: "";
lines.push(`${db}.${table}${colText}`);
if (lines.join("\n").length > MAX) {
return lines.join("\n").slice(0, MAX);
}
}
}
return lines.join("\n").slice(0, MAX);
}
export async function listTables(c: SavedConnection, database: string, sessionId?: string) {
const res = await post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
if (Array.isArray(res.tables)) cacheTables(c.id, database, res.tables);
return res;
}
export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) {
const res = await post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
if (Array.isArray(res.columns)) cacheColumns(c.id, database, table, res.columns);
return res;
}
/** E1:最近一次 execSql 的 requestId,供 cancelExec() 取消在途语句 */
let lastExecRequestId: string | undefined;
export async function execSql(
c: SavedConnection,
sql: string,
opts?: {
database?: string;
sessionId?: string;
limit?: number;
offset?: number;
confirmDanger?: boolean;
/** E2:单语句执行超时(毫秒),0/缺省 = 不额外限制 */
timeoutMs?: number;
signal?: AbortSignal;
},
) {
// E1:每次执行生成独立 requestId(模块级记录最后一次),取消端点按 id 精准取消
const requestId = crypto.randomUUID();
lastExecRequestId = requestId;
return post<QueryResult>(
"/api/db/exec",
body(c, opts?.sessionId, {
sql,
database: opts?.database || c.database,
limit: opts?.limit,
offset: opts?.offset,
confirmDanger: opts?.confirmDanger,
requestId,
timeoutMs: opts?.timeoutMs,
}),
opts?.signal,
);
}
/**
* E1:取消最近一次执行 —— fire-and-forget 调 POST /api/db/cancel。
* 与前端 abort 互补:服务端收到取消后释放连接/杀查询,错误一律吞掉。
*/
export function cancelExec(): void {
const rid = lastExecRequestId;
if (!rid) return;
void fetch(`${API}/api/db/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ requestId: rid }),
}).catch(() => {
/* 取消失败不影响本地中止流程 */
});
}
/** E5:查看表/视图 DDL(方言差异由服务端处理) */
export async function getDdl(
c: SavedConnection,
table: string,
opts?: { database?: string; sessionId?: string },
) {
return post<{ ddl?: string; error?: string }>(
"/api/db/ddl",
body(c, opts?.sessionId, { table, database: opts?.database || c.database }),
);
}
export type SearchObjectHit = { schema: string; name: string; type: "table" | "view" };
/** E6:跨库对象搜索(服务端 LIKE,大小写策略由驱动决定) */
export async function searchObjects(
c: SavedConnection,
keyword: string,
opts?: { database?: string; sessionId?: string },
) {
return post<{ results: SearchObjectHit[]; error?: string }>(
"/api/db/search",
body(c, opts?.sessionId, { keyword, database: opts?.database || c.database }),
);
}
export type ExportRequest = {
format: "csv" | "json" | "insert";
/** 自定义查询导出(单条 SELECT);命中危险/非 SELECT 时服务端返回 needConfirm */
sql?: string;
/** 整表导出 */
table?: string;
limit?: number;
database?: string;
sessionId?: string;
/** C1:危险确认二段流重发时置 true */
confirmDanger?: boolean;
};
/** E7 服务端导出失败:Error 上挂 needConfirm,由调用侧弹二段确认后重发 */
export type ExportError = Error & { needConfirm?: boolean };
/**
* E7:服务端导出 —— 文件流下载(Content-Disposition attachment)。
* 返回 blob 与解析出的文件名;失败时抛 ExportError(needConfirm=true 表示需二段确认)。
*/
export async function exportData(c: SavedConnection, opts: ExportRequest): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(`${API}/api/db/export`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(
body(c, opts.sessionId, {
format: opts.format,
sql: opts.sql,
table: opts.table,
limit: opts.limit,
database: opts.database || c.database,
confirmDanger: opts.confirmDanger,
}),
),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const err = new Error((data as { error?: string }).error || res.statusText || "导出失败") as ExportError;
if ((data as { needConfirm?: boolean }).needConfirm) err.needConfirm = true;
throw err;
}
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
const blob = await res.blob();
return { blob, filename };
}
/** C3:服务端专用执行计划端点(方言包装在服务端完成,前端不再拼 EXPLAIN) */
export type ExplainResult = {
columns?: string[];
rows?: Record<string, unknown>[];
/** MSSQL SHOWPLAN_XML / Oracle 文本计划 */
text?: string;
timeMs: number;
error?: string;
};
export async function explainSql(
c: SavedConnection,
sql: string,
opts?: { database?: string; sessionId?: string; signal?: AbortSignal },
) {
return post<ExplainResult>(
"/api/db/explain",
body(c, opts?.sessionId, { sql, database: opts?.database || c.database }),
opts?.signal,
);
}
export type RedisKeyInfo = { name: string; type: string; ttl: number };
export type RedisKeysResult = {
keys: RedisKeyInfo[];
truncated?: boolean;
timeMs?: number;
error?: string;
};
export type RedisKeyValue = {
name: string;
type: string;
ttl: number;
columns: string[];
rows: Record<string, unknown>[];
rowCount: number;
timeMs: number;
truncated?: boolean;
limit?: number;
/** >0 表示还有更多;续载时作为 cursor 回传 */
nextCursor?: number;
error?: string;
};
export async function listRedisKeys(
c: SavedConnection,
database: string,
opts?: { sessionId?: string; pattern?: string; limit?: number },
) {
return post<RedisKeysResult>(
"/api/db/redis/keys",
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, driver: c.driver }),
);
}
export async function getRedisKey(
c: SavedConnection,
database: string,
key: string,
opts?: { sessionId?: string; limit?: number; cursor?: number },
) {
return post<RedisKeyValue>(
"/api/db/redis/key",
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, cursor: opts?.cursor, driver: c.driver }),
);
}
export async function deleteRedisKey(
c: SavedConnection,
database: string,
key: string,
opts?: { sessionId?: string },
) {
return post<QueryResult>(
"/api/db/redis/key/delete",
body(c, opts?.sessionId, { database, key, table: key, driver: c.driver }),
);
}
/** 目录(前缀)批量删除:pattern 如 `agent:*`;后端 SCAN+UNLINK,拒全库与超上限 */
export async function deleteRedisPattern(
c: SavedConnection,
database: string,
pattern: string,
opts?: { sessionId?: string },
) {
return post<{ deleted: number; scanned: number; truncated: boolean; error?: string; timeMs?: number }>(
"/api/db/redis/del-pattern",
body(c, opts?.sessionId, { database, pattern, driver: c.driver }),
);
}
export async function loadTree(c: SavedConnection, sessionId?: string): Promise<TreeNode[]> {
const { databases } = await listDatabases(c, sessionId);
return databases.map((name) => ({ name, kind: "database" as const }));
}
export type AiAction =
| "ask"
| "generate"
| "explain"
| "optimize"
| "fix"
| "dialect"
| "sample";
export async function callAi(payload: {
action: AiAction;
prompt?: string;
sql?: string;
driver?: string;
database?: string;
error?: string;
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
schema?: string;
model?: string;
}) {
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
}
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置) */
export type AiConfig = {
provider: string;
baseURL: string;
model: string;
hasKey: boolean;
apiKey?: string;
};
export async function getAiConfig(signal?: AbortSignal) {
return get<AiConfig>("/api/ai/config", signal);
}
export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string }) {
return post<{ ok: boolean }>("/api/ai/config", cfg);
}
export async function health() {
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
if (!res.ok) throw new Error(`health ${res.status}`);
return res.json();
}
/** fetch AbortController / DOMException AbortError */
export function isAbortError(e: unknown): boolean {
if (!e || typeof e !== "object") return false;
const name = (e as { name?: string }).name;
if (name === "AbortError") return true;
const msg = e instanceof Error ? e.message : String(e);
return /aborted|abort/i.test(msg) && !/session/i.test(msg);
}
// ---------- 驱动管理(agent 驱动) ----------
export type DriverJarStatus = { file: string; size: number; installed: boolean };
export type DriverStatusItem = {
id: string;
label: string;
runtime: string;
license: string;
autoFetch: boolean;
jars: DriverJarStatus[];
allInstalled: boolean;
jdkRequired: boolean;
jdkOk: boolean;
javaVersion: string;
agentInstalled: boolean;
offlinePack: boolean;
};
export async function driverStatus() {
const res = await fetch(`${API}/api/drivers/status`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return (data as { drivers: DriverStatusItem[] }).drivers;
}
export async function driverInstall(driver: string) {
return post<{ ok: boolean; error?: string; drivers?: DriverStatusItem[] }>("/api/drivers/install", { driver });
}
export async function driverImportOffline(file: File) {
const form = new FormData();
form.append("file", file);
const res = await fetch(`${API}/api/drivers/import`, { method: "POST", body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
}