feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
commit
f8740d27f7
240 files changed
+21625
No files matched your search
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@opsxdb/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
HttpMethod,
|
||||
HttpSpec,
|
||||
WsSpec,
|
||||
HttpResult,
|
||||
WsSession,
|
||||
Transport,
|
||||
} from "./types";
|
||||
|
||||
export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
|
||||
|
||||
describe("replaceVariables", () => {
|
||||
it("replaces Postman-style {{var}}", () => {
|
||||
expect(replaceVariables("{{baseUrl}}/api", { baseUrl: "https://x.test" })).toBe(
|
||||
"https://x.test/api",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves unknown vars intact", () => {
|
||||
expect(replaceVariables("hello {{missing}}", {})).toBe("hello {{missing}}");
|
||||
});
|
||||
|
||||
it("supports multiple and spaced keys", () => {
|
||||
expect(
|
||||
replaceVariables("{{ host }}/{{path}}", { host: "h", path: "p" }),
|
||||
).toBe("h/p");
|
||||
});
|
||||
|
||||
it("replaceVariablesInSpec walks nested strings", () => {
|
||||
const out = replaceVariablesInSpec(
|
||||
{ url: "{{baseUrl}}/v1", headers: { Authorization: "Bearer {{token}}" } },
|
||||
{ baseUrl: "http://a", token: "t" },
|
||||
);
|
||||
expect(out.url).toBe("http://a/v1");
|
||||
expect(out.headers.Authorization).toBe("Bearer t");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Postman-style {{var}} replacement.
|
||||
* Unknown variables are left as-is.
|
||||
*/
|
||||
export function replaceVariables(
|
||||
input: string,
|
||||
env: Record<string, string>,
|
||||
): string {
|
||||
if (!input) return input;
|
||||
return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
return env[key] ?? "";
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceVariablesInSpec<T extends Record<string, unknown>>(
|
||||
spec: T,
|
||||
env: Record<string, string>,
|
||||
): T {
|
||||
const walk = (v: unknown): unknown => {
|
||||
if (typeof v === "string") return replaceVariables(v, env);
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
if (v && typeof v === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
out[k] = walk(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return walk(spec) as T;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type HttpSpec = {
|
||||
method: HttpMethod;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
body?: {
|
||||
mode: "none" | "json" | "raw" | "form" | "urlencoded";
|
||||
raw?: string;
|
||||
};
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type WsSpec = {
|
||||
url: string;
|
||||
protocols?: string[];
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type HttpResult = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
timeMs: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export interface WsSession {
|
||||
onMessage(cb: (data: string) => void): void;
|
||||
onStatus(cb: (s: string) => void): void;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
http(spec: HttpSpec): Promise<HttpResult>;
|
||||
ws(spec: WsSpec): WsSession;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@opsxdb/transport",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxdb/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tauri-apps/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
|
||||
|
||||
function buildUrl(spec: HttpSpec): string {
|
||||
const u = new URL(spec.url);
|
||||
if (spec.query) {
|
||||
for (const [k, v] of Object.entries(spec.query)) {
|
||||
if (v !== undefined && v !== "") u.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
h.forEach((v, k) => {
|
||||
out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const browserTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
const started = performance.now();
|
||||
const init: RequestInit = {
|
||||
method: spec.method,
|
||||
headers: { ...spec.headers },
|
||||
signal: spec.timeoutMs
|
||||
? AbortSignal.timeout(spec.timeoutMs)
|
||||
: undefined,
|
||||
};
|
||||
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
|
||||
init.body = spec.body.raw ?? "";
|
||||
if (spec.body.mode === "json" && !("content-type" in Object.fromEntries(
|
||||
Object.entries(spec.headers).map(([k, v]) => [k.toLowerCase(), v]),
|
||||
))) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
const res = await fetch(buildUrl(spec), init);
|
||||
const body = await res.text();
|
||||
const timeMs = Math.round(performance.now() - started);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: headersToRecord(res.headers),
|
||||
body,
|
||||
timeMs,
|
||||
size: new TextEncoder().encode(body).length,
|
||||
};
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const socket = new WebSocket(spec.url, spec.protocols);
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
emitStatus("connecting");
|
||||
socket.onopen = () => emitStatus("open");
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
socket.onmessage = (ev) => {
|
||||
const data = typeof ev.data === "string" ? ev.data : String(ev.data);
|
||||
messageCbs.forEach((cb) => cb(data));
|
||||
};
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
socket.send(data);
|
||||
},
|
||||
close() {
|
||||
socket.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Transport } from "@opsxdb/core";
|
||||
import { browserTransport } from "./browser";
|
||||
import { ginProxyTransport } from "./ginProxy";
|
||||
import { tauriNativeTransport } from "./tauriNative";
|
||||
|
||||
export type TransportKind = "tauri-native" | "gin-proxy" | "browser";
|
||||
|
||||
export type DetectOptions = {
|
||||
ginBaseUrl?: string;
|
||||
/** Force a transport regardless of environment */
|
||||
force?: TransportKind;
|
||||
};
|
||||
|
||||
function isTauri(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const w = window as unknown as { __TAURI_INTERNALS__?: unknown };
|
||||
return Boolean(w.__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
export function detectTransport(opts: DetectOptions = {}): {
|
||||
kind: TransportKind;
|
||||
transport: Transport;
|
||||
} {
|
||||
if (opts.force === "browser") {
|
||||
return { kind: "browser", transport: browserTransport };
|
||||
}
|
||||
if (opts.force === "tauri-native") {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
if (opts.force === "gin-proxy") {
|
||||
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
|
||||
const base = opts.ginBaseUrl ?? "http://127.0.0.1:8788";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
|
||||
import { browserTransport } from "./browser";
|
||||
|
||||
/**
|
||||
* HTTP goes through Gin POST /v1/proxy/http.
|
||||
* WS (MVP): falls back to browser WebSocket — custom headers require
|
||||
* desktop/Gin tunnel later (Week 2).
|
||||
*/
|
||||
export function ginProxyTransport(baseUrl: string): Transport {
|
||||
const root = baseUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
const res = await fetch(`${root}/v1/proxy/http`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(spec),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`gin-proxy HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as HttpResult;
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
// TODO(week2): custom headers need desktop/Gin WS tunnel.
|
||||
// For MVP, fall back to browser WebSocket (no custom headers).
|
||||
console.warn(
|
||||
"[opsxdb/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
|
||||
);
|
||||
return browserTransport.ws(spec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { browserTransport } from "./browser";
|
||||
export { ginProxyTransport } from "./ginProxy";
|
||||
export { tauriNativeTransport } from "./tauriNative";
|
||||
export { detectTransport } from "./detect";
|
||||
export type { TransportKind, DetectOptions } from "./detect";
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxdb/core";
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import("@tauri-apps/api/core");
|
||||
return await tauriInvoke<T>(cmd, args);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(
|
||||
`tauri-native invoke("${cmd}") failed (are you running inside OpsxDB desktop?): ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const tauriNativeTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
return invoke<HttpResult>("http_send", { spec });
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
emitStatus("connecting");
|
||||
sessionId = await invoke<string>("ws_connect", { spec });
|
||||
emitStatus("open");
|
||||
// Full event listen pipeline is Week 2; stub keeps UI usable.
|
||||
} catch (e) {
|
||||
emitStatus("error");
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
if (!sessionId) {
|
||||
messageCbs.forEach((cb) => cb("[ws] not connected"));
|
||||
return;
|
||||
}
|
||||
void invoke("ws_send", { sessionId, data }).catch((e) => {
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (!sessionId) return;
|
||||
void invoke("ws_close", { sessionId }).finally(() => {
|
||||
sessionId = null;
|
||||
emitStatus("closed");
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@opsxdb/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.12",
|
||||
"@opsxdb/core": "*",
|
||||
"@opsxdb/transport": "*",
|
||||
"sql-formatter": "^15.8.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { callAi, type AiAction } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const ACTIONS: { id: AiAction; label: string }[] = [
|
||||
{ id: "generate", label: "生成" },
|
||||
{ id: "explain", label: "解释" },
|
||||
{ id: "optimize", label: "优化" },
|
||||
{ id: "fix", label: "修复" },
|
||||
{ id: "dialect", label: "方言" },
|
||||
{ id: "sample", label: "样例" },
|
||||
];
|
||||
|
||||
export function AiPanel() {
|
||||
const messages = useWorkspaceStore((s) => s.aiMessages);
|
||||
const ask = useWorkspaceStore((s) => s.aiAsk);
|
||||
const model = useWorkspaceStore((s) => s.aiModel);
|
||||
const busy = useWorkspaceStore((s) => s.aiBusy);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const tabs = useWorkspaceStore((s) => s.tabs);
|
||||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const pushAi = useWorkspaceStore((s) => s.pushAi);
|
||||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
const sql = tab?.sql ?? "";
|
||||
|
||||
const run = async (action: AiAction, prompt?: string) => {
|
||||
const text = (prompt ?? ask).trim();
|
||||
if (action === "ask" && !text) return;
|
||||
set("aiBusy", true);
|
||||
if (action === "ask" || action === "generate") {
|
||||
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
|
||||
} else {
|
||||
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
||||
}
|
||||
try {
|
||||
const res = await callAi({
|
||||
action,
|
||||
prompt: text,
|
||||
sql,
|
||||
driver: conn?.driver,
|
||||
database: conn?.database || tab?.database,
|
||||
error: result?.error,
|
||||
});
|
||||
pushAi("assistant", res.content);
|
||||
if (res.sql) updateActiveSql(res.sql);
|
||||
if (action === "ask" || action === "generate") set("aiAsk", "");
|
||||
} catch (e) {
|
||||
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
set("aiBusy", false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
||||
<div className="ops-section-label justify-between border-b gap-1">
|
||||
<span>AI</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="ops-input h-6 py-0 text-[10px] normal-case tracking-normal"
|
||||
value={model}
|
||||
onChange={(e) => set("aiModel", e.target.value)}
|
||||
>
|
||||
<option value="opsxdb-stub">opsxdb-stub</option>
|
||||
<option value="gpt-stub">gpt-stub</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-close-faint"
|
||||
title="关闭"
|
||||
aria-label="关闭 AI"
|
||||
onClick={() => {
|
||||
set("showAi", false);
|
||||
useWorkspaceStore.getState().persistPrefs();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<select
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
value={activeConnectionId ?? ""}
|
||||
onChange={(e) => set("activeConnectionId", e.target.value || null)}
|
||||
>
|
||||
<option value="">连接</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
placeholder="库"
|
||||
value={conn?.database ?? ""}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap gap-1 border-b px-1.5 py-1"
|
||||
style={{ borderColor: "var(--ops-border)" }}
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run(a.id, ask)}
|
||||
className="ops-btn-ai"
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
||||
{messages.length === 0 && (
|
||||
<div
|
||||
className="grid h-full place-items-center px-2 text-center text-[12px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
>
|
||||
描述需求,或点上方快捷操作
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded border px-2 py-1.5 text-[12px] whitespace-pre-wrap"
|
||||
style={
|
||||
m.role === "user"
|
||||
? {
|
||||
borderColor: "color-mix(in srgb, var(--ops-accent) 35%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-accent) 8%, transparent)",
|
||||
}
|
||||
: {
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface-2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mb-0.5 text-[10px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
>
|
||||
{m.role === "user" ? "你" : "AI"} · {m.at}
|
||||
</div>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="ops-input flex-1"
|
||||
placeholder="描述查询…"
|
||||
value={ask}
|
||||
disabled={busy}
|
||||
onChange={(e) => set("aiAsk", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void run("ask");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="ops-btn-primary" disabled={busy} onClick={() => void run("ask")}>
|
||||
{busy ? "…" : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { formKindOf, type DriverDef } from "./catalog";
|
||||
import type { SavedConnection } from "./store";
|
||||
|
||||
const COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#64748b"];
|
||||
|
||||
type TabId = "general" | "advanced" | "ssl" | "ssh";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "general", label: "常规" },
|
||||
{ id: "advanced", label: "高级" },
|
||||
{ id: "ssl", label: "SSL" },
|
||||
{ id: "ssh", label: "SSH" },
|
||||
];
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_1fr] items-center gap-x-2" style={{ minHeight: 30 }}>
|
||||
<div className="text-right text-[12px] leading-none" style={{ color: "var(--ops-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? "text" : "password"}
|
||||
className="ops-input h-7 w-full pr-8 text-[12.5px]"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded px-1 text-[11px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? "隐" : "显"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ConnectionFormProps = {
|
||||
selected: DriverDef;
|
||||
form: Partial<SavedConnection>;
|
||||
setForm: (next: Partial<SavedConnection>) => void;
|
||||
dsn: string;
|
||||
setDsn: (v: string) => void;
|
||||
onApplyDsn: () => void;
|
||||
};
|
||||
|
||||
export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDsn }: ConnectionFormProps) {
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
const kind = formKindOf(selected.id);
|
||||
const patch = (p: Partial<SavedConnection>) => setForm({ ...form, ...p });
|
||||
|
||||
const text = (key: keyof SavedConnection, ph = "") => (
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={ph}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(e) => patch({ [key]: e.target.value } as Partial<SavedConnection>)}
|
||||
/>
|
||||
);
|
||||
|
||||
const portInput = (
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={String(selected.defaultPort)}
|
||||
value={form.port === undefined || form.port === null ? "" : String(form.port)}
|
||||
onChange={(e) => patch({ port: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const general = () => {
|
||||
if (kind === "file") {
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "我的数据库")}</Row>
|
||||
<Row label="文件路径">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? "/path/to/demo.sqlite"}
|
||||
value={form.database ?? ""}
|
||||
onChange={(e) => patch({ database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "redis") {
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "Redis 连接")}</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="密码">
|
||||
<PasswordInput
|
||||
value={form.password ?? ""}
|
||||
onChange={(v) => patch({ password: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="用户名">{text("user", "ACL ≥6 可选")}</Row>
|
||||
<Row label="分隔符">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder=":"
|
||||
value={form.keySeparator ?? ":"}
|
||||
onChange={(e) => patch({ keySeparator: e.target.value || ":" })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="模式">
|
||||
<div className="flex flex-wrap items-center gap-3 text-[12px]">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.redisMode ?? "standalone") === "standalone"}
|
||||
onChange={() => patch({ redisMode: "standalone" })}
|
||||
/>
|
||||
单机
|
||||
</label>
|
||||
<label className="flex items-center gap-1 opacity-50" title="本迭代未做">
|
||||
<input type="checkbox" disabled checked={false} readOnly />
|
||||
Sentinel
|
||||
</label>
|
||||
<label className="flex items-center gap-1 opacity-50" title="本迭代未做">
|
||||
<input type="checkbox" disabled checked={false} readOnly />
|
||||
Cluster
|
||||
</label>
|
||||
<span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
Sentinel / Cluster 本迭代未做
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "oracle") {
|
||||
const idKind = form.oracleIdKind ?? "service";
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "Oracle 连接")}</Row>
|
||||
<Row label="连接类型">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.oracleConnType ?? "basic"}
|
||||
onChange={(e) => patch({ oracleConnType: e.target.value as "basic" })}
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="标识">
|
||||
<div className="flex items-center gap-3 text-[12px]">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="oracleIdKind"
|
||||
checked={idKind === "service"}
|
||||
onChange={() => patch({ oracleIdKind: "service" })}
|
||||
/>
|
||||
服务名
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="oracleIdKind"
|
||||
checked={idKind === "sid"}
|
||||
onChange={() => patch({ oracleIdKind: "sid" })}
|
||||
/>
|
||||
SID
|
||||
</label>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={idKind === "sid" ? "SID" : "服务名"}>
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? "ORCL"}
|
||||
value={form.service || form.database || ""}
|
||||
onChange={(e) => patch({ service: e.target.value, database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="角色">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.oracleRole ?? "default"}
|
||||
onChange={(e) => patch({ oracleRole: e.target.value })}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="SYSDBA">SYSDBA</option>
|
||||
<option value="SYSOPER">SYSOPER</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="用户">{text("user", selected.hints?.user ?? "system")}</Row>
|
||||
<Row label="密码">
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
</Row>
|
||||
<Row label="保存密码">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.savePassword !== false}
|
||||
onChange={(e) => patch({ savePassword: e.target.checked })}
|
||||
/>
|
||||
记住密码
|
||||
</label>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// standard (mysql-like) + PG / SQL Server extras
|
||||
const isPg = selected.id === "postgresql" || selected.id === "gaussdb" || selected.id === "kingbase";
|
||||
const isMssql = selected.id === "sqlserver";
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "我的数据库")}</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="用户">{text("user", selected.hints?.user ?? "root")}</Row>
|
||||
<Row label="密码">
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
</Row>
|
||||
<Row label="保存密码">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.savePassword !== false}
|
||||
onChange={(e) => patch({ savePassword: e.target.checked })}
|
||||
/>
|
||||
记住密码
|
||||
</label>
|
||||
</Row>
|
||||
{(isPg || isMssql || selected.id === "mysql" || selected.mysqlCompat) && (
|
||||
<Row label={isPg || isMssql ? "初始数据库" : "库名"}>
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? (isPg ? "postgres" : isMssql ? "master" : "")}
|
||||
value={form.database ?? ""}
|
||||
onChange={(e) => patch({ database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{isMssql && (
|
||||
<Row label="验证方式">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.authMode ?? "sql"}
|
||||
onChange={(e) => patch({ authMode: e.target.value as "sql" | "windows" })}
|
||||
>
|
||||
<option value="sql">SQL Server 身份验证</option>
|
||||
<option value="windows" disabled>
|
||||
Windows 身份验证(未实现)
|
||||
</option>
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const advanced = () => (
|
||||
<>
|
||||
<Row label="默认连接">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.isDefault}
|
||||
onChange={(e) => patch({ isDefault: e.target.checked })}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
</Row>
|
||||
<Row label="颜色">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className="h-4 w-4 rounded-full border"
|
||||
style={{
|
||||
background: c,
|
||||
borderColor: form.color === c ? "var(--ops-text)" : "transparent",
|
||||
}}
|
||||
onClick={() => patch({ color: c })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
{kind === "redis" && (
|
||||
<Row label="DB 索引">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="0–15(database index)"
|
||||
inputMode="numeric"
|
||||
value={form.database ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v === "") {
|
||||
patch({ database: "" });
|
||||
return;
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(v)) return;
|
||||
const n = Number(v);
|
||||
if (n >= 0 && n <= 15) patch({ database: v });
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{kind !== "file" && (
|
||||
<Row label="连接 URL">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="ops-input h-7 flex-1 text-[12.5px]"
|
||||
placeholder="mysql://root:pass@127.0.0.1:3306/demo"
|
||||
value={dsn}
|
||||
onChange={(e) => setDsn(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onApplyDsn();
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="ops-btn-secondary h-7 px-2 text-[12px]" onClick={onApplyDsn}>
|
||||
填入
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const sslOn = !!(form.ssl || (form.sslMode && form.sslMode !== "disable"));
|
||||
const sslMode = (form.sslMode && form.sslMode !== "disable" ? form.sslMode : "require") as
|
||||
| "require"
|
||||
| "skip-verify"
|
||||
| "verify-ca";
|
||||
|
||||
const ssl = () => (
|
||||
<>
|
||||
<Row label="SSL">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={kind !== "file" && sslOn}
|
||||
disabled={kind === "file"}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
patch(
|
||||
on
|
||||
? {
|
||||
ssl: true,
|
||||
sslMode: form.sslMode && form.sslMode !== "disable" ? form.sslMode : "require",
|
||||
}
|
||||
: { ssl: false, sslMode: "disable" },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
使用 SSL
|
||||
</label>
|
||||
</Row>
|
||||
{kind === "file" ? (
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
文件库(SQLite)不支持 SSL。
|
||||
</p>
|
||||
) : sslOn ? (
|
||||
<>
|
||||
<Row label="模式">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={sslMode}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
ssl: true,
|
||||
sslMode: e.target.value as "require" | "skip-verify" | "verify-ca",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="require">require(加密)</option>
|
||||
<option value="skip-verify">skip-verify(跳过证书校验)</option>
|
||||
<option value="verify-ca">verify-ca(校验 CA)</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="CA 证书">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="可选,本机 PEM 路径 /path/to/ca.pem"
|
||||
value={form.sslCA ?? ""}
|
||||
onChange={(e) => patch({ sslCA: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="客户端证书">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="可选,客户端证书 PEM 路径"
|
||||
value={form.sslCert ?? ""}
|
||||
onChange={(e) => patch({ sslCert: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="客户端密钥">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="可选,客户端私钥 PEM 路径"
|
||||
value={form.sslKey ?? ""}
|
||||
onChange={(e) => patch({ sslKey: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{sslMode === "require"
|
||||
? "仅加密传输;路径可留空。verify-ca 需填 CA。"
|
||||
: sslMode === "skip-verify"
|
||||
? "加密且不校验服务端证书(自签常用);路径可留空。"
|
||||
: "校验服务端证书链;请填写 CA 证书路径。客户端证书/密钥可选。"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
开启后可选择 require / skip-verify / verify-ca,并可选填证书路径。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ssh = () => (
|
||||
<>
|
||||
<Row label="SSH 隧道">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.sshEnabled}
|
||||
onChange={(e) => patch({ sshEnabled: e.target.checked })}
|
||||
/>
|
||||
使用 SSH 隧道
|
||||
</label>
|
||||
</Row>
|
||||
{form.sshEnabled && (
|
||||
<>
|
||||
<Row label="SSH 主机">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="跳板机地址"
|
||||
value={form.sshHost ?? ""}
|
||||
onChange={(e) => patch({ sshHost: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="SSH 端口">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="22"
|
||||
value={form.sshPort === undefined || form.sshPort === null ? "22" : String(form.sshPort)}
|
||||
onChange={(e) => patch({ sshPort: Number(e.target.value) || 22 })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="SSH 用户">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="root"
|
||||
value={form.sshUser ?? ""}
|
||||
onChange={(e) => patch({ sshUser: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="认证方式">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.sshAuth ?? "password"}
|
||||
onChange={(e) => patch({ sshAuth: e.target.value as "password" | "privateKey" })}
|
||||
>
|
||||
<option value="password">密码</option>
|
||||
<option value="privateKey">私钥</option>
|
||||
</select>
|
||||
</Row>
|
||||
{(form.sshAuth ?? "password") === "password" ? (
|
||||
<Row label="SSH 密码">
|
||||
<PasswordInput
|
||||
value={form.sshPassword ?? ""}
|
||||
onChange={(v) => patch({ sshPassword: v })}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<>
|
||||
<Row label="私钥">
|
||||
<textarea
|
||||
className="ops-input min-h-[72px] w-full py-1 text-[11px] leading-snug"
|
||||
placeholder="PEM 内容或私钥路径 ~/.ssh/id_ed25519"
|
||||
value={form.sshPrivateKey ?? ""}
|
||||
onChange={(e) => patch({ sshPrivateKey: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="口令">
|
||||
<PasswordInput
|
||||
value={form.sshPassphrase ?? ""}
|
||||
placeholder="私钥口令(可选)"
|
||||
onChange={(v) => patch({ sshPassphrase: v })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
开启后,测试/连接先经本机端口转发再访问数据库。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex shrink-0 gap-0 border-b px-1"
|
||||
style={{ borderColor: "var(--ops-border)" }}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={cx(
|
||||
"px-3 py-1.5 text-[12.5px] transition",
|
||||
tab === t.id ? "border-b-2 font-medium" : "opacity-70 hover:opacity-100",
|
||||
)}
|
||||
style={
|
||||
tab === t.id
|
||||
? { borderColor: "var(--ops-accent)", color: "var(--ops-text)" }
|
||||
: { borderColor: "transparent", color: "var(--ops-muted)" }
|
||||
}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-auto px-3 py-2">
|
||||
{tab === "general" && general()}
|
||||
{tab === "advanced" && advanced()}
|
||||
{tab === "ssl" && ssl()}
|
||||
{tab === "ssh" && ssh()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
|
||||
function hostSummary(c: SavedConnection) {
|
||||
const d = driverById(c.driver);
|
||||
if (c.driver === "sqlite") {
|
||||
return c.database || "本地文件";
|
||||
}
|
||||
const host = c.host || "127.0.0.1";
|
||||
const port = c.port ? `:${c.port}` : "";
|
||||
const db = c.database ? ` / ${c.database}` : "";
|
||||
return `${host}${port}${db}`;
|
||||
}
|
||||
|
||||
function matchesQuery(c: SavedConnection, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
const d = driverById(c.driver);
|
||||
const hay = [c.name, c.host || "", c.driver, d?.name ?? "", c.database || "", String(c.port || "")]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(needle);
|
||||
}
|
||||
|
||||
function ConnRow({
|
||||
c,
|
||||
onOpen,
|
||||
onDelete,
|
||||
setDefaultConnection,
|
||||
editConnection,
|
||||
notify,
|
||||
}: {
|
||||
c: SavedConnection;
|
||||
onOpen: (c: SavedConnection) => void;
|
||||
onDelete: (c: SavedConnection) => void;
|
||||
setDefaultConnection: (id: string) => void;
|
||||
editConnection: (c: SavedConnection) => void;
|
||||
notify: ReturnType<typeof useWorkspaceStore.getState>["notify"];
|
||||
}) {
|
||||
const d = driverById(c.driver);
|
||||
return (
|
||||
<div className="ops-conn-row group">
|
||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => onOpen(c)}>
|
||||
<DriverIcon driver={c.driver} size={22} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 truncate text-[12.5px] font-medium">
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.isDefault && (
|
||||
<span
|
||||
className="shrink-0 rounded px-1 text-[10px] font-semibold"
|
||||
style={{
|
||||
color: "var(--ops-accent)",
|
||||
background: "color-mix(in srgb, var(--ops-accent) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{d?.name ?? c.driver} · {hostSummary(c)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100">
|
||||
{!c.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
onClick={() => {
|
||||
setDefaultConnection(c.id);
|
||||
notify({ kind: "success", title: "默认连接", message: `已设「${c.name}」为默认。`, mode: "toast" });
|
||||
}}
|
||||
>
|
||||
设默认
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => editConnection(c)}>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => void onDelete(c)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-[1] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{
|
||||
color: "var(--ops-muted)",
|
||||
background: "color-mix(in srgb, var(--ops-surface) 92%, transparent)",
|
||||
borderBottom: "1px solid var(--ops-border)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RECENT_N = 5;
|
||||
|
||||
export function ConnectionList() {
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const openPicker = useWorkspaceStore((s) => s.openPicker);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
const removeConnection = useWorkspaceStore((s) => s.removeConnection);
|
||||
const setDefaultConnection = useWorkspaceStore((s) => s.setDefaultConnection);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
[...connections]
|
||||
.filter((c) => matchesQuery(c, needle))
|
||||
.sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0);
|
||||
}),
|
||||
[connections, needle],
|
||||
);
|
||||
|
||||
const recent = useMemo(() => {
|
||||
const ids = new Set(filtered.map((c) => c.id));
|
||||
return [...connections]
|
||||
.filter((c) => (c.lastUsedAt ?? 0) > 0 && ids.has(c.id))
|
||||
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
|
||||
.slice(0, RECENT_N);
|
||||
}, [connections, filtered]);
|
||||
|
||||
/** 「全部」list: filtered, with recent still shown (pinned section above). */
|
||||
const allList = filtered;
|
||||
|
||||
const onDelete = async (c: SavedConnection) => {
|
||||
const ok = await askConfirm({
|
||||
title: "删除连接",
|
||||
message: `确认删除「${c.name}」?此操作不可撤销。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
removeConnection(c.id);
|
||||
notify({ kind: "success", title: "已删除", message: `「${c.name}」已移除。`, mode: "toast" });
|
||||
};
|
||||
|
||||
const onOpen = (c: SavedConnection) => {
|
||||
set("activeConnectionId", c.id);
|
||||
set("view", "workbench");
|
||||
set("status", `已选择 ${c.name}`);
|
||||
};
|
||||
|
||||
const rowProps = { onOpen, onDelete, setDefaultConnection, editConnection, notify };
|
||||
|
||||
return (
|
||||
<div className="ops-shell flex h-screen flex-col">
|
||||
<header className="ops-chrome-header px-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-semibold leading-tight">连接</div>
|
||||
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
选择或新建数据库连接
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
|
||||
新建连接
|
||||
</button>
|
||||
{connections.length > 0 && (
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => set("view", "workbench")}>
|
||||
进入工作台
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{connections.length > 0 && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-3"
|
||||
style={{ height: 36, borderColor: "var(--ops-border)", background: "var(--ops-surface)" }}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
className="h-7 w-full max-w-md rounded border px-2 text-[12px] outline-none"
|
||||
style={{
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-bg, transparent)",
|
||||
color: "var(--ops-fg, inherit)",
|
||||
}}
|
||||
placeholder="搜索名称 / 主机 / 驱动…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="搜索连接"
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" className="ops-btn-ghost shrink-0 text-[11px]" onClick={() => setSearch("")}>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="ops-empty">
|
||||
<h2>还没有连接</h2>
|
||||
<p>新建一个连接,保存后会出现在这里。</p>
|
||||
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
|
||||
新建连接
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="ops-empty">
|
||||
<h2>没有匹配的连接</h2>
|
||||
<p>换个关键词试试,或清除搜索查看全部。</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => setSearch("")}>
|
||||
清除搜索
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>最近</SectionLabel>
|
||||
{recent.map((c) => (
|
||||
<ConnRow key={`recent-${c.id}`} c={c} {...rowProps} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SectionLabel>全部{needle ? ` · ${allList.length}` : ""}</SectionLabel>
|
||||
{allList.map((c) => (
|
||||
<ConnRow key={c.id} c={c} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CATEGORIES, DRIVERS, driverById, isDriverReady, type DriverDef, type DriverId } from "./catalog";
|
||||
import { ConnectionForm } from "./ConnectionForm";
|
||||
import { ConnectionList } from "./ConnectionList";
|
||||
import { parseDSN } from "./dsn";
|
||||
import { sanitizeSecret } from "./secrets";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { openSession, testConnection } from "./api";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function LocalDriverIcon({ d, size = 20 }: { d: DriverDef; size?: number }) {
|
||||
if (d.icon) {
|
||||
return <img src={d.icon} alt="" style={{ width: size, height: size }} className="shrink-0 object-contain" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="grid shrink-0 place-items-center rounded text-[9px] font-bold text-white"
|
||||
style={{ width: size, height: size, background: d.color }}
|
||||
>
|
||||
{d.name.slice(0, 2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionPicker() {
|
||||
const search = useWorkspaceStore((s) => s.pickerSearch);
|
||||
const category = useWorkspaceStore((s) => s.pickerCategory);
|
||||
const driver = useWorkspaceStore((s) => s.pickerDriver);
|
||||
const pickerMode = useWorkspaceStore((s) => s.pickerMode);
|
||||
const form = useWorkspaceStore((s) => s.form);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const openPicker = useWorkspaceStore((s) => s.openPicker);
|
||||
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList);
|
||||
const upsert = useWorkspaceStore((s) => s.upsertConnection);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [dsn, setDsn] = useState("");
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return DRIVERS.filter((d) => {
|
||||
if (category !== "全部" && d.category !== category) return false;
|
||||
if (!q) return true;
|
||||
return d.name.toLowerCase().includes(q) || d.id.includes(q);
|
||||
});
|
||||
}, [search, category]);
|
||||
|
||||
const selected = driver ? driverById(driver) : null;
|
||||
|
||||
const buildConnection = (): SavedConnection | null => {
|
||||
if (!form.driver || !form.name?.trim()) {
|
||||
notify({ kind: "warning", title: "保存", message: "请填写连接名称。", mode: "toast" });
|
||||
return null;
|
||||
}
|
||||
const savePassword = form.savePassword !== false;
|
||||
const sslOn = !!(form.ssl || (form.sslMode && form.sslMode !== "disable"));
|
||||
// Keep live secrets for test/connect; upsertConnection clears them when savePassword=false.
|
||||
return {
|
||||
id: form.id || crypto.randomUUID(),
|
||||
name: form.name.trim(),
|
||||
driver: form.driver as DriverId,
|
||||
host: form.host || "127.0.0.1",
|
||||
port: Number(form.port || selected?.defaultPort || 3306),
|
||||
user: form.user || "",
|
||||
password: sanitizeSecret(form.password || ""),
|
||||
database: form.database || "",
|
||||
ssl: sslOn,
|
||||
sslMode: (sslOn
|
||||
? form.sslMode && form.sslMode !== "disable"
|
||||
? form.sslMode
|
||||
: "require"
|
||||
: "disable") as SavedConnection["sslMode"],
|
||||
sslCA: form.sslCA || "",
|
||||
sslCert: form.sslCert || "",
|
||||
sslKey: form.sslKey || "",
|
||||
service: form.service || "",
|
||||
color: form.color,
|
||||
lastUsedAt: Date.now(),
|
||||
groupId: form.groupId,
|
||||
isDefault: !!form.isDefault,
|
||||
savePassword,
|
||||
keySeparator: form.keySeparator,
|
||||
redisMode: form.redisMode,
|
||||
oracleConnType: form.oracleConnType,
|
||||
oracleIdKind: form.oracleIdKind,
|
||||
oracleRole: form.oracleRole,
|
||||
authMode: form.authMode,
|
||||
sshEnabled: !!form.sshEnabled,
|
||||
sshHost: form.sshHost || "",
|
||||
sshPort: Number(form.sshPort || 22),
|
||||
sshUser: form.sshUser || "",
|
||||
sshAuth: form.sshAuth || "password",
|
||||
sshPassword: sanitizeSecret(form.sshPassword || ""),
|
||||
sshPrivateKey: form.sshPrivateKey || "",
|
||||
sshPassphrase: sanitizeSecret(form.sshPassphrase || ""),
|
||||
};
|
||||
};
|
||||
|
||||
const backToList = () => {
|
||||
setTestMsg(null);
|
||||
openConnectionList();
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const c = buildConnection();
|
||||
if (!c) return;
|
||||
upsert(c);
|
||||
set("form", { ...form, id: c.id });
|
||||
set("status", `已保存 ${c.name}`);
|
||||
notify({ kind: "success", title: "保存", message: `已保存「${c.name}」。`, mode: "toast" });
|
||||
backToList();
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
const c = buildConnection();
|
||||
if (!c) return;
|
||||
if (!isDriverReady(c.driver)) {
|
||||
setTestMsg({ ok: false, text: `${driverById(c.driver)?.name ?? c.driver} 驱动未就绪。` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await testConnection(c);
|
||||
setTestMsg({ ok: true, text: "连接成功。" });
|
||||
notify({ kind: "success", title: "测试", message: "连接成功。", mode: "toast" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setTestMsg({ ok: false, text: msg });
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const onConnect = async () => {
|
||||
const c = buildConnection();
|
||||
if (!c) return;
|
||||
if (!isDriverReady(c.driver)) {
|
||||
notify({ kind: "warning", title: "连接", message: `${driverById(c.driver)?.name ?? c.driver} 驱动未就绪,可先保存。`, mode: "toast" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { sessionId } = await openSession(c);
|
||||
useWorkspaceStore.getState().setSession(c.id, sessionId);
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
return;
|
||||
}
|
||||
upsert(c);
|
||||
set("activeConnectionId", c.id);
|
||||
set("view", "workbench");
|
||||
set("status", `已连接 ${c.name}`);
|
||||
};
|
||||
|
||||
if (!selected) {
|
||||
if (pickerMode === "list") {
|
||||
return <ConnectionList />;
|
||||
}
|
||||
return (
|
||||
<div className="ops-shell flex h-screen">
|
||||
<aside className="ops-panel flex w-44 shrink-0 flex-col border-r">
|
||||
<div className="border-b px-2.5 py-2" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="text-[14px] font-semibold">新建连接</div>
|
||||
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
选择引擎
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-px p-1.5">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => set("pickerCategory", c)}
|
||||
className={cx(
|
||||
"block w-full rounded px-2 py-1 text-left text-[12.5px] transition",
|
||||
category === c ? "ops-tree-item-active" : "ops-tree-item",
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button type="button" className="m-1.5 ops-btn-secondary" onClick={backToList}>
|
||||
返回列表
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="ops-chrome-header px-3">
|
||||
<input
|
||||
className="ops-input w-full max-w-sm"
|
||||
placeholder="搜索类型…"
|
||||
value={search}
|
||||
onChange={(e) => set("pickerSearch", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="ops-section-label border-b">{category === "全部" ? "全部引擎" : category}</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{filtered.map((d) => (
|
||||
<button key={d.id} type="button" className="ops-driver-row" onClick={() => openPicker(d.id)}>
|
||||
<LocalDriverIcon d={d} />
|
||||
<span className="min-w-0 truncate font-medium">{d.name}</span>
|
||||
<span className="ops-driver-meta">{d.ready || d.mysqlCompat ? "可用" : "未就绪"}</span>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-4 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
没有匹配的引擎
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ready = isDriverReady(selected.id);
|
||||
const applyDsn = () => {
|
||||
const parsed = parseDSN(dsn);
|
||||
if (!parsed) {
|
||||
notify({ kind: "warning", title: "URL", message: "无法解析。试试 mysql://user:pass@host:3306/db", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
set("form", {
|
||||
...form,
|
||||
driver: parsed.driver,
|
||||
host: parsed.host ?? form.host,
|
||||
port: parsed.port ?? form.port,
|
||||
user: parsed.user ?? form.user,
|
||||
password: parsed.password ?? form.password,
|
||||
database: parsed.database ?? form.database,
|
||||
ssl: parsed.ssl ?? form.ssl,
|
||||
name: form.name || `${driverById(parsed.driver)?.name ?? parsed.driver} 连接`,
|
||||
});
|
||||
if (parsed.driver !== selected.id) {
|
||||
set("pickerDriver", parsed.driver);
|
||||
}
|
||||
notify({ kind: "success", title: "已填入", message: "从 URL 拆出字段。", mode: "toast" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-shell flex h-screen flex-col">
|
||||
<header className="ops-chrome-header gap-2 px-3">
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => { setTestMsg(null); set("pickerDriver", null); set("pickerMode", "drivers"); }}>
|
||||
返回
|
||||
</button>
|
||||
<LocalDriverIcon d={selected} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-semibold leading-tight">{selected.name}</div>
|
||||
<div className="text-[11px]" style={{ color: ready ? "var(--ops-accent)" : "var(--ops-muted)" }}>
|
||||
{ready ? "可连接" : "驱动未就绪"}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="ops-btn-ghost ml-auto" onClick={backToList}>
|
||||
连接列表
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-xl min-h-0 flex-1 flex-col">
|
||||
<ConnectionForm
|
||||
selected={selected}
|
||||
form={form}
|
||||
setForm={(next) => set("form", next)}
|
||||
dsn={dsn}
|
||||
setDsn={setDsn}
|
||||
onApplyDsn={applyDsn}
|
||||
/>
|
||||
|
||||
{testMsg && (
|
||||
<div
|
||||
className="mx-3 mb-2 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: testMsg.ok
|
||||
? "color-mix(in srgb, var(--ops-accent) 40%, var(--ops-border))"
|
||||
: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
color: testMsg.ok ? "var(--ops-accent)" : "var(--ops-danger)",
|
||||
background: testMsg.ok
|
||||
? "color-mix(in srgb, var(--ops-accent) 8%, transparent)"
|
||||
: "color-mix(in srgb, var(--ops-danger) 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
{testMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ready && (
|
||||
<div
|
||||
className="mx-3 mb-2 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-warn) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warn) 8%, transparent)",
|
||||
color: "var(--ops-warn)",
|
||||
}}
|
||||
>
|
||||
{selected.name} 驱动未就绪,可先保存配置。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5 border-t px-3 py-2"
|
||||
style={{ borderColor: "var(--ops-border)" }}
|
||||
>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => void onTest()}>
|
||||
测试连接
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button type="button" className="ops-btn-ghost" onClick={backToList}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => onSave()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="ops-btn-primary" onClick={() => void onConnect()}>
|
||||
保存并连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import {
|
||||
DEFAULT_CONN_GROUP,
|
||||
useWorkspaceStore,
|
||||
type SavedConnection,
|
||||
} from "./store";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type DropHint = { groupId: string; index: number } | null;
|
||||
|
||||
export function ConnectionSidebar({
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const groups = useWorkspaceStore((s) => s.connGroups);
|
||||
const expanded = useWorkspaceStore((s) => s.expandedConnGroups);
|
||||
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const addConnGroup = useWorkspaceStore((s) => s.addConnGroup);
|
||||
const renameConnGroup = useWorkspaceStore((s) => s.renameConnGroup);
|
||||
const removeConnGroup = useWorkspaceStore((s) => s.removeConnGroup);
|
||||
const moveConnection = useWorkspaceStore((s) => s.moveConnection);
|
||||
const toggleConnGroup = useWorkspaceStore((s) => s.toggleConnGroup);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dropHint, setDropHint] = useState<DropHint>(null);
|
||||
const dragIdRef = useRef<string | null>(null);
|
||||
|
||||
const byGroup = useMemo(() => {
|
||||
const map = new Map<string, SavedConnection[]>();
|
||||
for (const g of groups) map.set(g.id, []);
|
||||
for (const c of connections) {
|
||||
const gid = c.groupId || DEFAULT_CONN_GROUP;
|
||||
if (!map.has(gid)) map.set(gid, []);
|
||||
map.get(gid)!.push(c);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
}
|
||||
return map;
|
||||
}, [connections, groups]);
|
||||
|
||||
const onAddGroup = () => {
|
||||
const name = window.prompt("分组名称", "新分组");
|
||||
if (name == null) return;
|
||||
const id = addConnGroup(name);
|
||||
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
|
||||
void id;
|
||||
};
|
||||
|
||||
const onRenameGroup = (id: string, cur: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const name = window.prompt("重命名分组", cur);
|
||||
if (name == null || !name.trim()) return;
|
||||
renameConnGroup(id, name);
|
||||
};
|
||||
|
||||
const onRemoveGroup = (id: string, name: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
if (!window.confirm(`删除分组「${name}」?连接将移回默认。`)) return;
|
||||
removeConnGroup(id);
|
||||
};
|
||||
|
||||
const setDrop = (groupId: string, index: number) => {
|
||||
const id = dragIdRef.current;
|
||||
if (!id) return;
|
||||
setDropHint({ groupId, index });
|
||||
};
|
||||
|
||||
const commitDrop = () => {
|
||||
const id = dragIdRef.current;
|
||||
const hint = dropHint;
|
||||
dragIdRef.current = null;
|
||||
setDragId(null);
|
||||
setDropHint(null);
|
||||
if (!id || !hint) return;
|
||||
moveConnection(id, hint.groupId, hint.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[42%] min-h-[120px] flex-col border-b" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
<span>连接</span>
|
||||
<button type="button" className="normal-case tracking-normal hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||||
+ 分组
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||||
{connections.length === 0 && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
暂无连接
|
||||
</div>
|
||||
)}
|
||||
{groups.map((g) => {
|
||||
const list = byGroup.get(g.id) ?? [];
|
||||
const open = expanded.includes(g.id);
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
className="mb-0.5"
|
||||
data-group-id={g.id}
|
||||
onDragOver={(e) => {
|
||||
if (!dragIdRef.current) return;
|
||||
e.preventDefault();
|
||||
setDrop(g.id, list.length);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<div className="ops-tree-item group/g cursor-pointer" onClick={() => toggleConnGroup(g.id)}>
|
||||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">{open ? "▾" : "▸"}</span>
|
||||
<span
|
||||
className="mr-1 inline-block h-3 w-3 shrink-0 rounded-[2px] border"
|
||||
style={{ borderColor: "var(--ops-muted)", background: "color-mix(in srgb, var(--ops-muted) 25%, transparent)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{g.name}</span>
|
||||
<span className="text-[10px] opacity-40">{list.length}</span>
|
||||
{g.id !== DEFAULT_CONN_GROUP && (
|
||||
<span className="ml-1 hidden gap-0.5 group-hover/g:inline-flex" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" title="重命名" onClick={() => onRenameGroup(g.id, g.name)}>
|
||||
✎
|
||||
</button>
|
||||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" style={{ color: "var(--ops-danger)" }} title="删除分组" onClick={() => onRemoveGroup(g.id, g.name)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="ml-1 border-l pl-0.5" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{list.length === 0 && (
|
||||
<div
|
||||
className={cx("ops-drop-slot mx-0.5 my-0.5 rounded px-1.5 py-1 text-[10px]", dropHint?.groupId === g.id ? "ops-drop-slot-active" : "")}
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDrop(g.id, 0);
|
||||
}}
|
||||
>
|
||||
拖到此处
|
||||
</div>
|
||||
)}
|
||||
{list.map((c, i) => (
|
||||
<div key={c.id}>
|
||||
{dropHint?.groupId === g.id && dropHint.index === i && <div className="ops-drop-line" />}
|
||||
<div
|
||||
className={cx(
|
||||
"group flex items-center gap-0.5 rounded",
|
||||
activeId === c.id ? "ops-tree-item-active" : "",
|
||||
dragId === c.id ? "opacity-45" : "",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragIdRef.current = c.id;
|
||||
setDragId(c.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", c.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragIdRef.current = null;
|
||||
setDragId(null);
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!dragIdRef.current || dragIdRef.current === c.id) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
setDrop(g.id, before ? i : i + 1);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ops-tree-item min-w-0 flex-1" onClick={() => onSelect(c.id)}>
|
||||
{c.color && (
|
||||
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
|
||||
)}
|
||||
<DriverIcon driver={c.driver} size={14} />
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="mr-1 text-[10px] opacity-50">{driverById(c.driver)?.name}</span>
|
||||
{c.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden px-0.5 text-[10px] opacity-50 group-hover:inline hover:opacity-100"
|
||||
title="编辑"
|
||||
onClick={() => editConnection(c)}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-0.5 hidden px-1 text-[11px] opacity-50 group-hover:inline hover:opacity-100"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => onDelete(c.id)}
|
||||
title="删除连接"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
|
||||
<div className="ops-drop-line" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { driverById, type DriverId } from "./catalog";
|
||||
|
||||
export function DriverIcon({
|
||||
driver,
|
||||
size = 16,
|
||||
className = "",
|
||||
}: {
|
||||
driver: DriverId | string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const d = driverById(driver);
|
||||
const label = (d?.name ?? driver).slice(0, 2).toUpperCase();
|
||||
const color = d?.color ?? "#64748b";
|
||||
if (d?.icon) {
|
||||
return (
|
||||
<img
|
||||
src={d.icon}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className={`shrink-0 object-contain ${className}`}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-grid shrink-0 place-items-center rounded-[3px] text-[9px] font-bold text-white ${className}`}
|
||||
style={{ width: size, height: size, background: color }}
|
||||
title={d?.name ?? String(driver)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect } from "react";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
import type { NotifyKind, NotifyPayload } from "./notify";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const KIND_META: Record<
|
||||
NotifyKind,
|
||||
{ badge: string; ring: string; iconBg: string; label: string }
|
||||
> = {
|
||||
error: {
|
||||
badge: "text-red-300",
|
||||
ring: "border-red-800/70",
|
||||
iconBg: "bg-red-500/15 text-red-400",
|
||||
label: "错误",
|
||||
},
|
||||
warning: {
|
||||
badge: "text-amber-300",
|
||||
ring: "border-amber-800/70",
|
||||
iconBg: "bg-amber-500/15 text-amber-400",
|
||||
label: "提示",
|
||||
},
|
||||
info: {
|
||||
badge: "text-cyan-300",
|
||||
ring: "border-cyan-800/70",
|
||||
iconBg: "bg-cyan-500/15 text-cyan-400",
|
||||
label: "信息",
|
||||
},
|
||||
success: {
|
||||
badge: "text-emerald-300",
|
||||
ring: "border-emerald-800/70",
|
||||
iconBg: "bg-emerald-500/15 text-emerald-400",
|
||||
label: "成功",
|
||||
},
|
||||
};
|
||||
|
||||
function KindIcon({ kind }: { kind: NotifyKind }) {
|
||||
const meta = KIND_META[kind];
|
||||
const glyph =
|
||||
kind === "error" ? "!" : kind === "warning" ? "!" : kind === "success" ? "✓" : "i";
|
||||
return (
|
||||
<div className={cx("grid h-9 w-9 shrink-0 place-items-center rounded-full text-sm font-bold", meta.iconBg)}>
|
||||
{glyph}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
const meta = KIND_META[n.kind];
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`notify-title-${n.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"w-full max-w-md overflow-hidden rounded-xl border bg-slate-950 shadow-2xl shadow-black/50",
|
||||
meta.ring,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-slate-800 px-5 py-4">
|
||||
<KindIcon kind={n.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cx("text-[11px] font-semibold uppercase tracking-wider", meta.badge)}>
|
||||
{meta.label}
|
||||
</div>
|
||||
<h2 id={`notify-title-${n.id}`} className="mt-0.5 text-base font-semibold text-slate-100">
|
||||
{n.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-2 py-1 text-slate-500 transition hover:bg-slate-800 hover:text-slate-300"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-5 py-4 text-sm">
|
||||
<p className="leading-relaxed text-slate-300">{n.message}</p>
|
||||
|
||||
{n.tips && n.tips.length > 0 && (
|
||||
<ul className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2.5 text-xs text-slate-400">
|
||||
{n.tips.map((t) => (
|
||||
<li key={t} className="flex gap-2">
|
||||
<span className="mt-0.5 text-cyan-500">•</span>
|
||||
<span>{t}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{n.detail && (
|
||||
<details className="group rounded-lg border border-slate-800 bg-[#070b11]">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-slate-500 hover:text-slate-300">
|
||||
技术详情
|
||||
</summary>
|
||||
<pre className="max-h-40 overflow-auto border-t border-slate-800 px-3 py-2 font-mono text-[11px] leading-5 text-slate-400 whitespace-pre-wrap break-all">
|
||||
{n.detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-slate-800 px-5 py-3">
|
||||
<button type="button" className="ops-btn-primary min-w-[88px]" onClick={onClose} autoFocus>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
const meta = KIND_META[n.kind];
|
||||
useEffect(() => {
|
||||
const ms = n.durationMs ?? 3200;
|
||||
const t = window.setTimeout(onClose, ms);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [n.durationMs, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto flex w-[360px] max-w-[calc(100vw-2rem)] items-start gap-3 rounded-xl border bg-slate-950/95 px-4 py-3 shadow-xl backdrop-blur",
|
||||
meta.ring,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<KindIcon kind={n.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-slate-100">{n.title}</div>
|
||||
<div className="mt-0.5 text-xs leading-relaxed text-slate-400">{n.message}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 text-slate-500 hover:text-slate-300"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmCard() {
|
||||
const confirm = useWorkspaceStore((s) => s.confirm);
|
||||
const resolveConfirm = useWorkspaceStore((s) => s.resolveConfirm);
|
||||
useEffect(() => {
|
||||
if (!confirm) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") resolveConfirm(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [confirm, resolveConfirm]);
|
||||
if (!confirm) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] grid place-items-center bg-black/55 p-4" role="alertdialog">
|
||||
<div className="ops-modal w-full max-w-md">
|
||||
<div className="ops-modal-header">{confirm.title}</div>
|
||||
<div className="ops-modal-body">{confirm.message}</div>
|
||||
<div className="ops-modal-footer">
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => resolveConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary"
|
||||
style={confirm.danger ? { background: "var(--ops-danger)" } : undefined}
|
||||
onClick={() => resolveConfirm(true)}
|
||||
autoFocus
|
||||
>
|
||||
{confirm.okLabel || "确定"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotifyHost() {
|
||||
const modal = useWorkspaceStore((s) => s.notifyModal);
|
||||
const toasts = useWorkspaceStore((s) => s.notifyToasts);
|
||||
const dismissNotify = useWorkspaceStore((s) => s.dismissNotify);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal && <ModalCard n={modal} onClose={() => dismissNotify(modal.id)} />}
|
||||
<ConfirmCard />
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[90] flex flex-col-reverse gap-2">
|
||||
{toasts.map((n) => (
|
||||
<ToastCard key={n.id} n={n} onClose={() => dismissNotify(n.id)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
import { useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import {
|
||||
deleteRedisKey,
|
||||
execSql,
|
||||
getRedisKey,
|
||||
listColumns,
|
||||
listRedisKeys,
|
||||
listTables,
|
||||
loadTree,
|
||||
openSession,
|
||||
} from "./api";
|
||||
import type { ColumnInfo } from "./store";
|
||||
import { isDriverReady } from "./catalog";
|
||||
import { sampleSelectSql } from "./sql";
|
||||
import { useWorkspaceStore, type SavedConnection, type TreeNode } from "./store";
|
||||
|
||||
const GROUPS: { id: string; label: string; kind: TreeNode["kind"] }[] = [
|
||||
{ id: "tables", label: "表", kind: "group" },
|
||||
{ id: "views", label: "视图", kind: "group" },
|
||||
{ id: "procedures", label: "存储过程", kind: "group" },
|
||||
{ id: "functions", label: "函数", kind: "group" },
|
||||
];
|
||||
|
||||
|
||||
function isRedisConn(driver?: string) {
|
||||
return (driver || "").toLowerCase() === "redis";
|
||||
}
|
||||
|
||||
/** TTL 可读:永久 / 不存在 / Ns */
|
||||
export function formatRedisTtl(ttl: number | undefined): string {
|
||||
if (ttl == null) return "";
|
||||
if (ttl === -1) return "永久";
|
||||
if (ttl === -2) return "不存在";
|
||||
if (ttl < 0) return String(ttl);
|
||||
if (ttl < 60) return `${ttl}s`;
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m${ttl % 60 ? `${ttl % 60}s` : ""}`;
|
||||
if (ttl < 86400) {
|
||||
const h = Math.floor(ttl / 3600);
|
||||
const m = Math.floor((ttl % 3600) / 60);
|
||||
return m ? `${h}h${m}m` : `${h}h`;
|
||||
}
|
||||
const d = Math.floor(ttl / 86400);
|
||||
const h = Math.floor((ttl % 86400) / 3600);
|
||||
return h ? `${d}d${h}h` : `${d}d`;
|
||||
}
|
||||
|
||||
export async function ensureSession(c: SavedConnection): Promise<string> {
|
||||
const store = useWorkspaceStore.getState();
|
||||
const existing = store.sessions[c.id];
|
||||
if (existing) return existing;
|
||||
const { sessionId } = await openSession(c);
|
||||
store.setSession(c.id, sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function emptyGroups(): TreeNode[] {
|
||||
return GROUPS.map((g) => ({
|
||||
name: g.label,
|
||||
kind: "group" as const,
|
||||
groupId: g.id,
|
||||
count: 0,
|
||||
children: [],
|
||||
loaded: false,
|
||||
}));
|
||||
}
|
||||
|
||||
type Ctx = {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
onRefresh: () => void;
|
||||
onSchema?: () => void;
|
||||
onView?: () => void;
|
||||
onDelete?: () => void;
|
||||
} | null;
|
||||
|
||||
export function ObjectTree() {
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const tree = useWorkspaceStore((s) => s.tree);
|
||||
const treeLoading = useWorkspaceStore((s) => s.treeLoading);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
||||
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const pushHistory = useWorkspaceStore((s) => s.pushHistory);
|
||||
const tabs = useWorkspaceStore((s) => s.tabs);
|
||||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||||
const [ctx, setCtx] = useState<Ctx>(null);
|
||||
const [keyFilter, setKeyFilter] = useState("");
|
||||
|
||||
const conn = connections.find((c) => c.id === activeId);
|
||||
|
||||
const refresh = async (id: string) => {
|
||||
const c = connections.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
if (!isDriverReady(c.driver)) {
|
||||
set("tree", []);
|
||||
set("status", "驱动未就绪");
|
||||
return;
|
||||
}
|
||||
set("treeLoading", true);
|
||||
set("status", `加载 ${c.name}…`);
|
||||
try {
|
||||
const sid = await ensureSession(c);
|
||||
const nodes = await loadTree(c, sid);
|
||||
set("tree", nodes);
|
||||
set("status", `${c.name} · ${nodes.length} 库`);
|
||||
} catch (e) {
|
||||
set("tree", []);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("status", msg);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session") || msg.toLowerCase().includes("connect")) {
|
||||
set("connState", "down");
|
||||
}
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
set("treeLoading", false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId) void refresh(activeId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) return;
|
||||
const close = () => setCtx(null);
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, [ctx]);
|
||||
|
||||
const patchDb = (dbName: string, patch: Partial<TreeNode> | ((n: TreeNode) => TreeNode)) => {
|
||||
const cur = useWorkspaceStore.getState().tree;
|
||||
set(
|
||||
"tree",
|
||||
cur.map((d) => {
|
||||
if (d.name !== dbName || d.kind !== "database") return d;
|
||||
return typeof patch === "function" ? patch(d) : { ...d, ...patch };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const expandDb = async (dbName: string) => {
|
||||
if (!conn) return;
|
||||
const node = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
||||
if (node?.loaded || node?.loading) return;
|
||||
const redis = isRedisConn(conn.driver);
|
||||
patchDb(dbName, {
|
||||
loading: true,
|
||||
children: redis
|
||||
? [{ name: "键", kind: "group" as const, groupId: "keys", count: 0, children: [], loaded: false }]
|
||||
: emptyGroups(),
|
||||
});
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
if (redis) {
|
||||
const pat = keyFilter.trim() || undefined;
|
||||
const res = await listRedisKeys(conn, dbName, { sessionId: sid, pattern: pat });
|
||||
if (res.error) throw new Error(res.error);
|
||||
const keys = res.keys ?? [];
|
||||
patchDb(dbName, {
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: [
|
||||
{
|
||||
name: "键",
|
||||
kind: "group",
|
||||
groupId: "keys",
|
||||
count: keys.length,
|
||||
loaded: true,
|
||||
children: keys.map((k) => ({
|
||||
name: k.name,
|
||||
kind: "key" as const,
|
||||
type: k.type,
|
||||
ttl: k.ttl,
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (res.truncated) {
|
||||
notify({
|
||||
kind: "info",
|
||||
title: "键列表已截断",
|
||||
message: `最多展示 ${keys.length} 个键(SCAN)。可用过滤缩小范围。`,
|
||||
mode: "toast",
|
||||
durationMs: 2200,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { tables } = await listTables(conn, dbName, sid);
|
||||
let views: string[] = [];
|
||||
let procedures: string[] = [];
|
||||
let functions: string[] = [];
|
||||
try {
|
||||
const v = await listSchemaObjects(conn, sid, dbName, "views");
|
||||
views = v;
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
try {
|
||||
procedures = await listSchemaObjects(conn, sid, dbName, "procedures");
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
try {
|
||||
functions = await listSchemaObjects(conn, sid, dbName, "functions");
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
patchDb(dbName, {
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: [
|
||||
{
|
||||
name: "表",
|
||||
kind: "group",
|
||||
groupId: "tables",
|
||||
count: tables.length,
|
||||
loaded: true,
|
||||
children: tables.map((t) => ({ name: t, kind: "table" as const })),
|
||||
},
|
||||
{
|
||||
name: "视图",
|
||||
kind: "group",
|
||||
groupId: "views",
|
||||
count: views.length,
|
||||
loaded: true,
|
||||
children: views.map((t) => ({ name: t, kind: "view" as const })),
|
||||
},
|
||||
{
|
||||
name: "存储过程",
|
||||
kind: "group",
|
||||
groupId: "procedures",
|
||||
count: procedures.length,
|
||||
loaded: true,
|
||||
children: procedures.map((t) => ({ name: t, kind: "procedure" as const })),
|
||||
},
|
||||
{
|
||||
name: "函数",
|
||||
kind: "group",
|
||||
groupId: "functions",
|
||||
count: functions.length,
|
||||
loaded: true,
|
||||
children: functions.map((t) => ({ name: t, kind: "function" as const })),
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (e) {
|
||||
patchDb(dbName, {
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: isRedisConn(conn.driver)
|
||||
? [{ name: "键", kind: "group", groupId: "keys", count: 0, children: [], loaded: true }]
|
||||
: emptyGroups(),
|
||||
});
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
const expandTable = async (dbName: string, table: string) => {
|
||||
if (!conn) return;
|
||||
const dbNode = useWorkspaceStore.getState().tree.find((n) => n.name === dbName);
|
||||
const tablesGroup = dbNode?.children?.find((n) => n.groupId === "tables");
|
||||
const tNode = tablesGroup?.children?.find((n) => n.name === table);
|
||||
if (tNode?.loaded || tNode?.loading) return;
|
||||
|
||||
const mark = (patch: Partial<TreeNode>) => {
|
||||
patchDb(dbName, (d) => ({
|
||||
...d,
|
||||
children: (d.children ?? []).map((g) =>
|
||||
g.groupId !== "tables"
|
||||
? g
|
||||
: {
|
||||
...g,
|
||||
children: (g.children ?? []).map((t) => (t.name === table ? { ...t, ...patch } : t)),
|
||||
},
|
||||
),
|
||||
}));
|
||||
};
|
||||
mark({ loading: true });
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const { columns } = await listColumns(conn, dbName, table, sid);
|
||||
mark({
|
||||
loading: false,
|
||||
loaded: true,
|
||||
children: columns.map((col) => ({
|
||||
name: col.name,
|
||||
kind: "column" as const,
|
||||
type: col.type,
|
||||
nullable: col.nullable,
|
||||
key: col.key,
|
||||
})),
|
||||
});
|
||||
} catch (e) {
|
||||
mark({ loading: false });
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const openSchema = async (dbName: string, table: string) => {
|
||||
if (!conn) return;
|
||||
set("resultTab", "schema");
|
||||
set("schemaView", { database: dbName, table, columns: [], loading: true });
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const { columns } = await listColumns(conn, dbName, table, sid);
|
||||
set("schemaView", { database: dbName, table, columns, loading: false });
|
||||
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
|
||||
set("resultMeta", { database: dbName, table, pkCols });
|
||||
set("connState", "ok");
|
||||
set("status", `结构 ${table} · ${columns.length} 列`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("schemaView", { database: dbName, table, columns: [], loading: false, error: msg });
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const openRedisKey = async (dbName: string, key: string) => {
|
||||
if (!conn) return;
|
||||
set("running", true);
|
||||
set("status", `读取 ${key}…`);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await getRedisKey(conn, dbName, key, { sessionId: sid, limit: 500 });
|
||||
const qr = {
|
||||
columns: res.columns ?? [],
|
||||
rows: res.rows ?? [],
|
||||
rowCount: res.rowCount ?? 0,
|
||||
timeMs: res.timeMs ?? 0,
|
||||
error: res.error,
|
||||
truncated: res.truncated,
|
||||
limit: res.limit,
|
||||
};
|
||||
set("result", qr);
|
||||
set("resultTab", "data");
|
||||
set("resultMeta", { database: dbName, table: key, pkCols: [] });
|
||||
set("schemaView", {
|
||||
database: dbName,
|
||||
table: key,
|
||||
columns: [
|
||||
{
|
||||
name: key,
|
||||
type: res.type || "unknown",
|
||||
nullable: true,
|
||||
comment: `ttl=${formatRedisTtl(res.ttl)}`,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
});
|
||||
set("connState", "ok");
|
||||
const ttlLabel = formatRedisTtl(res.ttl);
|
||||
set(
|
||||
"status",
|
||||
res.error
|
||||
? res.error
|
||||
: `${key} · ${res.type || "?"} · TTL ${ttlLabel} · ${qr.rowCount} 行`,
|
||||
);
|
||||
// 编辑器里放一条等价命令,便于再跑;不依赖假 SELECT
|
||||
const hint =
|
||||
res.type === "hash"
|
||||
? `HGETALL ${key}`
|
||||
: res.type === "list"
|
||||
? `LRANGE ${key} 0 499`
|
||||
: res.type === "set"
|
||||
? `SMEMBERS ${key}`
|
||||
: res.type === "zset"
|
||||
? `ZRANGE ${key} 0 499 WITHSCORES`
|
||||
: `GET ${key}`;
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!tab?.dirty) updateActiveSql(hint);
|
||||
pushHistory({
|
||||
sql: hint,
|
||||
connectionId: conn.id,
|
||||
rowCount: qr.rowCount,
|
||||
timeMs: qr.timeMs,
|
||||
error: qr.error,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg });
|
||||
set("status", msg);
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
set("running", false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRedisKey = async (dbName: string, key: string) => {
|
||||
if (!conn) return;
|
||||
const ok = await askConfirm({
|
||||
title: "删除键",
|
||||
message: `确认删除 Redis 键「${key}」?此操作不可恢复。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await deleteRedisKey(conn, dbName, key, { sessionId: sid });
|
||||
if (res.error) throw new Error(res.error);
|
||||
notify({ kind: "success", title: "已删除", message: key, mode: "toast", durationMs: 1400 });
|
||||
// 从树移除
|
||||
patchDb(dbName, (d) => ({
|
||||
...d,
|
||||
children: (d.children ?? []).map((g) =>
|
||||
g.groupId !== "keys"
|
||||
? g
|
||||
: {
|
||||
...g,
|
||||
count: Math.max(0, (g.count ?? 1) - 1),
|
||||
children: (g.children ?? []).filter((k) => k.name !== key),
|
||||
},
|
||||
),
|
||||
}));
|
||||
const meta = useWorkspaceStore.getState().resultMeta;
|
||||
if (meta?.table === key && meta?.database === dbName) {
|
||||
set("result", null);
|
||||
set("resultMeta", null);
|
||||
}
|
||||
set("status", `已删除 ${key}`);
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const putSelectSql = async (dbName: string, table: string) => {
|
||||
if (!conn) return;
|
||||
if (isRedisConn(conn.driver)) {
|
||||
await openRedisKey(dbName, table);
|
||||
return;
|
||||
}
|
||||
const sql = sampleSelectSql(conn.driver, dbName, table);
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (tab?.dirty) {
|
||||
const openNew = await askConfirm({
|
||||
title: "当前查询未保存",
|
||||
message: `「${tab.title}」有未保存改动。新开标签写入 SELECT?`,
|
||||
okLabel: "新开标签",
|
||||
});
|
||||
if (!openNew) return;
|
||||
newQueryTab();
|
||||
await Promise.resolve();
|
||||
useWorkspaceStore.getState().updateActiveSql(sql);
|
||||
} else {
|
||||
updateActiveSql(sql);
|
||||
}
|
||||
set("running", true);
|
||||
set("status", `打开 ${table}…`);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await execSql(conn, sql, { database: dbName, sessionId: sid, limit: 100 });
|
||||
set("result", res);
|
||||
set("resultTab", "data");
|
||||
try {
|
||||
const { columns } = await listColumns(conn, dbName, table, sid);
|
||||
const pkCols = columns.filter((c: ColumnInfo) => c.key === "PRI").map((c: ColumnInfo) => c.name);
|
||||
set("resultMeta", { database: dbName, table, pkCols });
|
||||
} catch {
|
||||
set("resultMeta", { database: dbName, table, pkCols: [] });
|
||||
}
|
||||
set("connState", "ok");
|
||||
set("status", res.error ? res.error : `${table} · ${res.rowCount} 行`);
|
||||
pushHistory({ sql, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
|
||||
void expandTable(dbName, table);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg });
|
||||
set("status", msg);
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
set("running", false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyName = async (name: string) => {
|
||||
await navigator.clipboard.writeText(name);
|
||||
notify({ kind: "success", title: "已复制", message: name, mode: "toast", durationMs: 1200 });
|
||||
};
|
||||
|
||||
const onCtx = (
|
||||
e: ReactMouseEvent,
|
||||
label: string,
|
||||
onRefresh: () => void,
|
||||
onSchema?: () => void,
|
||||
onView?: () => void,
|
||||
onDelete?: () => void,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtx({ x: e.clientX, y: e.clientY, label, onRefresh, onSchema, onView, onDelete });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
<span>对象</span>
|
||||
<button
|
||||
type="button"
|
||||
className="normal-case tracking-normal hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => activeId && void refresh(activeId)}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{isRedisConn(conn?.driver) && (
|
||||
<div className="flex items-center gap-1 border-b px-1.5 py-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<input
|
||||
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
||||
placeholder="键过滤(SCAN,如 user:*)"
|
||||
value={keyFilter}
|
||||
onChange={(e) => setKeyFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
|
||||
for (const d of openDbs) {
|
||||
patchDb(d.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(d.name);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-[11px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => {
|
||||
const openDbs = useWorkspaceStore.getState().tree.filter((n) => n.loaded || n.loading);
|
||||
for (const d of openDbs) {
|
||||
patchDb(d.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(d.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1 text-[12.5px]">
|
||||
{treeLoading && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
{!treeLoading && tree.length === 0 && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{conn ? "点刷新加载库;无权限时会提示错误" : "选择连接后浏览对象"}
|
||||
</div>
|
||||
)}
|
||||
{tree.map((db) => (
|
||||
<details
|
||||
key={db.name}
|
||||
className="mb-px"
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open) void expandDb(db.name);
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
onCtx(e, db.name, () => {
|
||||
patchDb(db.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(db.name);
|
||||
})
|
||||
}
|
||||
>
|
||||
<summary className="ops-tree-item cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">▸</span>
|
||||
<span className="truncate font-medium">{db.name}</span>
|
||||
{db.loading && <span className="ml-auto text-[10px] opacity-40">…</span>}
|
||||
</summary>
|
||||
<div className="ml-1.5 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{(db.children ?? (isRedisConn(conn?.driver)
|
||||
? [{ name: "键", kind: "group" as const, groupId: "keys", count: 0, children: [], loaded: false }]
|
||||
: emptyGroups())).map((g) => (
|
||||
<details key={g.groupId || g.name} className="mb-px">
|
||||
<summary
|
||||
className="ops-tree-item list-none [&::-webkit-details-marker]:hidden"
|
||||
onContextMenu={(e) =>
|
||||
onCtx(e, g.name, () => {
|
||||
patchDb(db.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(db.name);
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
|
||||
<span className="ops-tree-group min-w-0 flex-1 truncate">
|
||||
{g.name}
|
||||
<span className="ml-1 font-normal opacity-70">{g.count ?? g.children?.length ?? 0}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{(g.children ?? []).length === 0 && g.loaded && (
|
||||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
空
|
||||
</div>
|
||||
)}
|
||||
{(g.children ?? []).map((obj) =>
|
||||
obj.kind === "key" ? (
|
||||
<div
|
||||
key={obj.name}
|
||||
className="ops-tree-item"
|
||||
onContextMenu={(e) =>
|
||||
onCtx(
|
||||
e,
|
||||
obj.name,
|
||||
() => {
|
||||
patchDb(db.name, { loaded: false, loading: false, children: undefined });
|
||||
void expandDb(db.name);
|
||||
},
|
||||
undefined,
|
||||
() => void openRedisKey(db.name, obj.name),
|
||||
() => void removeRedisKey(db.name, obj.name),
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="w-3 shrink-0 text-center text-[10px] opacity-30">·</span>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
title="点击查看值"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void openRedisKey(db.name, obj.name);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{obj.name}</span>
|
||||
<span className="ml-1 font-normal opacity-45">
|
||||
{obj.type || "?"}
|
||||
{obj.ttl != null ? ` · ${formatRedisTtl(obj.ttl)}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : obj.kind === "table" || obj.kind === "view" ? (
|
||||
<details
|
||||
key={obj.name}
|
||||
onToggle={(e) => {
|
||||
if ((e.currentTarget as HTMLDetailsElement).open && obj.kind === "table") {
|
||||
void expandTable(db.name, obj.name);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
onCtx(
|
||||
e,
|
||||
obj.name,
|
||||
() => {
|
||||
if (obj.kind === "table") {
|
||||
patchDb(db.name, (d) => ({
|
||||
...d,
|
||||
children: (d.children ?? []).map((gg) =>
|
||||
gg.groupId !== "tables"
|
||||
? gg
|
||||
: {
|
||||
...gg,
|
||||
children: (gg.children ?? []).map((t) =>
|
||||
t.name === obj.name ? { ...t, loaded: false, children: undefined } : t,
|
||||
),
|
||||
},
|
||||
),
|
||||
}));
|
||||
void expandTable(db.name, obj.name);
|
||||
} else {
|
||||
patchDb(db.name, { loaded: false, children: undefined });
|
||||
void expandDb(db.name);
|
||||
}
|
||||
},
|
||||
() => void openSchema(db.name, obj.name),
|
||||
)
|
||||
}
|
||||
>
|
||||
<summary className="ops-tree-item list-none [&::-webkit-details-marker]:hidden">
|
||||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-40">▸</span>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
title="双击生成 SELECT"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void putSelectSql(db.name, obj.name);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{obj.name}
|
||||
</button>
|
||||
</summary>
|
||||
<div className="ml-2 border-l pl-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{(obj.children ?? []).map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="ops-tree-item opacity-80"
|
||||
title={col.type}
|
||||
onContextMenu={(e) => onCtx(e, col.name, () => void expandTable(db.name, obj.name))}
|
||||
>
|
||||
<span className="w-3 shrink-0 text-center text-[10px] opacity-30">·</span>
|
||||
<span className="truncate">
|
||||
{col.key === "PRI" ? "🔑 " : ""}
|
||||
{col.name}
|
||||
<span className="ml-1 opacity-50">{col.type}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{obj.loading && (
|
||||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
列…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div
|
||||
key={obj.name}
|
||||
className="ops-tree-item"
|
||||
onContextMenu={(e) =>
|
||||
onCtx(e, obj.name, () => {
|
||||
patchDb(db.name, { loaded: false, children: undefined });
|
||||
void expandDb(db.name);
|
||||
})
|
||||
}
|
||||
onDoubleClick={() => void copyName(obj.name)}
|
||||
>
|
||||
<span className="w-3 shrink-0" />
|
||||
<span className="truncate">{obj.name}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{db.loaded && (db.children ?? []).length === 0 && (
|
||||
<div className="px-1.5 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
空库或无权限
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{ctx && (
|
||||
<div className="ops-ctx" style={{ left: ctx.x, top: ctx.y }} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
ctx.onRefresh();
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void copyName(ctx.label);
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
复制名
|
||||
</button>
|
||||
{ctx.onSchema && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
ctx.onSchema?.();
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
查看结构
|
||||
</button>
|
||||
)}
|
||||
{ctx.onView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
ctx.onView?.();
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
查看值
|
||||
</button>
|
||||
)}
|
||||
{ctx.onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => {
|
||||
ctx.onDelete?.();
|
||||
setCtx(null);
|
||||
}}
|
||||
>
|
||||
删除键
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function listSchemaObjects(
|
||||
conn: SavedConnection,
|
||||
sid: string,
|
||||
db: string,
|
||||
kind: "views" | "procedures" | "functions",
|
||||
): Promise<string[]> {
|
||||
const d = conn.driver;
|
||||
let sql = "";
|
||||
if (kind === "views") {
|
||||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase") {
|
||||
sql = `SELECT table_name AS n FROM information_schema.views WHERE table_schema = 'public' ORDER BY 1`;
|
||||
} else if (d === "sqlite") {
|
||||
sql = `SELECT name AS n FROM sqlite_master WHERE type='view' ORDER BY 1`;
|
||||
} else {
|
||||
sql = `SELECT TABLE_NAME AS n FROM information_schema.views WHERE TABLE_SCHEMA = '${db.replace(/'/g, "''")}' ORDER BY 1`;
|
||||
}
|
||||
} else if (kind === "procedures") {
|
||||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase") {
|
||||
sql = `SELECT routine_name AS n FROM information_schema.routines WHERE routine_schema='public' AND routine_type='PROCEDURE' ORDER BY 1`;
|
||||
} else if (d === "sqlite") {
|
||||
return [];
|
||||
} else {
|
||||
sql = `SELECT ROUTINE_NAME AS n FROM information_schema.routines WHERE ROUTINE_SCHEMA='${db.replace(/'/g, "''")}' AND ROUTINE_TYPE='PROCEDURE' ORDER BY 1`;
|
||||
}
|
||||
} else {
|
||||
if (d === "postgresql" || d === "gaussdb" || d === "kingbase") {
|
||||
sql = `SELECT routine_name AS n FROM information_schema.routines WHERE routine_schema='public' AND routine_type='FUNCTION' ORDER BY 1`;
|
||||
} else if (d === "sqlite") {
|
||||
return [];
|
||||
} else {
|
||||
sql = `SELECT ROUTINE_NAME AS n FROM information_schema.routines WHERE ROUTINE_SCHEMA='${db.replace(/'/g, "''")}' AND ROUTINE_TYPE='FUNCTION' ORDER BY 1`;
|
||||
}
|
||||
}
|
||||
const res = await execSql(conn, sql, { database: db, sessionId: sid, limit: 5000 });
|
||||
if (res.error) return [];
|
||||
const key = res.columns[0];
|
||||
return res.rows.map((r) => String(r[key] ?? "")).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { execSql } from "./api";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver } from "./sql";
|
||||
import { SchemaPane } from "./SchemaPane";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const DISPLAY_CAP = 2000;
|
||||
|
||||
function formatCell(v: unknown) {
|
||||
if (v == null) return <span className="ops-null">NULL</span>;
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
const s = String(v);
|
||||
if (s.length > 120) return s.slice(0, 120) + "…";
|
||||
return s;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function cmpVal(a: unknown, b: unknown): number {
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return -1;
|
||||
if (b == null) return 1;
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb) && String(a).trim() !== "" && String(b).trim() !== "") {
|
||||
return na - nb;
|
||||
}
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const resultSets = useWorkspaceStore((s) => s.resultSets);
|
||||
const activeResultSetId = useWorkspaceStore((s) => s.activeResultSetId);
|
||||
const resultTab = useWorkspaceStore((s) => s.resultTab);
|
||||
const explainText = useWorkspaceStore((s) => s.explainText);
|
||||
const resultMeta = useWorkspaceStore((s) => s.resultMeta);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
const writable = isWritableDriver(conn?.driver);
|
||||
const canEdit = writable && !!resultMeta?.table && (resultMeta.pkCols?.length ?? 0) > 0;
|
||||
|
||||
const [sortCol, setSortCol] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | null>(null);
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
||||
const [selectedCol, setSelectedCol] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState<{ row: number; col: string; value: string; original: string } | null>(null);
|
||||
const dragRef = useRef<{ col: string; startX: number; startW: number } | null>(null);
|
||||
const editRef = useRef(edit);
|
||||
editRef.current = edit;
|
||||
|
||||
useEffect(() => {
|
||||
setSortCol(null);
|
||||
setSortDir(null);
|
||||
setSelectedIdx(null);
|
||||
setSelectedCol(null);
|
||||
setColWidths({});
|
||||
setEdit(null);
|
||||
}, [result]);
|
||||
|
||||
// discard pending edit when switching tabs
|
||||
useEffect(() => {
|
||||
if (resultTab !== "data" && editRef.current) {
|
||||
notify({ kind: "info", title: "已丢弃未提交编辑", message: "切换标签时未保存的单元格改动已丢弃。", mode: "toast", durationMs: 1800 });
|
||||
setEdit(null);
|
||||
}
|
||||
}, [resultTab, notify]);
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!result || result.error) return [];
|
||||
let rows = result.rows;
|
||||
if (sortCol && sortDir) {
|
||||
rows = [...rows].sort((ra, rb) => {
|
||||
const c = cmpVal(ra[sortCol], rb[sortCol]);
|
||||
return sortDir === "asc" ? c : -c;
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [result, sortCol, sortDir]);
|
||||
|
||||
const truncatedLocal = sortedRows.length > DISPLAY_CAP;
|
||||
const visibleRows = truncatedLocal ? sortedRows.slice(0, DISPLAY_CAP) : sortedRows;
|
||||
|
||||
useEffect(() => {
|
||||
if (!result || result.error) return;
|
||||
const parts: string[] = [];
|
||||
parts.push(`${result.rowCount} 行`);
|
||||
if (result.timeMs != null) parts.push(`${result.timeMs} ms`);
|
||||
if (selectedIdx != null) parts.push(`选中第 ${selectedIdx + 1} 行`);
|
||||
if (result.truncated || truncatedLocal) parts.push("已截断");
|
||||
set("status", parts.join(" · "));
|
||||
}, [result, selectedIdx, truncatedLocal, set]);
|
||||
|
||||
const cycleSort = (col: string) => {
|
||||
if (sortCol !== col) {
|
||||
setSortCol(col);
|
||||
setSortDir("asc");
|
||||
return;
|
||||
}
|
||||
if (sortDir === "asc") setSortDir("desc");
|
||||
else if (sortDir === "desc") {
|
||||
setSortCol(null);
|
||||
setSortDir(null);
|
||||
} else setSortDir("asc");
|
||||
};
|
||||
|
||||
const onResizeStart = (col: string, e: ReactMouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startW = colWidths[col] ?? 140;
|
||||
dragRef.current = { col, startX: e.clientX, startW };
|
||||
const move = (ev: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const w = Math.max(64, Math.min(480, d.startW + (ev.clientX - d.startX)));
|
||||
setColWidths((prev) => ({ ...prev, [d.col]: w }));
|
||||
};
|
||||
const up = () => {
|
||||
dragRef.current = null;
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
};
|
||||
|
||||
const beginEdit = (rowIdx: number, col: string) => {
|
||||
if (!result || result.error) return;
|
||||
if (!writable) {
|
||||
notify({ kind: "warning", title: "只读", message: "当前引擎不支持结果内联写回。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (!resultMeta?.table) {
|
||||
notify({ kind: "warning", title: "无法编辑", message: "请从对象树打开表后再编辑(需表上下文)。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (!(resultMeta.pkCols?.length ?? 0)) {
|
||||
notify({ kind: "warning", title: "无法编辑", message: "未识别主键,暂不支持写回。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (resultMeta.pkCols!.includes(col)) {
|
||||
notify({ kind: "warning", title: "主键", message: "暂不支持直接改主键列。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
const row = visibleRows[rowIdx];
|
||||
const v = row?.[col];
|
||||
const s = v == null ? "" : String(v);
|
||||
setEdit({ row: rowIdx, col, value: s, original: s });
|
||||
};
|
||||
|
||||
const commitEdit = async () => {
|
||||
if (!edit || !result || !conn || !resultMeta?.table) {
|
||||
setEdit(null);
|
||||
return;
|
||||
}
|
||||
const row = visibleRows[edit.row];
|
||||
if (!row) {
|
||||
setEdit(null);
|
||||
return;
|
||||
}
|
||||
const oldVal = row[edit.col];
|
||||
const newRaw = edit.value;
|
||||
const newVal: unknown = newRaw === "" ? null : newRaw;
|
||||
if (String(oldVal ?? "") === String(newVal ?? "")) {
|
||||
setEdit(null);
|
||||
return;
|
||||
}
|
||||
const sql = buildUpdateSql(conn.driver, resultMeta.database, resultMeta.table, resultMeta.pkCols || [], row, edit.col, newVal);
|
||||
if (!sql) {
|
||||
notify({ kind: "warning", title: "无法写回", message: "缺少主键,无法生成 UPDATE。", mode: "toast" });
|
||||
setEdit(null);
|
||||
return;
|
||||
}
|
||||
const prev = edit;
|
||||
setEdit(null);
|
||||
// optimistic
|
||||
const nextRows = result.rows.map((r, i) => {
|
||||
// map via visible index carefully — use identity on sorted view
|
||||
if (visibleRows[prev.row] !== r && sortedRows[prev.row] !== r) return r;
|
||||
// find by reference in original
|
||||
return r === row ? { ...r, [prev.col]: newVal } : r;
|
||||
});
|
||||
// safer: update by matching PK
|
||||
const pk = resultMeta.pkCols || [];
|
||||
const patched = result.rows.map((r) => {
|
||||
const same = pk.every((k) => String(r[k] ?? "") === String(row[k] ?? ""));
|
||||
return same ? { ...r, [prev.col]: newVal } : r;
|
||||
});
|
||||
set("result", { ...result, rows: patched });
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
|
||||
if (res.error) throw new Error(res.error);
|
||||
set("connState", "ok");
|
||||
notify({ kind: "success", title: "已写回", message: `${prev.col} 已更新`, mode: "toast", durationMs: 1400 });
|
||||
} catch (e) {
|
||||
// rollback
|
||||
set("result", { ...result, rows: result.rows });
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEdit(null);
|
||||
|
||||
const pasteGrid = async (e: ClipboardEvent) => {
|
||||
if (edit) return; // let input handle paste
|
||||
if (!result || result.error) return;
|
||||
if (!canEdit) {
|
||||
e.preventDefault();
|
||||
notify({ kind: "warning", title: "只读", message: "当前结果不可写回,无法粘贴。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (!conn || !resultMeta?.table) return;
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
|
||||
const matrix = parseClipboardGrid(text);
|
||||
if (!matrix.length) return;
|
||||
|
||||
const pk = resultMeta.pkCols || [];
|
||||
const cols = result.columns;
|
||||
const startRow = selectedIdx ?? 0;
|
||||
let startColName = selectedCol;
|
||||
if (!startColName || !cols.includes(startColName)) {
|
||||
startColName = cols.find((c) => !pk.includes(c)) ?? cols[0] ?? null;
|
||||
}
|
||||
if (!startColName) return;
|
||||
let startColIdx = cols.indexOf(startColName);
|
||||
if (startColIdx < 0) startColIdx = 0;
|
||||
|
||||
type CellChange = { row: Record<string, unknown>; col: string; newVal: unknown; oldVal: unknown };
|
||||
const changes: CellChange[] = [];
|
||||
|
||||
for (let ri = 0; ri < matrix.length; ri++) {
|
||||
const rowIdx = startRow + ri;
|
||||
if (rowIdx >= visibleRows.length) break;
|
||||
const row = visibleRows[rowIdx];
|
||||
if (!row) break;
|
||||
const line = matrix[ri];
|
||||
let colCursor = startColIdx;
|
||||
for (let ci = 0; ci < line.length; ci++) {
|
||||
while (colCursor < cols.length && pk.includes(cols[colCursor])) colCursor++;
|
||||
if (colCursor >= cols.length) break;
|
||||
const col = cols[colCursor];
|
||||
const cell = line[ci];
|
||||
// empty → null (match commitEdit); literal NULL already null from parser
|
||||
const newVal: unknown = cell == null || cell === "" ? null : cell;
|
||||
const oldVal = row[col];
|
||||
if (String(oldVal ?? "") !== String(newVal ?? "")) {
|
||||
changes.push({ row, col, newVal, oldVal });
|
||||
}
|
||||
colCursor++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changes.length) {
|
||||
notify({ kind: "info", title: "粘贴", message: "没有可写入的单元格(可能全是主键或未越界)。", mode: "toast", durationMs: 1600 });
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotRows = result.rows.map((r) => ({ ...r }));
|
||||
// optimistic batch
|
||||
let patched = result.rows;
|
||||
for (const ch of changes) {
|
||||
patched = patched.map((r) => {
|
||||
const same = pk.every((k) => String(r[k] ?? "") === String(ch.row[k] ?? ""));
|
||||
return same ? { ...r, [ch.col]: ch.newVal } : r;
|
||||
});
|
||||
}
|
||||
set("result", { ...result, rows: patched });
|
||||
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
for (const ch of changes) {
|
||||
const sql = buildUpdateSql(conn.driver, resultMeta.database, resultMeta.table, pk, ch.row, ch.col, ch.newVal);
|
||||
if (!sql) throw new Error("缺少主键,无法生成 UPDATE");
|
||||
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
|
||||
if (res.error) throw new Error(res.error);
|
||||
}
|
||||
set("connState", "ok");
|
||||
notify({ kind: "success", title: "已粘贴", message: `已粘贴 ${changes.length} 格`, mode: "toast", durationMs: 1600 });
|
||||
} catch (err) {
|
||||
set("result", { ...result, rows: snapshotRows });
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(err, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSelectedRow = async () => {
|
||||
if (!canEdit || selectedIdx == null || !result || result.error || !conn || !resultMeta?.table) return;
|
||||
const row = visibleRows[selectedIdx];
|
||||
if (!row) return;
|
||||
const n = selectedIdx + 1;
|
||||
const ok = await askConfirm({
|
||||
title: "删除行",
|
||||
message: `确认删除第 ${n} 行?不可恢复。`,
|
||||
okLabel: "删除",
|
||||
});
|
||||
if (!ok) return;
|
||||
const pk = resultMeta.pkCols || [];
|
||||
const sql = buildDeleteSql(conn.driver, resultMeta.database, resultMeta.table, pk, row);
|
||||
if (!sql) {
|
||||
notify({ kind: "warning", title: "无法删除", message: "缺少主键,无法生成 DELETE。", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
const snapshot = result.rows;
|
||||
const snapshotCount = result.rowCount;
|
||||
const filtered = result.rows.filter((r) => !pk.every((k) => String(r[k] ?? "") === String(row[k] ?? "")));
|
||||
set("result", { ...result, rows: filtered, rowCount: Math.max(0, (result.rowCount ?? filtered.length) - 1) });
|
||||
setSelectedIdx(null);
|
||||
setSelectedCol(null);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
|
||||
if (res.error) throw new Error(res.error);
|
||||
set("connState", "ok");
|
||||
notify({ kind: "success", title: "已删除", message: `第 ${n} 行已删除`, mode: "toast", durationMs: 1400 });
|
||||
} catch (err) {
|
||||
set("result", { ...result, rows: snapshot, rowCount: snapshotCount });
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(err, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const onGridKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (edit) return;
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && canEdit && selectedIdx != null) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
void deleteSelectedRow();
|
||||
}
|
||||
};
|
||||
|
||||
const onEditKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
// 未提交前 ⌘/Ctrl+Z:恢复进入编辑时的值(不提交、不关编辑)
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!edit) return;
|
||||
if (edit.value === edit.original) return;
|
||||
setEdit({ ...edit, value: edit.original });
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (kind: "tsv" | "csv" | "insert") => {
|
||||
if (!result || result.error) return;
|
||||
const text =
|
||||
kind === "csv"
|
||||
? rowsToCSV(result.columns, result.rows)
|
||||
: kind === "insert"
|
||||
? rowsToInsert(resultMeta?.table || tableHint || "t", result.columns, result.rows)
|
||||
: rowsToTSV(result.columns, result.rows);
|
||||
await copyText(text);
|
||||
notify({ kind: "success", title: "已复制", message: kind === "insert" ? "INSERT 语句" : kind.toUpperCase(), mode: "toast" });
|
||||
};
|
||||
|
||||
const downloadCsv = () => {
|
||||
if (!result || result.error) return;
|
||||
const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `opsxdb-${Date.now()}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
|
||||
const emptyResult = result && !result.error && result.rows.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="ops-section-label justify-between border-b gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto normal-case tracking-normal">
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "data" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
|
||||
onClick={() => {
|
||||
if (edit) {
|
||||
void askConfirm({ title: "未提交编辑", message: "切换将丢弃未提交的单元格改动。", okLabel: "丢弃并切换" }).then((ok) => {
|
||||
if (ok) {
|
||||
setEdit(null);
|
||||
set("resultTab", "data");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
set("resultTab", "data");
|
||||
}}
|
||||
>
|
||||
数据
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "schema" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
|
||||
onClick={() => set("resultTab", "schema")}
|
||||
>
|
||||
结构
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "explain" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
|
||||
onClick={() => set("resultTab", "explain")}
|
||||
>
|
||||
计划
|
||||
</button>
|
||||
{resultSets.length > 1 && (
|
||||
<>
|
||||
<span className="mx-1 opacity-30">|</span>
|
||||
{resultSets.map((rs) => (
|
||||
<button
|
||||
key={rs.id}
|
||||
type="button"
|
||||
className={activeResultSetId === rs.id && resultTab === "data" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{
|
||||
borderBottomWidth: 2,
|
||||
padding: "2px 6px",
|
||||
height: 28,
|
||||
color: rs.result.error ? "var(--ops-danger)" : undefined,
|
||||
}}
|
||||
title={rs.sql.slice(0, 120)}
|
||||
onClick={() => {
|
||||
useWorkspaceStore.getState().selectResultSet(rs.id);
|
||||
}}
|
||||
>
|
||||
{rs.label}
|
||||
{rs.result.error ? " !" : ""}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{resultTab === "data" && result && !result.error && (
|
||||
<span className="flex items-center gap-2 font-normal normal-case tracking-normal">
|
||||
<span>
|
||||
{result.rowCount} 行 · {result.timeMs} ms
|
||||
{selectedIdx != null ? ` · #${selectedIdx + 1}` : ""}
|
||||
{canEdit ? "" : writable ? "" : " · 只读"}
|
||||
</span>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy("tsv")}>
|
||||
复制全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
disabled={selectedIdx == null}
|
||||
onClick={() => {
|
||||
if (selectedIdx == null || !result) return;
|
||||
const row = visibleRows[selectedIdx];
|
||||
if (!row) return;
|
||||
void copyText(result.columns.map((c) => (row[c] == null ? "" : String(row[c]))).join("\t")).then(() =>
|
||||
notify({ kind: "success", title: "已复制行", message: `#${selectedIdx + 1}`, mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
复制行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: canEdit && selectedIdx != null ? "var(--ops-danger)" : "var(--ops-accent)", opacity: canEdit && selectedIdx != null ? 1 : 0.45 }}
|
||||
disabled={!canEdit || selectedIdx == null}
|
||||
onClick={() => void deleteSelectedRow()}
|
||||
>
|
||||
删除行
|
||||
</button>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={downloadCsv}>
|
||||
CSV
|
||||
</button>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy("insert")}>
|
||||
INSERT
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resultTab === "schema" ? (
|
||||
<SchemaPane />
|
||||
) : resultTab === "explain" ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{!explainText ? (
|
||||
<div className="ops-empty" style={{ minHeight: 100 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>执行计划</h2>
|
||||
<p>点工具栏「执行计划」获取(部分引擎可用)。</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="m-0 whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed"
|
||||
style={{ color: "var(--ops-text)" }}
|
||||
>
|
||||
{explainText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(result?.truncated || truncatedLocal) && (
|
||||
<div
|
||||
className="shrink-0 border-b px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-warn) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warn) 10%, transparent)",
|
||||
color: "var(--ops-warn)",
|
||||
}}
|
||||
>
|
||||
已截断:界面最多显示 {result?.limit || DISPLAY_CAP} 行
|
||||
{truncatedLocal && sortedRows.length > DISPLAY_CAP ? `(共 ${sortedRows.length})` : ""}。加 LIMIT 或导出 CSV。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{result?.error && (
|
||||
<div
|
||||
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
}}
|
||||
>
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>还没有结果</h2>
|
||||
<p>写一条 SQL,⌘/Ctrl+Enter 运行;或点左侧表/键预览数据。</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
|
||||
去写查询
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emptyResult && (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>空结果</h2>
|
||||
<p>查询成功,但没有返回行。</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
|
||||
去写查询
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && result.rows.length > 0 && (
|
||||
<div
|
||||
className="outline-none"
|
||||
tabIndex={0}
|
||||
onPaste={(e) => void pasteGrid(e)}
|
||||
onKeyDown={onGridKeyDown}
|
||||
>
|
||||
<table className="ops-grid" style={{ tableLayout: "fixed", width: "max-content", minWidth: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ops-rownum" style={{ width: 40 }}>
|
||||
#
|
||||
</th>
|
||||
{result.columns.map((c) => {
|
||||
const w = colWidths[c] ?? 140;
|
||||
const mark = sortCol === c ? (sortDir === "asc" ? " ↑" : sortDir === "desc" ? " ↓" : "") : "";
|
||||
return (
|
||||
<th
|
||||
key={c}
|
||||
className="ops-sortable"
|
||||
style={{ width: w, minWidth: w, maxWidth: w }}
|
||||
onClick={() => cycleSort(c)}
|
||||
title="点击排序:升 → 降 → 取消"
|
||||
>
|
||||
{c}
|
||||
{mark}
|
||||
<span className="ops-col-resize" onMouseDown={(e) => onResizeStart(c, e)} />
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={selectedIdx === i ? "ops-row-selected" : undefined}
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
>
|
||||
<td className="ops-rownum">{i + 1}</td>
|
||||
{result.columns.map((c) => {
|
||||
const editing = edit && edit.row === i && edit.col === c;
|
||||
const cellSelected = selectedIdx === i && selectedCol === c;
|
||||
return (
|
||||
<td
|
||||
key={c}
|
||||
className={cellSelected ? "ops-cell-selected" : undefined}
|
||||
style={{
|
||||
width: colWidths[c] ?? 140,
|
||||
maxWidth: colWidths[c] ?? 140,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
outline: cellSelected ? "1px solid var(--ops-accent)" : undefined,
|
||||
outlineOffset: cellSelected ? -1 : undefined,
|
||||
}}
|
||||
title={row[c] == null ? "NULL" : String(row[c])}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedIdx(i);
|
||||
setSelectedCol(c);
|
||||
}}
|
||||
onDoubleClick={() => beginEdit(i, c)}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="ops-input w-full py-0"
|
||||
style={{ boxShadow: "0 0 0 1px var(--ops-accent)" }}
|
||||
value={edit!.value}
|
||||
onChange={(e) => setEdit({ ...edit!, value: e.target.value })}
|
||||
onBlur={() => void commitEdit()}
|
||||
onKeyDown={onEditKey}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
formatCell(row[c])
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
export function SchemaPane() {
|
||||
const schema = useWorkspaceStore((s) => s.schemaView);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>表结构</h2>
|
||||
<p>在对象树点表,或右键「查看结构」。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.loading) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
加载 {schema.table} 结构…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.error) {
|
||||
return (
|
||||
<div
|
||||
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
}}
|
||||
>
|
||||
{schema.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
<span>
|
||||
结构 · {schema.database ? `${schema.database}.` : ""}
|
||||
{schema.table}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="normal-case tracking-normal hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => set("resultTab", "data")}
|
||||
>
|
||||
回结果
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{schema.columns.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
无列信息
|
||||
</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>#</th>
|
||||
<th>列名</th>
|
||||
<th>类型</th>
|
||||
<th style={{ width: 64 }}>可空</th>
|
||||
<th>默认值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schema.columns.map((c, i) => (
|
||||
<tr key={c.name}>
|
||||
<td className="ops-rownum">{i + 1}</td>
|
||||
<td>
|
||||
{c.key === "PRI" ? <span title="主键">🔑 </span> : null}
|
||||
{c.name}
|
||||
</td>
|
||||
<td className="font-mono text-[12px]">{c.type}</td>
|
||||
<td>{c.nullable ? "YES" : "NO"}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{c.default == null ? <span className="ops-null">NULL</span> : String(c.default)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { formatSqlText, statementAt } from "./sql";
|
||||
|
||||
export type SqlEditorHandle = {
|
||||
/** Selection trim if nonempty; else statement at cursor (statementAt). */
|
||||
getRunSql: () => string;
|
||||
/** Format selection (if prefer + nonempty) or whole doc; single history-friendly change. */
|
||||
format: (selectionPrefer?: boolean) => void;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
export const SqlEditor = forwardRef<
|
||||
SqlEditorHandle,
|
||||
{
|
||||
value: string;
|
||||
theme: "dark" | "light";
|
||||
onChange: (sql: string) => void;
|
||||
onRun: (sql: string) => void;
|
||||
/** Optional driver for dialect-aware format. */
|
||||
driver?: string;
|
||||
}
|
||||
>(function SqlEditor({ value, theme, onChange, onRun, driver }, ref) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onRunRef = useRef(onRun);
|
||||
const valueRef = useRef(value);
|
||||
const driverRef = useRef(driver);
|
||||
onChangeRef.current = onChange;
|
||||
onRunRef.current = onRun;
|
||||
valueRef.current = value;
|
||||
driverRef.current = driver;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getRunSql: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return valueRef.current.trim();
|
||||
const sel = view.state.selection.main;
|
||||
const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to);
|
||||
return statementAt(view.state.doc.toString(), sel.head, selected);
|
||||
},
|
||||
format: (selectionPrefer = true) => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
const useSel = selectionPrefer && !sel.empty;
|
||||
const from = useSel ? sel.from : 0;
|
||||
const to = useSel ? sel.to : view.state.doc.length;
|
||||
const raw = view.state.sliceDoc(from, to);
|
||||
if (!raw.trim()) return;
|
||||
const formatted = formatSqlText(raw, driverRef.current);
|
||||
if (formatted === raw) return;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: formatted },
|
||||
selection: useSel
|
||||
? EditorSelection.range(from, from + formatted.length)
|
||||
: EditorSelection.cursor(Math.min(from + formatted.length, from + formatted.length)),
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
focus: () => viewRef.current?.focus(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const runKey = {
|
||||
key: "Mod-Enter",
|
||||
run: (view: EditorView) => {
|
||||
const sel = view.state.selection.main;
|
||||
const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to);
|
||||
onRunRef.current(statementAt(view.state.doc.toString(), sel.head, selected));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const state = EditorState.create({
|
||||
doc: valueRef.current,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
sql(),
|
||||
placeholder("写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
|
||||
keymap.of([runKey, indentWithTab, ...historyKeymap, ...defaultKeymap]),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "12.5px" },
|
||||
".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: "1.45" },
|
||||
".cm-content": { padding: "8px 0" },
|
||||
".cm-gutters": { border: "none" },
|
||||
}),
|
||||
...(theme === "dark" ? [oneDark] : []),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: host.current });
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// recreate on theme change only
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
if (view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={host} className="ops-code h-full min-h-[140px] w-full overflow-hidden" />;
|
||||
});
|
||||
@@ -0,0 +1,931 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AiPanel } from "./AiPanel";
|
||||
import { ConnectionPicker } from "./ConnectionPicker";
|
||||
import { NotifyHost } from "./NotifyHost";
|
||||
import { ConnectionSidebar } from "./ConnectionSidebar";
|
||||
import { ObjectTree, ensureSession } from "./ObjectTree";
|
||||
import { ResultPane } from "./ResultPane";
|
||||
import { SqlEditor, type SqlEditorHandle } from "./SqlEditor";
|
||||
import {execSql, health, isAbortError} from "./api";
|
||||
import { driverById, isDriverReady } from "./catalog";
|
||||
import { isDangerousSQL, splitStatements, buildExplainSql, supportsExplain } from "./sql";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function TopNav() {
|
||||
const openPicker = useWorkspaceStore((s) => s.openPicker);
|
||||
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
|
||||
const theme = useWorkspaceStore((s) => s.theme);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const showAi = useWorkspaceStore((s) => s.showAi);
|
||||
const showHistory = useWorkspaceStore((s) => s.showHistory);
|
||||
const showHelp = useWorkspaceStore((s) => s.showHelp);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("light", theme === "light");
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<header className="ops-chrome-header">
|
||||
<div className="mr-1.5 flex items-baseline gap-1.5 shrink-0">
|
||||
<span className="text-[13px] font-semibold tracking-tight">OpsxDB</span>
|
||||
<span className="text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
客户端
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => useWorkspaceStore.getState().openConnectionList()}>
|
||||
连接
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => openPicker()}>
|
||||
新建连接
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
|
||||
新建查询
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("ops-btn-ghost", showHistory && "ops-tree-item-active")}
|
||||
onClick={() => set("showHistory", !showHistory)}
|
||||
>
|
||||
历史
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost" onClick={() => set("showHelp", !showHelp)}>
|
||||
快捷键
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<span className="hidden max-w-[240px] truncate sm:inline" title={conn?.name}>
|
||||
{conn ? conn.name : "未连接"}
|
||||
</span>
|
||||
<button type="button" className="ops-btn-ghost" onClick={() => set("showAi", !showAi)}>
|
||||
{showAi ? "隐藏 AI" : "AI"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost"
|
||||
onClick={() => set("theme", theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? "浅色" : "深色"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function DragX({ onMove }: { onMove: (dx: number) => void }) {
|
||||
return (
|
||||
<div
|
||||
className="ops-split-x"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const start = e.clientX;
|
||||
const move = (ev: MouseEvent) => onMove(ev.clientX - start);
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DragY({ onMove }: { onMove: (dy: number) => void }) {
|
||||
return (
|
||||
<div
|
||||
className="ops-split-y"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const start = e.clientY;
|
||||
const move = (ev: MouseEvent) => onMove(ev.clientY - start);
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorPane() {
|
||||
const tabs = useWorkspaceStore((s) => s.tabs);
|
||||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||||
const running = useWorkspaceStore((s) => s.running);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const theme = useWorkspaceStore((s) => s.theme);
|
||||
const resultRatio = useWorkspaceStore((s) => s.resultRatio);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const closeTab = useWorkspaceStore((s) => s.closeTab);
|
||||
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
|
||||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const pushHistory = useWorkspaceStore((s) => s.pushHistory);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const showHistory = useWorkspaceStore((s) => s.showHistory);
|
||||
const history = useWorkspaceStore((s) => s.queryHistory);
|
||||
const showBookmarks = useWorkspaceStore((s) => s.showBookmarks);
|
||||
const bookmarks = useWorkspaceStore((s) => s.bookmarks);
|
||||
const editorRef = useRef<SqlEditorHandle>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
|
||||
const cancelRun = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0];
|
||||
const conn = connections.find((c) => c.id === (tab?.connectionId || activeConnectionId));
|
||||
|
||||
const run = async (sql?: string) => {
|
||||
if (!conn) {
|
||||
set("status", "请先选择连接");
|
||||
return;
|
||||
}
|
||||
if (!isDriverReady(conn.driver)) {
|
||||
set("result", { columns: [], rows: [], rowCount: 0, timeMs: 0, error: `${driverById(conn.driver)?.name} 驱动未就绪` });
|
||||
return;
|
||||
}
|
||||
const text = (sql ?? tab.sql).trim();
|
||||
if (!text) {
|
||||
set("status", "没有可执行的 SQL");
|
||||
return;
|
||||
}
|
||||
const parts = splitStatements(text)
|
||||
.map((r) => r.text.trim())
|
||||
.filter(Boolean);
|
||||
const stmts = parts.length ? parts : [text];
|
||||
if (stmts.some((s) => isDangerousSQL(s))) {
|
||||
const ok = await askConfirm({
|
||||
title: "确认执行",
|
||||
message: `将执行 ${stmts.length} 条语句,其中可能含修改/删除。确认继续?`,
|
||||
okLabel: "执行",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
set("running", true);
|
||||
set("status", stmts.length > 1 ? `执行 ${stmts.length} 条语句…` : "执行中…");
|
||||
set("explainText", null);
|
||||
const sets: import("./store").ResultSetItem[] = [];
|
||||
let cancelled = false;
|
||||
try {
|
||||
let sid = await ensureSession(conn);
|
||||
for (let i = 0; i < stmts.length; i++) {
|
||||
if (ac.signal.aborted) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
const stmt = stmts[i];
|
||||
const label = `结果 ${i + 1}`;
|
||||
try {
|
||||
const res = await execSql(conn, stmt, {
|
||||
sessionId: sid,
|
||||
database: tab.database || conn.database,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (res.error === "已取消" || ac.signal.aborted) {
|
||||
cancelled = true;
|
||||
sets.push({
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
sql: stmt,
|
||||
result: { columns: [], rows: [], rowCount: 0, timeMs: res.timeMs || 0, error: "已取消" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
sets.push({ id: crypto.randomUUID(), label, sql: stmt, result: res });
|
||||
pushHistory({ sql: stmt, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
|
||||
} catch (e) {
|
||||
if (isAbortError(e) || ac.signal.aborted) {
|
||||
cancelled = true;
|
||||
sets.push({
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
sql: stmt,
|
||||
result: { columns: [], rows: [], rowCount: 0, timeMs: 0, error: "已取消" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) {
|
||||
set("connState", "down");
|
||||
useWorkspaceStore.getState().setSession(conn.id, null);
|
||||
try {
|
||||
if (ac.signal.aborted) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
sid = await ensureSession(conn);
|
||||
set("connState", "ok");
|
||||
const res = await execSql(conn, stmt, {
|
||||
sessionId: sid,
|
||||
database: tab.database || conn.database,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (res.error === "已取消" || ac.signal.aborted) {
|
||||
cancelled = true;
|
||||
sets.push({
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
sql: stmt,
|
||||
result: { columns: [], rows: [], rowCount: 0, timeMs: res.timeMs || 0, error: "已取消" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
sets.push({ id: crypto.randomUUID(), label, sql: stmt, result: res });
|
||||
pushHistory({ sql: stmt, connectionId: conn.id, rowCount: res.rowCount, timeMs: res.timeMs, error: res.error });
|
||||
continue;
|
||||
} catch (e2) {
|
||||
if (isAbortError(e2) || ac.signal.aborted) {
|
||||
cancelled = true;
|
||||
sets.push({
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
sql: stmt,
|
||||
result: { columns: [], rows: [], rowCount: 0, timeMs: 0, error: "已取消" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
const m2 = e2 instanceof Error ? e2.message : String(e2);
|
||||
sets.push({
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
sql: stmt,
|
||||
result: { columns: [], rows: [], rowCount: 0, timeMs: 0, error: m2 },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
sets.push({
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
sql: stmt,
|
||||
result: { columns: [], rows: [], rowCount: 0, timeMs: 0, error: msg },
|
||||
});
|
||||
}
|
||||
}
|
||||
useWorkspaceStore.getState().setResultSets(sets);
|
||||
if (cancelled) {
|
||||
set("status", "已取消");
|
||||
notify({ kind: "warning", title: "已取消", message: "查询已停止", mode: "toast", durationMs: 2200 });
|
||||
} else {
|
||||
const okN = sets.filter((s) => !s.result.error).length;
|
||||
const errN = sets.length - okN;
|
||||
set("status", errN ? `${okN} 成功 · ${errN} 失败` : `${sets.length} 个结果集`);
|
||||
set("connState", "ok");
|
||||
}
|
||||
} catch (e) {
|
||||
if (isAbortError(e) || ac.signal.aborted) {
|
||||
set("status", "已取消");
|
||||
notify({ kind: "warning", title: "已取消", message: "查询已停止", mode: "toast", durationMs: 2200 });
|
||||
} else {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("status", msg);
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
set("running", false);
|
||||
}
|
||||
};
|
||||
|
||||
const runExplain = async () => {
|
||||
if (!conn) {
|
||||
set("status", "请先选择连接");
|
||||
return;
|
||||
}
|
||||
if (!supportsExplain(conn.driver)) {
|
||||
useWorkspaceStore.getState().notify({
|
||||
kind: "warning",
|
||||
title: "执行计划",
|
||||
message: `${driverById(conn.driver)?.name ?? conn.driver} 暂不支持执行计划。`,
|
||||
mode: "toast",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const text = (tab?.sql || "").trim();
|
||||
if (!text) {
|
||||
set("status", "没有可分析的 SQL");
|
||||
return;
|
||||
}
|
||||
const stmts = splitStatements(text).map((r) => r.text.trim()).filter(Boolean);
|
||||
const target = stmts[0] || text;
|
||||
const planSql = buildExplainSql(conn.driver, target);
|
||||
if (!planSql) {
|
||||
useWorkspaceStore.getState().notify({
|
||||
kind: "warning",
|
||||
title: "执行计划",
|
||||
message: "无法生成 EXPLAIN 语句。",
|
||||
mode: "toast",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
set("running", true);
|
||||
set("status", "获取执行计划…");
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await execSql(conn, planSql, {
|
||||
sessionId: sid,
|
||||
database: tab.database || conn.database,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (res.error === "已取消" || ac.signal.aborted) {
|
||||
set("status", "已取消");
|
||||
notify({ kind: "warning", title: "已取消", message: "执行计划已停止", mode: "toast", durationMs: 2200 });
|
||||
return;
|
||||
}
|
||||
if (res.error) {
|
||||
set("explainText", res.error);
|
||||
} else if (res.columns.length === 1 && res.rows.length) {
|
||||
const col = res.columns[0];
|
||||
set("explainText", res.rows.map((r) => String(r[col] ?? "")).join("\n"));
|
||||
} else {
|
||||
const header = res.columns.join("\t");
|
||||
const body = res.rows
|
||||
.map((r) => res.columns.map((c) => (r[c] == null ? "NULL" : String(r[c]))).join("\t"))
|
||||
.join("\n");
|
||||
set("explainText", [header, body].filter(Boolean).join("\n"));
|
||||
}
|
||||
set("resultTab", "explain");
|
||||
set("status", "执行计划已就绪");
|
||||
set("connState", "ok");
|
||||
} catch (e) {
|
||||
if (isAbortError(e) || ac.signal.aborted) {
|
||||
set("status", "已取消");
|
||||
notify({ kind: "warning", title: "已取消", message: "执行计划已停止", mode: "toast", durationMs: 2200 });
|
||||
} else {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("explainText", msg);
|
||||
set("resultTab", "explain");
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
set("running", false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && e.key === "t") {
|
||||
e.preventDefault();
|
||||
newQueryTab();
|
||||
}
|
||||
if (mod && e.key === "w") {
|
||||
e.preventDefault();
|
||||
if (!tab) return;
|
||||
void (async () => {
|
||||
if (tab.dirty) {
|
||||
const ok = await askConfirm({
|
||||
title: "关闭查询",
|
||||
message: `「${tab.title}」有未保存改动,确认关闭?`,
|
||||
okLabel: "关闭",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
closeTab(tab.id);
|
||||
})();
|
||||
}
|
||||
if (mod && e.key === "/") {
|
||||
e.preventDefault();
|
||||
set("showHelp", !useWorkspaceStore.getState().showHelp);
|
||||
}
|
||||
if (mod && e.key === "b") {
|
||||
e.preventDefault();
|
||||
set("showAi", !useWorkspaceStore.getState().showAi);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [closeTab, newQueryTab, set, tab]);
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="ops-tabbar">
|
||||
{tabs.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cx(
|
||||
"group flex items-center gap-1 border-b-2 px-2 text-[12px]",
|
||||
t.id === activeTabId ? "border-[var(--ops-accent)]" : "border-transparent opacity-70 hover:opacity-100",
|
||||
)}
|
||||
style={
|
||||
t.id === activeTabId
|
||||
? { color: "var(--ops-accent)", background: "var(--ops-surface)" }
|
||||
: { color: "var(--ops-muted)" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={t.dirty ? "ops-tab-dirty" : undefined}
|
||||
onClick={() => set("activeTabId", t.id)}
|
||||
>
|
||||
{t.title}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden text-[11px] opacity-50 group-hover:inline"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
if (t.dirty) {
|
||||
const ok = await askConfirm({
|
||||
title: "关闭查询",
|
||||
message: `「${t.title}」有未保存改动,确认关闭?`,
|
||||
okLabel: "关闭",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
closeTab(t.id);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="ops-btn-ghost px-1.5" onClick={() => newQueryTab()} title="新建查询 ⌘T">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center gap-2 border-b px-2"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface)" }}
|
||||
>
|
||||
{running ? (
|
||||
<button type="button" className="ops-btn-secondary" onClick={cancelRun} title="取消当前查询">
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary"
|
||||
onClick={() => void run(editorRef.current?.getRunSql())}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary"
|
||||
disabled={running}
|
||||
title="格式化当前选区或全文(⌘Z 可撤销)"
|
||||
onClick={() => {
|
||||
editorRef.current?.format(true);
|
||||
notify({
|
||||
kind: "success",
|
||||
title: "已格式化",
|
||||
message: "⌘Z 可撤销",
|
||||
mode: "toast",
|
||||
durationMs: 1800,
|
||||
});
|
||||
}}
|
||||
>
|
||||
格式化
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary"
|
||||
disabled={running || !supportsExplain(conn?.driver)}
|
||||
title={supportsExplain(conn?.driver) ? "执行计划" : "当前引擎不支持执行计划"}
|
||||
onClick={() => void runExplain()}
|
||||
>
|
||||
执行计划
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary"
|
||||
onClick={() => {
|
||||
const name = window.prompt("书签名称", "常用查询");
|
||||
if (name == null) return;
|
||||
if (!name.trim()) {
|
||||
useWorkspaceStore.getState().notify({ kind: "warning", title: "书签", message: "名称必填", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
useWorkspaceStore.getState().saveBookmark(name, tab?.sql ?? "");
|
||||
useWorkspaceStore.getState().notify({ kind: "success", title: "已保存书签", message: name.trim(), mode: "toast" });
|
||||
}}
|
||||
>
|
||||
存书签
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("ops-btn-ghost", showBookmarks && "ops-tree-item-active")}
|
||||
onClick={() => set("showBookmarks", !showBookmarks)}
|
||||
>
|
||||
书签
|
||||
</button>
|
||||
<span className="text-[10px] opacity-60" style={{ color: "var(--ops-muted)" }}>
|
||||
⌘/Ctrl+Enter 选区或当前语句;多语句出多结果
|
||||
</span>
|
||||
<span className="truncate text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{conn ? `${conn.name} · ${driverById(conn.driver)?.name}` : "未选择连接"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showBookmarks && (
|
||||
<div className="max-h-36 shrink-0 overflow-auto border-b px-2 py-1 text-[11px]" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="mb-1" style={{ color: "var(--ops-muted)" }}>书签 {bookmarks.length}</div>
|
||||
{bookmarks.length === 0 && <div style={{ color: "var(--ops-muted)" }}>暂无书签,点「存书签」</div>}
|
||||
{bookmarks.map((b) => (
|
||||
<div key={b.id} className="group flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-tree-item min-w-0 flex-1"
|
||||
onClick={() => updateActiveSql(b.sql)}
|
||||
title={b.sql}
|
||||
>
|
||||
<span className="truncate font-medium">{b.name}</span>
|
||||
<span className="ml-auto shrink-0 opacity-40">{new Date(b.at).toLocaleDateString()}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden text-[10px] opacity-60 group-hover:inline"
|
||||
onClick={() => {
|
||||
const name = window.prompt("重命名书签", b.name);
|
||||
if (name == null || !name.trim()) return;
|
||||
useWorkspaceStore.getState().renameBookmark(b.id, name);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden text-[10px] opacity-60 group-hover:inline"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => useWorkspaceStore.getState().removeBookmark(b.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showHistory && (
|
||||
<div className="max-h-36 shrink-0 overflow-auto border-b px-2 py-1 text-[11px]" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span style={{ color: "var(--ops-muted)" }}>最近 {history.length} 条</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => useWorkspaceStore.getState().clearHistory()}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
{history.length === 0 && <div style={{ color: "var(--ops-muted)" }}>暂无历史</div>}
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="ops-tree-item w-full"
|
||||
onClick={() => updateActiveSql(h.sql)}
|
||||
title={h.sql}
|
||||
>
|
||||
<span className="w-16 shrink-0 opacity-50">{new Date(h.at).toLocaleTimeString()}</span>
|
||||
<span className="truncate font-mono">{h.sql.replace(/\s+/g, " ")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-[120px]" style={{ flex: `${1 - resultRatio} 1 0` }}>
|
||||
<SqlEditor
|
||||
ref={editorRef}
|
||||
value={tab?.sql ?? ""}
|
||||
theme={theme}
|
||||
driver={conn?.driver}
|
||||
onChange={updateActiveSql}
|
||||
onRun={(sql) => void run(sql)}
|
||||
/>
|
||||
</div>
|
||||
<DragY
|
||||
onMove={(dy) => {
|
||||
const h = Math.max(280, window.innerHeight - 80);
|
||||
const next = Math.min(0.7, Math.max(0.2, resultRatio - dy / h));
|
||||
set("resultRatio", next);
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-[96px]" style={{ flex: `${resultRatio} 1 0` }}>
|
||||
<ResultPane tableHint={conn?.database} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBar() {
|
||||
const status = useWorkspaceStore((s) => s.status);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const connState = useWorkspaceStore((s) => s.connState);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
|
||||
const reconnect = async () => {
|
||||
if (!conn) return;
|
||||
set("connState", "reconnecting");
|
||||
set("status", "重连中…");
|
||||
try {
|
||||
useWorkspaceStore.getState().setSession(conn.id, null);
|
||||
const { sessionId } = await (await import("./api")).openSession(conn);
|
||||
useWorkspaceStore.getState().setSession(conn.id, sessionId);
|
||||
set("connState", "ok");
|
||||
set("status", `已重连 ${conn.name}` + (conn.database ? ` · ${conn.database}` : ""));
|
||||
notify({ kind: "success", title: "重连成功", message: conn.name, mode: "toast", durationMs: 1600 });
|
||||
} catch (e) {
|
||||
set("connState", "down");
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("status", `重连失败:${msg}`);
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="ops-statusbar">
|
||||
<span className="truncate">{conn ? conn.name : "未连接"}</span>
|
||||
<span className="opacity-30">|</span>
|
||||
{connState === "down" && (
|
||||
<>
|
||||
<span style={{ color: "var(--ops-danger)" }}>已断开</span>
|
||||
<button type="button" className="ops-btn-secondary h-5 px-1.5 text-[10px]" onClick={() => void reconnect()}>
|
||||
重连
|
||||
</button>
|
||||
<span className="opacity-30">|</span>
|
||||
</>
|
||||
)}
|
||||
{connState === "reconnecting" && (
|
||||
<>
|
||||
<span style={{ color: "var(--ops-warn)" }}>重连中…</span>
|
||||
<span className="opacity-30">|</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{status || "就绪"}</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span className="truncate">
|
||||
{conn ? `${driverById(conn.driver)?.name} · ${conn.host || conn.database}${conn.port ? `:${conn.port}` : ""}` : "无连接"}
|
||||
</span>
|
||||
{result && !result.error && (
|
||||
<>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>
|
||||
{result.rowCount} 行 / {result.timeMs} ms
|
||||
{result.truncated ? " · 已截断" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpOverlay() {
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const rows = [
|
||||
["⌘/Ctrl + Enter", "执行当前语句或选区"],
|
||||
["⌘/Ctrl + T", "新建查询"],
|
||||
["⌘/Ctrl + W", "关闭查询"],
|
||||
["⌘/Ctrl + B", "显示/隐藏 AI"],
|
||||
["⌘/Ctrl + /", "本帮助"],
|
||||
["点表名", "打开前 100 行"],
|
||||
["展开表", "查看列"],
|
||||
["双击单元格", "复制值"],
|
||||
];
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4" onClick={() => set("showHelp", false)}>
|
||||
<div className="ops-modal w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ops-modal-header">快捷键</div>
|
||||
<div className="ops-modal-body space-y-1.5">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 text-[12px]">
|
||||
<span className="font-mono" style={{ color: "var(--ops-accent)" }}>{k}</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ops-modal-footer">
|
||||
<button type="button" className="ops-btn-primary" onClick={() => set("showHelp", false)}>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsxWorkspace() {
|
||||
/** null = first probe pending; true/false = known */
|
||||
const [backendDown, setBackendDown] = useState<boolean | null>(null);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
|
||||
const view = useWorkspaceStore((s) => s.view);
|
||||
const loadConnections = useWorkspaceStore((s) => s.loadConnections);
|
||||
const leftWidth = useWorkspaceStore((s) => s.leftWidth);
|
||||
const aiWidth = useWorkspaceStore((s) => s.aiWidth);
|
||||
const showAi = useWorkspaceStore((s) => s.showAi);
|
||||
const showHelp = useWorkspaceStore((s) => s.showHelp);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const connState = useWorkspaceStore((s) => s.connState);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
}, [loadConnections]);
|
||||
|
||||
const probeHealth = useCallback(async (opts?: { toastOnFail?: boolean }) => {
|
||||
try {
|
||||
await health();
|
||||
setBackendDown(false);
|
||||
return true;
|
||||
} catch {
|
||||
setBackendDown(true);
|
||||
if (opts?.toastOnFail) {
|
||||
notify({
|
||||
kind: "warning",
|
||||
title: "本地后端未就绪",
|
||||
message: "连库依赖本机 OpsxDB 服务(默认 127.0.0.1:8788)。桌面版会自动拉起;开发态请先 ./run.sh。",
|
||||
mode: "toast",
|
||||
durationMs: 8000,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let warned = false;
|
||||
const tick = async () => {
|
||||
const ok = await probeHealth();
|
||||
if (cancelled) return;
|
||||
if (!ok && !warned) {
|
||||
warned = true;
|
||||
notify({
|
||||
kind: "warning",
|
||||
title: "本地后端未就绪",
|
||||
message: "连库依赖本机 OpsxDB 服务(默认 127.0.0.1:8788)。桌面版会自动拉起;开发态请先 ./run.sh。",
|
||||
mode: "toast",
|
||||
durationMs: 8000,
|
||||
});
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [notify, probeHealth]);
|
||||
|
||||
const retryHealth = () => {
|
||||
void probeHealth({ toastOnFail: true });
|
||||
};
|
||||
|
||||
const launchStrip =
|
||||
backendDown === null ? (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-2.5 text-[11px]"
|
||||
style={{
|
||||
height: 28,
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface)",
|
||||
color: "var(--ops-muted)",
|
||||
}}
|
||||
>
|
||||
<span>启动中…</span>
|
||||
</div>
|
||||
) : backendDown ? (
|
||||
<BackendDownBanner onRetry={retryHealth} />
|
||||
) : null;
|
||||
|
||||
if (view === "picker") {
|
||||
return (
|
||||
<>
|
||||
{launchStrip}
|
||||
<ConnectionPicker />
|
||||
<NotifyHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ops-shell flex h-screen min-h-[640px] flex-col">
|
||||
<TopNav />
|
||||
{launchStrip}
|
||||
{connState === "down" && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-2.5 text-[11px]"
|
||||
style={{
|
||||
height: 28,
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 35%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
}}
|
||||
>
|
||||
<span>连接已断开</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>可在底栏点「重连」恢复当前库上下文</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="ops-panel flex h-full shrink-0 flex-col border-r" style={{ width: leftWidth }}>
|
||||
<ConnectionSidebar
|
||||
onSelect={(id) => set("activeConnectionId", id)}
|
||||
onDelete={(id) => {
|
||||
void (async () => {
|
||||
const c = useWorkspaceStore.getState().connections.find((x) => x.id === id);
|
||||
const ok = await useWorkspaceStore.getState().askConfirm({
|
||||
title: "删除连接",
|
||||
message: `确认删除「${c?.name ?? id}」?`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (ok) useWorkspaceStore.getState().removeConnection(id);
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<ObjectTree />
|
||||
</aside>
|
||||
<DragX
|
||||
onMove={(dx) => set("leftWidth", Math.min(360, Math.max(200, leftWidth + dx)))}
|
||||
/>
|
||||
{activeConnectionId || connections.length > 0 ? (
|
||||
<EditorPane />
|
||||
) : (
|
||||
<div className="ops-empty">
|
||||
<h2>未选择连接</h2>
|
||||
<p>先新建或打开一个连接,再写 SQL。断连时不会白屏。</p>
|
||||
<button type="button" className="ops-btn-primary" onClick={() => useWorkspaceStore.getState().openConnectionList()}>
|
||||
打开连库页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showAi && (
|
||||
<>
|
||||
<DragX onMove={(dx) => set("aiWidth", Math.min(420, Math.max(220, aiWidth - dx)))} />
|
||||
<div className="shrink-0" style={{ width: aiWidth }}>
|
||||
<AiPanel />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar />
|
||||
<NotifyHost />
|
||||
{showHelp && <HelpOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function BackendDownBanner({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-2.5 text-[11px]"
|
||||
style={{
|
||||
height: 28,
|
||||
borderColor: "color-mix(in srgb, var(--ops-warning, #f59e0b) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warning, #f59e0b) 12%, transparent)",
|
||||
color: "var(--ops-warning, #f59e0b)",
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 font-medium">本地后端未就绪</span>
|
||||
<span className="min-w-0 truncate" style={{ color: "var(--ops-muted)" }}>
|
||||
开发态请先 <span style={{ color: "var(--ops-fg, inherit)", fontFamily: "ui-monospace, monospace" }}>./run.sh</span>
|
||||
{" · "}
|
||||
<span style={{ fontFamily: "ui-monospace, monospace" }}>127.0.0.1:8788</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary ml-auto h-5 shrink-0 px-2 text-[10px]"
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store";
|
||||
import { decodeSecret } from "./secrets";
|
||||
|
||||
/** 开发态走 Vite 代理(空串);桌面生产包直连本机 Gin。 */
|
||||
function resolveApiBase(): string {
|
||||
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();
|
||||
|
||||
|
||||
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" },
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
key?: string;
|
||||
pattern?: 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 || "",
|
||||
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,
|
||||
key: extra?.key,
|
||||
pattern: extra?.pattern,
|
||||
service: extra?.service ?? c.service,
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export async function listTables(c: SavedConnection, database: string, sessionId?: string) {
|
||||
return post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
|
||||
}
|
||||
|
||||
export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) {
|
||||
return post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
|
||||
}
|
||||
|
||||
export async function execSql(
|
||||
c: SavedConnection,
|
||||
sql: string,
|
||||
opts?: { database?: string; sessionId?: string; limit?: number; signal?: AbortSignal },
|
||||
) {
|
||||
return post<QueryResult>(
|
||||
"/api/db/exec",
|
||||
body(c, opts?.sessionId, { sql, database: opts?.database || c.database, limit: opts?.limit }),
|
||||
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;
|
||||
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 },
|
||||
) {
|
||||
return post<RedisKeyValue>(
|
||||
"/api/db/redis/key",
|
||||
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, 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 }),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
|
||||
}
|
||||
|
||||
export async function health() {
|
||||
const res = await fetch(`${API}/api/health`);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type DriverId =
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "tidb"
|
||||
| "postgresql"
|
||||
| "sqlserver"
|
||||
| "oracle"
|
||||
| "sqlite"
|
||||
| "mongodb"
|
||||
| "redis"
|
||||
| "elasticsearch"
|
||||
| "zookeeper"
|
||||
| "influxdb"
|
||||
| "dameng"
|
||||
| "oceanbase"
|
||||
| "gaussdb"
|
||||
| "polardb"
|
||||
| "tdsql"
|
||||
| "kingbase";
|
||||
|
||||
export type DriverCategory = "关系型" | "NoSQL" | "国产库" | "时序/搜索" | "全部";
|
||||
|
||||
export type FormKind = "standard" | "file" | "redis" | "oracle";
|
||||
|
||||
export type DriverDef = {
|
||||
id: DriverId;
|
||||
name: string;
|
||||
category: Exclude<DriverCategory, "全部">;
|
||||
defaultPort: number;
|
||||
color: string;
|
||||
icon?: string;
|
||||
mysqlCompat?: boolean;
|
||||
ready?: boolean;
|
||||
formKind?: FormKind;
|
||||
hints?: { host?: string; port?: string; user?: string; database?: string };
|
||||
};
|
||||
|
||||
export const DRIVERS: DriverDef[] = [
|
||||
{ id: "mysql", name: "MySQL", category: "关系型", defaultPort: 3306, color: "#00618A", icon: "/icons/databases/mysql.svg", mysqlCompat: true, ready: true, hints: { host: "127.0.0.1", port: "3306", user: "root", database: "" } },
|
||||
{ id: "mariadb", name: "MariaDB", category: "关系型", defaultPort: 3307, color: "#C0765A", icon: "/icons/databases/mariadb.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "tidb", name: "TiDB", category: "关系型", defaultPort: 4000, color: "#E11D48", icon: "/icons/databases/tidb.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "postgresql", name: "PostgreSQL", category: "关系型", defaultPort: 5432, color: "#336791", icon: "/icons/databases/postgresql.svg", ready: true, hints: { host: "127.0.0.1", port: "5432", user: "postgres", database: "postgres" } },
|
||||
{ id: "sqlserver", name: "SQL Server", category: "关系型", defaultPort: 1433, color: "#CC2927", icon: "/icons/databases/sqlserver.svg", ready: true, hints: { host: "127.0.0.1", port: "1433", user: "sa", database: "master" } },
|
||||
{ id: "oracle", name: "Oracle", category: "关系型", defaultPort: 1521, color: "#F80000", icon: "/icons/databases/oracle.svg", ready: true, formKind: "oracle", hints: { host: "127.0.0.1", port: "1521", user: "system", database: "ORCL" } },
|
||||
{ id: "sqlite", name: "SQLite", category: "关系型", defaultPort: 0, color: "#003B57", icon: "/icons/databases/sqlite.svg", ready: true, formKind: "file", hints: { database: "tests/tmp/demo.sqlite" } },
|
||||
{ id: "mongodb", name: "MongoDB", category: "NoSQL", defaultPort: 27017, color: "#00ED64", icon: "/icons/databases/mongodb.svg" },
|
||||
{ id: "redis", name: "Redis", category: "NoSQL", defaultPort: 6379, color: "#DC382D", icon: "/icons/databases/redis.svg", ready: true, formKind: "redis", hints: { host: "127.0.0.1", port: "6379", user: "", database: "" } },
|
||||
{ id: "elasticsearch", name: "Elasticsearch", category: "时序/搜索", defaultPort: 9200, color: "#FEC514", icon: "/icons/databases/elasticsearch.svg" },
|
||||
{ id: "zookeeper", name: "ZooKeeper", category: "时序/搜索", defaultPort: 2181, color: "#2C5F8A", icon: "/icons/databases/zookeeper.svg" },
|
||||
{ id: "influxdb", name: "InfluxDB", category: "时序/搜索", defaultPort: 8086, color: "#22ADF6", icon: "/icons/databases/influxdb.svg" },
|
||||
{ id: "dameng", name: "达梦", category: "国产库", defaultPort: 5236, color: "#1E40AF", icon: "/icons/databases/dameng.svg" },
|
||||
{ id: "oceanbase", name: "OceanBase", category: "国产库", defaultPort: 2881, color: "#EE0A24", icon: "/icons/databases/oceanbase.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "gaussdb", name: "GaussDB / openGauss", category: "国产库", defaultPort: 5432, color: "#C7000B", icon: "/icons/databases/gaussdb.svg", ready: true, hints: { host: "127.0.0.1", port: "5432", user: "gaussdb", database: "postgres" } },
|
||||
{ id: "polardb", name: "PolarDB", category: "国产库", defaultPort: 3306, color: "#FF6A00", icon: "/icons/databases/polardb.png", mysqlCompat: true, ready: true },
|
||||
{ id: "tdsql", name: "TDSQL", category: "国产库", defaultPort: 3306, color: "#0052D9", icon: "/icons/databases/tdsql.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "kingbase", name: "Kingbase", category: "国产库", defaultPort: 54321, color: "#0B5FFF", icon: "/icons/databases/kingbase.svg", ready: true, hints: { host: "127.0.0.1", port: "54321", user: "system", database: "test" } },
|
||||
];
|
||||
|
||||
export const CATEGORIES: DriverCategory[] = ["全部", "关系型", "NoSQL", "国产库", "时序/搜索"];
|
||||
|
||||
export function driverById(id: string) {
|
||||
return DRIVERS.find((d) => d.id === id);
|
||||
}
|
||||
|
||||
export function isDriverReady(id: string) {
|
||||
const d = driverById(id);
|
||||
return !!(d?.ready || d?.mysqlCompat);
|
||||
}
|
||||
|
||||
export function formKindOf(id: string): FormKind {
|
||||
return driverById(id)?.formKind ?? "standard";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
|
||||
const PROTO: Record<string, DriverId> = {
|
||||
mysql: "mysql",
|
||||
mariadb: "mariadb",
|
||||
postgres: "postgresql",
|
||||
postgresql: "postgresql",
|
||||
redis: "redis",
|
||||
sqlite: "sqlite",
|
||||
file: "sqlite",
|
||||
sqlserver: "sqlserver",
|
||||
mssql: "sqlserver",
|
||||
oracle: "oracle",
|
||||
tidb: "tidb",
|
||||
mongodb: "mongodb",
|
||||
mongo: "mongodb",
|
||||
};
|
||||
|
||||
export type ParsedDSN = {
|
||||
driver: DriverId;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
ssl?: boolean;
|
||||
};
|
||||
|
||||
/** 粘贴 mysql:// / postgres:// / redis:// / sqlite: 路径即可拆字段 */
|
||||
export function parseDSN(raw: string): ParsedDSN | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
if (/^[a-zA-Z]:[\\/]/.test(s) || s.startsWith("/") || s.endsWith(".db") || s.endsWith(".sqlite")) {
|
||||
return { driver: "sqlite", database: s };
|
||||
}
|
||||
try {
|
||||
const u = new URL(s.includes("://") ? s : `mysql://${s}`);
|
||||
const proto = u.protocol.replace(":", "").toLowerCase();
|
||||
const driver = PROTO[proto];
|
||||
if (!driver) return null;
|
||||
const database = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
|
||||
const port = u.port ? Number(u.port) : driverById(driver)?.defaultPort;
|
||||
const ssl = u.searchParams.get("sslmode") === "require" || u.searchParams.get("ssl") === "true";
|
||||
return {
|
||||
driver,
|
||||
host: u.hostname || undefined,
|
||||
port: port || undefined,
|
||||
user: decodeURIComponent(u.username || "") || undefined,
|
||||
password: decodeURIComponent(u.password || "") || undefined,
|
||||
database: database || undefined,
|
||||
ssl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { OpsxWorkspace } from "./Workspace";
|
||||
export { useWorkspaceStore } from "./store";
|
||||
export { ConnectionPicker } from "./ConnectionPicker";
|
||||
export { ConnectionList } from "./ConnectionList";
|
||||
export { AiPanel } from "./AiPanel";
|
||||
export { DRIVERS, CATEGORIES, driverById, isDriverReady } from "./catalog";
|
||||
export type { DriverId, DriverDef, DriverCategory, FormKind } from "./catalog";
|
||||
export { NotifyHost } from "./NotifyHost";
|
||||
export { formatDbError } from "./notify";
|
||||
export { DriverIcon } from "./DriverIcon";
|
||||
|
||||
export { SchemaPane } from "./SchemaPane";
|
||||
@@ -0,0 +1,86 @@
|
||||
/** Format raw DB / API errors into a brief in-app dialog payload. */
|
||||
|
||||
export type NotifyKind = "error" | "warning" | "info" | "success";
|
||||
|
||||
export type NotifyPayload = {
|
||||
id: string;
|
||||
kind: NotifyKind;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
/** auto-dismiss ms for toast mode; modal stays until closed */
|
||||
mode: "modal" | "toast";
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
type Parsed = {
|
||||
kind: NotifyKind;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
};
|
||||
|
||||
export function formatDbError(raw: unknown): Parsed {
|
||||
const text = raw instanceof Error ? raw.message : String(raw ?? "未知错误");
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
// MySQL / MariaDB Access denied
|
||||
if (/error\s*1045|access denied for user/i.test(text)) {
|
||||
const noPassword = /using password:\s*NO/i.test(text);
|
||||
return {
|
||||
kind: "error",
|
||||
title: "认证失败",
|
||||
message: noPassword ? "未填密码,服务器拒绝空密码登录。" : "用户名或密码错误,或主机无权访问。",
|
||||
detail: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (/error\s*2003|can't connect|connection refused|econnrefused/i.test(text) || lower.includes("connect")) {
|
||||
if (/refused|2003|econnrefused|timed out|timeout|unreachable/i.test(text)) {
|
||||
return {
|
||||
kind: "error",
|
||||
title: "无法连接",
|
||||
message: "主机不可达,或端口未开 / 服务未启动。",
|
||||
detail: text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (/error\s*1049|unknown database/i.test(text)) {
|
||||
return {
|
||||
kind: "error",
|
||||
title: "库不存在",
|
||||
message: "数据库名不正确;可留空连接后再选。",
|
||||
detail: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (/驱动开发中/.test(text)) {
|
||||
return {
|
||||
kind: "warning",
|
||||
title: "驱动开发中",
|
||||
message: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (/开发中/.test(text)) {
|
||||
return {
|
||||
kind: "info",
|
||||
title: "即将推出",
|
||||
message: text.replace(/:开发中$/, "功能开发中。"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "error",
|
||||
title: "操作失败",
|
||||
message: text.length > 120 ? "发生错误,详见详情。" : text,
|
||||
detail: text.length > 120 ? text : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const PREFIX = "ox1.";
|
||||
|
||||
/** 本机混淆,不是保险箱。专业客户端最终应走 OS Keychain。 */
|
||||
export function encodeSecret(plain: string): string {
|
||||
if (!plain || plain.startsWith(PREFIX)) return plain;
|
||||
try {
|
||||
return PREFIX + btoa(unescape(encodeURIComponent(plain)));
|
||||
} catch {
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeSecret(stored: string): string {
|
||||
if (!stored) return "";
|
||||
if (!stored.startsWith(PREFIX)) return stored;
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(stored.slice(PREFIX.length))));
|
||||
} catch {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeSecret(raw: string): string {
|
||||
return raw.replace(/[\u200B-\u200D\uFEFF]/g, "").trim();
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { format as sqlFormat } from "sql-formatter";
|
||||
|
||||
export function isDangerousSQL(sql: string): boolean {
|
||||
const s = stripLead(sql);
|
||||
if (!s) return false;
|
||||
const up = s.toUpperCase();
|
||||
if (/^(DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b/.test(up)) return true;
|
||||
if (/^DELETE\b/.test(up) && !/\bWHERE\b/.test(up)) return true;
|
||||
if (/^UPDATE\b/.test(up) && !/\bWHERE\b/.test(up)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripLead(sql: string): string {
|
||||
let s = sql.trim();
|
||||
while (true) {
|
||||
if (s.startsWith("--")) {
|
||||
const i = s.indexOf("\n");
|
||||
if (i < 0) return "";
|
||||
s = s.slice(i + 1).trim();
|
||||
continue;
|
||||
}
|
||||
if (s.startsWith("/*")) {
|
||||
const i = s.indexOf("*/");
|
||||
if (i < 0) return "";
|
||||
s = s.slice(i + 2).trim();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 选区优先;否则取光标所在语句(分号分隔,忽略引号内)。 */
|
||||
export function statementAt(sql: string, cursor: number, selection?: string): string {
|
||||
const sel = selection?.trim();
|
||||
if (sel) return sel;
|
||||
const ranges = splitStatements(sql);
|
||||
const pos = Math.max(0, Math.min(cursor, sql.length));
|
||||
const hit = ranges.find((r) => pos >= r.start && pos <= r.end) ?? ranges.find((r) => pos < r.start);
|
||||
return (hit?.text || sql).trim();
|
||||
}
|
||||
|
||||
export function splitStatements(sql: string): { start: number; end: number; text: string }[] {
|
||||
const out: { start: number; end: number; text: string }[] = [];
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
let inS = false;
|
||||
let inD = false;
|
||||
let inLine = false;
|
||||
let inBlock = false;
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i];
|
||||
const nxt = sql[i + 1];
|
||||
if (inLine) {
|
||||
if (ch === "\n") inLine = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inBlock) {
|
||||
if (ch === "*" && nxt === "/") {
|
||||
inBlock = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!inS && !inD && ch === "-" && nxt === "-") {
|
||||
inLine = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (!inS && !inD && ch === "/" && nxt === "*") {
|
||||
inBlock = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" && !inD) {
|
||||
inS = !inS;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' && !inS) {
|
||||
inD = !inD;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!inS && !inD && ch === ";") {
|
||||
const text = sql.slice(start, i);
|
||||
if (text.trim()) out.push({ start, end: i, text });
|
||||
start = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const tail = sql.slice(start);
|
||||
if (tail.trim()) out.push({ start, end: sql.length, text: tail });
|
||||
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
|
||||
}
|
||||
|
||||
export function sampleSelectSql(driver: string | undefined, db: string, table: string) {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "redis") return `GET ${table}`;
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
const ident = table.includes(".") ? table : `"${table}"`;
|
||||
return `SELECT * FROM ${ident} LIMIT 100;`;
|
||||
}
|
||||
if (d === "sqlserver" || d === "mssql") {
|
||||
return `SELECT TOP 100 * FROM ${table};`;
|
||||
}
|
||||
if (d === "oracle") {
|
||||
return `SELECT * FROM ${table} FETCH FIRST 100 ROWS ONLY`;
|
||||
}
|
||||
if (d === "sqlite") {
|
||||
return `SELECT * FROM "${table}" LIMIT 100;`;
|
||||
}
|
||||
const tbl = table.includes(".") ? table : `\`${table}\``;
|
||||
if (db) return `SELECT * FROM \`${db}\`.${tbl} LIMIT 100;`;
|
||||
return `SELECT * FROM ${tbl} LIMIT 100;`;
|
||||
}
|
||||
|
||||
export function rowsToTSV(columns: string[], rows: Record<string, unknown>[], headers = true): string {
|
||||
const esc = (v: unknown) => {
|
||||
if (v == null) return "";
|
||||
return String(v).replace(/\t/g, " ").replace(/\r?\n/g, " ");
|
||||
};
|
||||
const lines = rows.map((r) => columns.map((c) => esc(r[c])).join("\t"));
|
||||
if (headers) lines.unshift(columns.join("\t"));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function rowsToCSV(columns: string[], rows: Record<string, unknown>[]): string {
|
||||
const esc = (v: unknown) => {
|
||||
if (v == null) return "";
|
||||
const s = String(v);
|
||||
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
};
|
||||
return [columns.map(esc).join(","), ...rows.map((r) => columns.map((c) => esc(r[c])).join(","))].join("\n");
|
||||
}
|
||||
|
||||
export function rowsToInsert(table: string, columns: string[], rows: Record<string, unknown>[]): string {
|
||||
const ident = table || "t";
|
||||
const cols = columns.map((c) => `\`${c}\``).join(", ");
|
||||
const lit = (v: unknown) => {
|
||||
if (v == null) return "NULL";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
};
|
||||
return rows
|
||||
.map((r) => `INSERT INTO ${ident} (${cols}) VALUES (${columns.map((c) => lit(r[c])).join(", ")});`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
|
||||
export function isWritableDriver(driver: string | undefined): boolean {
|
||||
const d = (driver || "").toLowerCase();
|
||||
return [
|
||||
"mysql", "mariadb", "tidb", "postgresql", "postgres", "sqlite",
|
||||
"sqlserver", "mssql", "oracle", "oceanbase", "polardb", "tdsql",
|
||||
"gaussdb", "kingbase", "opengauss",
|
||||
].includes(d);
|
||||
}
|
||||
|
||||
export function quoteIdent(driver: string | undefined, name: string): string {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss" || d === "sqlite") {
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
if (d === "sqlserver" || d === "mssql") return `[${name.replace(/]/g, "]]")}]`;
|
||||
if (d === "oracle") return name; // often unquoted uppercase
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
|
||||
export function sqlLiteral(v: unknown): string {
|
||||
if (v == null || v === "") return "NULL";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Build UPDATE … SET col=? WHERE pk… from edited cell. */
|
||||
export function buildUpdateSql(
|
||||
driver: string | undefined,
|
||||
database: string | undefined,
|
||||
table: string,
|
||||
pkCols: string[],
|
||||
row: Record<string, unknown>,
|
||||
col: string,
|
||||
newVal: unknown,
|
||||
): string | null {
|
||||
if (!pkCols.length) return null;
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
const t = table.includes(".")
|
||||
? table
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase"].includes(d)
|
||||
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
|
||||
: quoteIdent(driver, table);
|
||||
const set = `${quoteIdent(driver, col)} = ${sqlLiteral(newVal === "" ? null : newVal)}`;
|
||||
const where = pkCols
|
||||
.map((k) => {
|
||||
const v = row[k];
|
||||
if (v == null) return `${quoteIdent(driver, k)} IS NULL`;
|
||||
return `${quoteIdent(driver, k)} = ${sqlLiteral(v)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
return `UPDATE ${t} SET ${set} WHERE ${where}`;
|
||||
}
|
||||
|
||||
|
||||
export function supportsExplain(driver: string | undefined): boolean {
|
||||
const d = (driver || "").toLowerCase();
|
||||
return [
|
||||
"mysql", "mariadb", "tidb", "postgresql", "postgres", "sqlite",
|
||||
"sqlserver", "mssql", "oracle", "oceanbase", "polardb", "tdsql",
|
||||
"gaussdb", "kingbase", "opengauss",
|
||||
].includes(d);
|
||||
}
|
||||
|
||||
/** Wrap a single SELECT (etc.) with EXPLAIN for the driver. */
|
||||
export function buildExplainSql(driver: string | undefined, sql: string): string | null {
|
||||
if (!supportsExplain(driver)) return null;
|
||||
const s = sql.trim().replace(/;+\s*$/, "");
|
||||
if (!s) return null;
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "sqlserver" || d === "mssql") {
|
||||
return `-- SQL Server: approximate plan via SET STATISTICS\nSET STATISTICS PROFILE ON;\n${s};\nSET STATISTICS PROFILE OFF;`;
|
||||
}
|
||||
if (d === "oracle") return `EXPLAIN PLAN FOR ${s}`;
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
return `EXPLAIN (FORMAT TEXT) ${s}`;
|
||||
}
|
||||
if (d === "sqlite") return `EXPLAIN QUERY PLAN ${s}`;
|
||||
return `EXPLAIN ${s}`;
|
||||
}
|
||||
|
||||
/** Map OpsxDB driver id → sql-formatter language. */
|
||||
export function sqlFormatterLanguage(driver?: string): string {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
return "postgresql";
|
||||
}
|
||||
if (d === "sqlite") return "sqlite";
|
||||
if (d === "sqlserver" || d === "mssql") return "tsql";
|
||||
if (d === "oracle") return "plsql";
|
||||
if (d === "mariadb") return "mariadb";
|
||||
// mysql / tidb / oceanbase / polardb / tdsql / redis fallback
|
||||
return "mysql";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print SQL via sql-formatter.
|
||||
* On formatter parse errors, falls back to a minimal indent printer.
|
||||
*/
|
||||
export function formatSqlText(sql: string, driver?: string): string {
|
||||
const input = sql.replace(/\s+$/, "");
|
||||
if (!input.trim()) return sql;
|
||||
try {
|
||||
return sqlFormat(input, {
|
||||
// dialect heuristic — cast: sql-formatter language union is wide
|
||||
language: sqlFormatterLanguage(driver) as "mysql",
|
||||
tabWidth: 2,
|
||||
keywordCase: "upper",
|
||||
linesBetweenQueries: 1,
|
||||
});
|
||||
} catch {
|
||||
return minimalPrettySql(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal pretty-printer when sql-formatter is not installed yet. */
|
||||
function minimalPrettySql(sql: string): string {
|
||||
const s = sql.trim();
|
||||
// Insert newline before major keywords (best-effort, ignores strings — fine as fallback)
|
||||
return s
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(
|
||||
/\b(SELECT|FROM|WHERE|AND|OR|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|GROUP BY|ORDER BY|LIMIT|OFFSET|HAVING|UNION|INSERT INTO|VALUES|UPDATE|SET|DELETE FROM)\b/gi,
|
||||
(m) => `\n${m.toUpperCase()}`,
|
||||
)
|
||||
.replace(/^\n/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Build DELETE FROM … WHERE pk… for a row. */
|
||||
export function buildDeleteSql(
|
||||
driver: string | undefined,
|
||||
database: string | undefined,
|
||||
table: string,
|
||||
pkCols: string[],
|
||||
row: Record<string, unknown>,
|
||||
): string | null {
|
||||
if (!pkCols.length) return null;
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
const t = table.includes(".")
|
||||
? table
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase"].includes(d)
|
||||
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
|
||||
: quoteIdent(driver, table);
|
||||
const where = pkCols
|
||||
.map((k) => {
|
||||
const v = row[k];
|
||||
if (v == null) return `${quoteIdent(driver, k)} IS NULL`;
|
||||
return `${quoteIdent(driver, k)} = ${sqlLiteral(v)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
return `DELETE FROM ${t} WHERE ${where}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse clipboard TSV/CSV into a grid.
|
||||
* - Split lines on newlines (handles CRLF); cells on tabs.
|
||||
* - If a line has no tabs but looks like simple CSV (has commas), split on commas.
|
||||
* - Empty cell → ""; literal "NULL" (any case) → null.
|
||||
*/
|
||||
export function parseClipboardGrid(text: string): (string | null)[][] {
|
||||
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
// Drop a single trailing newline so Excel paste does not invent an empty row
|
||||
const trimmed = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
||||
if (!trimmed) return [];
|
||||
const lines = trimmed.split("\n");
|
||||
return lines.map((line) => {
|
||||
let cells: string[];
|
||||
if (line.includes("\t")) {
|
||||
cells = line.split("\t");
|
||||
} else if (line.includes(",") && looksLikeSimpleCsv(line)) {
|
||||
cells = splitSimpleCsv(line);
|
||||
} else {
|
||||
cells = [line];
|
||||
}
|
||||
return cells.map((c) => {
|
||||
if (c === "") return "";
|
||||
if (/^null$/i.test(c)) return null;
|
||||
return c;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeSimpleCsv(line: string): boolean {
|
||||
// Prefer CSV when commas separate simple unquoted or simply-quoted fields
|
||||
if (!line.includes(",")) return false;
|
||||
// Reject if it looks like a single free-text phrase with incidental commas and spaces only mid-word — still allow
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Minimal CSV split: handles commas and ""-quoted fields; no escaped newlines. */
|
||||
function splitSimpleCsv(line: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
let inQ = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQ) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
cur += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQ = false;
|
||||
}
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQ = true;
|
||||
} else if (ch === ",") {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
import { create } from "zustand";
|
||||
import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
|
||||
import { decodeSecret, encodeSecret } from "./secrets";
|
||||
|
||||
export type SavedConnection = {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: DriverId;
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
lastUsedAt?: number;
|
||||
/** 侧栏分组 id,缺省 default */
|
||||
groupId?: string;
|
||||
/** 组内排序,越小越靠前 */
|
||||
sortOrder?: number;
|
||||
ssl?: boolean;
|
||||
/** disable|require|skip-verify|verify-ca;关 SSL 时视为 disable */
|
||||
sslMode?: "disable" | "require" | "skip-verify" | "verify-ca";
|
||||
sslCA?: string;
|
||||
sslCert?: string;
|
||||
sslKey?: string;
|
||||
service?: string;
|
||||
color?: string;
|
||||
/** 连库页默认连接 */
|
||||
isDefault?: boolean;
|
||||
/** 是否持久化密码,默认 true */
|
||||
savePassword?: boolean;
|
||||
/** Redis key 分隔符,默认 : */
|
||||
keySeparator?: string;
|
||||
redisMode?: "standalone" | "sentinel" | "cluster";
|
||||
/** Oracle */
|
||||
oracleConnType?: "basic";
|
||||
oracleIdKind?: "service" | "sid";
|
||||
oracleRole?: string;
|
||||
/** SQL Server 验证方式 */
|
||||
authMode?: "sql" | "windows";
|
||||
/** SSH 隧道 */
|
||||
sshEnabled?: boolean;
|
||||
sshHost?: string;
|
||||
sshPort?: number;
|
||||
sshUser?: string;
|
||||
sshAuth?: "password" | "privateKey";
|
||||
sshPassword?: string;
|
||||
sshPrivateKey?: string;
|
||||
sshPassphrase?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_CONN_GROUP = "default";
|
||||
|
||||
export type ConnectionGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type QueryTab = {
|
||||
id: string;
|
||||
title: string;
|
||||
sql: string;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
/** 相对打开/保存基线有改动 */
|
||||
dirty?: boolean;
|
||||
savedSql?: string;
|
||||
};
|
||||
|
||||
export type QueryResult = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
timeMs: number;
|
||||
error?: string;
|
||||
truncated?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type SchemaView = {
|
||||
database: string;
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ResultMeta = {
|
||||
database?: string;
|
||||
table?: string;
|
||||
pkCols?: string[];
|
||||
};
|
||||
|
||||
export type ConnState = "ok" | "down" | "reconnecting";
|
||||
|
||||
export type ResultSetItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
sql: string;
|
||||
result: QueryResult;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
key?: string;
|
||||
default?: unknown;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key";
|
||||
/** group id: tables|views|procedures|functions|keys */
|
||||
groupId?: string;
|
||||
count?: number;
|
||||
children?: TreeNode[];
|
||||
loaded?: boolean;
|
||||
loading?: boolean;
|
||||
type?: string;
|
||||
/** Redis TTL seconds; -1 permanent; -2 missing */
|
||||
ttl?: number;
|
||||
nullable?: boolean;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type QueryHistoryItem = {
|
||||
id: string;
|
||||
sql: string;
|
||||
at: number;
|
||||
connectionId?: string;
|
||||
rowCount?: number;
|
||||
timeMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ConfirmRequest = {
|
||||
title: string;
|
||||
message: string;
|
||||
okLabel?: string;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
export type AiMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type ViewMode = "workbench" | "picker";
|
||||
export type PickerMode = "list" | "drivers";
|
||||
|
||||
type State = {
|
||||
theme: "dark" | "light";
|
||||
view: ViewMode;
|
||||
connections: SavedConnection[];
|
||||
connGroups: ConnectionGroup[];
|
||||
expandedConnGroups: string[];
|
||||
activeConnectionId: string | null;
|
||||
tree: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
tabs: QueryTab[];
|
||||
activeTabId: string;
|
||||
result: QueryResult | null;
|
||||
resultSets: ResultSetItem[];
|
||||
activeResultSetId: string | null;
|
||||
resultMeta: ResultMeta | null;
|
||||
schemaView: SchemaView | null;
|
||||
resultTab: "data" | "schema" | "explain";
|
||||
explainText: string | null;
|
||||
bookmarks: Bookmark[];
|
||||
showBookmarks: boolean;
|
||||
connState: ConnState;
|
||||
running: boolean;
|
||||
pickerSearch: string;
|
||||
pickerCategory: string;
|
||||
pickerDriver: DriverId | null;
|
||||
pickerMode: PickerMode;
|
||||
form: Partial<SavedConnection>;
|
||||
aiMessages: AiMessage[];
|
||||
aiAsk: string;
|
||||
aiModel: string;
|
||||
aiBusy: boolean;
|
||||
status: string;
|
||||
notifyModal: NotifyPayload | null;
|
||||
notifyToasts: NotifyPayload[];
|
||||
sessions: Record<string, string>;
|
||||
queryHistory: QueryHistoryItem[];
|
||||
leftWidth: number;
|
||||
aiWidth: number;
|
||||
resultRatio: number;
|
||||
showAi: boolean;
|
||||
showHelp: boolean;
|
||||
showHistory: boolean;
|
||||
confirm: ConfirmRequest | null;
|
||||
|
||||
set: <K extends keyof State>(key: K, value: State[K]) => void;
|
||||
notify: (input: {
|
||||
kind?: NotifyKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
mode?: "modal" | "toast";
|
||||
durationMs?: number;
|
||||
raw?: unknown;
|
||||
}) => void;
|
||||
notifyError: (raw: unknown, mode?: "modal" | "toast") => void;
|
||||
dismissNotify: (id: string) => void;
|
||||
loadConnections: () => void;
|
||||
saveConnections: (list: SavedConnection[]) => void;
|
||||
upsertConnection: (c: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
setDefaultConnection: (id: string) => void;
|
||||
addConnGroup: (name: string) => string;
|
||||
renameConnGroup: (id: string, name: string) => void;
|
||||
removeConnGroup: (id: string) => void;
|
||||
moveConnection: (id: string, groupId: string, index: number) => void;
|
||||
toggleConnGroup: (id: string) => void;
|
||||
openPicker: (driver?: DriverId | null) => void;
|
||||
openConnectionList: () => void;
|
||||
editConnection: (c: SavedConnection) => void;
|
||||
newQueryTab: () => void;
|
||||
closeTab: (id: string) => void;
|
||||
updateActiveSql: (sql: string) => void;
|
||||
pushAi: (role: AiMessage["role"], content: string) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||
clearHistory: () => void;
|
||||
setResultSets: (items: ResultSetItem[]) => void;
|
||||
selectResultSet: (id: string) => void;
|
||||
loadBookmarks: () => void;
|
||||
saveBookmark: (name: string, sql: string) => void;
|
||||
renameBookmark: (id: string, name: string) => void;
|
||||
removeBookmark: (id: string) => void;
|
||||
askConfirm: (req: ConfirmRequest) => Promise<boolean>;
|
||||
resolveConfirm: (ok: boolean) => void;
|
||||
persistPrefs: () => void;
|
||||
};
|
||||
|
||||
const LS_KEY = "opsxdb.connections.v1";
|
||||
const LS_GROUPS = "opsxdb.conn-groups.v1";
|
||||
const LS_EXPANDED = "opsxdb.conn-groups-expanded.v1";
|
||||
const LS_PREFS = "opsxdb.prefs.v1";
|
||||
const LS_TABS = "opsxdb.tabs.v1";
|
||||
const LS_HISTORY = "opsxdb.history.v1";
|
||||
const LS_BOOKMARKS = "opsxdb.bookmarks.v1";
|
||||
|
||||
let confirmResolver: ((ok: boolean) => void) | null = null;
|
||||
|
||||
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi">> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_PREFS);
|
||||
if (!raw) return {};
|
||||
const p = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
theme: p.theme === "light" ? "light" : "dark",
|
||||
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
|
||||
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
|
||||
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
|
||||
showAi: typeof p.showAi === "boolean" ? p.showAi : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadTabs(): { tabs: QueryTab[]; activeTabId: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_TABS);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as { tabs?: QueryTab[]; activeTabId?: string };
|
||||
if (!Array.isArray(p.tabs) || !p.tabs.length) return null;
|
||||
const tabs = p.tabs.map((tab) => {
|
||||
const savedSql = tab.savedSql ?? tab.sql ?? "";
|
||||
const sql = tab.sql ?? "";
|
||||
return { ...tab, sql, savedSql, dirty: sql !== savedSql };
|
||||
});
|
||||
return { tabs, activeTabId: p.activeTabId || tabs[0].id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistory(): QueryHistoryItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_HISTORY);
|
||||
if (!raw) return [];
|
||||
const p = JSON.parse(raw);
|
||||
return Array.isArray(p) ? p.slice(0, 50) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadGroups(): ConnectionGroup[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_GROUPS);
|
||||
if (!raw) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || !parsed.length) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
const list = parsed.filter((g: ConnectionGroup) => g && g.id && g.name);
|
||||
if (!list.some((g: ConnectionGroup) => g.id === DEFAULT_CONN_GROUP)) {
|
||||
list.unshift({ id: DEFAULT_CONN_GROUP, name: "默认" });
|
||||
}
|
||||
return list;
|
||||
} catch {
|
||||
return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
}
|
||||
}
|
||||
|
||||
function saveGroups(list: ConnectionGroup[]) {
|
||||
localStorage.setItem(LS_GROUPS, JSON.stringify(list));
|
||||
}
|
||||
|
||||
function loadExpanded(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_EXPANDED);
|
||||
if (!raw) return [DEFAULT_CONN_GROUP];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [DEFAULT_CONN_GROUP];
|
||||
} catch {
|
||||
return [DEFAULT_CONN_GROUP];
|
||||
}
|
||||
}
|
||||
|
||||
function loadLs(): SavedConnection[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTab(): QueryTab {
|
||||
const sql = "SELECT 1 AS ok;";
|
||||
return { id: crypto.randomUUID(), title: "查询 1", sql, savedSql: sql, dirty: false };
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
theme: "dark",
|
||||
view: "workbench",
|
||||
connections: [],
|
||||
connGroups: [{ id: DEFAULT_CONN_GROUP, name: "默认" }],
|
||||
expandedConnGroups: [DEFAULT_CONN_GROUP],
|
||||
activeConnectionId: null,
|
||||
tree: [],
|
||||
treeLoading: false,
|
||||
tabs: [defaultTab()],
|
||||
activeTabId: "",
|
||||
result: null,
|
||||
resultSets: [],
|
||||
activeResultSetId: null,
|
||||
resultMeta: null,
|
||||
schemaView: null,
|
||||
resultTab: "data",
|
||||
explainText: null,
|
||||
bookmarks: [],
|
||||
showBookmarks: false,
|
||||
connState: "ok",
|
||||
running: false,
|
||||
pickerSearch: "",
|
||||
pickerCategory: "全部",
|
||||
pickerDriver: null,
|
||||
pickerMode: "list",
|
||||
form: {},
|
||||
aiMessages: [],
|
||||
aiAsk: "",
|
||||
aiModel: "opsxdb-stub",
|
||||
aiBusy: false,
|
||||
status: "就绪",
|
||||
notifyModal: null,
|
||||
notifyToasts: [],
|
||||
sessions: {},
|
||||
queryHistory: [],
|
||||
leftWidth: 260,
|
||||
aiWidth: 280,
|
||||
resultRatio: 0.38,
|
||||
showAi: true,
|
||||
showHelp: false,
|
||||
showHistory: false,
|
||||
confirm: null,
|
||||
|
||||
set: (key, value) => {
|
||||
set({ [key]: value } as Partial<State>);
|
||||
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi") {
|
||||
get().persistPrefs();
|
||||
}
|
||||
if (key === "tabs" || key === "activeTabId") {
|
||||
const s = get();
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: s.tabs, activeTabId: s.activeTabId }));
|
||||
}
|
||||
},
|
||||
|
||||
notify: (input) => {
|
||||
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
|
||||
const kind = input.kind ?? parsed?.kind ?? "info";
|
||||
const mode = input.mode ?? (kind === "error" ? "modal" : "toast");
|
||||
const payload: NotifyPayload = {
|
||||
id: notifyId(),
|
||||
kind,
|
||||
title: input.title ?? parsed?.title ?? (kind === "error" ? "错误" : "提示"),
|
||||
message: input.message || parsed?.message || "",
|
||||
detail: input.detail ?? parsed?.detail,
|
||||
tips: input.tips ?? parsed?.tips,
|
||||
mode,
|
||||
durationMs: input.durationMs,
|
||||
};
|
||||
if (mode === "modal") {
|
||||
set({ notifyModal: payload });
|
||||
} else {
|
||||
set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
|
||||
}
|
||||
},
|
||||
|
||||
notifyError: (raw, mode = "modal") => {
|
||||
const parsed = formatDbError(raw);
|
||||
const payload: NotifyPayload = {
|
||||
id: notifyId(),
|
||||
kind: parsed.kind,
|
||||
title: parsed.title,
|
||||
message: parsed.message,
|
||||
detail: parsed.detail,
|
||||
tips: parsed.tips,
|
||||
mode,
|
||||
};
|
||||
if (mode === "modal") set({ notifyModal: payload });
|
||||
else set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
|
||||
},
|
||||
|
||||
dismissNotify: (id) => {
|
||||
const s = get();
|
||||
if (s.notifyModal?.id === id) set({ notifyModal: null });
|
||||
set({ notifyToasts: get().notifyToasts.filter((t) => t.id !== id) });
|
||||
},
|
||||
|
||||
loadConnections: () => {
|
||||
const list = loadLs().map((c) => ({
|
||||
...c,
|
||||
groupId: c.groupId || DEFAULT_CONN_GROUP,
|
||||
sortOrder: c.sortOrder ?? 0,
|
||||
}));
|
||||
const prefs = loadPrefs();
|
||||
const savedTabs = loadTabs();
|
||||
const tabs = savedTabs?.tabs ?? get().tabs;
|
||||
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
|
||||
set({
|
||||
connections: list,
|
||||
connGroups: loadGroups(),
|
||||
expandedConnGroups: loadExpanded(),
|
||||
tabs,
|
||||
activeTabId,
|
||||
queryHistory: loadHistory(),
|
||||
...prefs,
|
||||
});
|
||||
},
|
||||
|
||||
saveConnections: (list) => {
|
||||
const encoded = list.map((c) => ({
|
||||
...c,
|
||||
password: encodeSecret(c.password || ""),
|
||||
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
|
||||
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
|
||||
}));
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
|
||||
set({ connections: encoded });
|
||||
},
|
||||
|
||||
upsertConnection: (c) => {
|
||||
const next = {
|
||||
...c,
|
||||
groupId: c.groupId || DEFAULT_CONN_GROUP,
|
||||
sortOrder: c.sortOrder ?? Date.now(),
|
||||
password: c.savePassword === false ? "" : c.password,
|
||||
sshPassword: c.savePassword === false ? "" : c.sshPassword,
|
||||
};
|
||||
let list = [...get().connections];
|
||||
if (next.isDefault) {
|
||||
list = list.map((x) => ({ ...x, isDefault: x.id === next.id ? true : false }));
|
||||
}
|
||||
const i = list.findIndex((x) => x.id === next.id);
|
||||
if (i >= 0) list[i] = { ...list[i], ...next };
|
||||
else list.unshift(next);
|
||||
get().saveConnections(list);
|
||||
},
|
||||
|
||||
removeConnection: (id) => {
|
||||
get().saveConnections(get().connections.filter((c) => c.id !== id));
|
||||
const sessions = { ...get().sessions };
|
||||
delete sessions[id];
|
||||
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
|
||||
else set({ sessions });
|
||||
},
|
||||
|
||||
setDefaultConnection: (id) => {
|
||||
const list = get().connections.map((c) => ({ ...c, isDefault: c.id === id }));
|
||||
get().saveConnections(list);
|
||||
},
|
||||
|
||||
|
||||
addConnGroup: (name) => {
|
||||
const n = name.trim() || "新分组";
|
||||
const id = crypto.randomUUID();
|
||||
const list = [...get().connGroups, { id, name: n }];
|
||||
saveGroups(list);
|
||||
const expanded = Array.from(new Set([...get().expandedConnGroups, id]));
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ connGroups: list, expandedConnGroups: expanded });
|
||||
return id;
|
||||
},
|
||||
|
||||
renameConnGroup: (id, name) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = get().connGroups.map((g) => (g.id === id ? { ...g, name: n } : g));
|
||||
saveGroups(list);
|
||||
set({ connGroups: list });
|
||||
},
|
||||
|
||||
removeConnGroup: (id) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const list = get().connGroups.filter((g) => g.id !== id);
|
||||
saveGroups(list);
|
||||
const connections = get().connections.map((c) =>
|
||||
(c.groupId || DEFAULT_CONN_GROUP) === id ? { ...c, groupId: DEFAULT_CONN_GROUP } : c,
|
||||
);
|
||||
get().saveConnections(connections);
|
||||
const expanded = get().expandedConnGroups.filter((x) => x !== id);
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ connGroups: list, expandedConnGroups: expanded });
|
||||
},
|
||||
|
||||
moveConnection: (id, groupId, index) => {
|
||||
const gid = groupId || DEFAULT_CONN_GROUP;
|
||||
const all = [...get().connections];
|
||||
const moving = all.find((c) => c.id === id);
|
||||
if (!moving) return;
|
||||
const othersInGroup = all
|
||||
.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) === gid)
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const clamped = Math.max(0, Math.min(index, othersInGroup.length));
|
||||
othersInGroup.splice(clamped, 0, { ...moving, groupId: gid });
|
||||
othersInGroup.forEach((c, i) => {
|
||||
c.sortOrder = i;
|
||||
});
|
||||
const rest = all.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) !== gid);
|
||||
get().saveConnections([...rest, ...othersInGroup]);
|
||||
if (!get().expandedConnGroups.includes(gid)) {
|
||||
const expanded = [...get().expandedConnGroups, gid];
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ expandedConnGroups: expanded });
|
||||
}
|
||||
},
|
||||
|
||||
toggleConnGroup: (id) => {
|
||||
const cur = get().expandedConnGroups;
|
||||
const expanded = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ expandedConnGroups: expanded });
|
||||
},
|
||||
|
||||
openPicker: (driver = null) => {
|
||||
const d = driver ? driverById(driver) : null;
|
||||
set({
|
||||
view: "picker",
|
||||
pickerMode: d ? "drivers" : "drivers",
|
||||
pickerDriver: driver,
|
||||
form: d
|
||||
? {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${d.name} 连接`,
|
||||
driver: d.id,
|
||||
host: d.hints?.host ?? "127.0.0.1",
|
||||
port: d.defaultPort,
|
||||
user: d.hints?.user ?? (d.id === "redis" ? "" : "root"),
|
||||
password: "",
|
||||
database: d.hints?.database ?? "",
|
||||
ssl: false,
|
||||
sslMode: "disable",
|
||||
sslCA: "",
|
||||
sslCert: "",
|
||||
sslKey: "",
|
||||
service: d.id === "oracle" ? (d.hints?.database ?? "ORCL") : "",
|
||||
savePassword: true,
|
||||
keySeparator: d.id === "redis" ? ":" : undefined,
|
||||
redisMode: d.id === "redis" ? "standalone" : undefined,
|
||||
oracleConnType: d.id === "oracle" ? "basic" : undefined,
|
||||
oracleIdKind: d.id === "oracle" ? "service" : undefined,
|
||||
oracleRole: d.id === "oracle" ? "default" : undefined,
|
||||
authMode: d.id === "sqlserver" ? "sql" : undefined,
|
||||
sshEnabled: false,
|
||||
sshPort: 22,
|
||||
sshAuth: "password",
|
||||
}
|
||||
: {},
|
||||
});
|
||||
},
|
||||
|
||||
openConnectionList: () => {
|
||||
set({ view: "picker", pickerMode: "list", pickerDriver: null, form: {} });
|
||||
},
|
||||
|
||||
editConnection: (c) => {
|
||||
set({
|
||||
view: "picker",
|
||||
pickerMode: "drivers",
|
||||
pickerDriver: c.driver,
|
||||
form: {
|
||||
...c,
|
||||
password: decodeSecret(c.password),
|
||||
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
|
||||
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
newQueryTab: () => {
|
||||
const n = get().tabs.length + 1;
|
||||
const tab: QueryTab = {
|
||||
id: crypto.randomUUID(),
|
||||
title: `查询 ${n}`,
|
||||
sql: "",
|
||||
savedSql: "",
|
||||
dirty: false,
|
||||
connectionId: get().activeConnectionId ?? undefined,
|
||||
};
|
||||
const tabs = [...get().tabs, tab];
|
||||
set({ tabs, activeTabId: tab.id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id !== id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
const activeTabId = get().activeTabId === id ? next[0].id : get().activeTabId;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
updateActiveSql: (sql) => {
|
||||
const id = get().activeTabId;
|
||||
const tabs = get().tabs.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
const saved = t.savedSql ?? "";
|
||||
return { ...t, sql, dirty: sql !== saved };
|
||||
});
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
|
||||
},
|
||||
|
||||
pushAi: (role, content) =>
|
||||
set((s) => ({
|
||||
aiMessages: [
|
||||
...s.aiMessages,
|
||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() },
|
||||
],
|
||||
})),
|
||||
|
||||
setSession: (connectionId, sessionId) => {
|
||||
const sessions = { ...get().sessions };
|
||||
if (sessionId) sessions[connectionId] = sessionId;
|
||||
else delete sessions[connectionId];
|
||||
set({ sessions });
|
||||
},
|
||||
|
||||
pushHistory: (item) => {
|
||||
const next: QueryHistoryItem[] = [
|
||||
{ id: crypto.randomUUID(), at: item.at ?? Date.now(), sql: item.sql, connectionId: item.connectionId, rowCount: item.rowCount, timeMs: item.timeMs, error: item.error },
|
||||
...get().queryHistory,
|
||||
].slice(0, 50);
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify(next));
|
||||
set({ queryHistory: next });
|
||||
},
|
||||
|
||||
clearHistory: () => {
|
||||
set({ queryHistory: [] });
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify([]));
|
||||
},
|
||||
|
||||
|
||||
setResultSets: (items) => {
|
||||
const active = items[0]?.id ?? null;
|
||||
set({
|
||||
resultSets: items,
|
||||
activeResultSetId: active,
|
||||
result: items[0]?.result ?? null,
|
||||
resultTab: "data",
|
||||
});
|
||||
},
|
||||
|
||||
selectResultSet: (id) => {
|
||||
const item = get().resultSets.find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
set({ activeResultSetId: id, result: item.result, resultTab: "data" });
|
||||
},
|
||||
|
||||
loadBookmarks: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_BOOKMARKS);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) set({ bookmarks: parsed });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
saveBookmark: (name, sql) => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = [{ id: crypto.randomUUID(), name: n, sql, at: Date.now() }, ...get().bookmarks].slice(0, 100);
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
renameBookmark: (id, name) => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = get().bookmarks.map((b) => (b.id === id ? { ...b, name: n } : b));
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
removeBookmark: (id) => {
|
||||
const list = get().bookmarks.filter((b) => b.id !== id);
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
askConfirm: (req) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
confirmResolver?.(false);
|
||||
confirmResolver = resolve;
|
||||
set({ confirm: req });
|
||||
}),
|
||||
|
||||
resolveConfirm: (ok) => {
|
||||
const r = confirmResolver;
|
||||
confirmResolver = null;
|
||||
set({ confirm: null });
|
||||
r?.(ok);
|
||||
},
|
||||
|
||||
persistPrefs: () => {
|
||||
const s = get();
|
||||
localStorage.setItem(
|
||||
LS_PREFS,
|
||||
JSON.stringify({
|
||||
theme: s.theme,
|
||||
leftWidth: s.leftWidth,
|
||||
aiWidth: s.aiWidth,
|
||||
resultRatio: s.resultRatio,
|
||||
showAi: s.showAi,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// init bookmarks
|
||||
try {
|
||||
const _bm = localStorage.getItem(LS_BOOKMARKS);
|
||||
if (_bm) {
|
||||
const parsed = JSON.parse(_bm);
|
||||
if (Array.isArray(parsed)) useWorkspaceStore.setState({ bookmarks: parsed });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// init activeTabId after create
|
||||
const boot = useWorkspaceStore.getState();
|
||||
if (!boot.activeTabId && boot.tabs[0]) {
|
||||
useWorkspaceStore.setState({ activeTabId: boot.tabs[0].id });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user