feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s

驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

驱动管理:
- 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口
- opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh)
- 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退

UI/交互:
- 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell)
- 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角)
- 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行)
- 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
This commit is contained in:
logan committed 2026-09-22 01:07:46 +08:00
1 parent 26f459d1b8
commit a9510235bb
156 files changed
+17200 -2520

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
{
"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"
}
}
-10
View File
@@ -1,10 +0,0 @@
export type {
HttpMethod,
HttpSpec,
WsSpec,
HttpResult,
WsSession,
Transport,
} from "./types";
export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
@@ -1,29 +0,0 @@
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");
});
});
-35
View File
@@ -1,35 +0,0 @@
/**
* 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;
}
-39
View File
@@ -1,39 +0,0 @@
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;
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
-25
View File
@@ -1,25 +0,0 @@
{
"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
}
}
}
-81
View File
@@ -1,81 +0,0 @@
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();
},
};
},
};
-41
View File
@@ -1,41 +0,0 @@
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) };
}
-35
View File
@@ -1,35 +0,0 @@
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);
},
};
}
-5
View File
@@ -1,5 +0,0 @@
export { browserTransport } from "./browser";
export { ginProxyTransport } from "./ginProxy";
export { tauriNativeTransport } from "./tauriNative";
export { detectTransport } from "./detect";
export type { TransportKind, DetectOptions } from "./detect";
-68
View File
@@ -1,68 +0,0 @@
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");
});
},
};
},
};
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": [
"src"
]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#ea4646" d="M295.526 392.14c19.456-16.486 40.704-33.177 62.67-47.615-16.692 55.04-19.866 173.568 106.905 193.536 95.232 15.001 119.296 45.21 108.083 195.942-19.558 9.216-39.014 16.896-58.88 24.064 12.544-62.464 26.47-174.592-64.307-192.819-110.541-22.272-167.936-36.864-154.47-173.107"/><path fill="#ea4646" d="M373.248 334.643a889 889 0 0 1 58.368-33.536c-21.504 100.864-14.336 188.416 103.782 206.848 69.99 10.957 124.263 36.25 110.234 186.368a714 714 0 0 1-54.374 31.027c10.393-59.904 10.65-180.838-72.192-193.587-124.57-19.2-162.253-53.248-145.818-197.12"/><path fill="#ea4646" d="M449.075 292.71c19.61-8.755 47.053-20.48 57.856-23.552-21.35 75.93-10.496 195.994 86.835 211.968 90.983 14.9 132.813 65.69 123.188 162.356-18.484 14.694-35.482 27.238-54.426 39.936 16.128-93.082-18.432-172.237-82.79-181.556-138.138-20.07-153.6-77.875-130.663-209.152"/><path fill="#1a2781" d="M267.008 418.918c-89.702 84.685-137.78 197.786-100.915 280.064 41.984 93.799 195.891 111.002 315.494 68.71 25.395-110.796 38.298-172.338-63.488-191.897-148.173-28.57-149.913-87.193-151.091-156.877m484.3 193.536c87.758-85.81 148.583-202.547 104.346-289.075-50.022-97.792-207.974-100.454-316.006-64.46-31.744 86.527-23.04 194.355 65.946 207.308 129.228 18.74 134.348 72.858 145.715 146.227"/><path fill="#de1a1b" d="M295.526 392.09c19.456-16.538 40.704-33.178 62.67-47.668-16.692 55.092-19.866 173.62 106.905 193.536 95.232 15.053 119.296 45.261 108.083 195.994-19.558 9.216-39.014 16.896-58.88 24.064 12.544-62.515 26.47-174.592-64.307-192.87-110.541-22.221-167.936-36.864-154.47-173.056"/><path fill="#de1a1b" d="M373.248 334.643c18.842-11.98 37.427-22.323 58.368-33.536-21.504 100.864-14.336 188.416 103.782 206.848 69.99 10.957 124.263 36.25 110.234 186.368-18.534 11.879-35.533 21.095-54.374 31.027 10.393-59.904 10.65-180.838-72.192-193.587-124.57-19.2-162.253-53.248-145.818-197.12"/><path fill="#de1a1b" d="M449.075 292.71c19.61-8.755 47.053-20.48 57.856-23.552-21.35 75.93-10.496 195.994 86.835 211.968 90.983 14.9 132.813 65.69 123.188 162.407-18.484 14.592-35.482 27.136-54.426 39.885 16.128-93.03-18.432-172.237-82.79-181.556-138.138-20.07-153.6-77.875-130.663-209.152"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" preserveAspectRatio="xMinYMin meet" viewBox="0 0 256 256"><path fill="#FFF" d="M255.96 134.393c0-21.521-13.373-40.117-33.223-47.43a75 75 0 0 0 1.253-13.791c0-39.909-32.386-72.295-72.295-72.295-23.193 0-44.923 11.074-58.505 30.088-6.686-5.224-14.835-7.94-23.402-7.94-21.104 0-38.446 17.133-38.446 38.446 0 4.597.836 9.194 2.298 13.373C13.582 81.739 0 100.962 0 122.274c0 21.522 13.373 40.327 33.431 47.64q-1.253 6.582-1.253 13.79c0 39.7 32.386 72.087 72.086 72.087 23.402 0 44.924-11.283 58.505-30.088 6.686 5.223 15.044 8.149 23.611 8.149 21.104 0 38.446-17.134 38.446-38.446 0-4.597-.836-9.194-2.298-13.373 19.64-7.104 33.431-26.327 33.431-47.64z"/><path fill="#F4BD19" d="m100.085 110.364 57.043 26.119 57.669-50.565a64.3 64.3 0 0 0 1.253-12.746c0-35.52-28.834-64.355-64.355-64.355-21.313 0-41.162 10.447-53.072 27.998l-9.612 49.73 11.074 23.82z"/><path fill="#3CBEB1" d="M40.953 170.75c-.835 4.179-1.253 8.567-1.253 12.955 0 35.52 29.043 64.564 64.564 64.564 21.522 0 41.372-10.656 53.49-28.208l9.403-49.729-12.746-24.238-57.251-26.118z"/><path fill="#E9478C" d="m40.536 71.918 39.073 9.194 8.775-44.506c-5.432-4.179-11.91-6.268-18.805-6.268-16.925 0-30.924 13.79-30.924 30.924 0 3.552.627 7.313 1.88 10.656z"/><path fill="#2C458F" d="M37.192 81.32c-17.551 5.642-29.67 22.567-29.67 40.954 0 17.97 11.074 34.059 27.79 40.327l54.953-49.73-10.03-21.52-43.043-10.03z"/><path fill="#95C63D" d="M167.784 219.852c5.432 4.18 11.91 6.478 18.596 6.478 16.925 0 30.924-13.79 30.924-30.924q0-5.642-1.88-10.657l-39.073-9.193z"/><path fill="#176655" d="m175.724 165.317 43.043 10.03c17.551-5.85 29.67-22.566 29.67-40.954 0-17.97-11.074-33.849-27.79-40.326l-56.415 49.311 11.492 21.94z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="209.961" height="200" class="icon" viewBox="0 0 1075 1024"><path fill="#9f53e6" d="M170.24 333.005a20.5 20.5 0 0 0-3.84 8.601h-.205v84.122a19.87 19.87 0 0 0 11.367 16.691l269.363 158.26a136.45 136.45 0 0 0 125.542 0l269.517-157.697a19.87 19.87 0 0 0 11.366-16.742l1.024-83.968-217.241-116.838-160.461 92.979a19.35 19.35 0 0 0-11.315 16.691v83.814c2.048 5.888 6.093 10.957 11.315 14.336 2.56-35.84 42.496-58.265 42.496-58.265l116.94-66.765 152.577 82.893-216.218 128.307a137.47 137.47 0 0 1-125.491 0l-226.509-133.58 226.407-133.53a126.5 126.5 0 0 1 62.873-15.207V153.6a126.8 126.8 0 0 0-62.873 15.155l-269.21 158.31a20.5 20.5 0 0 0-7.373 5.94zm679.22 236.851a19.9 19.9 0 0 0 3.378-9.882l-.307-83.302a19.87 19.87 0 0 1-11.366 16.691L572.109 651.571a136.5 136.5 0 0 1-125.491 0L177.05 492.75a19.87 19.87 0 0 1-11.367-16.691v83.456a19.87 19.87 0 0 0 11.367 16.69l269.568 158.414a137.42 137.42 0 0 0 125.49 0l269.364-157.901a19.97 19.97 0 0 0 7.987-6.81zm3.378 119.654a19.92 19.92 0 0 1-11.366 16.692L572.109 863.949a136.5 136.5 0 0 1-125.491 0L177.05 705.74a19.87 19.87 0 0 1-11.367-16.743v-83.456a19.71 19.71 0 0 0 11.367 16.692l269.568 158.412a137.47 137.47 0 0 0 125.49 0l269.057-157.9a19.56 19.56 0 0 0 11.366-16.692z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M507.2 308.1 458.3 96c-2.7-11.4-13.2-23.3-25.1-26.5L210.5.9c-2.8-.9-6-.9-9.6-.9-9.6 0-19.2 3.6-26.1 9.6L15.3 158.2C6.6 166 2 181.5 4.8 192.4l52.1 227.2c2.7 11.4 13.2 23.3 25.1 26.5L290.5 511c2.7.9 5.9.9 9.6.9 9.6 0 19.2-3.6 26-9.6l170.5-158.6c8.8-9.1 13.4-23.7 10.6-35.6M233.8 51.2 387 98.3c6 1.8 6 4.6 0 6l-80.4 18.3c-6 1.8-14.6-.9-18.8-5.5L232 56.7c-5-5.1-4.1-7.3 1.8-5.5m95.6 274.3c1.8 6-2.3 9.6-8.2 7.8l-165-51.2c-6-1.8-7.3-6.8-2.8-11l126.2-117.5c4.6-4.6 9.6-2.8 11 3.2zM57.4 176 191.3 51.2c4.6-4.6 11.4-3.6 16 .4l67.2 72.7c4.6 4.6 3.6 11.4-.5 16L140.1 265.2c-4.6 4.6-11.4 3.6-16-.5L56.9 192c-4.5-5-4.1-12.4.5-16m32.5 197.5L54.7 218c-1.8-5.9.9-7.3 5-2.7l55.8 60.3c4.6 4.6 5.9 13.3 4.6 19.2l-24.2 78.6c-1.9 6.5-4.7 6.5-6 .1M286 464.9l-175.1-54.4c-6.1-2-9.6-8.5-7.8-14.6l29.3-94.2c2-6.1 8.4-9.6 14.6-7.8l175.1 53.9c6 1.8 9.6 8.2 7.8 14.6l-29.3 94.2c-2.1 6.1-8.4 9.6-14.6 8.3m155.4-127.5-117 108.8c-4.6 4.6-6.9 2.8-5-3.2l24.2-78.6c1.8-6 8.2-12.4 14.6-13.3l80.5-18.3c5.9-2.3 6.8.4 2.7 4.6m12.8-23.8-96.4 22c-6 1.8-12.4-2.3-13.7-8.2L302.9 149c-1.8-6 2.3-12.4 8.2-13.7l96.5-22c5.9-1.8 12.3 2.3 13.7 8.2l41.2 178.3c1.3 7-2.4 12.9-8.3 13.8" style="fill:#4aabf4"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#c9151e" d="M339.2 204.8v236.083l-148.531 73.421 148.48 69.99v239.104h130.457V650.342L833.28 823.398V687.872L469.606 514.304 833.28 338.381V209.05L469.606 377.702V204.8z"/></svg>

After

Width:  |  Height:  |  Size: 292 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 85.79 512.01 340.29"><style>.st0{fill-rule:evenodd;clip-rule:evenodd}.st0,.st3{fill:#002b64}</style><path d="M500.7 86.5c-7.9.3-5.4 2.5-22.5 6.7-17.3 4.2-38.4 2.9-57 10.7-55.5 23.3-66.6 102.8-117.1 131.3-37.6 21.3-75.8 23-109.9 33.6-22.5 7.1-47 21.5-67.5 39-15.8 13.6-16.2 25.6-32.8 42.7C76.3 368.9 23.6 351 0 379c7.7 7.7 11.1 9.9 26.2 7.9-3.1 5.9-21.6 10.9-18 19.7 3.8 9.2 48.4 15.4 88.9-9.1 18.9-11.4 33.9-27.9 63.2-31.9 38.2-5.1 82 3.2 125.9 9.6-6.6 19.5-19.7 32.5-30.2 47.8-3.2 3.5 6.6 3.9 17.7 1.8 20.1-5 34.5-9 49.8-17.8 18.6-10.9 21.4-38.7 44.4-44.7 12.7 19.6 47.3 24.2 68.9 8.5-18.9-5.4-24.1-45.6-17.7-63.2 6-16.7 12-43.6 18.1-65.8 6.5-23.8 8.9-53.8 16.8-65.8 11.9-18.2 25-24.5 36.5-34.7C501.9 131 512.3 121 512 97.4c-.1-7.6-4-11.8-11.2-11.6z" class="st0"/><path d="M16.5 405.5c28.8 4.1 46.4 0 69.8-10.1 19.7-8.6 38.7-26.2 62.1-33.6 34.2-11 71.8 0 108.5 2.2 8.9.5 17.8.5 26.5-.4 13.6-8.4 13.4-39.6 26.6-42.7-.4 43.9-18.4 70.3-37.3 95.7 39.9-7 63.5-29.9 79.5-60.7 4.9-9.3 9-19.3 12.7-29.9 5.7 4.4 2.5 17.7 5.3 24.8 27.4-15.2 43-50.1 53.5-85.2 12-40.7 16.9-82 24.7-94 7.5-11.8 19.3-19 30.2-26.5 12.2-8.6 23.1-17.5 24.9-33.9-12.9-1.2-15.8-4.2-17.7-10.7-6.4 3.6-12.4 4.4-19.1 4.6-5.8.2-12.2-.1-20 .7-64.4 6.6-72.6 77.8-113.9 117.9-3 2.9-6.3 5.7-9.7 8.2-14.5 10.8-32.2 18.5-48.4 24.7-26.4 10.1-51.5 10.8-76.3 19.5-18.2 6.4-36.7 15.7-51.5 25.9-3.7 2.6-7.3 5.2-10.5 7.8-8.8 7.2-14.5 15.1-20.1 23.3-5.8 8.4-11.3 17.1-19.7 25.4-13.7 13.4-64.9 3.9-82.9 16.4-2 1.4-3.6 3-4.7 5 9.8 4.5 16.4 1.7 27.7 3 1.5 10.7-23.3 17.1-19.7 22z" style="fill:#c49a6c"/><path d="M406.7 325.8c.8 12.3 7.9 36.7 14.2 42.7-12.4 3-33.6-2-39-10.7 2.8-12.6 17.4-24.1 24.9-31.9z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#c49a6c"/><path d="M423.8 132.1c9.1 7.9 28.3 1.6 24.8-14.2-14.1-1.2-22.3 3.7-24.8 14.2" class="st0"/><path d="M486.5 112.2c-2.4 5.1-7.1 11.6-7.1 24.6 0 2.2-1.7 3.8-1.7.3.1-12.7 3.5-18.1 7-25.3 1.7-3 2.7-1.8 1.8.4" class="st3"/><path d="M486.5 112.2c-2.8 4.8-9.7 13.7-10.9 26.6-.2 2.2-2 3.6-1.7.2 1.2-12.6 6.7-20.5 10.9-27.3 1.9-2.9 2.8-1.6 1.7.5m-2.9-2.9c-3.2 4.6-13.9 15.2-16.1 28-.4 2.2-2.3 3.4-1.7 0 2.3-12.4 11.4-22.2 16.2-28.8 2.1-2.6 2.9-1.2 1.6.8m-2.8-2.8c-3.9 4.1-16.5 17.7-20.5 29.9-.7 2.1-2.8 3-1.7-.2 4-12 15.1-24.9 20.7-30.8 2.5-2.3 3-.8 1.5.9z" class="st3"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="141.14 0 229.74 512.1"><path d="M366.8 203.8c-27-119-83.2-150.7-97.5-173.1-5.9-9.9-11.2-20.1-15.7-30.7-.8 10.6-2.1 17.2-11.1 25.2-18 16.1-94.7 78.5-101.1 213.7-6 126.1 92.7 203.8 105.7 211.8 10 4.9 22.2.1 28.2-4.4 47.5-32.6 112.5-119.6 91.5-242.5" style="fill:#10aa50"/><path d="M257.7 383.7c-2.5 31.2-4.3 49.3-10.6 67.2 0 0 4.1 29.7 7 61.2h10.3c2.5-22.2 6.2-44.2 11.2-65.9-13.2-6.6-17.4-35.2-17.9-62.5" style="fill:#b8c4c2"/><path d="M275.7 446.1c-13.5-6.2-17.4-35.3-18-62.5 3.3-44.8 4.3-89.7 2.9-134.5-.7-23.5.3-218-5.8-246.5 4.2 9.6 9 19 14.5 28 14.3 22.4 70.6 54.1 97.5 173.1 21 122.8-43.6 209.6-91.1 242.4" style="fill:#12924f"/></svg>

After

Width:  |  Height:  |  Size: 710 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="800" width="1200" viewBox="-58.46985 -50.32625 506.7387 301.9575"><path d="M0 183.354h12.842v-50.711l19.88 44.208c2.346 5.35 5.557 7.244 11.854 7.244 6.298 0 9.385-1.893 11.732-7.244l19.88-44.208v50.71H89.03v-50.627c0-4.94-1.976-7.327-6.05-8.561-9.755-3.046-16.3-.412-19.264 6.174l-19.51 43.63-18.892-43.63c-2.84-6.586-9.508-9.22-19.263-6.174C1.976 125.399 0 127.787 0 132.725v50.629zm99.708-41.276h12.838v27.938c-.12 1.518.487 5.08 7.522 5.19 3.589.057 27.7 0 27.925 0v-33.264h12.868c.059 0-.013 45.364-.012 45.557.07 11.188-13.882 13.618-20.313 13.806h-40.55v-8.64c.072 0 40.52.009 40.622-.001 8.265-.873 7.289-4.981 7.288-6.364v-3.368H120.6c-12.7-.117-20.786-5.66-20.886-12.035-.01-.591.274-28.54-.007-28.82z" fill="#00618a"/><path d="M170.758 183.354h36.92c4.322 0 8.52-.905 11.855-2.47 5.556-2.551 8.273-6.008 8.273-10.537v-9.384c0-3.705-3.087-7.163-9.138-9.467-3.211-1.236-7.162-1.894-10.99-1.894h-15.56c-5.184 0-7.654-1.564-8.271-5.021-.124-.412-.124-.742-.124-1.153v-5.844c0-.33 0-.66.124-1.071.617-2.634 1.976-3.376 6.544-3.787.37 0 .864-.083 1.235-.083H228.3v-8.56h-36.057c-5.186 0-7.903.329-10.372 1.069-7.656 2.388-10.99 6.175-10.99 12.76v7.492c0 5.763 6.545 10.702 17.534 11.854 1.236.082 2.47.164 3.704.164h13.336c.495 0 .989 0 1.359.083 4.075.33 5.803 1.07 7.039 2.552.74.74.987 1.482.987 2.305v7.49c0 .907-.617 2.06-1.851 3.047-1.112.988-2.964 1.647-5.434 1.812-.494 0-.864.082-1.358.082h-35.44v8.56zm137.16-14.9c0 8.808 6.544 13.748 19.757 14.736 1.234.082 2.47.164 3.704.164h33.464v-8.561h-33.711c-7.532 0-10.372-1.894-10.372-6.421v-44.29h-12.842v44.372zm-71.916.449V138.38c0-7.754 5.445-12.457 16.209-13.942a24.63 24.63 0 013.465-.247h24.376c1.237 0 2.352.083 3.589.247 10.765 1.485 16.209 6.188 16.209 13.942v30.522c0 6.29-2.312 9.658-7.641 11.852l12.65 11.418h-14.911l-10.234-9.238-10.303.653h-13.735c-2.351 0-4.825-.331-7.548-1.073-8.166-2.227-12.126-6.517-12.126-13.612zm13.86-.742c0 .413.124.825.248 1.32.742 3.547 4.083 5.527 9.156 5.527h11.667l-10.717-9.675h14.91l9.348 8.438c1.722-.918 2.856-2.322 3.252-4.125.123-.412.123-.825.123-1.237v-29.285c0-.33 0-.743-.123-1.156-.743-3.3-4.084-5.196-9.033-5.196h-19.427c-5.691 0-9.403 2.475-9.403 6.352v29.037z" fill="#e48e00"/><path d="M376.14 109.88c-7.893-.214-13.923.52-19.078 2.694-1.465.618-3.801.634-4.04 2.47.805.844.93 2.104 1.57 3.142 1.23 1.992 3.309 4.661 5.163 6.061 2.026 1.53 4.114 3.165 6.286 4.49 3.863 2.355 8.176 3.7 11.896 6.06 2.192 1.391 4.37 3.143 6.509 4.713 1.057.776 1.768 1.983 3.142 2.47v-.225c-.722-.919-.908-2.183-1.571-3.143l-2.918-2.918c-2.853-3.787-6.475-7.113-10.325-9.876-3.07-2.204-9.942-5.18-11.223-8.754l-.225-.225c2.177-.245 4.726-1.033 6.735-1.57 3.375-.906 6.39-.672 9.876-1.572 1.57-.449 3.142-.899 4.714-1.347v-.898c-1.762-1.808-3.018-4.2-4.94-5.836-5.025-4.28-10.511-8.556-16.16-12.121-3.133-1.978-7.005-3.263-10.325-4.939-1.117-.563-3.08-.856-3.817-1.796-1.744-2.223-2.694-5.042-4.04-7.632-2.817-5.425-5.584-11.351-8.08-17.06-1.703-3.892-2.815-7.73-4.938-11.223-10.192-16.757-21.163-26.87-38.158-36.812-3.616-2.114-7.97-2.95-12.571-4.04l-7.407-.45c-1.507-.63-3.075-2.473-4.49-3.367-5.63-3.556-20.07-11.293-24.24-1.122-2.633 6.42 3.935 12.685 6.284 15.938 1.649 2.282 3.76 4.84 4.938 7.407.775 1.687.909 3.378 1.572 5.163 1.632 4.397 3.05 9.18 5.162 13.243 1.067 2.056 2.243 4.222 3.592 6.06.827 1.129 2.244 1.626 2.468 3.368-1.386 1.94-1.465 4.95-2.244 7.407-3.508 11.062-2.185 24.81 2.918 32.997 1.566 2.513 5.254 7.903 10.325 5.836 4.436-1.807 3.445-7.406 4.714-12.345.287-1.12.11-1.944.673-2.694v.225l4.04 8.08c2.99 4.815 8.298 9.848 12.795 13.244 2.332 1.761 4.168 4.806 7.183 5.836v-.225h-.225c-.584-.91-1.498-1.288-2.244-2.02-1.757-1.722-3.71-3.863-5.163-5.836-4.09-5.553-7.705-11.63-10.998-17.957-1.573-3.02-2.94-6.354-4.266-9.428-.511-1.185-.505-2.977-1.57-3.592-1.452 2.253-3.591 4.075-4.714 6.735-1.796 4.252-2.028 9.437-2.693 14.814-.394.141-.22.044-.45.224-3.126-.754-4.225-3.972-5.386-6.733-2.94-6.982-3.485-18.225-.9-26.262.67-2.08 3.694-8.63 2.47-10.55-.584-1.917-2.512-3.026-3.591-4.49-1.335-1.811-2.668-4.195-3.592-6.286-2.405-5.444-3.528-11.555-6.06-17.058-1.21-2.631-3.257-5.293-4.938-7.632-1.861-2.59-3.945-4.5-5.387-7.633-.513-1.113-1.21-2.895-.45-4.04.243-.772.584-1.095 1.348-1.347 1.302-1.003 4.928.334 6.284.898 3.6 1.495 6.604 2.919 9.653 4.938 1.464.971 2.944 2.848 4.713 3.368h2.02c3.16.726 6.7.225 9.652 1.122 5.218 1.586 9.894 4.052 14.14 6.734 12.939 8.169 23.517 19.798 30.753 33.67 1.164 2.233 1.668 4.365 2.693 6.734 2.069 4.778 4.675 9.694 6.733 14.366 2.054 4.66 4.056 9.365 6.959 13.244 1.527 2.039 7.422 3.133 10.1 4.264 1.879.794 4.956 1.621 6.735 2.693 3.398 2.05 6.69 4.49 9.877 6.735 1.593 1.122 6.489 3.583 6.733 5.611z" fill="#00618a"/><path d="M275.807 24.359c-1.646-.03-2.81.18-4.041.448v.225h.225c.785 1.613 2.17 2.651 3.142 4.04a6815.3 6815.3 0 002.245 4.714l.224-.224c1.39-.98 2.027-2.547 2.02-4.938-.557-.586-.64-1.322-1.122-2.021-.642-.932-1.884-1.46-2.693-2.244z" fill="#00618a" fill-ruLine truncated

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#0181fd" d="M906.598 322.509v265.113a791.9 791.9 0 0 0-333.516 39.783 1051.55 1051.55 0 0 1-418.919 54.886V416.768c96.205 9.216 195.687.82 293.53-27.034 13.21-3.788 26.726-8.09 39.936-12.39a1055 1055 0 0 1 419.225-54.886h-.256z"/><path fill="#ffa005" d="M496.589 256a591 591 0 0 1-44.851 17.05c-7.066 2.406-14.08 4.608-21.35 7.014-5.684 1.638-11.367 3.533-17.306 5.12-86.784 24.064-174.592 30.566-259.482 21.094v88.628a770.3 770.3 0 0 0 303.77-31.079l22.732-7.27a762 762 0 0 0 43.52-16.487L496.332 256z"/><path fill="#07c846" d="M563.866 748.39a616 616 0 0 1 44.902-17.05c7.014-2.406 14.029-4.607 21.35-7.014 5.632-1.587 11.316-3.481 17.255-5.12a685.6 685.6 0 0 1 259.481-21.094v-88.627a770.3 770.3 0 0 0-286.72 25.958c-5.734 1.587-11.417 3.226-17.05 5.12l-22.732 7.27a762 762 0 0 0-43.52 16.487l27.34 84.07z"/></svg>

After

Width:  |  Height:  |  Size: 931 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#1f80c4" d="M158.208 333.005a20.5 20.5 0 0 0-3.942 8.601h-.154v84.122a19.87 19.87 0 0 0 11.366 16.691l269.364 158.26a136.45 136.45 0 0 0 125.49 0l269.569-157.697a19.92 19.92 0 0 0 11.366-16.742l1.024-83.968-217.293-116.838-160.41 92.979a19.35 19.35 0 0 0-11.366 16.691v83.814c2.1 5.888 6.144 10.957 11.367 14.336 2.56-35.84 42.496-58.265 42.496-58.265l116.94-66.765 152.577 82.893-216.27 128.307a137.47 137.47 0 0 1-125.44 0l-226.508-133.58 226.406-133.53a126.4 126.4 0 0 1 62.823-15.207V153.6a126.8 126.8 0 0 0-62.823 15.155l-269.312 158.31a20.5 20.5 0 0 0-7.321 5.94zm679.117 236.851a19.87 19.87 0 0 0 3.43-9.882l-.358-83.302a19.87 19.87 0 0 1-11.367 16.691L560.026 651.571a136.45 136.45 0 0 1-125.543 0L164.966 492.75a19.87 19.87 0 0 1-11.366-16.743v83.456a19.87 19.87 0 0 0 11.366 16.692l269.517 158.412a137.42 137.42 0 0 0 125.543 0l269.312-157.9a19.87 19.87 0 0 0 7.987-6.81m3.43 119.654a19.87 19.87 0 0 1-11.366 16.692L560.026 863.949a136.45 136.45 0 0 1-125.543 0L164.966 705.74a19.87 19.87 0 0 1-11.366-16.794v-83.456a19.71 19.71 0 0 0 11.366 16.691l269.517 158.413a137.47 137.47 0 0 0 125.543 0l269.004-157.9a19.56 19.56 0 0 0 11.367-16.692l.358 83.456z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Oracle</title><path fill="#C74634" d="M16.412 4.412h-8.82a7.588 7.588 0 0 0-.008 15.176h8.828a7.588 7.588 0 0 0 0-15.176zm-.193 12.502H7.786a4.915 4.915 0 0 1 0-9.828h8.433a4.914 4.914 0 1 1 0 9.828z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st2{fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round}</style><g id="Layer_x0020_3"><path d="M378.5 372.5c3.2-26.9 2.3-30.8 22.3-26.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.8 40.6-31.5 15.5-26.4-57.3 11.8-61.2-7.6-61.2-7.6 60.5-89.7 85.8-203.6 63.9-231.5C411.9-3 308.8 33 307.1 33.9l-.5.1c-11.3-2.3-24-3.8-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 5.7 34.9 6.5 56.3.9 21.4 2.3 41.5 6.7 53.3s9.5 42.2 50.1 33.5c34-7.3 59.9-17.7 62.3-115.1" style="stroke:#000;stroke-width:37.3953"/><path d="M468.7 312.1c-57.3 11.8-61.2-7.6-61.2-7.6C468 214.8 493.3 100.9 471.4 73 411.9-3 308.8 33 307.1 33.9l-.6.1c-11.3-2.3-24-3.7-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 10.3 31.6 9.6 55.8s-1.2 40.8 3.6 53.8 9.5 42.2 50.1 33.5c33.9-7.3 51.5-26.1 54-57.6 1.7-22.4 5.7-19 5.9-39l3.2-9.5c3.6-30.3.6-40.1 21.5-35.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.7 40.5-31.4 15.5-26.3" style="fill:#336791"/><path d="M256.3 329.5c-1.6 56.4.4 113.2 5.9 126.9 5.5 13.8 17.3 40.6 58 31.9 33.9-7.3 46.3-21.4 51.6-52.4 3.9-22.9 11.6-86.4 12.5-99.4M207.6 46.9S23.7-28.3 32.2 142.7c1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 41.8-47.1 41.8-47.1M306.9 33.2c-6.4 2 102.3-39.7 164.1 39.2 21.8 27.9-3.5 141.8-63.9 231.5" class="st2"/><path d="M407 303.9s3.9 19.4 61.2 7.6c25.1-5.2 10 14.5-15.5 26.4-20.9 9.7-67.7 12.2-68.5-1.2-1.9-34.7 24.8-24.2 22.8-32.8-1.7-7.8-13.6-15.5-21.5-34.5-6.9-16.7-94.3-144.4 24.2-125.5 4.3-.9-30.9-112.7-141.8-114.5S160.7 165.8 160.7 165.8" style="fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:bevel"/><path d="M225.2 315.7c-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23 6.9-9.7 0-25.1-9.5-29.1-4.6-2-10.8-4.4-18.7 4.5" class="st2"/><path d="M224.2 315.4c-1.6-10.2 3.3-22.2 8.6-36.4 7.9-21.2 26.1-42.4 11.5-109.7-10.8-50.1-83.6-10.4-83.6-3.6s3.3 34.5-1.2 66.7c-5.9 42 26.7 77.6 64.3 73.9" class="st2"/><path d="M206.9 164.7c-.3 2.3 4.3 8.5 10.2 9.3 6 .8 11.1-4 11.4-6.3s-4.2-4.9-10.2-5.7c-6-.9-11.1.3-11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:4.155"/><path d="M388.4 159.9c.3 2.3-4.2 8.5-10.2 9.3s-11.1-4-11.4-6.3 4.3-4.9 10.2-5.7 11.1.4 11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:2.0775"/><path d="M409.8 143.9c1 18.2-3.9 30.6-4.5 50-.9 28.2 13.4 60.4-8.2 92.7" class="st2"/></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0.58 256 218.59"><path fill="#912626" d="M245.97 168.943c-13.662 7.121-84.434 36.22-99.501 44.075s-23.437 7.78-35.34 2.09c-11.902-5.69-87.216-36.112-100.783-42.597C3.566 169.271 0 166.535 0 163.951v-25.876s98.05-21.345 113.879-27.024c15.828-5.679 21.32-5.884 34.79-.95 13.472 4.936 94.018 19.468 107.331 24.344l-.006 25.51c.002 2.558-3.07 5.364-10.024 8.988"/><path fill="#C6302B" d="M245.965 143.22c-13.661 7.118-84.431 36.218-99.498 44.072-15.066 7.857-23.436 7.78-35.338 2.09-11.903-5.686-87.214-36.113-100.78-42.594-13.566-6.485-13.85-10.948-.524-16.166 13.326-5.22 88.224-34.605 104.055-40.284 15.828-5.677 21.319-5.884 34.789-.948 13.471 4.934 83.819 32.935 97.13 37.81 13.316 4.881 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 127.074c-13.662 7.122-84.434 36.22-99.501 44.078-15.067 7.853-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.112-100.783-42.597C3.566 127.402 0 124.67 0 122.085V96.206s98.05-21.344 113.879-27.023c15.828-5.679 21.32-5.885 34.79-.95C162.142 73.168 242.688 87.697 256 92.574l-.006 25.513c.002 2.557-3.07 5.363-10.024 8.987"/><path fill="#C6302B" d="M245.965 101.351c-13.661 7.12-84.431 36.218-99.498 44.075-15.066 7.854-23.436 7.777-35.338 2.087-11.903-5.686-87.214-36.112-100.78-42.594-13.566-6.483-13.85-10.947-.524-16.167C23.151 83.535 98.05 54.148 113.88 48.47c15.828-5.678 21.319-5.884 34.789-.949 13.471 4.934 83.819 32.933 97.13 37.81 13.316 4.88 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 83.653c-13.662 7.12-84.434 36.22-99.501 44.078-15.067 7.854-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.113-100.783-42.595C3.566 83.98 0 81.247 0 78.665v-25.88s98.05-21.343 113.879-27.021c15.828-5.68 21.32-5.884 34.79-.95C162.142 29.749 242.688 44.278 256 49.155l-.006 25.512c.002 2.555-3.07 5.361-10.024 8.986"/><path fill="#C6302B" d="M245.965 57.93c-13.661 7.12-84.431 36.22-99.498 44.074-15.066 7.854-23.436 7.777-35.338 2.09C99.227 98.404 23.915 67.98 10.35 61.497-3.217 55.015-3.5 50.55 9.825 45.331 23.151 40.113 98.05 10.73 113.88 5.05c15.828-5.679 21.319-5.883 34.789-.948s83.819 32.934 97.13 37.811c13.316 4.876 13.827 8.897.166 16.017"/><path fill="#FFF" d="m159.283 32.757-22.01 2.285-4.927 11.856-7.958-13.23-25.415-2.284 18.964-6.839-5.69-10.498 17.755 6.944 16.738-5.48-4.524 10.855zm-28.251 57.518L89.955 73.238l58.86-9.035zm-56.95-50.928c17.375 0 31.46 5.46 31.46 12.194 0 6.736-14.085 12.195-31.46 12.195s-31.46-5.46-31.46-12.195c0-6.734 14.085-12.194 31.46-12.194"/><path fill="#621B1C" d="m185.295 35.998 34.836 13.766-34.806 13.753z"/><path fill="#9A2928" d="m146.755 51.243 38.54-15.245.03 27.519-3.779 1.478z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img">
<title>SQLite</title>
<rect x="4" y="4" width="120" height="120" rx="22" fill="#E8F6FC"/>
<g transform="translate(8 8) scale(0.875)">
<defs><linearGradient id="sqlite-dark-a" x1="-15.615" x2="-6.741" y1="-9.108" y2="-9.108" gradientTransform="rotate(90 -90.486 64.634) scale(9.2712)" gradientUnits="userSpaceOnUse"><stop stop-color="#B8ECFF" offset="0"/><stop stop-color="#5EC4F0" offset=".92"/><stop stop-color="#5EC4F0" offset="1"/></linearGradient></defs><path d="M69.5 99.176c-.059-.73-.094-1.2-.094-1.2S67.2 83.087 64.57 78.642c-.414-.707.043-3.594 1.207-7.88.68 1.169 3.54 6.192 4.118 7.81.648 1.824.78 2.347.78 2.347s-1.57-8.082-4.144-12.797a162.286 162.286 0 012.004-6.265c.973 1.71 3.313 5.859 3.828 7.3.102.293.192.543.27.774.023-.137.05-.274.074-.414-.59-2.504-1.75-6.86-3.336-10.082 3.52-18.328 15.531-42.824 27.84-53.754H16.9c-5.387 0-9.789 4.406-9.789 9.789v88.57c0 5.383 4.406 9.789 9.79 9.789h52.897a118.657 118.657 0 01-.297-14.652" fill="#5EC4F0"/><path d="M65.777 70.762c.68 1.168 3.54 6.188 4.117 7.809.649 1.824.781 2.347.781 2.347s-1.57-8.082-4.144-12.797a164.535 164.535 0 012.004-6.27c.887 1.567 2.922 5.169 3.652 6.872l.082-.961c-.648-2.496-1.633-5.766-2.898-8.328 3.242-16.871 13.68-38.97 24.926-50.898H16.899a6.94 6.94 0 00-6.934 6.933v82.11c17.527-6.731 38.664-12.88 56.855-12.614-.672-2.605-1.441-4.96-2.25-6.324-.414-.707.043-3.597 1.207-7.879" fill="url(#sqlite-dark-a)"/><path d="M115.95 2.781c-5.5-4.906-12.164-2.933-18.734 2.899a44.347 44.347 0 00-2.914 2.859c-11.25 11.926-21.684 34.023-24.926 50.895 1.262 2.563 2.25 5.832 2.894 8.328.168.64.32 1.242.442 1.754.285 1.207.437 1.996.437 1.996s-.101-.383-.515-1.582c-.078-.23-.168-.484-.27-.773-.043-.125-.105-.274-.172-.434-.734-1.703-2.765-5.305-3.656-6.867-.762 2.25-1.437 4.36-2.004 6.265 2.578 4.715 4.149 12.797 4.149 12.797s-.137-.523-.782-2.347c-.578-1.621-3.441-6.64-4.117-7.809-1.164 4.281-1.625 7.172-1.207 7.88.809 1.362 1.574 3.722 2.25 6.323 1.524 5.867 2.586 13.012 2.586 13.012s.031.469.094 1.2a118.653 118.653 0 00.297 14.651c.504 6.11 1.453 11.363 2.664 14.172l.828-.449c-1.781-5.535-2.504-12.793-2.188-21.156.48-12.793 3.422-28.215 8.856-44.289 9.191-24.27 21.938-43.738 33.602-53.035-10.633 9.602-25.023 40.684-29.332 52.195-4.82 12.891-8.238 24.984-10.301 36.574 3.55-10.863 15.047-15.53 15.047-15.53s5.637-6.958 12.227-16.888c-3.95.903-10.43 2.442-12.598 3.352-3.2 1.344-4.067 1.8-4.067 1.8s10.371-6.312 19.27-9.171c12.234-19.27 25.562-46.648 12.141-58.621" fill="#3BA3D8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0.03 0 633.27 511.9"><linearGradient id="a" x1="62.23" x2="62.483" y1="81.873" y2="81.873" gradientTransform="scale(115.9464 -115.9464)rotate(30.578 187.04 -69.817)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#909ca9"/><stop offset="1" style="stop-color:#ededee"/></linearGradient><path d="m429.3 163-69.9 22.8-60.8 26.9-17 4.5c-4.3 4.1-8.9 8.3-13.8 12.5-5.4 4.7-10.4 8.9-14.3 11.9-4.3 3.4-10.6 9.7-13.8 13.7-4.8 6-8.6 12.4-10.3 17.3-2.9 8.9-1.5 17.9 4.1 26.2 7.2 10.6 21.5 21.4 38.2 28.7 8.5 3.8 22.8 8.6 33.6 11.3 17.9 4.5 52.6 9.5 71.7 10.2 3.9.2 9 .2 9.3 0 .4-.3 3.4-5.9 6.8-13 11.7-23.9 20.2-46.4 24.8-65.6 2.7-11.6 4.9-27.1 6.3-45.5.4-5.1.5-22.3.2-28.1-.5-9.5-1.3-17.2-2.6-24.8-.2-1.1-.3-2.1-.2-2.2.2-.2.8-.4 9.5-2.9zm-15.9 9.4c.6 0 2.3 16.2 2.7 26.5.1 2.2.1 3.6-.1 3.6-.4 0-8.9-5-15-8.8-5.3-3.3-15.3-10-16.9-11.3-.5-.4-.5-.4 3.9-1.9 7.4-2.5 24.8-8.1 25.4-8.1m-35.6 11.7c.5 0 1.7.7 4.6 2.5 10.9 6.8 25.7 15.1 32 17.8 2 .8 2.2.5-2.3 3.6-9.6 6.6-21.6 13-36.3 19.5-2.6 1.2-4.8 2.1-4.8 2.1-.1 0 .2-1.3.6-2.9 3.5-13.2 5.6-26.5 5.7-37.2 0-5.3 0-5.3.5-5.4-.2 0-.1 0 0 0m-7.4 2.8c.3.3.1 12.2-.3 15.4-1 7.7-2.4 14.9-4.9 23-.6 2-1.1 3.6-1.2 3.7-.2.3-7.5-6.8-9.8-9.6-4.1-4.8-7.3-9.5-9.7-14.2-1.2-2.4-3.1-7-3-7.2.8-.5 28.7-11.3 28.9-11.1m-34.6 13.6c.1 0 .1 0 .2.1s.5 1 .8 1.9c1.7 4.6 5.5 11.4 8.8 15.7 3.6 4.8 8.3 9.8 12.2 13.2 1.3 1.1 2.4 2.1 2.6 2.2.3.3.4.3-8.2 3.5-10 3.8-20.9 7.6-33.4 11.6-3 1-6 1.9-8.9 2.9-.5.2-.3-.1 1.1-2.3 6.2-9.7 15.6-28.7 20.9-42.1.9-2.3 1.8-4.7 2-5.2.2-.7.5-1 1.2-1.4.2 0 .6-.1.7-.1m-10.5 4.4c.2.1-2.5 5.8-5.2 10.9-5.1 10-10.7 19.8-18.2 31.8-1.3 2.1-2.5 4-2.6 4.2-.3.4-.4.3-1.2-1.4-1.8-3.5-3.3-8.1-4.1-12.3-.8-4.1-.6-11.3.3-15.8.7-3.3.6-3.2 2.2-4 6.7-3.4 28.5-13.6 28.8-13.4m91.1 3.7v2.2c0 11.8-1.3 28-3.1 39.8-.3 2.1-.6 3.8-.6 3.8s-1.5-.4-3.3-1c-7.9-2.5-16.5-6.1-24.3-10.4-5.1-2.8-12.6-7.4-12.4-7.6.1-.1 2.3-1.2 4.9-2.6 10.4-5.4 20.3-11.2 28.9-17 3.2-2.2 8.1-5.7 9.1-6.6zm-131.1 15.6c.2 0 .2.4-.2 2.3-.2 1.4-.5 3.9-.6 5.7-.4 7.7.8 13.4 4.7 21.3 1.1 2.2 1.9 4 1.9 4-.4.3-35.4 10.6-46.4 13.6-3.3.9-6.1 1.7-6.3 1.7-.4.1-.4.1-.3-.8 1.2-7.8 7.1-17.9 15.4-26.5 5.5-5.7 9.9-9 17.4-13.3 5.4-3.1 13.7-7.7 14.3-7.9 0-.1.1-.1.1-.1m82.5 14.8c.1-.1 1.3.6 2.9 1.5 11.3 6.6 27.1 12.6 40.5 15.8l1.2.3-1.7 1c-7 3.9-30.1 13.5-53.8 22.4-3.4 1.3-6.8 2.5-7.5 2.8-.6.3-1.2.4-1.2.4 0-.1 1-1.9 2.2-4.2 6.6-12.4 13.3-27.4 16.7-37.9zm-8.4 2.7c.1.1-.4 1.2-.9 2.4-4.6 11.2-10.6 23.3-18.3 37l-3.6 6.3c-.1 0-1.6-1-3.5-2.1-11.2-6.9-21.1-15.3-27.6-23.5l-1-1.2 4.8-1.3c17.2-4.7 31.9-9.8 46.4-16 2-.9 3.7-1.6 3.7-1.6m52.2 18.3s.1 0 0 0c.1 1.2-2.6 12.1-4.9 20-1.8 6.6-3.4 11.8-6.3 20.9-1.3 4-2.4 7.3-2.4 7.3-.1 0-.4-.1-.7-.2-15.7-2.9-29.8-6.8-43-12.1-3.7-1.5-9-3.9-9.3-4.1-.1-.1 3.1-1.6 7.1-3.3 24.1-10.5 49-22.5 57.6-27.6 1-.6 1.8-.9 1.9-.9m-120.5 4.1c.1.1-6.6 9.8-16 22.9-3.3 4.6-7.1 10-8.5 12s-3.6 5.2-4.8 7.1l-2.2 3.4-2.4-2c-2.8-2.3-7.7-7.3-9.8-10-4.5-5.7-7.6-11.6-8.8-17.1-.6-2.5-.6-3.8-.1-4 .8-.2 14.9-3.5 28.1-6.6 7.3-1.7 15.9-3.7 18.9-4.4s5.5-1.3 5.6-1.3m6.7 2.6 1.7 1.9c7.6 8.5 15.4 14.8 24.8 20.3 1.7 1 3 1.8 2.9 1.9-.4.3-32.7 11.7-47.6 16.9-8.4 3-15.3 5.3-15.4 5.3s-.5-.3-1.1-.7l-1-.7 1.5-2.2c5-7.2 11.2-15.1 24.8-31.5zm42.2 30.2c.1-.1 2.4.8 5.2 1.9 6.9 2.6 12.3 4.2 19.6 6.1 9 2.3 22 4.5 29.7 5.1 1.2.1 1.8.2 1.6.4-.4.2-8.1 2.8-13.8 4.6-9.1 2.9-36.8 11-59.5 17.6-4.2 1.2-7.8 2.2-8 2.3-.5.1-2.3-.4-2.3-.6 0-.1 1.3-1.7 2.8-3.5 7.6-9.1 15.2-19.2 21.5-28.9 1.7-2.8 3.2-5 3.2-5m-9.3.2c.1.1-3.7 6.1-10.3 16.4-2.8 4.4-5.9 9.4-7 11.1s-2.6 4.3-3.5 5.8l-1.5 2.7-.8-.2c-1.9-.5-15.3-5.2-18.8-6.7-4.4-1.7-8.9-3.9-12.3-5.7-4.2-2.3-9.5-5.7-9.1-5.8.1-.1 7.3-2 16.1-4.4 23.2-6.3 36-9.9 44.4-12.6 1.4-.2 2.8-.6 2.8-.6m65.8 15.5c.3.5-8.3 24.3-11.4 31.8-.7 1.7-.9 2.1-1.3 2.1-.9-.1-13.3-1.8-20.9-2.9-13.2-2-35.3-5.9-40.8-7.1l-1.3-.3 7.9-1.8c16.9-3.8 25.1-5.9 33.3-8.4 10.4-3.2 20.7-7.1 31.1-12 1.7-.8 3-1.4 3.4-1.4" style="fill:url(#a)"/><linearGradient id="b" x1="61.44" x2="61.693" y1="73.151" y2="73.151" gradientTransform="matrix(80.3196 0 0 -80.3196 -4653.717 5908.5)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#939fab"/><stop offset="1" style="stop-color:#dcdee1"/></linearGradient><path d="M357.2 0c-1.2-.2-19.9 6.6-32 11.5-16.3 6.7-29 13-36.8 18.5-2.9 2.1-6.6 5.7-7.1 7.1-.2.5-.3 1.2-.3 1.8l7.1 6.7L305 51l40.1 7.2 45.9 7.9.5-4c-.2 0-.3-.1-.4-.1l-6-1-1.2-2.2c-6.2-11-13.1-24.6-17.1-33.8-3.1-7.1-6.1-15.4-7.7-21.3-1.2-3.4-1.3-3.6-1.9-3.7m-.8 2.7c.1.1.3 1.5.5 3.3.9 7.4 2.5 14.5 5.1 22.3 2 5.8 2 5.5-.3 4.8-5.4-1.5-29.5-5.7-47-8.1-2.8-.4-5.2-.7-5.2-.8-.2-.2 12.6-6.9 18.3-9.6C335 11.3 354.9 3 356.4 2.7m-51 23.7 2.1.7c11.2 3.8 39.4 9.2 54.9 10.5 1.7.2 3.2.3 3.3.3.1.1-1.4.8-3.3 1.7-7.5 3.8-15.8 8.4-21.5 11.9-1.7 1.1-3.2 1.9-3.4 1.9s-1.3-.2-2.5-.4l-2.1-.3-5.3-5.2c-9.3-9-16.6-16-19.4-18.7zm-2.1 1.6 7.5 9.3c4.1 5.1 8.2 10.1 9.1 11.3.9 1.1 1.6 2 1.6 2.1-.2.2-10.8-1.9-16.4-3.2-5.8-1.3-8.1-2-11.7-3.1l-2.9-1v-.7c.1-3.5 4.5-8.8 12.2-14.2zm63.4 12.7c.2 0 .5.5 1.1 1.9 1.8 4 7.4 14.6 8.8 16.7.4.7 1.2.Line truncated

After

Width:  |  Height:  |  Size: 14 KiB

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<!-- Copyright Tencent Cloud. Sourced from TencentCloud/tencentcloud-monitor-grafana-app under Apache License 2.0. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 286.65 207.43">
<defs>
<style>.cls-1{fill:#00a3ff;}.cls-2{fill:#00c8dc;}.cls-3{fill:#006eff;}</style>
</defs>
<title>Tencent Cloud</title>
<g id="&#x56FE;&#x5C42;_2" data-name="&#x56FE;&#x5C42; 2">
<g id="&#x56FE;&#x5C42;_1-2" data-name="&#x56FE;&#x5C42; 1">
<path class="cls-1" d="M248.41,169.83c-5.11,5-14.21,10.79-29.16,11.3-6.91.23-15,.26-18.67.26h-80.3l57.59-55.94c2.65-2.57,8.61-8.27,13.75-12.9,11.28-10.15,21.43-12.2,28.61-12.13a40.46,40.46,0,0,1,28.18,69.41m19.23-75.5a66.15,66.15,0,0,0-47.31-20c-16.07,0-29.91,5.54-42,15.38-5.27,4.29-10.79,9.42-17.74,16.17-3.45,3.36-103.53,100.46-103.53,100.46a156.54,156.54,0,0,0,18.9,1c6,0,121,0,125.82,0,9.69,0,16,0,22.76-.5,15.53-1.13,30.21-6.83,42.07-18.48A66.49,66.49,0,0,0,267.64,94.33Z"/>
<path class="cls-2" d="M106.18,88.09C94.42,79.28,81.25,74.36,66.32,74.38A66.5,66.5,0,0,0,20,188.44a64.54,64.54,0,0,0,37.05,17.94l25.76-25c-4.16,0-10.14-.07-15.45-.25-14.94-.51-24-6.25-29.15-11.3a40.46,40.46,0,0,1,28.18-69.41c7,0,16.62,2,27.43,11,5.16,4.28,16.62,14.36,21.65,18.89a.62.62,0,0,0,.87,0L134.11,113a.64.64,0,0,0,0-1c-8.53-7.71-20.62-18.5-27.91-23.92"/>
<path class="cls-3" d="M227.3,59.36a89.08,89.08,0,0,0-172,16,65.32,65.32,0,0,1,11-.93,64.36,64.36,0,0,1,15,1.76,2.8,2.8,0,0,1,.28.06A63,63,0,0,1,200.56,62.78a.58.58,0,0,0,.7.38,76.32,76.32,0,0,1,25.14-2.62c.85.07,1.18-.41.9-1.18"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#e80230" d="M855.654 512.154c0 56.422-.153 112.896.256 169.267 0 9.062-2.713 13.773-10.854 17.92a74510 74510 0 0 0-321.024 169.984c-7.68 3.993-13.824 4.813-22.22.358a62695 62695 0 0 0-323.84-170.7c-7.527-3.943-9.882-8.602-9.882-17.05.256-113.255.256-226.56 0-339.866 0-8.448 2.713-13.107 10.035-16.998A92518 92518 0 0 0 501.914 154.47c7.526-3.993 13.21-4.352 20.992-.256A65522 65522 0 0 0 847.462 326.4c7.783 4.096 8.295 9.267 8.295 16.538a25193 25193 0 0 0-.103 169.216M489.472 727.552V709.12c0-101.888.154-203.776-.358-305.664 0-11.622 2.713-17.971 13.363-23.194 28.774-14.182 56.832-29.696 85.248-44.697 3.174-1.946 6.246-4.096 9.216-6.349-27.648-14.643-53.402-28.672-79.616-41.83-3.482-1.741-9.728-.666-13.568 1.33a21905 21905 0 0 0-166.81 87.604c-13.517 7.168-32 11.878-38.502 23.398-6.912 12.186-2.355 31.13-2.714 47.104-.153 13.517 0 26.983 0 42.701l107.879-56.73v16.897c0 73.37 0 146.79.153 220.262 0 4.915 1.331 12.493 4.455 14.285 25.958 14.54 52.377 28.057 81.254 43.213zm85.248-22.528c4.915-2.15 8.397-3.43 11.674-5.12 20.787-11.008 41.37-22.323 62.464-32.666 8.857-4.352 12.185-9.523 12.134-19.814-.41-81.664-.205-163.328-.205-245.094 0-4.352-.46-8.704-.717-14.95-25.753 13.567-49.766 26.93-74.393 38.911-8.96 4.352-11.11 9.882-11.06 19.098.257 64.717 0 129.433 0 194.048.103 21.197.103 42.291.103 65.536v.102z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="45.02 22.32 53.115 57.774"><style><![CDATA[.I{stroke-width:.5}.J{stroke-miterlimit:10}.K{stroke:#000}.L{fill:none}.M{fill:#f9c89a}.N{fill:#4f2e0e}.O{fill:#6d9758}.P{stroke-width:.1}]]></style><g transform="matrix(.159884 0 0 .159884 34.20631 17.28392)"><g class="K I"><ellipse cx="299.73" cy="339.813" rx="10.957" ry="5.14" fill="#331e09"/><g stroke-miterlimit="10"><path d="M217.706 319.453s4.363 18.614 4.944 24.142 11.635 8.145 12.798 1.745 5.236-22.105 5.236-22.105zm69.518 4.072s5.816 13.962 5.816 21.814 13.96 9.017 15.125 3.78 2.908-25.887 2.908-25.887z" class="M"/><path d="M234.77 336.03c-2.327.97-8.92 1.746-11.44.777s.195 9.113.97 12.217 8.92 2.52 8.92 2.52z" fill="#a15813"/></g><g class="N"><ellipse cx="220.979" cy="344.03" rx="4.872" ry="11.636"/><ellipse cx="236.249" cy="345.268" rx="5.018" ry="11.416"/></g><g stroke-miterlimit="10"><path d="M202.145 377.77L202 364.1c-.145-7.562 7.854-17.16 24.142-16.725s23.85 9.453 23.85 18.18-.728 15.707-1.6 17.452-42.6 2.327-44.2.728-2.036-5.964-2.036-5.964z" fill="#9e5613"/><path d="M202.145 389.55c-2.763-.727-3.78-3.636-3.927-6.398s2.182-5.1 3.927-5.382c1.308 1.746 3.926 2.474 6.253 2.9s32.14.727 35.63.436 4.363-2.326 4.946-3.197c2.473.87 3.5 3.488 3.635 5.96s-1.018 4.8-3.5 5.673-15.562 2.9-24.578 2.763-22.396-2.764-22.396-2.764z" class="N"/><path d="M310.638 342.43s-7.854 4.653-17.9 1.164c-6.544-.873-8.3-.873-8.145 2.327s1.02 11.926-2.035 15.56-6.255 13.1-1.6 17.452 71.988 3.927 74.607 3.2 4.218-5.382 4.218-5.382.87-9.9.58-15.124-7.562-14.834-19.925-14.397-17.45 9.162-22.978 10.325c-4.654-4.362-6.4-9.452-6.4-10.472s-.434-4.653-.434-4.653z" fill="#9e5613"/><path d="M295.585 358.355c4.363 0 7.5-1.963 7.854-6.472s-.727-6.98-3.928-7.198-7.126-.873-9.016-2.108-2.1-3.5-1.3-4.437 2.18-1.817 2.18-1.817l-.5-1.38s-7.2 2.763-6.544 10.47 4.508 12.943 11.27 12.943zm7.927-13.67c2.398.945 2.907 2.764 2.545 4.727s-1.164 7.636 2.98 7.417 5.236-8.3 4.873-11.998-2.764-7.272-3.854-8.145l-.218 1.1s.824.58.85 2.036-2.45 4.216-7.175 4.872z" class="N"/><path d="M317.473 357.556c-2.23 4.85-4.363 20.363-2.424 24.626" class="L"/><path d="M280.97 378.935s-3.346 2.036-3.054 5.234 2.763 3.928 6.4 4.8 38.103 1.9 44.356 2.18 27.632-1.3 31.268-2.763 6.836-7.854-.145-11.636c-2.765 3.055-4.945 3.637-9.454 4.22s-25.597.727-34.322.436l-31.995-.727c-2.18-.146-3.053-1.745-3.053-1.745z" class="N"/></g><g stroke-miterlimit="10" class="M"><path d="M265.554 115.412c-1.02 1.163-5.817 6.7-3.5 11.343s15.56 25.015 15.56 25.015 29.232-14.834 28.36-21.815-6.836-15.27-6.836-15.27zm71.9 105.63c.97-.97 3.1-5.72-2.618-7.466-4.556-.582-7.853 1.552-8.822 2.62s2.035 6.787 3.877 6.98 7.563-2.133 7.563-2.133z"/><path d="M341.42 226.28c.873-1.26 2.715-4.46-1.55-6.593s-9.988.098-11.442 2.327 2.424 7.175 3.2 7.175 9.792-2.9 9.792-2.9z"/><path d="M331.87 202.816l-5.38 9.454s3.005 3.927 4.168 8.87-.776 6.7.872 6.4 11.248-2.908 13.1-1.746 4.363 5.915 2.618 9.114-6.787 9.792-13.865 11.538-15.608-1.455-18.8-5.915-8.24-18.8-8.436-21.33 3.2-10.18 3.2-10.18l13.186-5.914z"/><path d="M325.035 211.542c2.423 1.357 6.303 4.363 7.27 9.502s-1.066 11.635-4.362 14.35"/><path d="M341.7 234.423s-2.328 3.298-7.758 2.52-5.04-7.562-2.424-9.404 6.786-3.296 9.6-2.812 4.167 1.745 4.167 1.745"/><path d="M334.343 228.897l2.617 4.848s-1.65 1.357-3.588.484-1.94-2.327-1.455-3.587 2.426-1.745 2.426-1.745z"/></g><g stroke-miterlimit="10" class="O"><path d="M314.466 123.992s10.764 5.672 12.5 12.653c5.235 1.745 11.392 9.428 10.8 15.052 5.623 2.327 8.12 5.587 8.12 9.27 3.104 2.715 7.744 9.678 2.12 19.76s-8.927 17.055-8.927 17.055l-22.495-1.944-1.94-6.8 10.47-19.392s-4.654-7.562-10.085-7.757c-1.356 5.817-2.714 14.155-2.714 14.155l.58 31.995-2.908 1.745-.582 7.175 7.175 14.93 2.52 2.327-6.594 10.278 1.553 2.9 8.92 21.912-.194 2.715 5.237 6.786-.195 2.908 2.9 7.175-5.624 12.798-3.5 6.98 3.297 10.083s-9.113 8.34-24.24 8.145-25.4-7.95-25.4-7.95l-2.908-1.744s3.297-19.002 2.52-29.668-3.104-26.76-3.104-30.444-.97-14.155-.97-14.155l-1.553 33.546c-.193 11.247-1.745 26.565-3.683 29.667s-4.072 4.072-4.072 4.072l2.132 3.686.582 4.458-2.327 2.9-4.267-1.165s-8.337 9.697-23.462 7.953-24.432-9.308-25.402-15.514c-3.5-2.52-3.878-4.266-3.878-4.266l9.9-11.44-5.624-3.296 4.654-8.34s3.297-8.145 3.878-10.86 2.327-9.307 2.327-9.307l-4.848.193 7.95-12.4-.58-8.533 14.542-30.83-.582-5.042-1.95-2.715-1.587-10.762 1.02-5.818s5.03-6.107 5.6-11.15-2.417-10.083-2.417-10.083l-33.98 22.3-4.87.388-13.973 6.205-3.303-1.358s-1.166-1.938-1.554-5.04-.001-17.064 1.162-19.003 3.877-1.94 3.877-1.94 4.46-.775 7.95-3.103 25.014-18.228 30.25-22.3 23.463-19.778 23.463-19.778 6.205-4.848 9.695-6 5.815-.97 5.815-.97l.195 16.677 15.706 17.8 17.065-10.23 13.574-12.604z"/><path d="M262.936 115.993l-2.327 1.455-10.036 16.724 10.9 1.02-3.927 7.562 20.65 10.907 1.744-2.036-14.98-18.47c-3.5-4.363-4.5-14.107-.582-16.725s-1.453-.437-1.453-.437"/><path d="M272.242 155.915l31.196-5.2-3.673-13.3 13.344 1.7 1.164-15.417-13.816-8.87-1.3-.145s3.34Line truncated

After

Width:  |  Height:  |  Size: 14 KiB

+7 -3
View File
@@ -9,14 +9,13 @@
".": "./src/index.ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@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"
},
@@ -24,9 +23,14 @@
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"typescript": "^5.8.2"
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}
+245 -5
View File
@@ -1,5 +1,5 @@
import { callAi, type AiAction } from "./api";
import { driverById } from "./catalog";
import { useEffect, useState } from "react";
import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
import { useWorkspaceStore } from "./store";
const ACTIONS: { id: AiAction; label: string }[] = [
@@ -11,6 +11,205 @@ const ACTIONS: { id: AiAction; label: string }[] = [
{ id: "sample", label: "样例" },
];
type ContentSeg = { kind: "text" | "code"; text: string };
/** R2-F7:拆分 AI 回复中的 ``` 代码块(通常 sql);块外文本维持纯文本 */
function splitContent(content: string): ContentSeg[] {
const segs: ContentSeg[] = [];
const re = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(content))) {
if (m.index > last) segs.push({ kind: "text", text: content.slice(last, m.index) });
segs.push({ kind: "code", text: m[1].replace(/\n$/, "") });
last = m.index + m[0].length;
}
if (last < content.length) segs.push({ kind: "text", text: content.slice(last) });
return segs.length ? segs : [{ kind: "text", text: content }];
}
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
function CodeBlock({ code }: { code: string }) {
const notify = useWorkspaceStore((s) => s.notify);
const copy = async () => {
await navigator.clipboard.writeText(code);
notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 });
};
const insertToEditor = () => {
const st = useWorkspaceStore.getState();
const tab = st.tabs.find((t) => t.id === st.activeTabId);
const cur = tab?.sql ?? "";
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${code}` : code);
notify({ kind: "success", title: "已插入编辑器", message: "SQL 已追加到当前查询", mode: "toast", durationMs: 1400 });
};
return (
<div className="my-1 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border-soft)" }}>
<div
className="flex items-center justify-end gap-1.5 border-b px-1.5 py-0.5"
style={{ borderColor: "var(--ops-border-soft)" }}
>
<button
type="button"
className="text-[10px] hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={() => void copy()}
>
复制
</button>
<button
type="button"
className="text-[10px] hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={insertToEditor}
>
插入编辑器
</button>
</div>
<pre
className="m-0 overflow-auto whitespace-pre-wrap break-all px-2 py-1 font-mono"
style={{
fontSize: "var(--ops-text-xs)",
background: "var(--ops-editor-bg)",
color: "var(--ops-text)",
}}
>
{code}
</pre>
</div>
);
}
/** R2-F7:AI 消息渲染 —— 代码块走等宽块,其余纯文本(无代码块时整体不变) */
function MessageContent({ content }: { content: string }) {
const segs = splitContent(content);
if (segs.length === 1 && segs[0].kind === "text") return <>{content}</>;
return (
<>
{segs.map((s, i) =>
s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>,
)}
</>
);
}
/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
function AiSettings({ onClose }: { onClose: () => void }) {
const notify = useWorkspaceStore((s) => s.notify);
const set = useWorkspaceStore((s) => s.set);
const [provider, setProvider] = useState("openai");
const [baseURL, setBaseURL] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [hasKey, setHasKey] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
getAiConfig()
.then((cfg) => {
if (!alive) return;
setProvider(cfg.provider || "stub");
setBaseURL(cfg.baseURL || "");
setModel(cfg.model || "");
setHasKey(!!cfg.hasKey);
})
.catch(() => {});
return () => {
alive = false;
};
}, []);
const save = async () => {
setBusy(true);
try {
await saveAiConfig({
provider,
baseURL: baseURL.trim(),
// 留空表示不改动已保存的 key(服务端掩码不回传明文)
apiKey: apiKey.trim() || undefined,
model: model.trim(),
});
if (model.trim()) set("aiModel", model.trim());
setHasKey(hasKey || !!apiKey.trim());
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
return true;
} catch (e) {
notify({ kind: "error", title: "AI 配置", message: e instanceof Error ? e.message : String(e), mode: "modal" });
return false;
} finally {
setBusy(false);
}
};
const test = async () => {
if (!(await save())) return;
setBusy(true);
try {
const res = await callAi({ action: "ask", prompt: "用一句话回答:连接正常", model: model.trim() || undefined });
notify({
kind: "success",
title: "AI 连接正常",
message: res.content.slice(0, 120) || "(空响应)",
mode: "toast",
durationMs: 4000,
});
} catch (e) {
notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" });
} finally {
setBusy(false);
}
};
return (
<div className="space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex items-center gap-1">
<select
className="ops-input py-0.5 text-[11px]"
value={provider}
onChange={(e) => setProvider(e.target.value)}
aria-label="Provider"
>
<option value="stub">stub(离线占位)</option>
<option value="openai">openai(OpenAI 兼容)</option>
</select>
<input
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
placeholder="模型,如 gpt-4o-mini"
value={model}
onChange={(e) => setModel(e.target.value)}
aria-label="模型"
/>
</div>
<input
className="ops-input w-full py-0.5 text-[11px]"
placeholder="Base URL,如 https://api.openai.com/v1"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
aria-label="Base URL"
/>
<input
className="ops-input w-full py-0.5 text-[11px]"
type="password"
placeholder={hasKey ? "API Key(已配置,留空不改动)" : "API Key"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
aria-label="API Key"
/>
<div className="flex gap-1">
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
保存
</button>
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void test()}>
{busy ? "…" : "测试"}
</button>
<button type="button" className="ops-btn-ghost ml-auto h-6 px-2 text-[11px]" onClick={onClose}>
收起
</button>
</div>
</div>
);
}
export function AiPanel() {
const messages = useWorkspaceStore((s) => s.aiMessages);
const ask = useWorkspaceStore((s) => s.aiAsk);
@@ -24,6 +223,26 @@ export function AiPanel() {
const set = useWorkspaceStore((s) => s.set);
const pushAi = useWorkspaceStore((s) => s.pushAi);
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
const [showSettings, setShowSettings] = useState(false);
/** C4:真实配置的模型清单(来自 /api/ai/config) */
const [cfgModel, setCfgModel] = useState<string>("");
useEffect(() => {
let alive = true;
getAiConfig()
.then((cfg) => {
if (!alive) return;
if (cfg.model) {
setCfgModel(cfg.model);
const cur = useWorkspaceStore.getState().aiModel;
if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model);
}
})
.catch(() => {});
return () => {
alive = false;
};
}, [set]);
const conn = connections.find((c) => c.id === activeConnectionId);
const tab = tabs.find((t) => t.id === activeTabId);
@@ -39,6 +258,8 @@ export function AiPanel() {
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
}
try {
// C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts)
const schema = schemaContextFor(conn?.id, conn?.database || tab?.database);
const res = await callAi({
action,
prompt: text,
@@ -46,6 +267,8 @@ export function AiPanel() {
driver: conn?.driver,
database: conn?.database || tab?.database,
error: result?.error,
schema: schema || undefined,
model: model && model !== "opsxdb-stub" ? model : undefined,
});
pushAi("assistant", res.content);
if (res.sql) updateActiveSql(res.sql);
@@ -57,6 +280,8 @@ export function AiPanel() {
}
};
const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean)));
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">
@@ -66,10 +291,23 @@ export function AiPanel() {
className="ops-input h-6 py-0 text-[10px] normal-case tracking-normal"
value={model}
onChange={(e) => set("aiModel", e.target.value)}
title="模型(来自 /api/ai/config,可在设置中修改)"
>
<option value="opsxdb-stub">opsxdb-stub</option>
<option value="gpt-stub">gpt-stub</option>
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
title="AI 设置"
aria-label="AI 设置"
onClick={() => setShowSettings(!showSettings)}
>
⚙
</button>
<button
type="button"
className="ops-close-faint"
@@ -85,6 +323,8 @@ export function AiPanel() {
</div>
</div>
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
<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]"
@@ -155,7 +395,7 @@ export function AiPanel() {
>
{m.role === "user" ? "你" : "AI"} · {m.at}
</div>
{m.content}
<MessageContent content={m.content} />
</div>
))}
</div>
+96 -99
View File
@@ -111,19 +111,88 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
}
if (kind === "redis") {
const mode = form.redisMode ?? "standalone";
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="部署模式">
<div className="flex flex-wrap items-center gap-3 text-[12px]">
{(
[
["standalone", "单机"],
["sentinel", "Sentinel"],
["cluster", "Cluster"],
] as const
).map(([v, label]) => (
<label key={v} className="flex items-center gap-1">
<input
type="radio"
name="redisMode"
checked={mode === v}
onChange={() => patch({ redisMode: v })}
/>
{label}
</label>
))}
</div>
</Row>
{mode === "standalone" && (
<>
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
<Row label="端口">{portInput}</Row>
</>
)}
{mode === "sentinel" && (
<>
<Row label="Sentinel 节点">
<textarea
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
placeholder={"127.0.0.1:26379\n127.0.0.1:26380"}
value={form.redisNodes ?? ""}
onChange={(e) => patch({ redisNodes: e.target.value })}
/>
</Row>
<Row label="主节点名">
<input
className="ops-input h-7 w-full text-[12.5px]"
placeholder="mymaster"
value={form.masterName ?? ""}
onChange={(e) => patch({ masterName: e.target.value })}
/>
</Row>
<Row label="Sentinel 密码">
<PasswordInput
value={form.sentinelPassword ?? ""}
onChange={(v) => patch({ sentinelPassword: v })}
/>
</Row>
</>
)}
{mode === "cluster" && (
<Row label="种子节点">
<textarea
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
placeholder={"127.0.0.1:7000\n127.0.0.1:7001"}
value={form.redisNodes ?? ""}
onChange={(e) => patch({ redisNodes: e.target.value })}
/>
</Row>
)}
<Row label="密码">
<PasswordInput
value={form.password ?? ""}
onChange={(v) => patch({ password: v })}
/>
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
</Row>
<Row label="用户名">{text("user", "ACL ≥6 可选")}</Row>
<Row label="分隔符">
{mode !== "cluster" && (
<Row label="DB 索引">
<input
className="ops-input h-7 w-full text-[12.5px]"
placeholder="0"
value={form.database ?? "0"}
onChange={(e) => patch({ database: e.target.value })}
/>
</Row>
)}
<Row label="键分隔符">
<input
className="ops-input h-7 w-full text-[12.5px]"
placeholder=":"
@@ -131,72 +200,17 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
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" : "服务名"}>
<Row label="服务名">
<input
className="ops-input h-7 w-full text-[12.5px]"
placeholder={selected.hints?.database ?? "ORCL"}
@@ -204,17 +218,6 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
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 })} />
@@ -265,20 +268,6 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
/>
</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>
)}
</>
);
};
@@ -445,17 +434,23 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
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 && (
{kind === "file" ? (
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
文件库(SQLite)为本机文件,无网络端点,无需 SSH 隧道。
</p>
) : (
<>
<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
@@ -522,6 +517,8 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
</p>
</>
)}
</>
)}
</>
);
+107 -11
View File
@@ -1,7 +1,8 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react";
import { driverById } from "./catalog";
import { DriverIcon } from "./DriverIcon";
import { useWorkspaceStore, type SavedConnection } from "./store";
import { DriverManager } from "./DriverManager";
import { useWorkspaceStore, type ConnectionGroup, type SavedConnection, DEFAULT_CONN_GROUP } from "./store";
function hostSummary(c: SavedConnection) {
const d = driverById(c.driver);
@@ -118,7 +119,11 @@ export function ConnectionList() {
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
const set = useWorkspaceStore((s) => s.set);
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const [search, setSearch] = useState("");
const [showDrivers, setShowDrivers] = useState(false);
/** R2-F5:导入文件选择 */
const importFileRef = useRef<HTMLInputElement>(null);
const needle = search.trim().toLowerCase();
@@ -163,6 +168,58 @@ export function ConnectionList() {
set("status", `已选择 ${c.name}`);
};
/** R2-F5:导出全部连接 —— 原样导出存储形态(含分组结构与加密态 secrets),文件头带版本 */
const exportAll = () => {
const st = useWorkspaceStore.getState();
const payload = {
exportedAt: new Date().toISOString(),
version: 1,
connections: st.connections,
connGroups: st.connGroups,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `opsxdb-connections-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
notify({ kind: "success", title: "已导出", message: `${st.connections.length} 条连接`, mode: "toast", durationMs: 1600 });
};
/** R2-F5:导入连接 —— 校验数组结构;id 冲突重新生成后追加;持久化走 store 现有 action */
const onImportFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text) as { connections?: unknown; connGroups?: unknown } | unknown[];
const arr = Array.isArray(parsed) ? parsed : (parsed as { connections?: unknown }).connections;
if (!Array.isArray(arr)) throw new Error("不是有效的连接导出文件(缺少 connections 数组)");
const st = useWorkspaceStore.getState();
const existingIds = new Set(st.connections.map((c) => c.id));
const groupIds = new Set(st.connGroups.map((g) => g.id));
let n = 0;
for (const raw of arr) {
if (!raw || typeof raw !== "object") continue;
const item = { ...(raw as SavedConnection) };
if (!item.name || !item.driver) continue; // 跳过非法条目
if (!item.id || existingIds.has(item.id)) item.id = crypto.randomUUID();
existingIds.add(item.id);
if (!item.groupId || !groupIds.has(item.groupId)) item.groupId = DEFAULT_CONN_GROUP;
st.upsertConnection(item);
n++;
}
if (!n) throw new Error("文件中没有可导入的连接");
if (!Array.isArray(parsed) && Array.isArray((parsed as { connGroups?: unknown }).connGroups)) {
st.importConnGroups((parsed as { connGroups: ConnectionGroup[] }).connGroups);
}
notify({ kind: "success", title: "导入完成", message: `已导入 ${n} 条连接`, mode: "toast", durationMs: 2000 });
} catch (err) {
notifyError(err, "modal");
}
};
const rowProps = { onOpen, onDelete, setDefaultConnection, editConnection, notify };
return (
@@ -174,13 +231,51 @@ export function ConnectionList() {
选择或新建数据库连接
</div>
</div>
<div className="ml-auto flex items-center gap-1.5">
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
新建连接
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
<button
type="button"
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
title="驱动管理(agent 驱动:MongoDB / ZooKeeper / 达梦)"
aria-label="驱动管理"
onClick={() => setShowDrivers(true)}
>
⚙
</button>
{/* R2-F5:连接配置导入/导出 */}
{connections.length > 0 && (
<button
type="button"
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
title="导出全部连接(JSON,含分组与密码存储形态)"
aria-label="导出全部连接"
onClick={exportAll}
>
⤓
</button>
)}
<button
type="button"
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
title="导入连接(JSON)"
aria-label="导入连接"
onClick={() => importFileRef.current?.click()}
>
⤒
</button>
<input
ref={importFileRef}
type="file"
accept=".json,application/json"
className="hidden"
aria-label="选择连接导出文件"
onChange={(e) => void onImportFile(e)}
/>
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
新建
</button>
{connections.length > 0 && (
<button type="button" className="ops-btn-secondary" onClick={() => set("view", "workbench")}>
进入工作台
<button type="button" className="ops-btn-secondary px-3" onClick={() => set("view", "workbench")} title="返回工作台">
工作台
</button>
)}
</div>
@@ -196,8 +291,8 @@ export function ConnectionList() {
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)",
background: "var(--ops-bg)",
color: "var(--ops-text)",
}}
placeholder="搜索名称 / 主机 / 驱动…"
value={search}
@@ -216,8 +311,8 @@ export function ConnectionList() {
<div className="ops-empty">
<h2>还没有连接</h2>
<p>新建一个连接,保存后会出现在这里。</p>
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
新建连接
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
新建
</button>
</div>
) : filtered.length === 0 ? (
@@ -244,6 +339,7 @@ export function ConnectionList() {
))}
</div>
)}
{showDrivers && <DriverManager onClose={() => setShowDrivers(false)} />}
</div>
);
}
+36 -23
View File
@@ -6,24 +6,12 @@ import { parseDSN } from "./dsn";
import { sanitizeSecret } from "./secrets";
import { useWorkspaceStore, type SavedConnection } from "./store";
import { openSession, testConnection } from "./api";
import { DriverIcon } from "./DriverIcon";
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);
@@ -64,7 +52,7 @@ export function ConnectionPicker() {
name: form.name.trim(),
driver: form.driver as DriverId,
host: form.host || "127.0.0.1",
port: Number(form.port || selected?.defaultPort || 3306),
port: Number(form.port ?? selected?.defaultPort ?? 3306),
user: form.user || "",
password: sanitizeSecret(form.password || ""),
database: form.database || "",
@@ -85,6 +73,9 @@ export function ConnectionPicker() {
savePassword,
keySeparator: form.keySeparator,
redisMode: form.redisMode,
redisNodes: form.redisNodes,
masterName: form.masterName,
sentinelPassword: sanitizeSecret(form.sentinelPassword || ""),
oracleConnType: form.oracleConnType,
oracleIdKind: form.oracleIdKind,
oracleRole: form.oracleRole,
@@ -161,7 +152,7 @@ export function ConnectionPicker() {
<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-[14px] font-semibold">新建</div>
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
选择引擎
</div>
@@ -199,10 +190,32 @@ export function ConnectionPicker() {
<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} />
<button key={d.id} type="button" className={cx("ops-driver-row", !(d.ready || d.mysqlCompat) && "opacity-60")} onClick={() => openPicker(d.id)} title={!(d.ready || d.mysqlCompat) ? "驱动未接入" : d.verified === false ? "已接入,待验收" : undefined}>
<DriverIcon driver={d.id} size={20} />
<span className="min-w-0 truncate font-medium">{d.name}</span>
<span className="ops-driver-meta">{d.ready || d.mysqlCompat ? "可用" : "未就绪"}</span>
{d.verified === false && (
<span
className="shrink-0 rounded px-1 text-[10px]"
style={{
color: "var(--ops-warning)",
background: "color-mix(in srgb, var(--ops-warning) 12%, transparent)",
}}
title={d.notes?.trim() || "已接入,待验收"}
>
未验收
</span>
)}
{d.ready || d.mysqlCompat ? (
<span className="ops-driver-meta">可用</span>
) : (
<span
className="ops-driver-meta"
style={{ color: "var(--ops-muted)", opacity: 0.85 }}
title="驱动未接入"
>
未就绪
</span>
)}
</button>
))}
{filtered.length === 0 && (
@@ -247,7 +260,7 @@ export function ConnectionPicker() {
<button type="button" className="ops-btn-secondary" onClick={() => { setTestMsg(null); set("pickerDriver", null); set("pickerMode", "drivers"); }}>
返回
</button>
<LocalDriverIcon d={selected} />
<DriverIcon driver={selected.id} size={20} />
<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)" }}>
@@ -255,7 +268,7 @@ export function ConnectionPicker() {
</div>
</div>
<button type="button" className="ops-btn-ghost ml-auto" onClick={backToList}>
连接列表
返回
</button>
</header>
@@ -290,9 +303,9 @@ export function ConnectionPicker() {
<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)",
borderColor: "color-mix(in srgb, var(--ops-warning) 40%, var(--ops-border))",
background: "color-mix(in srgb, var(--ops-warning) 8%, transparent)",
color: "var(--ops-warning)",
}}
>
{selected.name} 驱动未就绪,可先保存配置。
+133 -17
View File
@@ -1,6 +1,10 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { closeSession } from "./api";
import { driverById } from "./catalog";
import { askInput } from "./dialogs";
import { DriverIcon } from "./DriverIcon";
import { ObjectTree } from "./ObjectTree";
import { TreeChevron } from "./TreeChevron";
import {
DEFAULT_CONN_GROUP,
useWorkspaceStore,
@@ -32,6 +36,23 @@ export function ConnectionSidebar({
const notify = useWorkspaceStore((s) => s.notify);
const editConnection = useWorkspaceStore((s) => s.editConnection);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; conn: SavedConnection } | null>(null);
/** 断开连接:关会话 + 清树 + 清激活 */
const disconnect = (c: SavedConnection) => {
const st = useWorkspaceStore.getState();
const sid = st.sessions[c.id];
if (sid) {
void closeSession(sid).catch(() => {});
}
st.setSession(c.id, null);
if (st.activeConnectionId === c.id) {
st.set("activeConnectionId", null);
st.set("tree", []);
st.set("connState", "down");
st.set("status", `已断开 ${c.name}`);
}
};
const [dragId, setDragId] = useState<string | null>(null);
const [dropHint, setDropHint] = useState<DropHint>(null);
const dragIdRef = useRef<string | null>(null);
@@ -51,24 +72,37 @@ export function ConnectionSidebar({
}, [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;
void (async () => {
// C6:askInput 替代 window.prompt(WKWebView 下 prompt 返回 null)
const name = await askInput({ title: "分组名称", defaultValue: "新分组" });
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);
void (async () => {
const name = await askInput({ title: "重命名分组", defaultValue: 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);
void (async () => {
const ok = await useWorkspaceStore.getState().askConfirm({
title: "删除分组",
message: `删除分组「${name}」?连接将移回默认。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
removeConnGroup(id);
})();
};
const setDrop = (groupId: string, index: number) => {
@@ -88,10 +122,10 @@ export function ConnectionSidebar({
};
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">
<div className="flex shrink-0 flex-col">
<div className="flex items-center justify-between px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
<span>连接</span>
<button type="button" className="normal-case tracking-normal hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
+ 分组
</button>
</div>
@@ -189,15 +223,49 @@ export function ConnectionSidebar({
commitDrop();
}}
>
<button type="button" className="ops-tree-item min-w-0 flex-1" onClick={() => onSelect(c.id)}>
<button
type="button"
className="ops-tree-item min-w-0 flex-1"
onClick={() => onSelect(c.id)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY, conn: c });
}}
>
{/* 实例级展开/收起箭头(行首树形 chevron,SVG 保证方向清晰) */}
<span
className="grid w-3.5 shrink-0 cursor-pointer place-items-center opacity-60 hover:opacity-100"
title={activeId === c.id ? "点击收起对象树" : "点击展开对象树"}
onClick={(e) => {
e.stopPropagation();
if (activeId === c.id) {
// 已激活 → 收起(清空树)
useWorkspaceStore.getState().set("tree", []);
useWorkspaceStore.getState().set("activeConnectionId", null);
} else {
// 未激活 → 展开(激活并加载树)
onSelect(c.id);
}
}}
>
<TreeChevron open={activeId === c.id} size={10} />
</span>
{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>
<span className="min-w-0 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name}`}>
{c.name}
</span>
<span
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
style={{
background: activeId === c.id ? '#22c55e' : 'var(--ops-muted)',
opacity: activeId === c.id ? 1 : 0.4,
}}
title={activeId === c.id ? '已连接' : '未激活'}
/>
</button>
<button
type="button"
@@ -217,6 +285,7 @@ export function ConnectionSidebar({
×
</button>
</div>
{activeId === c.id && <ObjectTree />}
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
<div className="ops-drop-line" />
)}
@@ -228,6 +297,53 @@ export function ConnectionSidebar({
);
})}
</div>
{ctxMenu && (
<div
className="ops-ctx"
style={{ position: "fixed", left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000 }}
>
{activeId === ctxMenu.conn.id && (
<button
type="button"
onClick={() => {
disconnect(ctxMenu.conn);
setCtxMenu(null);
}}
>
断开连接
</button>
)}
<button
type="button"
onClick={() => {
onSelect(ctxMenu.conn.id);
setCtxMenu(null);
}}
>
连接
</button>
<button
type="button"
onClick={() => {
editConnection(ctxMenu.conn);
setCtxMenu(null);
}}
>
编辑
</button>
<button
type="button"
style={{ color: "var(--ops-danger)" }}
onClick={() => {
onDelete(ctxMenu.conn.id);
setCtxMenu(null);
}}
>
删除
</button>
</div>
)}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useState } from "react";
import { getDdl } from "./api";
import { ensureSession } from "./ObjectTree";
import { useWorkspaceStore, type SavedConnection } from "./store";
import { OpsModal } from "./OpsModal";
/** 超长 DDL 截断阈值(R2-F3):超过只展示前 5000 字符并提示 */
const DDL_CAP = 5000;
type Props = {
conn: SavedConnection;
database: string;
table: string;
onClose: () => void;
};
/**
* R2-F3:DDL 查看 —— OpsModal(info 变体)展示服务端返回的建表语句。
* 等宽 pre + 加载态 + 错误重试;「复制」「插入编辑器」把 DDL 带到剪贴板/当前 Tab。
*/
export function DdlModal({ conn, database, table, onClose }: Props) {
const notify = useWorkspaceStore((s) => s.notify);
const [ddl, setDdl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sid = await ensureSession(conn);
const res = await getDdl(conn, table, { database, sessionId: sid });
if (res.error) throw new Error(res.error);
setDdl(res.ddl ?? "(空)");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setDdl(null);
} finally {
setLoading(false);
}
}, [conn, database, table]);
useEffect(() => {
void load();
}, [load]);
const truncated = ddl != null && ddl.length > DDL_CAP;
const text = truncated ? (ddl ?? "").slice(0, DDL_CAP) : (ddl ?? "");
const copy = async () => {
await navigator.clipboard.writeText(text);
notify({ kind: "success", title: "已复制", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1400 });
};
/** 插入编辑器:DDL 追加到当前激活 Tab 的 SQL 末尾 */
const insertToEditor = () => {
const st = useWorkspaceStore.getState();
const tab = st.tabs.find((t) => t.id === st.activeTabId);
const cur = tab?.sql ?? "";
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${text}` : text);
notify({ kind: "success", title: "已插入编辑器", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1600 });
onClose();
};
return (
<OpsModal variant="info" wide title={`DDL · ${database ? `${database}.` : ""}${table}`} okLabel="关闭" onOk={onClose}>
{loading ? (
<div style={{ color: "var(--ops-muted)" }}>加载 DDL…</div>
) : error ? (
<div>
<div
className="rounded border px-2.5 py-1.5"
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)",
fontSize: "var(--ops-text-sm)",
}}
>
{error}
</div>
<div className="mt-2 flex justify-end">
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => void load()}>
重试
</button>
</div>
</div>
) : (
<>
<div className="mb-1.5 flex items-center justify-end gap-1.5">
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => void copy()}>
复制
</button>
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={insertToEditor}>
插入编辑器
</button>
</div>
<pre
className="m-0 max-h-[50vh] overflow-auto whitespace-pre-wrap break-all rounded border px-2.5 py-2 font-mono leading-relaxed"
style={{
fontSize: "var(--ops-text-xs)",
borderColor: "var(--ops-border-soft)",
background: "var(--ops-editor-bg)",
color: "var(--ops-text)",
}}
>
{text}
</pre>
{truncated && (
<div className="mt-1.5" style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-warning)" }}>
内容过长,仅显示前 {DDL_CAP} 字符(共 {ddl?.length} 字符)。
</div>
)}
</>
)}
</OpsModal>
);
}
+45 -13
View File
@@ -1,4 +1,41 @@
import { useState } from "react";
import { driverById, type DriverId } from "./catalog";
import { resolveDriverIconUrl } from "./driverIconUrls";
function initials(name: string, id: string): string {
const raw = (name || id).replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "");
if (!raw) return "?";
// Prefer 2-letter latin abbreviation from id
if (/^[a-z]/i.test(id)) return id.slice(0, 2).toUpperCase();
return raw.slice(0, 2).toUpperCase();
}
function LetterFallback({
label,
size,
className,
title,
}: {
label: string;
size: number;
className?: string;
title?: string;
}) {
return (
<span
className={`inline-grid shrink-0 place-items-center rounded-[3px] text-[9px] font-bold ${className ?? ""}`}
style={{
width: size,
height: size,
background: "color-mix(in srgb, var(--ops-muted) 22%, transparent)",
color: "var(--ops-muted)",
}}
title={title}
>
{label}
</span>
);
}
export function DriverIcon({
driver,
@@ -10,27 +47,22 @@ export function DriverIcon({
className?: string;
}) {
const d = driverById(driver);
const label = (d?.name ?? driver).slice(0, 2).toUpperCase();
const color = d?.color ?? "#64748b";
if (d?.icon) {
const label = initials(d?.name ?? String(driver), String(driver));
const src = resolveDriverIconUrl(String(driver), d?.icon);
const [failed, setFailed] = useState(false);
if (src && !failed) {
return (
<img
src={d.icon}
src={src}
alt=""
width={size}
height={size}
className={`shrink-0 object-contain ${className}`}
draggable={false}
onError={() => setFailed(true)}
/>
);
}
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>
);
return <LetterFallback label={label} size={size} className={className} title={d?.name ?? String(driver)} />;
}
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { driverImportOffline, driverInstall, driverStatus, type DriverStatusItem } from "./api";
import { driverById } from "./catalog";
import { DriverIcon } from "./DriverIcon";
import { OpsModal } from "./OpsModal";
import { useWorkspaceStore } from "./store";
const LABELS: Record<string, string> = {
mongodb: "MongoDB",
zookeeper: "ZooKeeper",
dameng: "达梦 DM8",
};
function fmtSize(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
return `${n} B`;
}
/**
* 驱动管理弹框:agent 驱动(MongoDB / ZooKeeper / 达梦)的运行时状态与维护入口。
* 在线环境首次连接会自动下载;这里提供预装、离线包导入与商用 jar 指引。
*/
export function DriverManager({ onClose }: { onClose: () => void }) {
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const [items, setItems] = useState<DriverStatusItem[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const reload = async () => {
try {
setItems(await driverStatus());
} catch (e) {
notifyError(e, "toast");
}
};
useEffect(() => {
void reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const install = async (id: string) => {
setBusy(id);
try {
const r = await driverInstall(id);
if (r.drivers) setItems(r.drivers);
if (r.ok) {
notify({ kind: "success", title: "安装完成", message: `${LABELS[id] ?? id} 驱动已就绪`, mode: "toast" });
} else {
notify({ kind: "error", title: "安装失败", message: r.error ?? "未知错误", mode: "modal" });
}
} catch (e) {
notifyError(e, "modal");
} finally {
setBusy(null);
}
};
const importZip = async (f: File) => {
setBusy("import");
try {
const r = await driverImportOffline(f);
if (r.drivers) setItems(r.drivers);
if (r.ok) {
notify({ kind: "success", title: "导入完成", message: `已导入 ${r.imported} 个文件`, mode: "toast" });
} else {
notify({ kind: "error", title: "导入失败", message: r.error ?? "包校验失败", mode: "modal" });
}
} catch (e) {
notifyError(e, "modal");
} finally {
setBusy(null);
if (fileRef.current) fileRef.current.value = "";
}
};
const jdk = items?.find((x) => x.jdkRequired);
return (
<OpsModal variant="info" title="驱动管理" onOk={onClose}>
<div className="space-y-2" style={{ minWidth: 480 }}>
{/* 环境状态行 */}
<div
className="flex items-center gap-3 rounded border px-2.5 py-1.5 text-[11.5px]"
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
>
<span style={{ color: "var(--ops-muted)" }}>JVM:</span>
{jdk?.jdkOk ? (
<span style={{ color: "var(--ops-accent)" }}>已检测到 Java {jdk.javaVersion}</span>
) : (
<span style={{ color: "var(--ops-danger)" }}>未找到 Java(agent 驱动需 JDK 17+)</span>
)}
<span style={{ color: "var(--ops-muted)" }}>·</span>
<span style={{ color: "var(--ops-muted)" }}>agent:</span>
<span style={{ color: items?.[0]?.agentInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
{items?.[0]?.agentInstalled ? "已安装" : "缺失(连接时自动下载)"}
</span>
</div>
{/* 驱动行 */}
{items === null && <div className="py-3 text-[12px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
{items?.map((d) => {
const done = d.allInstalled;
const badge = !d.jdkOk
? { t: "缺 JDK", c: "var(--ops-danger)" }
: done
? { t: "已安装", c: "var(--ops-accent)" }
: d.license === "proprietary"
? { t: "需自备 jar", c: "var(--ops-warning)" }
: { t: "未安装", c: "var(--ops-muted)" };
const total = d.jars.reduce((s, j) => s + (j.size || 0), 0);
const got = d.jars.filter((j) => j.installed).length;
return (
<div
key={d.id}
className="flex items-center gap-2.5 rounded border px-2.5 py-2"
style={{ borderColor: "var(--ops-border-soft)" }}
>
<DriverIcon driver={d.id} size={20} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[12.5px] font-medium">{LABELS[d.id] ?? d.label}</span>
<span
className="rounded px-1 text-[10px]"
style={{ color: badge.c, background: `color-mix(in srgb, ${badge.c} 12%, transparent)` }}
>
{badge.t}
</span>
</div>
<div className="text-[10.5px]" style={{ color: "var(--ops-muted)" }}>
{d.license === "proprietary"
? "商用许可不代下载:把官方 DmJdbcDriver jar 放到 ~/.opsxdb/drivers/dameng/"
: d.jars.length > 0
? `驱动 jar ${got}/${d.jars.length}${total > 0 ? ` · ${fmtSize(total)}` : ""}${d.autoFetch ? " · 连接时自动下载" : ""}`
: "—"}
</div>
</div>
{d.autoFetch && !done && d.jdkOk && (
<button
type="button"
className="ops-btn-secondary h-6 px-2 text-[11px]"
disabled={busy !== null}
onClick={() => void install(d.id)}
>
{busy === d.id ? "安装中…" : "安装"}
</button>
)}
</div>
);
})}
{/* 离线包导入 */}
<div
className="flex items-center gap-2 border-t pt-2 text-[11px]"
style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)" }}
>
<span>内网/离线:</span>
<button
type="button"
className="ops-btn-secondary h-6 px-2 text-[11px]"
disabled={busy !== null}
onClick={() => fileRef.current?.click()}
>
{busy === "import" ? "导入中…" : "导入离线驱动包…"}
</button>
<span className="truncate">(opsxdb-driver 仓库 pack.sh 产物 .zip)</span>
<input
ref={fileRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void importZip(f);
}}
/>
</div>
</div>
</OpsModal>
);
}
+152 -139
View File
@@ -1,39 +1,22 @@
import { useEffect } from "react";
import { useWorkspaceStore } from "./store";
import type { ConfirmRequest } from "./store";
import type { NotifyKind, NotifyPayload } from "./notify";
import { OpsModal } from "./OpsModal";
import { settleInputRequest, useInputRequest } from "./dialogs";
function cx(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(" ");
}
// C6:items 字段由 WS-C 在 store.ts 正式补充,此处本地类型扩展先行渲染
type ConfirmRequestWithItems = ConfirmRequest & { items?: string[] };
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: "成功",
},
/**
* 分级语义色:统一走 --ops-* 令牌(修 V2,去除 slate 硬编码),亮暗主题随行
* error→danger / warning→warning / info→accent / success→success
*/
const KIND_META: Record<NotifyKind, { color: string; label: string }> = {
error: { color: "var(--ops-danger)", label: "错误" },
warning: { color: "var(--ops-warning)", label: "提示" },
info: { color: "var(--ops-accent)", label: "信息" },
success: { color: "var(--ops-success)", label: "成功" },
};
function KindIcon({ kind }: { kind: NotifyKind }) {
@@ -41,93 +24,94 @@ function KindIcon({ kind }: { kind: NotifyKind }) {
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)}>
<div
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-sm font-bold"
style={{
background: `color-mix(in srgb, ${meta.color} 15%, transparent)`,
color: meta.color,
}}
>
{glyph}
</div>
);
}
/** 通知模态:走 OpsModal info 变体(Esc/遮罩关闭、焦点管理由 OpsModal 负责) */
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">
<OpsModal
variant="info"
title={
<span className="flex items-center gap-2.5">
<KindIcon kind={n.kind} />
<div className="min-w-0 flex-1">
<div className={cx("text-[11px] font-semibold uppercase tracking-wider", meta.badge)}>
<span>
<span
className="block font-semibold uppercase"
style={{ fontSize: "var(--ops-text-xs)", letterSpacing: "0.05em", color: meta.color }}
>
{meta.label}
</div>
<h2 id={`notify-title-${n.id}`} className="mt-0.5 text-base font-semibold text-slate-100">
</span>
<span className="block" style={{ fontSize: "var(--ops-text-lg)" }}>
{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}
</span>
</span>
</span>
}
onOk={onClose}
>
<p className="m-0">{n.message}</p>
{n.tips && n.tips.length > 0 && (
<ul
className="m-0 mt-2 space-y-1.5 rounded border px-3 py-2.5"
style={{
fontSize: "var(--ops-text-sm)",
borderColor: "var(--ops-border-soft)",
background: "var(--ops-surface-2)",
color: "var(--ops-muted)",
listStyle: "none",
}}
>
{n.tips.map((t) => (
<li key={t} className="flex gap-2">
<span className="mt-0.5" style={{ color: "var(--ops-accent)" }}>
•
</span>
<span>{t}</span>
</li>
))}
</ul>
)}
{n.detail && (
<details
className="group mt-2 rounded border"
style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-editor-bg)" }}
>
<summary
className="cursor-pointer select-none px-3 py-2 hover:underline"
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
>
✕
</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>
技术详情
</summary>
<pre
className="max-h-40 overflow-auto border-t px-3 py-2 font-mono leading-5 whitespace-pre-wrap break-all"
style={{
fontSize: "var(--ops-text-xs)",
borderColor: "var(--ops-border-soft)",
color: "var(--ops-muted)",
}}
>
{n.detail}
</pre>
</details>
)}
</OpsModal>
);
}
/** Toast:保留分级图标 + 自动关闭,样式走令牌 */
function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
const meta = KIND_META[n.kind];
useEffect(() => {
@@ -138,20 +122,23 @@ function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
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,
)}
className="ops-toast"
style={{ borderColor: `color-mix(in srgb, ${meta.color} 45%, var(--ops-border))` }}
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 className="font-semibold" style={{ fontSize: "var(--ops-text-md)", color: "var(--ops-text)" }}>
{n.title}
</div>
<div className="mt-0.5 leading-relaxed" style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}>
{n.message}
</div>
</div>
<button
type="button"
className="rounded px-1.5 text-slate-500 hover:text-slate-300"
className="ops-btn-ghost"
style={{ height: "auto", padding: "2px 6px" }}
aria-label="关闭"
onClick={onClose}
>
@@ -161,39 +148,61 @@ function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
);
}
/** 危险/普通确认框:走 OpsModal confirm 变体;items 语句清单前 3 条 + 折叠其余(修 C2) */
function ConfirmCard() {
const confirm = useWorkspaceStore((s) => s.confirm);
const confirm = useWorkspaceStore((s) => s.confirm) as ConfirmRequestWithItems | null;
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;
const items = confirm.items;
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>
<OpsModal
variant="confirm"
title={confirm.title}
danger={confirm.danger}
okLabel={confirm.okLabel || "确定"}
zIndex="var(--ops-z-confirm)"
onOk={() => resolveConfirm(true)}
onCancel={() => resolveConfirm(false)}
>
<p className="m-0">{confirm.message}</p>
{items && items.length > 0 && (
<div className="ops-modal-items">
{items.slice(0, 3).map((s, i) => (
<div key={i}>{s}</div>
))}
{items.length > 3 && (
<details className="mt-1">
<summary className="cursor-pointer select-none" style={{ color: "var(--ops-accent)" }}>
还有 {items.length - 3} 条,展开查看
</summary>
{items.slice(3).map((s, i) => (
<div key={i + 3} className="mt-1">
{s}
</div>
))}
</details>
)}
</div>
</div>
</div>
)}
</OpsModal>
);
}
/** askInput(C6)渲染宿主:队列中的输入请求逐个以 OpsModal input 变体展示 */
function DialogHost() {
const req = useInputRequest();
if (!req) return null;
return (
<OpsModal
key={req.id}
variant="input"
title={req.title}
label={req.label}
defaultValue={req.defaultValue}
placeholder={req.placeholder}
onOk={(v) => settleInputRequest(v ?? null)}
onCancel={() => settleInputRequest(null)}
/>
);
}
@@ -206,7 +215,11 @@ export function NotifyHost() {
<>
{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">
<DialogHost />
<div
className="pointer-events-none fixed bottom-4 right-4 flex flex-col-reverse gap-2"
style={{ zIndex: "var(--ops-z-toast)" }}
>
{toasts.map((n) => (
<ToastCard key={n.id} n={n} onClose={() => dismissNotify(n.id)} />
))}
File diff suppressed because it is too large. Load diff
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
/**
* OpsModal — 统一模态基件(设计规范 v2 §2.2)
*
* - variant: info(仅"确定")/ confirm(取消+确定)/ input(带输入框,返回字符串)
* - danger: 主按钮渲染为红色(危险操作确认)
* - 可访问性:焦点陷阱(Tab 在模态内循环)、关闭后焦点归还触发元素、
* Esc 关闭、遮罩点击关闭
* - 样式全部走 styles.css 的 --ops-* 令牌,亮暗主题随行
*/
export type OpsModalVariant = "info" | "confirm" | "input";
export type OpsModalProps = {
title: ReactNode;
variant?: OpsModalVariant;
/** 主按钮红色(删除/截断等危险确认) */
danger?: boolean;
okLabel?: string;
cancelLabel?: string;
/** input 变体:输入框标签 */
label?: string;
/** input 变体:默认值 */
defaultValue?: string;
/** input 变体:占位符 */
placeholder?: string;
/** 层级,默认 var(--ops-z-modal);确认框传 var(--ops-z-confirm) */
zIndex?: string | number;
/** 宽版(max-w-2xl),DDL 等长文本展示用 */
wide?: boolean;
/** 确认回调;input 变体携带输入值 */
onOk: (value?: string) => void;
/** 取消回调(Esc / 遮罩点击 / 取消按钮),info 变体缺省时回落到 onOk */
onCancel?: () => void;
children?: ReactNode;
};
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function OpsModal(props: OpsModalProps) {
const {
title,
variant = "info",
danger = false,
okLabel = "确定",
cancelLabel = "取消",
label,
defaultValue = "",
placeholder,
zIndex = "var(--ops-z-modal)",
wide = false,
onOk,
onCancel,
children,
} = props;
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(defaultValue);
// 回调放 ref,避免键盘监听因回调变化反复挂载
const cbRef = useRef({ onOk, onCancel });
cbRef.current = { onOk, onCancel };
const cancel = () => (cbRef.current.onCancel ?? cbRef.current.onOk)();
const submit = () => cbRef.current.onOk(variant === "input" ? value : undefined);
// 焦点管理:打开时聚焦输入框/主按钮,Tab 在模态内循环,Esc 关闭,卸载时焦点归还触发元素
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const prev = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement,
);
const initial =
variant === "input" && inputRef.current ? inputRef.current : focusables().pop();
initial?.focus();
if (variant === "input") inputRef.current?.select();
const onKey = (e: KeyboardEvent) => {
// 模态可叠加(如确认框盖在通知模态上):仅最上层响应键盘
const overlays = document.querySelectorAll(".ops-modal-overlay");
if (overlays.length && overlays[overlays.length - 1] !== overlayRef.current) return;
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancel();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (!active || !dialog.contains(active) || active === first) {
e.preventDefault();
last.focus();
}
} else if (!active || !dialog.contains(active) || active === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("keydown", onKey, true);
// 焦点归还触发元素
prev?.focus?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={overlayRef}
className="ops-modal-overlay"
style={{ zIndex }}
onMouseDown={(e) => {
// 仅点击遮罩本体(非对话框内容)才关闭
if (e.target === overlayRef.current) cancel();
}}
>
<div
ref={dialogRef}
className={`ops-modal w-full ${wide ? "max-w-2xl" : "max-w-md"}`}
role="alertdialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : undefined}
>
<div className="ops-modal-header">{title}</div>
<div className="ops-modal-body">
{children}
{variant === "input" && (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{label && (
<label
htmlFor="ops-modal-input"
className="mb-1 block"
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
>
{label}
</label>
)}
<input
id="ops-modal-input"
ref={inputRef}
type="text"
className="ops-input w-full"
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
/>
</form>
)}
</div>
<div className="ops-modal-footer">
{variant !== "info" && (
<button type="button" className="ops-btn-secondary min-w-[72px]" onClick={cancel}>
{cancelLabel}
</button>
)}
<button
type="button"
className={`${danger ? "ops-btn-danger" : "ops-btn-primary"} min-w-[72px]`}
onClick={submit}
>
{okLabel}
</button>
</div>
</div>
</div>
);
}
+879
View File
@@ -0,0 +1,879 @@
import { useEffect, useMemo, useState } from "react";
import { execSql, getRedisKey } from "./api";
import { ensureSession, withSessionRetry } from "./ObjectTree";
import { OpsModal } from "./OpsModal";
import {
displayRedisValue,
formatRedisTtl,
redisCmd,
redisTypeColor,
tryPrettyJson,
tsLabel,
} from "./redis";
import { useWorkspaceStore } from "./store";
type ValueMode = "table" | "json";
/** 表格首屏渲染行数,超出走「加载更多」 */
const INITIAL_VISIBLE = 100;
const PAGE_STEP = 200;
/** 单元格单行摘要:JSON 压回单行、空白折叠、超长截断 —— 表格保持紧凑,完整 pretty 留给查看弹层 */
function cellSummary(raw: string, pretty: string | null): { text: string; more: boolean } {
const s = pretty ? JSON.stringify(JSON.parse(pretty)) : raw;
const one = s.replace(/\s+/g, " ").trim();
if (one.length > 160) return { text: `${one.slice(0, 160)}…`, more: true };
return { text: one, more: false };
}
type LineEdit = {
field: string;
value: string;
score: string;
isNew: boolean;
oldField?: string;
index?: number;
};
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}
function cellString(v: unknown): string {
if (v == null) return "";
return String(v);
}
export function RedisKeyPane() {
const result = useWorkspaceStore((s) => s.result);
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 askConfirm = useWorkspaceStore((s) => s.askConfirm);
const set = useWorkspaceStore((s) => s.set);
const conn = connections.find((c) => c.id === activeConnectionId);
const keyName = resultMeta?.table ?? "";
const dbName = resultMeta?.database ?? conn?.database ?? "0";
const type = resultMeta?.redisType || "string";
const [ttlDraft, setTtlDraft] = useState(String(resultMeta?.redisTtl ?? -1));
const [keyDraft, setKeyDraft] = useState(keyName);
const [mode, setMode] = useState<ValueMode>(type === "string" ? "json" : "table");
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState(false);
const [lineEdit, setLineEdit] = useState<LineEdit | null>(null);
/** 行内 JSON 放大查看(pretty 全文) */
const [cellViewer, setCellViewer] = useState<string | null>(null);
/** string 的 JSON 视图可编辑草稿(pretty);保存时校验并压缩回写 */
const [prettyDraft, setPrettyDraft] = useState<string | null>(null);
/** 列排序:null=默认序;dir 1 升 / -1 降 */
const [sort, setSort] = useState<{ col: string; dir: 1 | -1 } | null>(null);
/** 双击单元格就地编辑:i=行号(filtered 内)、col=列 */
const [inline, setInline] = useState<{ i: number; col: string; draft: string } | null>(null);
/** 表格分批渲染:首屏条数与当前已显示条数(宽/大键不全量铺 DOM) */
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
const [stringDraft, setStringDraft] = useState(() => cellString(result?.rows?.[0]?.value));
useEffect(() => {
setKeyDraft(keyName);
setTtlDraft(String(resultMeta?.redisTtl ?? -1));
setStringDraft(cellString(result?.rows?.[0]?.value));
setFilter("");
setMode(type === "string" ? "json" : "table");
setVisibleCount(INITIAL_VISIBLE);
setSort(null);
setInline(null);
}, [keyName, type, resultMeta?.redisTtl, result]);
const rows = result?.rows ?? [];
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return rows;
return rows.filter((r) => Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(q)));
}, [rows, filter]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const { col, dir } = sort;
return [...filtered].sort((a, b) => {
const av = a[col];
const bv = b[col];
if (col === "score") return ((Number(av) || 0) - (Number(bv) || 0)) * dir;
return String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true }) * dir;
});
}, [filtered, sort]);
const jsonText = useMemo(() => {
if (type === "string") {
const raw = cellString(rows[0]?.value);
return tryPrettyJson(raw) || raw;
}
if (type === "hash") {
const obj: Record<string, unknown> = {};
for (const r of rows) {
const f = cellString(r.field);
const v = r.value;
const parsed = tryPrettyJson(v);
obj[f] = parsed ? JSON.parse(parsed) : v;
}
return JSON.stringify(obj, null, 2);
}
return JSON.stringify(rows, null, 2);
}, [rows, type]);
// string 的 JSON 草稿随数据刷新同步(保存/重载后回到干净态)
useEffect(() => {
if (type === "string") setPrettyDraft(jsonText);
}, [jsonText, type]);
const reload = async () => {
if (!conn || !keyName) return;
setBusy(true);
try {
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { 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("resultMeta", {
database: dbName,
table: keyName,
pkCols: [],
redisType: res.type,
redisTtl: res.ttl,
});
setTtlDraft(String(res.ttl ?? -1));
setKeyDraft(keyName);
if (res.type === "string") setStringDraft(cellString(res.rows?.[0]?.value));
set("status", `${keyName} · ${res.type} · TTL ${formatRedisTtl(res.ttl)}`);
} catch (e) {
notifyError(e, "toast");
} finally {
setBusy(false);
}
};
const runCmd = async (sql: string, okMsg?: string, opts?: { reload?: boolean }) => {
if (!conn) return false;
setBusy(true);
try {
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName }));
if (res.error) throw new Error(res.error);
if (okMsg) notify({ kind: "success", title: okMsg, message: keyName, mode: "toast", durationMs: 1400 });
if (opts?.reload !== false) await reload();
return true;
} catch (e) {
notifyError(e, "modal");
return false;
} finally {
setBusy(false);
}
};
const saveTtl = async () => {
const n = Number(ttlDraft);
if (!Number.isFinite(n)) {
notify({ kind: "warning", title: "TTL", message: "请输入秒数,-1 表示永久", mode: "toast" });
return;
}
if (n < 0) await runCmd(redisCmd("PERSIST", keyName), "已设为永久");
else await runCmd(redisCmd("EXPIRE", keyName, String(Math.floor(n))), "已更新 TTL");
};
const rename = async () => {
const next = keyDraft.trim();
if (!next || next === keyName) return;
const ok = await runCmd(redisCmd("RENAME", keyName, next), "已重命名");
if (ok) {
set("resultMeta", {
database: dbName,
table: next,
pkCols: [],
redisType: type,
redisTtl: resultMeta?.redisTtl,
});
setKeyDraft(next);
}
};
/** 服务端游标续载:追加行而不是整键替换(hash/set=SCAN 游标;list/zset=偏移) */
const loadMore = async () => {
if (!conn || !keyName) return;
const cur = result?.nextCursor ?? 0;
if (!cur) return;
setBusy(true);
try {
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: result?.limit || 500, cursor: cur }));
if (res.error) throw new Error(res.error);
const merged = [...(result?.rows ?? []), ...(res.rows ?? [])];
set("result", {
columns: result?.columns ?? res.columns ?? [],
rows: merged,
rowCount: merged.length,
timeMs: result?.timeMs ?? res.timeMs ?? 0,
truncated: res.truncated,
nextCursor: res.nextCursor,
limit: result?.limit ?? res.limit,
});
setVisibleCount((v) => v + PAGE_STEP);
} catch (e) {
notifyError(e, "toast");
} finally {
setBusy(false);
}
};
/** 双击单元格就地编辑提交:按类型/列生成与「编辑」弹窗一致的命令 */
const commitInline = async (row: Record<string, unknown>, col: string, draft: string) => {
let sql = "";
if (type === "hash") {
if (col === "field") {
const old = cellString(row.field);
if (!draft.trim()) {
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
return;
}
if (draft !== old) sql = `${redisCmd("HDEL", keyName, old)}\n${redisCmd("HSET", keyName, draft, cellString(row.value))}`;
else return;
} else {
if (draft === cellString(row.value)) return;
sql = redisCmd("HSET", keyName, cellString(row.field), draft);
}
} else if (type === "zset") {
if (col === "member") {
const old = cellString(row.member);
if (draft === old) return;
sql = `${redisCmd("ZREM", keyName, old)}\n${redisCmd("ZADD", keyName, cellString(row.score), draft)}`;
} else {
if (String(draft) === cellString(row.score)) return;
if (Number.isNaN(Number(draft))) {
notify({ kind: "warning", title: "Score", message: "必须是数字", mode: "toast" });
return;
}
sql = redisCmd("ZADD", keyName, String(draft), cellString(row.member));
}
} else if (type === "set") {
const old = cellString(row.member ?? row.value);
if (draft === old) return;
sql = `${redisCmd("SREM", keyName, old)}\n${redisCmd("SADD", keyName, draft)}`;
} else if (type === "list") {
if (draft === cellString(row.value)) return;
sql = redisCmd("LSET", keyName, cellString(row.index ?? 0), draft);
}
if (!sql) return;
setInline(null);
for (const p of sql.split("\n")) {
const ok = await runCmd(p, "已更新");
if (!ok) break;
}
};
const toggleSort = (col: string) => {
setSort((s0) => (!s0 || s0.col !== col ? { col, dir: 1 } : s0.dir === 1 ? { col, dir: -1 } : null));
};
const sortTh = (col: string, label: string) => (
<button
type="button"
className="inline-flex items-center gap-0.5 hover:underline"
onClick={() => toggleSort(col)}
title="点击排序:升 → 降 → 默认"
>
{label}
{sort?.col === col ? <span className="text-[9px]">{sort.dir === 1 ? "▲" : "▼"}</span> : null}
</button>
);
/** 就地编辑输入框:Enter 提交 / Esc 取消 */
const InlineInput = ({ row, i, col, value }: { row: Record<string, unknown>; i: number; col: string; value: string }) => (
<input
autoFocus
className="ops-input h-6 w-full px-1 font-mono text-[11px]"
value={inline?.i === i && inline.col === col ? inline.draft : value}
onChange={(e) => setInline({ i, col, draft: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitInline(row, col, inline?.draft ?? value);
} else if (e.key === "Escape") setInline(null);
}}
onBlur={() => setInline(null)}
/>
);
const saveString = async () => {
let val = stringDraft;
if (type === "string" && mode === "json") {
// JSON 视图直接编辑:保存前校验并压回单行,避免把 pretty 缩进写进 Redis
const draft = prettyDraft ?? jsonText;
try {
val = JSON.stringify(JSON.parse(draft));
} catch (e) {
notify({
kind: "error",
title: "JSON 解析失败",
message: e instanceof Error ? e.message : String(e),
mode: "toast",
durationMs: 2600,
});
return;
}
}
await runCmd(redisCmd("SET", keyName, val), "已保存");
};
const deleteKey = async () => {
const ok = await askConfirm({
title: "删除键",
message: `确认删除 Redis 键「${keyName}」?此操作不可恢复。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
const done = await runCmd(redisCmd("DEL", keyName), "已删除", { reload: false });
if (done) {
set("result", null);
set("resultMeta", { database: dbName, pkCols: [] });
}
};
const openAdd = () => {
setLineEdit({
field: type === "hash" ? "" : type === "zset" ? "" : "",
value: "",
score: "0",
isNew: true,
});
};
const openEditRow = (row: Record<string, unknown>, index: number) => {
if (type === "hash") {
setLineEdit({
field: cellString(row.field),
value: cellString(row.value),
score: "0",
isNew: false,
oldField: cellString(row.field),
});
return;
}
if (type === "list") {
setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index });
return;
}
if (type === "set") {
setLineEdit({
field: "",
value: cellString(row.member ?? row.value),
score: "0",
isNew: false,
oldField: cellString(row.member ?? row.value),
});
return;
}
if (type === "zset") {
setLineEdit({
field: cellString(row.member),
value: cellString(row.member),
score: cellString(row.score),
isNew: false,
oldField: cellString(row.member),
});
}
};
const commitLine = async () => {
if (!lineEdit) return;
let sql = "";
if (type === "hash") {
if (!lineEdit.field.trim()) {
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
return;
}
if (!lineEdit.isNew && lineEdit.oldField && lineEdit.oldField !== lineEdit.field) {
sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, lineEdit.value)}`;
} else {
sql = redisCmd("HSET", keyName, lineEdit.field, lineEdit.value);
}
} else if (type === "list") {
if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, lineEdit.value);
else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), lineEdit.value);
} else if (type === "set") {
if (lineEdit.isNew) sql = redisCmd("SADD", keyName, lineEdit.value);
else if (lineEdit.oldField && lineEdit.oldField !== lineEdit.value) {
sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, lineEdit.value)}`;
} else sql = redisCmd("SADD", keyName, lineEdit.value);
} else if (type === "zset") {
const member = lineEdit.value || lineEdit.field;
if (lineEdit.isNew) sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
else if (lineEdit.oldField && lineEdit.oldField !== member) {
sql = `${redisCmd("ZREM", keyName, lineEdit.oldField)}\n${redisCmd("ZADD", keyName, lineEdit.score || "0", member)}`;
} else sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
}
if (!sql) return;
const parts = sql.split("\n");
setLineEdit(null);
for (const p of parts) {
const ok = await runCmd(p, lineEdit.isNew ? "已新增" : "已更新");
if (!ok) break;
}
};
const deleteLine = async (row: Record<string, unknown>, index: number) => {
const ok = await askConfirm({
title: "删除元素",
message: "确认删除这一行?",
okLabel: "删除",
danger: true,
});
if (!ok) return;
if (type === "hash") await runCmd(redisCmd("HDEL", keyName, cellString(row.field)), "已删除字段");
else if (type === "set") await runCmd(redisCmd("SREM", keyName, cellString(row.member ?? row.value)), "已删除成员");
else if (type === "zset") await runCmd(redisCmd("ZREM", keyName, cellString(row.member)), "已删除成员");
else if (type === "list") {
const val = cellString(row.value);
await runCmd(redisCmd("LREM", keyName, "1", val), "已删除元素");
void index;
}
};
/** 单行重建命令:复制后可直接在别处回放(zset→ZADD score member 等) */
const rowCommand = (row: Record<string, unknown>): string => {
switch (type) {
case "hash":
return redisCmd("HSET", keyName, cellString(row.field), cellString(row.value));
case "zset":
return redisCmd("ZADD", keyName, cellString(row.score), cellString(row.member));
case "set":
return redisCmd("SADD", keyName, cellString(row.member ?? row.value));
case "list":
return redisCmd("RPUSH", keyName, cellString(row.value));
default:
return cellString(row.value ?? row.member ?? "");
}
};
const dumpClipboard = async () => {
const lines: string[] = [];
if (type === "string") lines.push(redisCmd("SET", keyName, cellString(rows[0]?.value)));
else if (type === "hash") {
for (const r of rows) lines.push(redisCmd("HSET", keyName, cellString(r.field), cellString(r.value)));
} else if (type === "list") {
for (const r of rows) lines.push(redisCmd("RPUSH", keyName, cellString(r.value)));
} else if (type === "set") {
for (const r of rows) lines.push(redisCmd("SADD", keyName, cellString(r.member ?? r.value)));
} else if (type === "zset") {
for (const r of rows) lines.push(redisCmd("ZADD", keyName, cellString(r.score), cellString(r.member)));
}
const ttl = resultMeta?.redisTtl ?? -1;
if (ttl >= 0) lines.push(redisCmd("EXPIRE", keyName, String(ttl)));
await copyText(lines.join("\n") || redisCmd("GET", keyName));
notify({ kind: "success", title: "已复制命令", message: `${lines.length} 条`, mode: "toast", durationMs: 1400 });
};
const addLabel =
type === "hash" ? "新增字段" : type === "list" ? "追加元素" : type === "zset" ? "新增成员" : type === "set" ? "新增成员" : null;
const colTitle =
type === "hash" ? `ID(共 ${filtered.length})` : type === "list" ? "Index" : type === "zset" ? "#" : "#";
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-2 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<span
className="rounded px-2 py-0.5 text-[11px] font-semibold uppercase"
style={{ background: `${redisTypeColor(type)}22`, color: redisTypeColor(type) }}
>
{type}
</span>
<input
className="ops-input h-7 min-w-0 flex-1 font-mono text-[12px]"
value={keyDraft}
title="回车重命名"
onChange={(e) => setKeyDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void rename();
}}
/>
<div className="flex h-7 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border)" }}>
<span className="flex items-center px-2 text-[10px]" style={{ background: "var(--ops-surface-2)", color: "var(--ops-muted)" }}>
TTL
</span>
<input
className="w-20 bg-transparent px-2 text-[12px] outline-none"
value={ttlDraft}
title="秒;-1 永久。回车保存"
onChange={(e) => setTtlDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveTtl();
}}
/>
</div>
<button type="button" className="ops-btn-secondary h-7 px-2 text-[11px]" disabled={busy} onClick={() => void saveTtl()}>
保存 TTL
</button>
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" disabled={busy} title="刷新" onClick={() => void reload()}>
刷新
</button>
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" title="复制还原命令" onClick={() => void dumpClipboard()}>
导出命令
</button>
<button
type="button"
className="ops-btn-ghost h-7 px-2 text-[11px]"
style={{ color: "var(--ops-danger)" }}
disabled={busy}
onClick={() => void deleteKey()}
>
删除
</button>
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-2 py-1" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex items-center gap-2">
{addLabel && (
<button type="button" className="ops-btn-primary h-7 px-2 text-[11px]" disabled={busy} onClick={openAdd}>
{addLabel}
</button>
)}
{type === "string" && (
<button
type="button"
className="ops-btn-primary h-7 px-2 text-[11px]"
disabled={
busy ||
(mode === "json"
? (prettyDraft ?? jsonText) === jsonText
: stringDraft === cellString(rows[0]?.value))
}
onClick={() => void saveString()}
>
保存值
</button>
)}
<div className="ops-seg">
<button type="button" className={mode === "table" ? "ops-seg-active" : undefined} onClick={() => setMode("table")}>
{type === "string" ? "原文" : "结构"}
</button>
<button type="button" className={mode === "json" ? "ops-seg-active" : undefined} onClick={() => setMode("json")}>
JSON
</button>
</div>
</div>
{type !== "string" && (
<input
className="ops-input h-7 w-48 text-[11px]"
placeholder="搜索字段或值"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
)}
</div>
{result?.truncated && (
<div
className="shrink-0 px-2 py-1 text-[11px]"
style={{ color: "var(--ops-warning)", background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)" }}
>
{(result?.nextCursor ?? 0) > 0
? `已分页加载前 ${rows.length} 行,底部可继续加载。`
: `已截断,最多 ${result.limit || 500} 行。用过滤或命令缩小范围。`}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto">
{type === "string" || mode === "json" ? (
type === "string" && mode === "table" ? (
<textarea
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
value={stringDraft}
spellCheck={false}
onChange={(e) => setStringDraft(e.target.value)}
/>
) : type === "string" ? (
<textarea
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
value={mode === "json" ? prettyDraft ?? jsonText : stringDraft}
spellCheck={false}
onChange={(e) => {
if (mode === "json") setPrettyDraft(e.target.value);
else setStringDraft(e.target.value);
}}
placeholder="JSON 视图可直接编辑;保存时自动校验并压缩回写"
/>
) : (
<pre className="m-0 whitespace-pre-wrap break-all p-3 font-mono text-[12px] leading-relaxed">{jsonText}</pre>
)
) : (
<table className="ops-grid" style={{ tableLayout: "fixed", width: "100%" }}>
<thead>
<tr>
<th style={{ width: 72 }}>{colTitle}</th>
{type === "hash" && <th style={{ width: "28%" }}>{sortTh("field", "Key")}</th>}
{type === "zset" && <th style={{ width: "56%" }}>{sortTh("member", "Member")}</th>}
{type === "zset" && <th style={{ width: 120 }}>{sortTh("score", "Score")}</th>}
{type !== "zset" && <th>{type === "list" ? "Value" : sortTh("value", "Value")}</th>}
<th style={{ width: 108 }} />
</tr>
</thead>
<tbody>
{sorted.slice(0, visibleCount).map((row, i) => {
const valueRaw = type === "hash" ? row.value : type === "zset" ? row.member : row.member ?? row.value;
const shown = displayRedisValue(valueRaw);
const valuePretty = tryPrettyJson(cellString(valueRaw));
const summary = cellSummary(cellString(valueRaw), valuePretty);
const scoreTs = type === "zset" ? tsLabel(Number(row.score)) : null;
return (
<tr key={i}>
<td className="ops-rownum">{type === "list" ? cellString(row.index ?? i) : i + 1}</td>
{type === "hash" && (
<td className="font-mono" title={cellString(row.field)}>
{inline?.i === i && inline.col === "field" ? (
<InlineInput row={row} i={i} col="field" value={cellString(row.field)} />
) : (
<span
className="cursor-text"
title="双击重命名"
onDoubleClick={() => setInline({ i, col: "field", draft: cellString(row.field) })}
>
{cellString(row.field)}
</span>
)}
</td>
)}
{type === "zset" && (
<td className="font-mono">
{valuePretty ? (
<button
type="button"
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
style={{ cursor: "zoom-in" }}
title="点击查看完整 JSON"
onClick={() => setCellViewer(valuePretty)}
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
{summary.more && (
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
更多
</span>
)}
</button>
) : inline?.i === i && inline.col === "member" ? (
<InlineInput row={row} i={i} col="member" value={cellString(row.member)} />
) : (
<span
className="cursor-text"
title="双击编辑"
onDoubleClick={() => setInline({ i, col: "member", draft: cellString(row.member) })}
>
{cellString(row.member)}
</span>
)}
</td>
)}
{type === "zset" && (
<td className="font-mono" title={scoreTs ?? "双击改分"}>
{inline?.i === i && inline.col === "score" ? (
<InlineInput row={row} i={i} col="score" value={cellString(row.score)} />
) : (
<div
className="cursor-text"
onDoubleClick={() => setInline({ i, col: "score", draft: cellString(row.score) })}
>
{cellString(row.score)}
{scoreTs && <div className="text-[10px] leading-3 opacity-60">{scoreTs}</div>}
</div>
)}
</td>
)}
{type !== "zset" && (
<td title={valuePretty ? "点击查看完整 JSON" : shown.pretty ?? cellString(valueRaw)}>
{valuePretty || cellString(valueRaw).length > 180 ? (
<button
type="button"
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
style={{ cursor: "zoom-in" }}
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
{summary.more && (
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
更多
</span>
)}
</button>
) : inline?.i === i && inline.col === "value" ? (
<InlineInput row={row} i={i} col="value" value={cellString(valueRaw)} />
) : (
<pre
className="m-0 max-h-24 cursor-text overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-snug"
title="双击编辑"
onDoubleClick={() => setInline({ i, col: "value", draft: cellString(valueRaw) })}
>
{shown.text}
</pre>
)}
</td>
)}
<td>
<div className="flex items-center gap-0.5">
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
title="复制值(纯文本)"
onClick={() => {
const val = cellString(valueRaw);
void copyText(val).then(() =>
notify({ kind: "success", title: "已复制值", message: "", mode: "toast", durationMs: 1200 }),
);
}}
>
⧉
</button>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
title={`复制重建命令(${type === "zset" ? "ZADD key score member" : type === "hash" ? "HSET key field value" : type === "set" ? "SADD key member" : "RPUSH key value"})`}
onClick={() => {
const cmd = rowCommand(row);
void copyText(cmd).then(() =>
notify({ kind: "success", title: "已复制命令", message: cmd, mode: "toast", durationMs: 1600 }),
);
}}
>
⌘
</button>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
title={valuePretty ? "查看 JSON" : "查看文本"}
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
>
{valuePretty ? "{}" : "☰"}
</button>
<button type="button" className="ops-btn-ghost px-1 text-[11px]" title="编辑" onClick={() => openEditRow(row, i)}>
✎
</button>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[11px]"
style={{ color: "var(--ops-danger)" }}
title="删除此行"
onClick={() => void deleteLine(row, i)}
>
×
</button>
</div>
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-6 text-center" style={{ color: "var(--ops-muted)" }}>
{rows.length === 0 ? "空值" : "无匹配"}
</td>
</tr>
)}
{(result?.nextCursor ?? 0) > 0 ? (
<tr>
<td colSpan={4} className="px-3 py-2 text-center">
<button
type="button"
className="ops-btn-ghost text-[11px]"
disabled={busy}
onClick={() => void loadMore()}
>
加载更多(服务端续载 · 已加载 {rows.length} 行)
</button>
</td>
</tr>
) : filtered.length > visibleCount ? (
<tr>
<td colSpan={4} className="px-3 py-2 text-center">
<button
type="button"
className="ops-btn-ghost text-[11px]"
onClick={() => setVisibleCount((v) => v + PAGE_STEP)}
>
加载更多(已显示 {visibleCount} / 共 {filtered.length} 行)
</button>
</td>
</tr>
) : null}
</tbody>
</table>
)}
</div>
{lineEdit && (
<OpsModal
title={lineEdit.isNew ? addLabel || "新增" : "编辑"}
variant="confirm"
okLabel="保存"
onOk={() => void commitLine()}
onCancel={() => setLineEdit(null)}
>
<div className="space-y-2">
{type === "hash" && (
<label className="block text-[12px]">
<span style={{ color: "var(--ops-muted)" }}>Field</span>
<input
className="ops-input mt-1 w-full font-mono"
value={lineEdit.field}
onChange={(e) => setLineEdit({ ...lineEdit, field: e.target.value })}
/>
</label>
)}
{type === "zset" && (
<label className="block text-[12px]">
<span style={{ color: "var(--ops-muted)" }}>Score</span>
<input
className="ops-input mt-1 w-full font-mono"
value={lineEdit.score}
onChange={(e) => setLineEdit({ ...lineEdit, score: e.target.value })}
/>
</label>
)}
<label className="block text-[12px]">
<span style={{ color: "var(--ops-muted)" }}>{type === "zset" ? "Member" : "Value"}</span>
<textarea
className="ops-input mt-1 min-h-[120px] w-full font-mono text-[12px]"
value={lineEdit.value}
spellCheck={false}
onChange={(e) => setLineEdit({ ...lineEdit, value: e.target.value })}
/>
</label>
</div>
</OpsModal>
)}
{cellViewer != null && (
<OpsModal
title="JSON 查看"
variant="confirm"
okLabel="复制 JSON"
onOk={() => {
void copyText(cellViewer).then(() =>
notify({ kind: "success", title: "已复制 JSON", message: "", mode: "toast", durationMs: 1200 }),
);
setCellViewer(null);
}}
onCancel={() => setCellViewer(null)}
>
<pre
className="m-0 max-h-[56vh] overflow-auto whitespace-pre-wrap break-all rounded border p-2 font-mono text-[11px] leading-snug"
style={{ borderColor: "var(--ops-border)", background: "var(--ops-bg-soft, transparent)" }}
>
{cellViewer}
</pre>
</OpsModal>
)}
</div>
);
}
+555 -79
View File
@@ -1,12 +1,21 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react";
import { execSql } from "./api";
import { execSql, exportData, type ExportError, type ExportRequest } from "./api";
import { ensureSession } from "./ObjectTree";
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver } from "./sql";
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver, splitStatements } from "./sql";
import { RedisKeyPane } from "./RedisKeyPane";
import { SchemaPane } from "./SchemaPane";
import { useWorkspaceStore } from "./store";
const DISPLAY_CAP = 2000;
/** C5:仅单条 SELECT(含 WITH)启用分页 */
function isSingleSelect(sql: string): boolean {
const stmts = splitStatements(sql).map((r) => r.text.trim()).filter(Boolean);
if (stmts.length !== 1) return false;
const s = stmts[0].replace(/^(\s|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/g, "");
return /^(select|with)\b/i.test(s);
}
function formatCell(v: unknown) {
if (v == null) return <span className="ops-null">NULL</span>;
if (typeof v === "object") return JSON.stringify(v);
@@ -15,6 +24,13 @@ function formatCell(v: unknown) {
return s;
}
/** 值是否为被截断展示的长文本(修 G4:提供查看/复制全文 popover) */
function longText(v: unknown): string | null {
if (v == null || typeof v === "object") return null;
const s = String(v);
return s.length > 120 ? s : null;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}
@@ -44,12 +60,12 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
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 redisInspect = (conn?.driver || "").toLowerCase() === "redis" && !!resultMeta?.redisType && !!resultMeta?.table;
const [sortCol, setSortCol] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc" | null>(null);
@@ -57,18 +73,47 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
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);
/** 长值 popover(修 G4) */
const [peek, setPeek] = useState<{ text: string; x: number; y: number } | null>(null);
const [paging, setPaging] = useState(false);
/** R2-F2:导出中心下拉菜单 */
const [expMenu, setExpMenu] = useState(false);
const [exporting, setExporting] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{ col: string; startX: number; startW: number } | null>(null);
const editRef = useRef(edit);
editRef.current = edit;
const activeRs = resultSets.find((r) => r.id === activeResultSetId) ?? null;
const updateResultSet = useWorkspaceStore((s) => s.updateResultSet);
// 切换结果集时重置排序/编辑,并恢复该结果集持久化的列宽与选中态(修 G3)
useEffect(() => {
setSortCol(null);
setSortDir(null);
setSelectedIdx(null);
setSelectedCol(null);
setColWidths({});
setEdit(null);
}, [result]);
setPeek(null);
const cur = useWorkspaceStore.getState().resultSets.find((x) => x.id === activeResultSetId);
const vs = cur?.viewState;
setColWidths(vs?.colWidths ?? {});
setSelectedIdx(vs?.selectedIdx ?? null);
setSelectedCol(vs?.selectedCol ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeResultSetId, result]);
// 列宽/选中态变化时写回 resultSet(内容相同则跳过,避免回环)
useEffect(() => {
if (!activeResultSetId) return;
const cur = useWorkspaceStore.getState().resultSets.find((x) => x.id === activeResultSetId);
if (!cur) return;
const prev = cur.viewState;
const same =
JSON.stringify(prev?.colWidths ?? {}) === JSON.stringify(colWidths) &&
(prev?.selectedIdx ?? null) === selectedIdx &&
(prev?.selectedCol ?? null) === selectedCol;
if (same) return;
updateResultSet(activeResultSetId, { viewState: { colWidths, selectedIdx, selectedCol } });
}, [colWidths, selectedIdx, selectedCol, activeResultSetId, updateResultSet]);
// discard pending edit when switching tabs
useEffect(() => {
@@ -309,8 +354,9 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
const n = selectedIdx + 1;
const ok = await askConfirm({
title: "删除行",
message: `确认删除第 ${n} 行?不可恢复。`,
message: `确认删除第 ${n} 行?此操作不可撤销。`,
okLabel: "删除",
danger: true,
});
if (!ok) return;
const pk = resultMeta.pkCols || [];
@@ -341,11 +387,118 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
const onGridKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (edit) return;
const t = e.target as HTMLElement;
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) 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();
return;
}
// 结果网格行导航(修 K3):↑↓/PageUp/PageDown/Home/End
const moveSel = (next: number | null) => {
setSelectedIdx(next);
if (next != null) {
requestAnimationFrame(() => {
gridRef.current?.querySelector(`tr[data-row-idx="${next}"]`)?.scrollIntoView({ block: "nearest" });
});
}
};
const max = visibleRows.length - 1;
if (max < 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
moveSel(selectedIdx == null ? 0 : Math.min(max, selectedIdx + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveSel(selectedIdx == null ? 0 : Math.max(0, selectedIdx - 1));
} else if (e.key === "PageDown") {
e.preventDefault();
moveSel(selectedIdx == null ? 0 : Math.min(max, selectedIdx + 20));
} else if (e.key === "PageUp") {
e.preventDefault();
moveSel(selectedIdx == null ? 0 : Math.max(0, selectedIdx - 20));
} else if (e.key === "Home") {
e.preventDefault();
moveSel(0);
} else if (e.key === "End") {
e.preventDefault();
moveSel(max);
} else if (e.key === "Enter" && selectedIdx != null && selectedCol && canEdit) {
e.preventDefault();
beginEdit(selectedIdx, selectedCol);
}
};
/** C5:翻页 —— 重发同一语句,带 offset/limit */
const canPaginate =
!!activeRs && resultSets.length === 1 && !result?.error && isSingleSelect(activeRs.sql);
const page = activeRs?.page ?? 0;
const pageSize = activeRs?.pageSize ?? result?.limit ?? 1000;
const hasPrevPage = canPaginate && page > 0;
const hasNextPage = canPaginate && !!result?.truncated;
const gotoPage = async (p: number) => {
if (!conn || !activeRs || paging || p < 0) return;
setPaging(true);
set("status", `加载第 ${p + 1} 页…`);
try {
const sid = await ensureSession(conn);
const res = await execSql(conn, activeRs.sql, {
sessionId: sid,
database: activeRs.database || conn.database,
limit: pageSize,
offset: p * pageSize,
});
if (res.error) throw new Error(res.error);
updateResultSet(activeRs.id, { result: res, page: p, pageSize });
set("connState", "ok");
set("status", `第 ${p + 1} 页 · ${res.rowCount} 行`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
notifyError(e, "toast");
} finally {
setPaging(false);
}
};
/** E1:错误框「重试」—— 重发当前结果集语句(危险语句仍走 C1 二段确认) */
const retryRun = async () => {
if (!conn || !activeRs || paging) return;
setPaging(true);
try {
const sid = await ensureSession(conn);
let res = await execSql(conn, activeRs.sql, {
sessionId: sid,
database: activeRs.database || conn.database,
limit: pageSize,
offset: page * pageSize,
});
if (res.needConfirm) {
const ok = await askConfirm({
title: "危险语句确认",
message: res.error || "服务端检测到危险语句,确认后继续执行。",
okLabel: "仍要执行",
danger: true,
items: [activeRs.sql],
});
if (!ok) return;
res = await execSql(conn, activeRs.sql, {
sessionId: sid,
database: activeRs.database || conn.database,
limit: pageSize,
offset: page * pageSize,
confirmDanger: true,
});
}
updateResultSet(activeRs.id, { result: res });
if (!res.error) set("connState", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
notifyError(e, "toast");
} finally {
setPaging(false);
}
};
@@ -374,63 +527,166 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
kind === "csv"
? rowsToCSV(result.columns, result.rows)
: kind === "insert"
? rowsToInsert(resultMeta?.table || tableHint || "t", result.columns, result.rows)
? rowsToInsert(conn?.driver, 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 downloadBlob = (blob: Blob, filename: string) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `opsxdb-${Date.now()}.csv`;
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
};
/** R2-F2:当前结果本地导出(INSERT 走 quoteIdent 方言引号,修 01-P1-1) */
const exportCurrent = (format: "csv" | "json" | "insert") => {
if (!result || result.error) return;
const stamp = Date.now();
if (format === "csv") {
const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" });
downloadBlob(blob, `opsxdb-${stamp}.csv`);
} else if (format === "json") {
const blob = new Blob([JSON.stringify(result.rows, null, 2)], { type: "application/json;charset=utf-8" });
downloadBlob(blob, `opsxdb-${stamp}.json`);
} else {
const text = rowsToInsert(conn?.driver, resultMeta?.table || tableHint || "t", result.columns, result.rows);
const blob = new Blob([text], { type: "application/sql;charset=utf-8" });
downloadBlob(blob, `opsxdb-${stamp}.sql`);
}
notify({ kind: "success", title: "已导出", message: `当前结果 ${format.toUpperCase()}`, mode: "toast", durationMs: 1600 });
setExpMenu(false);
};
/** R2-F2/E7:服务端导出 —— needConfirm 时弹二段确认,带 confirmDanger 重发一次 */
const serverExport = async (req: ExportRequest) => {
if (!conn || exporting) return;
setExporting(true);
try {
const sid = await ensureSession(conn);
const ext = req.format === "insert" ? "sql" : req.format;
try {
const { blob, filename } = await exportData(conn, { ...req, sessionId: sid });
downloadBlob(blob, filename || `export.${ext}`);
notify({ kind: "success", title: "导出完成", message: filename || `export.${ext}`, mode: "toast", durationMs: 1800 });
setExpMenu(false);
} catch (e) {
const err = e as ExportError;
if (!err.needConfirm || !req.sql) throw e;
const ok = await askConfirm({
title: "危险语句确认",
message: err.message || "导出语句命中危险/非 SELECT,确认后继续导出?",
okLabel: "仍要导出",
danger: true,
items: [req.sql],
});
if (!ok) return;
const again = await exportData(conn, { ...req, sessionId: sid, confirmDanger: true });
downloadBlob(again.blob, again.filename || `export.${ext}`);
notify({ kind: "success", title: "导出完成", message: again.filename || `export.${ext}`, mode: "toast", durationMs: 1800 });
setExpMenu(false);
}
} catch (e) {
notifyError(e, "toast");
} finally {
setExporting(false);
}
};
/** 服务端导出可用性:整表(需表上下文) / 当前查询(需单条 SELECT) */
const canExportTable = !!conn && !!resultMeta?.table;
const canExportQuery = !!conn && canPaginate && !!activeRs;
const emptyResult = result && !result.error && result.rows.length === 0;
// 长值 popover:Esc / 点击外部关闭(修 G4)
useEffect(() => {
if (!peek) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setPeek(null);
}
};
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest?.("[data-ops-peek]")) setPeek(null);
};
window.addEventListener("keydown", onKey, true);
window.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("mousedown", onDown);
};
}, [peek]);
// R2-F2:导出菜单 —— Esc / 点击外部关闭
useEffect(() => {
if (!expMenu) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setExpMenu(false);
}
};
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest?.("[data-ops-export]")) setExpMenu(false);
};
window.addEventListener("keydown", onKey, true);
window.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("mousedown", onDown);
};
}, [expMenu]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div
className="flex min-h-0 flex-1 flex-col"
onContextMenu={(e) => {
// 拦系统菜单(Look Up / Inspect);结果表 P0 暂不自绘
e.preventDefault();
}}
>
<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>
<div className="ops-seg" role="tablist" aria-label="结果视图">
<button
type="button"
className={resultTab === "data" ? "ops-seg-active" : undefined}
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-seg-active" : undefined}
onClick={() => set("resultTab", "schema")}
>
结构
</button>
{!redisInspect && (
<button
type="button"
className={resultTab === "explain" ? "ops-seg-active" : undefined}
onClick={() => set("resultTab", "explain")}
>
计划
</button>
)}
</div>
{resultSets.length > 1 && (
<>
<span className="mx-1 opacity-30">|</span>
@@ -457,7 +713,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
</>
)}
</div>
{resultTab === "data" && result && !result.error && (
{resultTab === "data" && result && !result.error && !redisInspect && (
<span className="flex items-center gap-2 font-normal normal-case tracking-normal">
<span>
{result.rowCount} 行 · {result.timeMs} ms
@@ -492,29 +748,123 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
>
删除行
</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>
{/* R2-F2:导出中心 —— 本地三格式 + 服务端整表/当前查询(自绘下拉) */}
<div className="relative" data-ops-export>
<button
type="button"
className="hover:underline disabled:opacity-50"
style={{ color: "var(--ops-accent)" }}
disabled={exporting}
onClick={() => setExpMenu((v) => !v)}
>
导出{exporting ? "…" : " ▾"}
</button>
{expMenu && (
<div
className="absolute right-0 top-full z-10 mt-1 w-44 rounded border p-1"
style={{
borderColor: "var(--ops-border)",
background: "var(--ops-surface)",
boxShadow: "var(--ops-shadow-pop)",
}}
>
<div className="px-2 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
当前结果
</div>
{(["csv", "json", "insert"] as const).map((f) => (
<button
key={f}
type="button"
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
onClick={() => exportCurrent(f)}
>
{f.toUpperCase()}
</button>
))}
<button
type="button"
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
onClick={() => {
void copy("insert");
setExpMenu(false);
}}
>
复制 INSERT
</button>
{canExportTable && (
<>
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
<div className="px-2 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
整表(服务端)
</div>
{(["csv", "json", "insert"] as const).map((f) => (
<button
key={`table-${f}`}
type="button"
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
onClick={() =>
void serverExport({
format: f,
table: resultMeta!.table,
database: resultMeta!.database,
})
}
>
{f.toUpperCase()}
</button>
))}
</>
)}
{canExportQuery && (
<>
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
<div className="px-2 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
当前查询(服务端)
</div>
{(["csv", "json", "insert"] as const).map((f) => (
<button
key={`query-${f}`}
type="button"
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
onClick={() =>
void serverExport({
format: f,
sql: activeRs!.sql,
database: activeRs!.database,
})
}
>
{f.toUpperCase()}
</button>
))}
</>
)}
</div>
)}
</div>
</span>
)}
</div>
{resultTab === "schema" ? (
<SchemaPane />
) : resultTab === "data" && redisInspect && result && !result.error ? (
<RedisKeyPane />
) : 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>
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>执行计划</h2>
<p>点工具栏「执行计划」获取(部分引擎可用)。</p>
</div>
) : (
<pre
className="m-0 whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed"
style={{ color: "var(--ops-text)" }}
className="m-0 whitespace-pre-wrap font-mono leading-relaxed"
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-text)" }}
>
{explainText}
</pre>
@@ -526,9 +876,9 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
<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)",
borderColor: "color-mix(in srgb, var(--ops-warning) 40%, var(--ops-border))",
background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)",
color: "var(--ops-warning)",
}}
>
已截断:界面最多显示 {result?.limit || DISPLAY_CAP} 行
@@ -539,39 +889,62 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
<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]"
className="m-1.5 rounded border px-2.5 py-1.5"
style={{
fontSize: "var(--ops-text-sm)",
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 className="whitespace-pre-wrap break-all">{result.error}</div>
<div className="mt-1.5 flex gap-3" style={{ fontSize: "var(--ops-text-xs)" }}>
{activeRs && (
<button
type="button"
className="hover:underline disabled:opacity-50"
disabled={paging}
onClick={() => void retryRun()}
>
{paging ? "重试中…" : "重试"}
</button>
)}
<button
type="button"
className="hover:underline"
onClick={() =>
void copyText(activeRs ? `${activeRs.sql}\n\n${result.error ?? ""}` : (result.error ?? "")).then(() =>
notify({ kind: "success", title: "已复制", message: "错误详情已复制", mode: "toast", durationMs: 1400 }),
)
}
>
复制错误详情
</button>
</div>
</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>
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>还没有结果</h2>
<p>
{(conn?.driver || "").toLowerCase() === "redis"
? "写一条 Redis 命令,⌘/Ctrl+Enter 运行;或点左侧键查看值。"
: "写一条 SQL,⌘/Ctrl+Enter 运行;或点左侧表/键预览数据。"}
</p>
</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>
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>空结果</h2>
<p>查询成功,但没有返回行。试试改写 SQL 或放宽条件。</p>
</div>
)}
{result && !result.error && result.rows.length > 0 && (
<div
ref={gridRef}
className="outline-none"
tabIndex={0}
onPaste={(e) => void pasteGrid(e)}
@@ -592,10 +965,19 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
className="ops-sortable"
style={{ width: w, minWidth: w, maxWidth: w }}
onClick={() => cycleSort(c)}
title="点击排序:升 → 降 → 取消"
title="点击排序:升 → 降 → 取消(仅当前页数据)"
>
{c}
{mark}
{sortCol === c && sortDir && (
<span
className="ml-0.5 rounded px-0.5 font-normal"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-muted)", background: "var(--ops-surface-2)" }}
title="排序仅作用于当前页已加载数据"
>
本页
</span>
)}
<span className="ops-col-resize" onMouseDown={(e) => onResizeStart(c, e)} />
</th>
);
@@ -606,13 +988,15 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
{visibleRows.map((row, i) => (
<tr
key={i}
data-row-idx={i}
className={selectedIdx === i ? "ops-row-selected" : undefined}
onClick={() => setSelectedIdx(i)}
>
<td className="ops-rownum">{i + 1}</td>
<td className="ops-rownum">{(result.offset ?? 0) + i + 1}</td>
{result.columns.map((c) => {
const editing = edit && edit.row === i && edit.col === c;
const cellSelected = selectedIdx === i && selectedCol === c;
const long = longText(row[c]);
return (
<td
key={c}
@@ -645,7 +1029,24 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
onClick={(e) => e.stopPropagation()}
/>
) : (
formatCell(row[c])
<>
{formatCell(row[c])}
{long && (
<button
type="button"
className="ml-1 align-middle opacity-50 hover:opacity-100"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-accent)" }}
title="查看全文"
onClick={(e) => {
e.stopPropagation();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
setPeek({ text: long, x: Math.min(r.left, window.innerWidth - 460), y: r.bottom + 4 });
}}
>
展开
</button>
)}
</>
)}
</td>
);
@@ -657,8 +1058,83 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
</div>
)}
</div>
{/* C5 分页条:仅单条 SELECT 结果集启用;truncated = 还有下一页 */}
{resultTab === "data" && canPaginate && result && !result.error && (hasPrevPage || hasNextPage || page > 0) && (
<div
className="flex shrink-0 items-center gap-2 border-t px-2 py-1"
style={{ borderColor: "var(--ops-border)", fontSize: "var(--ops-text-xs)", color: "var(--ops-muted)" }}
>
<button
type="button"
className="ops-btn-secondary h-5 px-1.5"
disabled={!hasPrevPage || paging}
onClick={() => void gotoPage(page - 1)}
>
上一页
</button>
<span className="tabular-nums">
第 {page + 1} 页 · 本页 {result.rows.length} 行(自第 {(result.offset ?? 0) + 1} 行起)
</span>
<button
type="button"
className="ops-btn-secondary h-5 px-1.5"
disabled={!hasNextPage || paging}
onClick={() => void gotoPage(page + 1)}
>
下一页
</button>
{result.truncated && <span style={{ color: "var(--ops-warning)" }}>还有更多行</span>}
</div>
)}
</>
)}
{/* G4:长值查看/复制全文 popover */}
{peek && (
<div
data-ops-peek
className="fixed flex max-w-[440px] flex-col gap-1.5 rounded border p-2 shadow-lg"
style={{
left: peek.x,
top: peek.y,
zIndex: "var(--ops-z-ctx)",
borderColor: "var(--ops-border)",
background: "var(--ops-surface)",
boxShadow: "var(--ops-shadow-pop)",
}}
>
<pre
className="m-0 max-h-56 overflow-auto whitespace-pre-wrap break-all font-mono"
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
>
{peek.text}
</pre>
<div className="flex justify-end gap-2">
<button
type="button"
className="ops-btn-secondary h-5 px-2"
style={{ fontSize: "var(--ops-text-xs)" }}
onClick={() =>
void copyText(peek.text).then(() => {
notify({ kind: "success", title: "已复制", message: "全文已复制", mode: "toast", durationMs: 1200 });
setPeek(null);
})
}
>
复制全文
</button>
<button
type="button"
className="ops-btn-ghost h-5 px-2"
style={{ fontSize: "var(--ops-text-xs)" }}
onClick={() => setPeek(null)}
>
关闭
</button>
</div>
</div>
)}
</div>
);
}
+76 -13
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { execSql } from "./api";
import { execSql, listColumns } from "./api";
import { DdlModal } from "./DdlModal";
import { ensureSession } from "./ObjectTree";
import { useWorkspaceStore } from "./store";
@@ -26,7 +27,11 @@ export function SchemaPane() {
const set = useWorkspaceStore((s) => s.set);
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
/** R2-F3:DDL 查看弹窗 */
const [showDdl, setShowDdl] = useState(false);
const mysql = isMysqlFamily(conn?.driver);
/** Redis 键的结构页不展示 DDL(服务端仅支持表/视图) */
const ddlEnabled = !!conn && (conn.driver || "").toLowerCase() !== "redis";
useEffect(() => {
let alive = true;
@@ -62,12 +67,30 @@ export function SchemaPane() {
if (!schema) {
return (
<div className="ops-empty" style={{ minHeight: 120 }}>
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>表结构</h2>
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>表结构</h2>
<p>在对象树点表,或右键「查看结构」。</p>
</div>
);
}
/** E1:结构加载失败重试 —— 重新拉列元数据 */
const retryLoad = async () => {
if (!conn || !schema) return;
set("schemaView", { ...schema, loading: true, error: undefined });
try {
const sid = await ensureSession(conn);
const { columns } = await listColumns(conn, schema.database, schema.table, sid);
set("schemaView", { database: schema.database, table: schema.table, columns, loading: false });
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
set("resultMeta", { database: schema.database, table: schema.table, pkCols });
set("connState", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
set("schemaView", { database: schema.database, table: schema.table, columns: [], loading: false, error: msg });
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
}
};
if (schema.loading) {
return (
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
@@ -86,7 +109,15 @@ export function SchemaPane() {
color: "var(--ops-danger)",
}}
>
{schema.error}
<div>{schema.error}</div>
<button
type="button"
className="mt-1 hover:underline"
style={{ fontSize: "var(--ops-text-xs)" }}
onClick={() => void retryLoad()}
>
重试
</button>
</div>
);
}
@@ -100,14 +131,27 @@ export function SchemaPane() {
结构 · {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 className="flex shrink-0 items-center gap-2 normal-case tracking-normal">
{ddlEnabled && (
<button
type="button"
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
title="查看建表语句(DDL)"
onClick={() => setShowDdl(true)}
>
DDL
</button>
)}
<button
type="button"
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
onClick={() => set("resultTab", "data")}
>
回结果
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{schema.columns.length === 0 ? (
@@ -121,6 +165,7 @@ export function SchemaPane() {
<th style={{ width: 36 }}>#</th>
<th>列名</th>
<th>类型</th>
<th style={{ width: 52 }}>键</th>
<th style={{ width: 64 }}>可空</th>
<th>默认值</th>
</tr>
@@ -134,6 +179,22 @@ export function SchemaPane() {
{c.name}
</td>
<td className="font-mono text-[12px]">{c.type}</td>
<td>
{c.key === "PRI" ? (
<span
className="rounded px-1 font-semibold"
style={{
fontSize: "var(--ops-text-xs)",
color: "var(--ops-warning)",
background: "color-mix(in srgb, var(--ops-warning) 14%, transparent)",
}}
>
PRI
</span>
) : (
""
)}
</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)}
@@ -146,8 +207,7 @@ export function SchemaPane() {
{mysql && (
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
<details open className="px-3 py-1.5">
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
<details open className="px-3 py-1.5"> <summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
索引({meta.indexes.length})
</summary>
{meta.indexes.length === 0 ? (
@@ -234,6 +294,9 @@ export function SchemaPane() {
</div>
)}
</div>
{showDdl && conn && (
<DdlModal conn={conn} database={schema.database} table={schema.table} onClose={() => setShowDdl(false)} />
)}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { useEffect, useState } from "react";
import { driverStatus, health, type DriverStatusItem } from "./api";
import { OpsModal } from "./OpsModal";
import { useWorkspaceStore } from "./store";
const IS_MAC = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const MOD = IS_MAC ? "⌘" : "Ctrl";
type TabId = "general" | "execution" | "results" | "drivers" | "shortcuts" | "about";
const TABS: Array<{ id: TabId; icon: string; label: string; desc: string }> = [
{ id: "general", icon: "🎨", label: "常规", desc: "主题与界面" },
{ id: "execution", icon: "⚡", label: "执行", desc: "超时与多语句" },
{ id: "results", icon: "📊", label: "结果", desc: "面板与分页" },
{ id: "drivers", icon: "🧩", label: "驱动", desc: "agent 驱动状态" },
{ id: "shortcuts", icon: "⌨️", label: "快捷键", desc: "键盘操作一览" },
{ id: "about", icon: "ℹ️", label: "关于", desc: "版本与数据目录" },
];
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-[12.5px]" style={{ color: "var(--ops-text)" }}>{label}</span>
{children}
</div>
{hint && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>{hint}</div>}
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
className="h-4.5 w-9 rounded-full border transition"
style={{
height: 18,
borderColor: "var(--ops-border)",
background: checked ? "var(--ops-accent)" : "var(--ops-surface-2)",
}}
onClick={() => onChange(!checked)}
>
<span
className="block rounded-full bg-white transition-all"
style={{ width: 12, height: 12, marginLeft: checked ? 22 : 3, boxShadow: "0 1px 2px rgba(0,0,0,.3)" }}
/>
</button>
);
}
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: Array<{ v: T; label: string }>; onChange: (v: T) => void }) {
return (
<div className="ops-seg flex">
{options.map((o) => (
<button
key={String(o.v)}
type="button"
className={value === o.v ? "ops-tree-item-active" : ""}
style={{ fontSize: "var(--ops-text-xs)" }}
onClick={() => onChange(o.v)}
>
{o.label}
</button>
))}
</div>
);
}
/** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */
export function SettingsModal({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<TabId>("general");
const theme = useWorkspaceStore((s) => s.theme);
const showAi = useWorkspaceStore((s) => s.showAi);
const stopOnError = useWorkspaceStore((s) => s.stopOnError);
const execTimeoutMs = useWorkspaceStore((s) => s.execTimeoutMs);
const resultRatio = useWorkspaceStore((s) => s.resultRatio);
const set = useWorkspaceStore((s) => s.set);
const notify = useWorkspaceStore((s) => s.notify);
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList);
const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null);
const [version, setVersion] = useState<string>("");
useEffect(() => {
void driverStatus().then(setDrivers).catch(() => setDrivers([]));
void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {});
}, []);
const resetLayout = () => {
set("leftWidth", 260);
set("aiWidth", 280);
set("resultRatio", 0.38);
notify({ kind: "success", title: "已重置", message: "面板布局恢复默认", mode: "toast", durationMs: 1400 });
};
return (
<OpsModal variant="info" title="设置" onOk={onClose}>
<div className="flex" style={{ minWidth: 560, minHeight: 340 }}>
{/* 左导航 */}
<nav className="w-36 shrink-0 space-y-px border-r pr-1" style={{ borderColor: "var(--ops-border-soft)" }}>
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] transition ${
tab === t.id ? "ops-tree-item-active font-medium" : "opacity-70 hover:opacity-100"
}`}
onClick={() => setTab(t.id)}
>
<span className="w-4 text-center">{t.icon}</span>
<span>{t.label}</span>
</button>
))}
</nav>
{/* 右面板 */}
<div className="min-w-0 flex-1 space-y-4 pl-4">
{tab === "general" && (
<>
<Row label="主题">
<Seg
value={theme}
options={[{ v: "dark" as const, label: "深色" }, { v: "light" as const, label: "浅色" }]}
onChange={(v) => set("theme", v)}
/>
</Row>
<Row label="显示 AI 面板" hint="工作台右侧的 AI 助手面板">
<Toggle checked={showAi} onChange={(v) => set("showAi", v)} />
</Row>
<Row label="面板布局" hint="侧栏宽度、AI 面板宽度、结果面板占比">
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}>
恢复默认
</button>
</Row>
</>
)}
{tab === "execution" && (
<>
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
<Seg
value={execTimeoutMs}
options={[
{ v: 0, label: "无" },
{ v: 10000, label: "10s" },
{ v: 30000, label: "30s" },
{ v: 60000, label: "60s" },
{ v: 300000, label: "5min" },
]}
onChange={(v) => set("execTimeoutMs", v)}
/>
</Row>
<Row label="多语句遇错即停" hint="一次执行多条时,某条失败即停止后续">
<Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} />
</Row>
<Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)">
<span className="text-[11px]" style={{ color: "var(--ops-accent)" }}>始终开启</span>
</Row>
</>
)}
{tab === "results" && (
<>
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`} hint="编辑器与结果面板的高度分配">
<input
type="range"
min={25}
max={60}
value={Math.round(resultRatio * 100)}
onChange={(e) => set("resultRatio", Number(e.target.value) / 100)}
style={{ accentColor: "var(--ops-accent)", width: 120 }}
/>
</Row>
<Row label="行数上限" hint="查询默认 1000 行、硬上限 10000 行(超出截断提示),由服务端强制执行">
<span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>1000 / 10000</span>
</Row>
</>
)}
{tab === "drivers" && (
<>
<div className="space-y-1.5">
{drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
{drivers?.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded border px-2 py-1.5 text-[11.5px]" style={{ borderColor: "var(--ops-border-soft)" }}>
<span>{d.id}</span>
<span style={{ color: d.allInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
{d.allInstalled ? "已安装" : d.license === "proprietary" ? "需自备 jar" : "未安装"}
</span>
</div>
))}
</div>
<Row label="驱动管理" hint="预装下载、离线包导入、JDK 检测">
<button
type="button"
className="ops-btn-secondary h-6 px-2 text-[11px]"
onClick={() => {
onClose();
openConnectionList();
notify({ kind: "info", title: "驱动管理", message: "连接列表页右上角 ⚙ 打开驱动管理", mode: "toast", durationMs: 2600 });
}}
>
去管理
</button>
</Row>
</>
)}
{tab === "shortcuts" && (
<div className="space-y-1.5">
{[
[`${MOD} + Enter`, "执行当前语句或选区"],
[`${MOD} + T`, "新建查询"],
[`${MOD} + W`, "关闭查询"],
[`${MOD} + 1-9`, "切换到第 N 个查询"],
["Ctrl + Tab", "循环切换查询"],
[`${MOD} + ⇧ + F`, "聚焦对象树过滤框"],
[`${MOD} + B`, "显示/隐藏 AI"],
[`${MOD} + /`, "快捷键帮助"],
["双击表名", "打开前 100 行"],
["双击单元格", "进入行编辑"],
].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>
)}
{tab === "about" && (
<div className="space-y-2 text-[12px]" style={{ color: "var(--ops-text)" }}>
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>版本</span><span className="font-mono">{version || "—"}</span></div>
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>形态</span><span>桌面客户端(Tauri 2)</span></div>
<div className="flex justify-between gap-4">
<span style={{ color: "var(--ops-muted)" }}>驱动目录</span>
<span className="truncate font-mono" title="~/.opsxdb/drivers">~/.opsxdb/drivers</span>
</div>
<div className="flex justify-between gap-4">
<span style={{ color: "var(--ops-muted)" }}>驱动商店</span>
<a
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
href="https://gitee.com/attacker/opsxdb-driver"
target="_blank"
rel="noreferrer"
>
gitee.com/attacker/opsxdb-driver
</a>
</div>
</div>
)}
</div>
</div>
</OpsModal>
);
}
+154 -7
View File
@@ -4,10 +4,59 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } fro
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { sql } from "@codemirror/lang-sql";
import { autocompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
import { tags as t } from "@lezer/highlight";
import { oneDark } from "@codemirror/theme-one-dark";
import { getCachedSchema } from "./api";
import { REDIS_COMMANDS } from "./redis";
import { formatSqlText, statementAt } from "./sql";
/** §3.10:SQL 关键字静态补全表(通用 + MySQL/PG 常用) */
const SQL_KEYWORDS = [
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "INSERT", "INTO", "VALUES",
"UPDATE", "SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "VIEW",
"JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ON", "AS", "DISTINCT",
"GROUP", "BY", "ORDER", "ASC", "DESC", "LIMIT", "OFFSET", "HAVING", "UNION", "ALL",
"CASE", "WHEN", "THEN", "ELSE", "END", "IN", "EXISTS", "BETWEEN", "LIKE", "IS",
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "CAST", "WITH", "EXPLAIN",
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "DEFAULT", "UNIQUE", "CHECK",
"BEGIN", "COMMIT", "ROLLBACK", "GRANT", "REVOKE", "TRUNCATE", "RETURNING",
].map((kw) => ({ label: kw, type: "keyword" }) satisfies Completion);
/**
* §3.10/C8:补全数据源 = 关键字 + schemaCache(对象树懒加载时写入的库/表/列)。
* 列名携带类型信息(detail),表/库/列分别标注类型。
*/
function schemaOptions(connId?: string): Completion[] {
const cache = getCachedSchema(connId);
if (!cache) return [];
const out: Completion[] = [];
for (const [db, tables] of cache) {
out.push({ label: db, type: "namespace", detail: "库" });
for (const [table, cols] of tables) {
out.push({ label: table, type: "table", detail: db });
for (const col of cols) {
out.push({ label: col.name, type: "property", detail: `${table} · ${col.type}` });
}
}
}
return out;
}
function sqlComplete(connIdRef: React.RefObject<string | undefined>) {
return (ctx: CompletionContext): CompletionResult | null => {
const word = ctx.matchBefore(/[\w$一-龥]*$/);
if (!word || (word.from === word.to && !ctx.explicit)) return null;
return { from: word.from, options: [...SQL_KEYWORDS, ...schemaOptions(connIdRef.current)] };
};
}
function redisComplete(ctx: CompletionContext): CompletionResult | null {
const word = ctx.matchBefore(/[\w$]*$/);
if (!word || (word.from === word.to && !ctx.explicit)) return null;
return { from: word.from, options: REDIS_COMMANDS };
}
/**
* SQL 高亮走克制的四色方案(VS Code Dark+ 观感):
* 关键字+数字 = 蓝,字符串 = 橙,注释 = 绿,标识符/运算符 = 前景浅白。
@@ -38,6 +87,18 @@ export type SqlEditorHandle = {
/** Format selection (if prefer + nonempty) or whole doc; single history-friendly change. */
format: (selectionPrefer?: boolean) => void;
focus: () => void;
hasSelection: () => boolean;
selectedText: () => string;
selectAll: () => void;
cutSelection: () => Promise<void>;
copySelection: () => Promise<void>;
pasteClipboard: () => Promise<void>;
};
export type SqlEditorContextInfo = {
x: number;
y: number;
hasSelection: boolean;
};
export const SqlEditor = forwardRef<
@@ -49,18 +110,26 @@ export const SqlEditor = forwardRef<
onRun: (sql: string) => void;
/** Optional driver for dialect-aware format. */
driver?: string;
/** §3.10:schema 补全按连接隔离(C8 schemaCache) */
connectionId?: string;
/** 自绘右键:拦住系统菜单后回调 */
onContextMenu?: (info: SqlEditorContextInfo) => void;
}
>(function SqlEditor({ value, theme, onChange, onRun, driver }, ref) {
>(function SqlEditor({ value, theme, onChange, onRun, driver, connectionId, onContextMenu }, 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);
const connIdRef = useRef(connectionId);
const onContextMenuRef = useRef(onContextMenu);
onChangeRef.current = onChange;
onRunRef.current = onRun;
valueRef.current = value;
driverRef.current = driver;
connIdRef.current = connectionId;
onContextMenuRef.current = onContextMenu;
useImperativeHandle(ref, () => ({
getRunSql: () => {
@@ -68,7 +137,7 @@ export const SqlEditor = forwardRef<
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);
return statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current);
},
format: (selectionPrefer = true) => {
const view = viewRef.current;
@@ -90,6 +159,67 @@ export const SqlEditor = forwardRef<
view.focus();
},
focus: () => viewRef.current?.focus(),
hasSelection: () => {
const view = viewRef.current;
if (!view) return false;
return !view.state.selection.main.empty;
},
selectedText: () => {
const view = viewRef.current;
if (!view) return "";
const sel = view.state.selection.main;
if (sel.empty) return "";
return view.state.sliceDoc(sel.from, sel.to);
},
selectAll: () => {
const view = viewRef.current;
if (!view) return;
view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) });
view.focus();
},
cutSelection: async () => {
const view = viewRef.current;
if (!view) return;
const sel = view.state.selection.main;
if (sel.empty) return;
const text = view.state.sliceDoc(sel.from, sel.to);
try {
await navigator.clipboard.writeText(text);
} catch {
/* ignore */
}
view.dispatch({ changes: { from: sel.from, to: sel.to, insert: "" } });
view.focus();
},
copySelection: async () => {
const view = viewRef.current;
if (!view) return;
const sel = view.state.selection.main;
if (sel.empty) return;
const text = view.state.sliceDoc(sel.from, sel.to);
try {
await navigator.clipboard.writeText(text);
} catch {
/* ignore */
}
view.focus();
},
pasteClipboard: async () => {
const view = viewRef.current;
if (!view) return;
let text = "";
try {
text = await navigator.clipboard.readText();
} catch {
return;
}
const sel = view.state.selection.main;
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: text },
selection: EditorSelection.cursor(sel.from + text.length),
});
view.focus();
},
}));
useEffect(() => {
@@ -99,10 +229,11 @@ export const SqlEditor = forwardRef<
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));
onRunRef.current(statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current));
return true;
},
};
const redis = (driverRef.current || "").toLowerCase() === "redis";
const state = EditorState.create({
doc: valueRef.current,
extensions: [
@@ -110,14 +241,30 @@ export const SqlEditor = forwardRef<
highlightActiveLine(),
history(),
sql(),
placeholder("写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
// §3.10:关键字 + schemaCache 补全(override 覆盖 lang-sql 默认源)
autocompletion({ override: [redis ? redisComplete : sqlComplete(connIdRef)] }),
placeholder(redis ? "写 Redis 命令,⌘/Ctrl+Enter 执行当前行或选区" : "写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
keymap.of([runKey, indentWithTab, ...historyKeymap, ...defaultKeymap]),
EditorView.updateListener.of((u) => {
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
}),
EditorView.domEventHandlers({
contextmenu: (e, view) => {
e.preventDefault();
e.stopPropagation();
const sel = view.state.selection.main;
onContextMenuRef.current?.({
x: e.clientX,
y: e.clientY,
hasSelection: !sel.empty,
});
return true;
},
}),
EditorView.theme({
"&": { height: "100%", fontSize: "12.5px" },
".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: "1.45" },
// 字体栈走外层 .ops-code 的 tailwind font-mono 令牌(§2.1 修 V6),不再内联硬编码
".cm-scroller": { lineHeight: "1.45" },
".cm-content": { padding: "8px 0" },
".cm-gutters": { border: "none" },
}),
@@ -131,9 +278,9 @@ export const SqlEditor = forwardRef<
view.destroy();
viewRef.current = null;
};
// recreate on theme change only
// recreate on theme / Redis↔SQL 方言切换(占位与补全不同)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);
}, [theme, driver === "redis"]);
useEffect(() => {
const view = viewRef.current;
+29
View File
@@ -0,0 +1,29 @@
/** 树形方向箭头(SVG 描边 chevron,替代 ▸/▾ 字符 —— Unicode 小三角在部分字体下糊成圆点,
* 填充三角又显臃肿)。细长 ">" 造型,open 时旋转 90° 朝下。 */
export function TreeChevron({
open,
size = 10,
className = "",
}: {
/** undefined = 静态朝右(details 折叠组内由浏览器管开合) */
open?: boolean;
size?: number;
className?: string;
}) {
return (
<svg
viewBox="0 0 12 12"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={`shrink-0 ${open === undefined ? "" : `transition-transform duration-150 ${open ? "rotate-90" : ""}`} ${className}`}
>
<path d="M4.4 2.6 L8.4 6 L4.4 9.4" />
</svg>
);
}
File diff suppressed because it is too large. Load diff
+363 -9
View File
@@ -1,8 +1,29 @@
import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store";
import { decodeSecret } from "./secrets";
/** 开发态走 Vite 代理(空串);桌面生产包直连本机 Gin。 */
/** C2:桌面 sidecar 注入的运行时配置(缺省时行为与现状一致) */
declare global {
interface Window {
__OPSXDB_API__?: string;
__OPSXDB_TOKEN__?: string;
}
}
function injectedApi(): string | undefined {
if (typeof window === "undefined") return undefined;
const v = window.__OPSXDB_API__;
return v ? v.replace(/\/$/, "") : undefined;
}
function injectedToken(): string {
if (typeof window === "undefined") return "";
return window.__OPSXDB_TOKEN__ ?? "";
}
/** 开发态走 Vite 代理(空串);桌面生产包优先 sidecar 注入地址,回落默认 8788。 */
function resolveApiBase(): string {
const injected = injectedApi();
if (injected) return injected;
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_OPSXDB_API;
if (fromEnv) return fromEnv.replace(/\/$/, "");
if (typeof window === "undefined") return "";
@@ -15,11 +36,16 @@ function resolveApiBase(): string {
const API = resolveApiBase();
/** C2:token 存在时统一带 Authorization 头;token 为空(本机 dev)行为不变 */
function authHeaders(): Record<string, string> {
const token = injectedToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
signal,
});
@@ -30,6 +56,15 @@ async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promi
return data as T;
}
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
const res = await fetch(`${API}${path}`, { headers: { ...authHeaders() }, signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as T;
}
export type ConnBody = {
driver?: string;
host?: string;
@@ -47,8 +82,25 @@ export type ConnBody = {
sql?: string;
table?: string;
limit?: number;
/** E1:执行请求标识,配合 POST /api/db/cancel 取消在途语句 */
requestId?: string;
/** E2:单语句执行超时(毫秒),0/缺省 = 服务端默认不额外限制 */
timeoutMs?: number;
/** E6:对象搜索关键字 */
keyword?: string;
/** E7:服务端导出格式 */
format?: string;
/** C5:结果分页,跳过前 offset 行 */
offset?: number;
/** C1:前端已完成危险语句确认后重发时置 true */
confirmDanger?: boolean;
key?: string;
pattern?: string;
cursor?: number;
redisMode?: string;
redisNodes?: string;
masterName?: string;
sentinelPassword?: string;
sshEnabled?: boolean;
sshHost?: string;
sshPort?: number;
@@ -74,6 +126,10 @@ export function fullConnPayload(c: SavedConnection): ConnBody {
sslCA: c.sslCA || "",
sslCert: c.sslCert || "",
sslKey: c.sslKey || "",
redisMode: c.redisMode || "standalone",
redisNodes: c.redisNodes || "",
masterName: c.masterName || "",
sentinelPassword: c.sentinelPassword ? decodeSecret(c.sentinelPassword) : "",
sshEnabled: !!c.sshEnabled,
sshHost: c.sshHost,
sshPort: c.sshPort,
@@ -92,9 +148,15 @@ export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Pa
sql: extra?.sql,
table: extra?.table,
limit: extra?.limit,
offset: extra?.offset,
confirmDanger: extra?.confirmDanger,
key: extra?.key,
pattern: extra?.pattern,
service: extra?.service ?? c.service,
requestId: extra?.requestId,
timeoutMs: extra?.timeoutMs,
keyword: extra?.keyword,
format: extra?.format,
};
}
@@ -119,22 +181,239 @@ export async function listDatabases(c: SavedConnection, sessionId?: string) {
return post<{ databases: string[] }>("/api/db/databases", body(c, sessionId));
}
/**
* C8:schema 缓存 —— connId → database → table → columns。
* listTables/listColumns 成功时写入,供 SqlEditor 补全与 AiPanel 上下文注入共用。
*/
const schemaCache = new Map<string, Map<string, Map<string, ColumnInfo[]>>>();
function cacheTables(connId: string, db: string, tables: string[]) {
let dbMap = schemaCache.get(connId);
if (!dbMap) {
dbMap = new Map();
schemaCache.set(connId, dbMap);
}
let tblMap = dbMap.get(db);
if (!tblMap) {
tblMap = new Map();
dbMap.set(db, tblMap);
}
for (const t of tables) {
if (!tblMap.has(t)) tblMap.set(t, []);
}
}
function cacheColumns(connId: string, db: string, table: string, columns: ColumnInfo[]) {
let dbMap = schemaCache.get(connId);
if (!dbMap) {
dbMap = new Map();
schemaCache.set(connId, dbMap);
}
let tblMap = dbMap.get(db);
if (!tblMap) {
tblMap = new Map();
dbMap.set(db, tblMap);
}
tblMap.set(table, columns);
}
/** 连接关闭/删除后丢弃其缓存,避免跨连接串数据 */
export function dropSchemaCache(connId: string) {
schemaCache.delete(connId);
}
/** SqlEditor 补全数据源:已加载的表与列 */
export function getCachedSchema(
connId: string | null | undefined,
): Map<string, Map<string, ColumnInfo[]>> | null {
if (!connId) return null;
return schemaCache.get(connId) ?? null;
}
/**
* AiPanel 上下文注入:把已加载的库表列拼成文本,按 connId 隔离,截断 4000 字符。
* 优先输出当前库,其余库只列库.表名。
*/
export function schemaContextFor(connId: string | null | undefined, currentDb?: string): string {
const dbMap = connId ? schemaCache.get(connId) : undefined;
if (!dbMap || dbMap.size === 0) return "";
const MAX = 4000;
const lines: string[] = [];
const dbs = [...dbMap.keys()].sort((a, b) => {
if (a === currentDb) return -1;
if (b === currentDb) return 1;
return a.localeCompare(b);
});
for (const db of dbs) {
const tblMap = dbMap.get(db)!;
for (const [table, cols] of tblMap) {
const colText = cols.length
? `(${cols.map((c) => `${c.name} ${c.type}`.trim()).join(", ")})`
: "";
lines.push(`${db}.${table}${colText}`);
if (lines.join("\n").length > MAX) {
return lines.join("\n").slice(0, MAX);
}
}
}
return lines.join("\n").slice(0, MAX);
}
export async function listTables(c: SavedConnection, database: string, sessionId?: string) {
return post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
const res = await post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
if (Array.isArray(res.tables)) cacheTables(c.id, database, res.tables);
return res;
}
export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) {
return post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
const res = await post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
if (Array.isArray(res.columns)) cacheColumns(c.id, database, table, res.columns);
return res;
}
/** E1:最近一次 execSql 的 requestId,供 cancelExec() 取消在途语句 */
let lastExecRequestId: string | undefined;
export async function execSql(
c: SavedConnection,
sql: string,
opts?: { database?: string; sessionId?: string; limit?: number; signal?: AbortSignal },
opts?: {
database?: string;
sessionId?: string;
limit?: number;
offset?: number;
confirmDanger?: boolean;
/** E2:单语句执行超时(毫秒),0/缺省 = 不额外限制 */
timeoutMs?: number;
signal?: AbortSignal;
},
) {
// E1:每次执行生成独立 requestId(模块级记录最后一次),取消端点按 id 精准取消
const requestId = crypto.randomUUID();
lastExecRequestId = requestId;
return post<QueryResult>(
"/api/db/exec",
body(c, opts?.sessionId, { sql, database: opts?.database || c.database, limit: opts?.limit }),
body(c, opts?.sessionId, {
sql,
database: opts?.database || c.database,
limit: opts?.limit,
offset: opts?.offset,
confirmDanger: opts?.confirmDanger,
requestId,
timeoutMs: opts?.timeoutMs,
}),
opts?.signal,
);
}
/**
* E1:取消最近一次执行 —— fire-and-forget 调 POST /api/db/cancel。
* 与前端 abort 互补:服务端收到取消后释放连接/杀查询,错误一律吞掉。
*/
export function cancelExec(): void {
const rid = lastExecRequestId;
if (!rid) return;
void fetch(`${API}/api/db/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ requestId: rid }),
}).catch(() => {
/* 取消失败不影响本地中止流程 */
});
}
/** E5:查看表/视图 DDL(方言差异由服务端处理) */
export async function getDdl(
c: SavedConnection,
table: string,
opts?: { database?: string; sessionId?: string },
) {
return post<{ ddl?: string; error?: string }>(
"/api/db/ddl",
body(c, opts?.sessionId, { table, database: opts?.database || c.database }),
);
}
export type SearchObjectHit = { schema: string; name: string; type: "table" | "view" };
/** E6:跨库对象搜索(服务端 LIKE,大小写策略由驱动决定) */
export async function searchObjects(
c: SavedConnection,
keyword: string,
opts?: { database?: string; sessionId?: string },
) {
return post<{ results: SearchObjectHit[]; error?: string }>(
"/api/db/search",
body(c, opts?.sessionId, { keyword, database: opts?.database || c.database }),
);
}
export type ExportRequest = {
format: "csv" | "json" | "insert";
/** 自定义查询导出(单条 SELECT);命中危险/非 SELECT 时服务端返回 needConfirm */
sql?: string;
/** 整表导出 */
table?: string;
limit?: number;
database?: string;
sessionId?: string;
/** C1:危险确认二段流重发时置 true */
confirmDanger?: boolean;
};
/** E7 服务端导出失败:Error 上挂 needConfirm,由调用侧弹二段确认后重发 */
export type ExportError = Error & { needConfirm?: boolean };
/**
* E7:服务端导出 —— 文件流下载(Content-Disposition attachment)。
* 返回 blob 与解析出的文件名;失败时抛 ExportError(needConfirm=true 表示需二段确认)。
*/
export async function exportData(c: SavedConnection, opts: ExportRequest): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(`${API}/api/db/export`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(
body(c, opts.sessionId, {
format: opts.format,
sql: opts.sql,
table: opts.table,
limit: opts.limit,
database: opts.database || c.database,
confirmDanger: opts.confirmDanger,
}),
),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const err = new Error((data as { error?: string }).error || res.statusText || "导出失败") as ExportError;
if ((data as { needConfirm?: boolean }).needConfirm) err.needConfirm = true;
throw err;
}
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
const blob = await res.blob();
return { blob, filename };
}
/** C3:服务端专用执行计划端点(方言包装在服务端完成,前端不再拼 EXPLAIN) */
export type ExplainResult = {
columns?: string[];
rows?: Record<string, unknown>[];
/** MSSQL SHOWPLAN_XML / Oracle 文本计划 */
text?: string;
timeMs: number;
error?: string;
};
export async function explainSql(
c: SavedConnection,
sql: string,
opts?: { database?: string; sessionId?: string; signal?: AbortSignal },
) {
return post<ExplainResult>(
"/api/db/explain",
body(c, opts?.sessionId, { sql, database: opts?.database || c.database }),
opts?.signal,
);
}
@@ -159,6 +438,8 @@ export type RedisKeyValue = {
timeMs: number;
truncated?: boolean;
limit?: number;
/** >0 表示还有更多;续载时作为 cursor 回传 */
nextCursor?: number;
error?: string;
};
@@ -177,11 +458,11 @@ export async function getRedisKey(
c: SavedConnection,
database: string,
key: string,
opts?: { sessionId?: string; limit?: number },
opts?: { sessionId?: string; limit?: number; cursor?: number },
) {
return post<RedisKeyValue>(
"/api/db/redis/key",
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, driver: c.driver }),
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, cursor: opts?.cursor, driver: c.driver }),
);
}
@@ -197,6 +478,20 @@ export async function deleteRedisKey(
);
}
/** 目录(前缀)批量删除:pattern 如 `agent:*`;后端 SCAN+UNLINK,拒全库与超上限 */
export async function deleteRedisPattern(
c: SavedConnection,
database: string,
pattern: string,
opts?: { sessionId?: string },
) {
return post<{ deleted: number; scanned: number; truncated: boolean; error?: string; timeMs?: number }>(
"/api/db/redis/del-pattern",
body(c, opts?.sessionId, { database, pattern, driver: c.driver }),
);
}
export async function loadTree(c: SavedConnection, sessionId?: string): Promise<TreeNode[]> {
const { databases } = await listDatabases(c, sessionId);
return databases.map((name) => ({ name, kind: "database" as const }));
@@ -218,12 +513,32 @@ export async function callAi(payload: {
driver?: string;
database?: string;
error?: string;
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
schema?: string;
model?: string;
}) {
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
}
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置) */
export type AiConfig = {
provider: string;
baseURL: string;
model: string;
hasKey: boolean;
apiKey?: string;
};
export async function getAiConfig(signal?: AbortSignal) {
return get<AiConfig>("/api/ai/config", signal);
}
export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string }) {
return post<{ ok: boolean }>("/api/ai/config", cfg);
}
export async function health() {
const res = await fetch(`${API}/api/health`);
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
if (!res.ok) throw new Error(`health ${res.status}`);
return res.json();
}
@@ -236,3 +551,42 @@ export function isAbortError(e: unknown): boolean {
const msg = e instanceof Error ? e.message : String(e);
return /aborted|abort/i.test(msg) && !/session/i.test(msg);
}
// ---------- 驱动管理(agent 驱动) ----------
export type DriverJarStatus = { file: string; size: number; installed: boolean };
export type DriverStatusItem = {
id: string;
label: string;
runtime: string;
license: string;
autoFetch: boolean;
jars: DriverJarStatus[];
allInstalled: boolean;
jdkRequired: boolean;
jdkOk: boolean;
javaVersion: string;
agentInstalled: boolean;
offlinePack: boolean;
};
export async function driverStatus() {
const res = await fetch(`${API}/api/drivers/status`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return (data as { drivers: DriverStatusItem[] }).drivers;
}
export async function driverInstall(driver: string) {
return post<{ ok: boolean; error?: string; drivers?: DriverStatusItem[] }>("/api/drivers/install", { driver });
}
export async function driverImportOffline(file: File) {
const form = new FormData();
form.append("file", file);
const res = await fetch(`${API}/api/drivers/import`, { method: "POST", body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
}
+131 -20
View File
@@ -1,3 +1,9 @@
import registry from "../../../connection-types/connection-types.json";
/**
* 驱动目录 §3.11:单一事实来源是 connection-types/connection-types.json(运行时 import),
* 本文件只做类型收敛与查询辅助。新增驱动请先改 JSON(Go 侧 open.go 分发表仍需手工同步)。
*/
export type DriverId =
| "mysql"
| "mariadb"
@@ -14,6 +20,7 @@ export type DriverId =
| "dameng"
| "oceanbase"
| "gaussdb"
| "opengauss" // 服务端族接受此 id(老数据/URL 粘贴兼容),选择器与 gaussdb 合并展示
| "polardb"
| "tdsql"
| "kingbase";
@@ -31,30 +38,43 @@ export type DriverDef = {
icon?: string;
mysqlCompat?: boolean;
ready?: boolean;
/** 是否已在 docker 验收矩阵验收;false 时引擎列表显示「未验收」徽标 */
verified?: boolean;
/** 未验收/未就绪说明 */
notes?: string;
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" } },
];
type RegistryEntry = {
id: string;
label: string;
category: Exclude<DriverCategory, "全部">;
form?: FormKind;
ready?: boolean;
verified?: boolean;
notes?: string;
defaultPort?: number;
mysqlCompat?: boolean;
color?: string;
icon?: string;
hints?: { host?: string; port?: string; user?: string; database?: string };
};
export const DRIVERS: DriverDef[] = (registry.types as RegistryEntry[]).map((t) => ({
id: t.id as DriverId,
name: t.label,
category: t.category,
defaultPort: t.defaultPort ?? 0,
color: t.color ?? "#64748b",
icon: t.icon,
mysqlCompat: t.mysqlCompat,
ready: t.ready,
verified: t.verified,
notes: t.notes,
formKind: t.form,
hints: t.hints,
}));
export const CATEGORIES: DriverCategory[] = ["全部", "关系型", "NoSQL", "国产库", "时序/搜索"];
@@ -70,3 +90,94 @@ export function isDriverReady(id: string) {
export function formKindOf(id: string): FormKind {
return driverById(id)?.formKind ?? "standard";
}
// ---------- 驱动能力模型 ----------
// 对象树分组 / 右键菜单 / 行编辑等按驱动开关,禁止名不副实的功能展示
// (如 ES 挂「存储过程」、ZK 节点提供「删除表」)。
export type TreeGroupKind = "tables" | "views" | "procedures" | "functions";
export type DriverCaps = {
/** 库节点下展示哪些分组;空数组 = 该驱动无分组概念(走特殊树) */
treeGroups: TreeGroupKind[];
/** 分组显示别名(如 ES 的 tables →「索引」) */
groupLabel: Partial<Record<TreeGroupKind, string>>;
/** 对象节点可展开查看字段(列) */
columns: boolean;
/** 右键「查看 DDL」 */
ddl: boolean;
/** 右键「清空/截断」 */
truncate: boolean;
/** 右键「删除对象」(表/集合/索引…) */
dropObject: boolean;
/** 库节点「设为默认库」 */
setDefaultDb: boolean;
};
const ALL_GROUPS: TreeGroupKind[] = ["tables", "views", "procedures", "functions"];
/** 全能力 SQL 库(含达梦:JDBC 元数据全支持) */
const SQL_CAPS: DriverCaps = {
treeGroups: ALL_GROUPS,
groupLabel: {},
columns: true,
ddl: true,
truncate: true,
dropObject: true,
setDefaultDb: true,
};
/** 只读 flat 对象树(无分组包装,对象直挂库节点)的 NoSQL 驱动 */
function readonlyCaps(columns: boolean): DriverCaps {
return {
treeGroups: [], // flat:ES 索引/Mongo 集合/Influx measurement/ZK 子节点 直挂库节点
groupLabel: {},
columns,
ddl: false,
truncate: false,
dropObject: false,
setDefaultDb: false,
};
}
export function driverCaps(driver: string): DriverCaps {
const d = (driver || "").toLowerCase();
switch (d) {
case "mysql":
case "mariadb":
case "tidb":
case "oceanbase":
case "polardb":
case "tdsql":
case "postgresql":
case "postgres":
case "gaussdb":
case "opengauss":
case "kingbase":
case "sqlserver":
case "mssql":
case "oracle":
case "dameng":
return SQL_CAPS;
case "sqlite":
// SQLite 无存储过程/函数/TRUNCATE;有视图与 DROP TABLE
return { ...SQL_CAPS, treeGroups: ["tables", "views"], truncate: false };
case "elasticsearch":
// ES SQL 只读:索引≈表;无 DDL/写操作/默认库
return readonlyCaps(true);
case "influxdb":
// bucket→measurement;字段可探(Flux fieldKeys),只读
return readonlyCaps(true);
case "mongodb":
// 库→集合;抽样字段;写操作走 DSL 编辑器而非树右键
return readonlyCaps(true);
case "zookeeper":
// 路径→子节点;节点数据靠双击 GET;无列概念
return readonlyCaps(false);
case "redis":
// Redis 走专属键树(buildRedisKeyTree),这里全关兜底
return { treeGroups: [], groupLabel: {}, columns: false, ddl: false, truncate: false, dropObject: false, setDefaultDb: false };
default:
return readonlyCaps(false);
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 模块级输入对话框(C6 契约)
*
* 独立于 store.ts(避免与 WS-C 文件冲突):内部维护一个微型订阅队列,
* 由 NotifyHost 内嵌的 DialogHost 订阅并以 OpsModal input 变体渲染。
* 业务侧只需 `const name = await askInput({ title: "分组名称" })`,
* 确认返回输入字符串,Esc/取消返回 null(替代 WKWebView 下返回 null 的 window.prompt)。
*/
import { useSyncExternalStore } from "react";
export type AskInputOptions = {
title: string;
label?: string;
defaultValue?: string;
placeholder?: string;
};
type InputRequest = AskInputOptions & {
/** 递增 id,供渲染侧作 React key(连续请求时强制重挂,重置输入框状态) */
id: number;
resolve: (value: string | null) => void;
};
// 请求队列:允许连续发起多个 askInput,逐个展示
let queue: InputRequest[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((l) => l());
}
/** C6 契约:弹出令牌化输入框,确认返回值 / Esc 或取消返回 null */
export function askInput(opts: AskInputOptions): Promise<string | null> {
return new Promise((resolve) => {
queue = [...queue, { ...opts, id: nextId++, resolve }];
emit();
});
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): InputRequest | null {
return queue[0] ?? null;
}
/** 内部:NotifyHost 的 DialogHost 订阅当前待处理的输入请求 */
export function useInputRequest(): InputRequest | null {
return useSyncExternalStore(subscribe, getSnapshot);
}
/** 内部:消费当前请求(确认传值,取消传 null),并出队下一个 */
export function settleInputRequest(value: string | null) {
const [cur, ...rest] = queue;
queue = rest;
cur?.resolve(value);
emit();
}
+31
View File
@@ -0,0 +1,31 @@
/** Vite-bundled driver icons — works in Tauri/web without depending on /public path. */
const modules = import.meta.glob("../assets/databases/*.{svg,png}", {
eager: true,
query: "?url",
import: "default",
}) as Record<string, string>;
function fileKey(path: string): string {
const base = path.split("/").pop() ?? path;
return base.replace(/\.(svg|png)$/i, "").toLowerCase();
}
/** id → bundled asset URL */
export const DRIVER_ICON_URLS: Record<string, string> = {};
for (const [path, url] of Object.entries(modules)) {
DRIVER_ICON_URLS[fileKey(path)] = url;
}
/** Resolve catalog icon path or id to a loadable URL. */
export function resolveDriverIconUrl(id: string, icon?: string): string | undefined {
const byId = DRIVER_ICON_URLS[id.toLowerCase()];
if (byId) return byId;
if (!icon) return undefined;
const m = icon.match(/\/([^/]+)\.(svg|png)$/i);
if (m) {
const byFile = DRIVER_ICON_URLS[m[1].toLowerCase()];
if (byFile) return byFile;
}
// last resort: absolute/public path (web dev)
return icon;
}
+7
View File
@@ -15,6 +15,13 @@ const PROTO: Record<string, DriverId> = {
tidb: "tidb",
mongodb: "mongodb",
mongo: "mongodb",
oceanbase: "oceanbase",
gaussdb: "gaussdb",
opengauss: "gaussdb",
polardb: "polardb",
tdsql: "tdsql",
kingbase: "kingbase",
dameng: "dameng",
};
export type ParsedDSN = {
+4
View File
@@ -7,6 +7,10 @@ export { DRIVERS, CATEGORIES, driverById, isDriverReady } from "./catalog";
export type { DriverId, DriverDef, DriverCategory, FormKind } from "./catalog";
export { NotifyHost } from "./NotifyHost";
export { formatDbError } from "./notify";
export { OpsModal } from "./OpsModal";
export type { OpsModalProps, OpsModalVariant } from "./OpsModal";
export { askInput } from "./dialogs";
export type { AskInputOptions } from "./dialogs";
export { DriverIcon } from "./DriverIcon";
export { SchemaPane } from "./SchemaPane";
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
buildRedisKeyTree,
createKeyCommand,
isDangerousRedis,
quoteRedisArg,
redisCmd,
redisLeafLabel,
removeRedisKeyFromTree,
splitRedisCommands,
tryPrettyJson,
tsLabel,
} from "./redis";
describe("buildRedisKeyTree", () => {
it("按 : 编文件夹,叶子保留全名", () => {
const tree = buildRedisKeyTree(
[
{ name: "CEX:FOLLOW:FOLLOWERS:FUTURE:97865651", type: "hash", ttl: -1 },
{ name: "CEX:FOLLOW:TRADERS:1", type: "hash", ttl: -1 },
{ name: "LANG", type: "string", ttl: 12 },
],
":",
);
expect(tree.map((n) => n.name)).toEqual(["CEX", "LANG"]);
const cex = tree[0];
expect(cex.kind).toBe("folder");
expect(cex.count).toBe(2);
expect(cex.fullName).toBe("CEX:");
const follow = cex.children?.[0];
expect(follow?.name).toBe("FOLLOW");
const leaves = (follow?.children ?? []).flatMap((n) =>
n.kind === "folder" ? n.children ?? [] : [n],
);
expect(leaves.some((n) => n.name === "CEX:FOLLOW:TRADERS:1")).toBe(true);
});
it("无分隔符时保持扁平", () => {
const tree = buildRedisKeyTree(
[
{ name: "alpha", type: "string", ttl: -1 },
{ name: "beta", type: "hash", ttl: 3 },
],
":",
);
expect(tree.every((n) => n.kind === "key")).toBe(true);
expect(tree.map((n) => n.name)).toEqual(["alpha", "beta"]);
});
it("删除键后收起空文件夹", () => {
const tree = buildRedisKeyTree(
[
{ name: "a:b:c", type: "string", ttl: -1 },
{ name: "a:x", type: "string", ttl: -1 },
],
":",
);
const next = removeRedisKeyFromTree(tree, "a:b:c");
expect(next).toHaveLength(1);
expect(next[0].kind).toBe("folder");
expect(next[0].count).toBe(1);
expect(next[0].children?.[0].name).toBe("a:x");
});
});
describe("quote / cmd", () => {
it("空白与引号转义", () => {
expect(quoteRedisArg("abc")).toBe("abc");
expect(quoteRedisArg("a b")).toBe('"a b"');
expect(quoteRedisArg('say "hi"')).toBe('"say \\"hi\\""');
expect(redisCmd("SET", "k", "v w")).toBe('SET k "v w"');
});
it("新建键命令", () => {
expect(createKeyCommand("string", "demo", "x")).toBe("SET demo x");
expect(createKeyCommand("hash", "h", "v")).toBe("HSET h field v");
});
});
describe("splitRedisCommands", () => {
it("按行切,跳过空行和 # 注释", () => {
const parts = splitRedisCommands("GET a\n\n# skip\nHGETALL b");
expect(parts.map((p) => p.text)).toEqual(["GET a", "HGETALL b"]);
});
it("不把分号当分隔", () => {
const parts = splitRedisCommands('SET k "a;b"');
expect(parts).toHaveLength(1);
expect(parts[0].text).toBe('SET k "a;b"');
});
});
describe("misc", () => {
it("危险命令", () => {
expect(isDangerousRedis("FLUSHALL")).toBe(true);
expect(isDangerousRedis("GET k")).toBe(false);
expect(isDangerousRedis("keys *")).toBe(true);
});
it("JSON 美化", () => {
expect(tryPrettyJson('{"a":1}')).toBe('{\n "a": 1\n}');
expect(tryPrettyJson("not-json")).toBeNull();
});
it("leaf label", () => {
expect(redisLeafLabel("a:b:c", ":")).toBe("c");
expect(redisLeafLabel("plain", ":")).toBe("plain");
});
});
describe("tsLabel", () => {
it("毫秒时间戳给可读时间", () => {
expect(tsLabel(1782230400000)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it("秒时间戳给可读时间", () => {
expect(tsLabel(1782230400)).toMatch(/^\d{4}-/);
});
it("普通数字/非法值不误判", () => {
expect(tsLabel(10)).toBeNull();
expect(tsLabel(77777777777777)).toBeNull();
expect(tsLabel(Number.NaN)).toBeNull();
});
});
+280
View File
@@ -0,0 +1,280 @@
import type { TreeNode } from "./store";
export type RedisKeyMeta = { name: string; type: string; ttl: number };
/** 类型点颜色,对齐常见 Redis GUI */
export function redisTypeColor(type?: string): string {
switch ((type || "").toLowerCase()) {
case "string":
return "#22c55e";
case "hash":
return "#3b82f6";
case "list":
return "#f59e0b";
case "set":
return "#a855f7";
case "zset":
return "#06b6d4";
case "stream":
return "#ec4899";
case "rejson":
case "json":
case "rejson-rl":
return "#14b8a6";
default:
return "#ef4444";
}
}
/** 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`;
}
/** 数值若形如 Unix 时间戳(秒/毫秒)则给可读时间,否则 null —— zset Score 常存毫秒时间戳(如 K 线) */
export function tsLabel(n: number): string | null {
if (!Number.isFinite(n)) return null;
let d: Date | null = null;
if (n > 1e11 && n < 9e12) d = new Date(n); // 毫秒
else if (n > 1e8 && n < 9e9) d = new Date(n * 1000); // 秒
if (!d || Number.isNaN(d.getTime())) return null;
const p = (x: number) => String(x).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
export function redisLeafLabel(full: string, sep: string): string {
if (!sep) return full;
const i = full.lastIndexOf(sep);
if (i < 0) return full;
const leaf = full.slice(i + sep.length);
return leaf || full;
}
type Bucket = {
folders: Map<string, Bucket>;
keys: RedisKeyMeta[];
};
function keyCount(n: TreeNode): number {
if (n.kind === "key") return 1;
return n.count ?? (n.children ?? []).reduce((a, c) => a + keyCount(c), 0);
}
/** 按分隔符把 SCAN 结果编成文件夹树(默认 `:`,对标 Another Redis Desktop Manager)。 */
export function buildRedisKeyTree(keys: RedisKeyMeta[], separator = ":"): TreeNode[] {
const sep = separator || ":";
const root: Bucket = { folders: new Map(), keys: [] };
for (const k of keys) {
const parts = k.name.split(sep);
if (parts.length <= 1) {
root.keys.push(k);
continue;
}
let cur = root;
for (let i = 0; i < parts.length - 1; i++) {
const p = parts[i] ?? "";
let next = cur.folders.get(p);
if (!next) {
next = { folders: new Map(), keys: [] };
cur.folders.set(p, next);
}
cur = next;
}
cur.keys.push(k);
}
return bucketToNodes(root, "", sep);
}
function bucketToNodes(b: Bucket, prefix: string, sep: string): TreeNode[] {
const folderNames = [...b.folders.keys()].sort((a, c) => a.localeCompare(c, undefined, { numeric: true }));
const folders: TreeNode[] = folderNames.map((name) => {
const child = b.folders.get(name)!;
const fullName = prefix + name + sep;
const children = bucketToNodes(child, fullName, sep);
return {
name,
kind: "folder" as const,
groupId: "folder",
fullName,
loaded: true,
count: children.reduce((a, n) => a + keyCount(n), 0),
children,
};
});
const keys: TreeNode[] = [...b.keys]
.sort((a, c) => a.name.localeCompare(c.name, undefined, { numeric: true }))
.map((k) => ({
name: k.name,
kind: "key" as const,
type: k.type,
ttl: k.ttl,
}));
return [...folders, ...keys];
}
export function removeRedisKeyFromTree(nodes: TreeNode[], key: string): TreeNode[] {
const out: TreeNode[] = [];
for (const n of nodes) {
if (n.kind === "key" && n.name === key) continue;
if (n.kind === "folder") {
const children = removeRedisKeyFromTree(n.children ?? [], key);
if (!children.length) continue;
out.push({
...n,
children,
count: children.reduce((a, c) => a + keyCount(c), 0),
});
continue;
}
out.push(n);
}
return out;
}
/** 前端过滤键树:子串匹配键名/路径 + 类型筛选;空文件夹剪枝,计数同步 */
export function filterRedisTree(nodes: TreeNode[], q: string, type: string): TreeNode[] {
const needle = q.trim().toLowerCase();
const typeOn = type && type !== "all";
if (!needle && !typeOn) return nodes;
const keep = (n: TreeNode): boolean =>
!needle || n.name.toLowerCase().includes(needle) || (n.fullName || "").toLowerCase().includes(needle);
const typeOk = (n: TreeNode): boolean => !typeOn || (n.type || "").toLowerCase() === type;
const walk = (list: TreeNode[]): TreeNode[] => {
const out: TreeNode[] = [];
for (const n of list) {
if (n.kind === "folder") {
const children = walk(n.children ?? []);
if (!children.length) continue;
out.push({ ...n, children, count: children.reduce((a, c) => a + keyCount(c), 0) });
continue;
}
if (n.kind === "key") {
if (keep(n) && typeOk(n)) out.push(n);
continue;
}
out.push(n);
}
return out;
};
return walk(nodes);
}
export function quoteRedisArg(s: string): string {
if (s === "") return '""';
if (/^[\w\-.:/@+=]+$/.test(s)) return s;
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
export function redisCmd(...parts: string[]): string {
return parts.map(quoteRedisArg).join(" ");
}
export function tryPrettyJson(raw: unknown): string | null {
if (raw == null) return null;
if (typeof raw === "object") {
try {
return JSON.stringify(raw, null, 2);
} catch {
return null;
}
}
const s = String(raw).trim();
if (!s || (s[0] !== "{" && s[0] !== "[" && s[0] !== '"')) return null;
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return null;
}
}
export function displayRedisValue(raw: unknown, max = 180): { text: string; pretty: string | null } {
const pretty = tryPrettyJson(raw);
const full = pretty ?? (raw == null ? "" : String(raw));
if (full.length > max) return { text: `${full.slice(0, max)}…`, pretty };
return { text: full, pretty };
}
export const REDIS_COMMANDS = [
"PING", "GET", "SET", "DEL", "EXISTS", "TTL", "PTTL", "EXPIRE", "PERSIST", "TYPE", "RENAME",
"KEYS", "SCAN", "INFO", "DBSIZE", "SELECT", "ECHO",
"HGET", "HGETALL", "HSET", "HDEL", "HKEYS", "HVALS", "HLEN", "HEXISTS", "HSCAN",
"LPUSH", "RPUSH", "LRANGE", "LLEN", "LSET", "LPOP", "RPOP", "LINDEX",
"SADD", "SREM", "SMEMBERS", "SCARD", "SISMEMBER", "SSCAN",
"ZADD", "ZREM", "ZRANGE", "ZREVRANGE", "ZCARD", "ZSCORE", "ZSCAN",
"MGET", "MSET", "INCR", "DECR", "INCRBY", "DECRBY", "APPEND", "STRLEN",
"FLUSHDB", "FLUSHALL", "CONFIG", "CLIENT", "SLOWLOG", "MEMORY",
].map((label) => ({ label, type: "keyword" as const }));
/** Redis 按行切命令(不是 SQL 分号)。空行与 # 注释跳过。 */
export function splitRedisCommands(sql: string): { start: number; end: number; text: string }[] {
const out: { start: number; end: number; text: string }[] = [];
let i = 0;
while (i <= sql.length) {
const nl = sql.indexOf("\n", i);
const end = nl < 0 ? sql.length : nl;
const line = sql.slice(i, end);
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const lead = line.search(/\S/);
const from = i + (lead < 0 ? 0 : lead);
out.push({ start: from, end: from + trimmed.length, text: trimmed });
}
if (nl < 0) break;
i = end + 1;
}
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
}
export function isDangerousRedis(sql: string): boolean {
const cmd = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? "";
return ["FLUSHALL", "FLUSHDB", "KEYS", "CONFIG", "SHUTDOWN", "DEBUG", "REPLICAOF", "SLAVEOF"].includes(cmd);
}
export function redisHintForType(key: string, type?: string): string {
const k = quoteRedisArg(key);
switch (type) {
case "hash":
return `HGETALL ${k}`;
case "list":
return `LRANGE ${k} 0 499`;
case "set":
return `SMEMBERS ${k}`;
case "zset":
return `ZRANGE ${k} 0 499 WITHSCORES`;
default:
return `GET ${k}`;
}
}
export function createKeyCommand(type: string, name: string, value: string): string {
switch (type) {
case "hash":
return redisCmd("HSET", name, "field", value || "");
case "list":
return redisCmd("RPUSH", name, value || "");
case "set":
return redisCmd("SADD", name, value || "");
case "zset":
return redisCmd("ZADD", name, "0", value || "");
default:
return redisCmd("SET", name, value || "");
}
}
/** 是否 Redis 连接(按驱动名) */
export function isRedisConn(driver?: string): boolean {
return (driver || "").toLowerCase() === "redis";
}
+184
View File
@@ -0,0 +1,184 @@
import { describe, expect, it } from "vitest";
import {
isDangerousSQL,
parseClipboardGrid,
quoteIdent,
rowsToCSV,
rowsToInsert,
splitRunnable,
splitStatements,
statementAt,
} from "./sql";
describe("rowsToCSV", () => {
it("转义引号/逗号/换行", () => {
const out = rowsToCSV(
["a", "b"],
[{ a: 'x"y', b: "l1\nl2" }, { a: "c,d", b: null }],
);
expect(out).toBe('a,b\n"x""y","l1\nl2"\n"c,d",');
});
it("空值输出空串并保留表头", () => {
expect(rowsToCSV(["a"], [{ a: null }])).toBe("a\n");
});
});
describe("rowsToInsert(方言引号)", () => {
const rows = [{ id: 1, name: "O'Neil" }, { id: 2, name: null }];
it("mysql 族反引号 + 单引号字面量翻倍", () => {
expect(rowsToInsert("mysql", "users", ["id", "name"], rows)).toBe(
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'O''Neil');\n" +
"INSERT INTO `users` (`id`, `name`) VALUES (2, NULL);",
);
});
it("postgresql/sqlite 双引号", () => {
expect(rowsToInsert("postgresql", "users", ["id"], [{ id: 1 }])).toBe(
'INSERT INTO "users" ("id") VALUES (1);',
);
expect(rowsToInsert("sqlite", "users", ["id"], [{ id: 1 }])).toBe(
'INSERT INTO "users" ("id") VALUES (1);',
);
});
it("mssql 中括号", () => {
expect(rowsToInsert("sqlserver", "users", ["id"], [{ id: 1 }])).toBe(
"INSERT INTO [users] ([id]) VALUES (1);",
);
});
it("oracle 裸名(大写惯例不加引号)", () => {
expect(rowsToInsert("oracle", "USERS", ["ID"], [{ ID: 1 }])).toBe(
"INSERT INTO USERS (ID) VALUES (1);",
);
});
it("库.表 全限定逐段加引号;内部反引号/双引号/中括号转义", () => {
expect(rowsToInsert("mysql", "app.users", ["id"], [{ id: 1 }])).toBe(
"INSERT INTO `app`.`users` (`id`) VALUES (1);",
);
expect(quoteIdent("mysql", "a`b")).toBe("`a``b`");
expect(quoteIdent("postgresql", 'a"b')).toBe('"a""b"');
expect(quoteIdent("sqlserver", "a]b")).toBe("[a]]b]");
});
it("缺省 driver 按 mysql 处理", () => {
expect(rowsToInsert(undefined, "t", ["c"], [{ c: "v" }])).toBe("INSERT INTO `t` (`c`) VALUES ('v');");
});
});
describe("quoteIdent", () => {
it("各 family 引号风格", () => {
expect(quoteIdent("mysql", "t")).toBe("`t`");
expect(quoteIdent("mariadb", "t")).toBe("`t`");
expect(quoteIdent("postgresql", "t")).toBe('"t"');
expect(quoteIdent("gaussdb", "t")).toBe('"t"');
expect(quoteIdent("sqlserver", "t")).toBe("[t]");
expect(quoteIdent("mssql", "t")).toBe("[t]");
expect(quoteIdent("oracle", "EMP")).toBe("EMP");
expect(quoteIdent("sqlite", "t")).toBe('"t"');
});
});
describe("splitStatements", () => {
it("引号内分号不截断", () => {
const parts = splitStatements("SELECT ';' AS x; SELECT 2");
expect(parts).toHaveLength(2);
expect(parts[0].text).toBe("SELECT ';' AS x");
expect(parts[1].text.trim()).toBe("SELECT 2");
});
it("行注释内分号不截断", () => {
const parts = splitStatements("SELECT 1 -- c;\n; SELECT 2");
expect(parts).toHaveLength(2);
expect(parts[1].text.trim()).toBe("SELECT 2");
});
it("块注释内分号不截断", () => {
const parts = splitStatements("SELECT /* ; */ 1; SELECT 2");
expect(parts).toHaveLength(2);
});
it("双引号字符串内分号不截断", () => {
const parts = splitStatements('SELECT "a;b" FROM t; SELECT 2');
expect(parts).toHaveLength(2);
});
it("单语句无分号原样返回", () => {
const parts = splitStatements("SELECT 1");
expect(parts).toHaveLength(1);
expect(parts[0].text).toBe("SELECT 1");
});
});
describe("parseClipboardGrid", () => {
it("Tab 分隔", () => {
expect(parseClipboardGrid("a\tb\nc\td")).toEqual([
["a", "b"],
["c", "d"],
]);
});
it("literal NULL(任意大小写)→ null", () => {
expect(parseClipboardGrid("x,NULL,y")).toEqual([["x", null, "y"]]);
expect(parseClipboardGrid("null")).toEqual([[null]]);
});
it("无 Tab 的简单 CSV 按逗号拆", () => {
expect(parseClipboardGrid('a,"b,c"')).toEqual([["a", "b,c"]]);
});
it("CRLF 与末尾换行归一", () => {
expect(parseClipboardGrid("a\r\nb\n")).toEqual([["a"], ["b"]]);
});
it("空串返回空数组", () => {
expect(parseClipboardGrid("")).toEqual([]);
});
});
describe("isDangerousSQL", () => {
it("基本危险动词", () => {
expect(isDangerousSQL("DROP TABLE t")).toBe(true);
expect(isDangerousSQL("TRUNCATE TABLE t")).toBe(true);
expect(isDangerousSQL("ALTER TABLE t ADD c INT")).toBe(true);
expect(isDangerousSQL("GRANT ALL ON a TO b")).toBe(true);
expect(isDangerousSQL("REVOKE SELECT ON a FROM b")).toBe(true);
});
it("DELETE/UPDATE 无 WHERE 才算危险", () => {
expect(isDangerousSQL("DELETE FROM t")).toBe(true);
expect(isDangerousSQL("DELETE FROM t WHERE id=1")).toBe(false);
expect(isDangerousSQL("UPDATE t SET a=1")).toBe(true);
expect(isDangerousSQL("UPDATE t SET a=1 WHERE id=2")).toBe(false);
});
it("注释开头的语句仍判定", () => {
expect(isDangerousSQL("-- hi\nDROP TABLE t")).toBe(true);
expect(isDangerousSQL("/* c */ DROP TABLE t")).toBe(true);
expect(isDangerousSQL(" -- hi\n /* c */ TRUNCATE TABLE t")).toBe(true);
});
it("普通查询与纯注释不误伤", () => {
expect(isDangerousSQL("SELECT 1")).toBe(false);
expect(isDangerousSQL("WITH x AS (SELECT 1) SELECT * FROM x")).toBe(false);
expect(isDangerousSQL("-- only comment")).toBe(false);
expect(isDangerousSQL("")).toBe(false);
});
it("Redis 危险命令按 driver 判定", () => {
expect(isDangerousSQL("FLUSHALL", "redis")).toBe(true);
expect(isDangerousSQL("GET k", "redis")).toBe(false);
expect(isDangerousSQL("FLUSHALL")).toBe(false);
});
});
describe("splitRunnable / statementAt redis", () => {
it("Redis 按行而不是分号", () => {
const parts = splitRunnable("GET a\nHGETALL b", "redis");
expect(parts.map((p) => p.text)).toEqual(["GET a", "HGETALL b"]);
expect(statementAt('SET k "a;b"\nGET k', 0, undefined, "redis")).toBe('SET k "a;b"');
});
});
+50 -32
View File
@@ -1,6 +1,13 @@
import { format as sqlFormat } from "sql-formatter";
import { isDangerousRedis, splitRedisCommands } from "./redis";
export function isDangerousSQL(sql: string): boolean {
/**
* 危险语句 UX 预检(免一次往返)。
* 注意:服务端 CheckDangerousSQL 为权威判定(逐语句/词边界/注释处理),
* 此处只做宽松预提示,判定冲突时以服务端为准(needConfirm 二段流)。
*/
export function isDangerousSQL(sql: string, driver?: string): boolean {
if ((driver || "").toLowerCase() === "redis") return isDangerousRedis(sql);
const s = stripLead(sql);
if (!s) return false;
const up = s.toUpperCase();
@@ -30,16 +37,22 @@ function stripLead(sql: string): string {
return s;
}
/** 选区优先;否则取光标所在语句(分号分隔,忽略引号内)。 */
export function statementAt(sql: string, cursor: number, selection?: string): string {
/** 选区优先;否则取光标所在语句。Redis 按行,SQL 按分号。 */
export function statementAt(sql: string, cursor: number, selection?: string, driver?: string): string {
const sel = selection?.trim();
if (sel) return sel;
const ranges = splitStatements(sql);
const ranges = splitRunnable(sql, driver);
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();
}
/** Redis 按行;其它引擎按 SQL 分号。 */
export function splitRunnable(sql: string, driver?: string): { start: number; end: number; text: string }[] {
if ((driver || "").toLowerCase() === "redis") return splitRedisCommands(sql);
return splitStatements(sql);
}
export function splitStatements(sql: string): { start: number; end: number; text: string }[] {
const out: { start: number; end: number; text: string }[] = [];
let start = 0;
@@ -97,25 +110,36 @@ export function splitStatements(sql: string): { start: number; end: number; text
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
}
export function sampleSelectSql(driver: string | undefined, db: string, table: string) {
/** limit:false 时不拼行数上限(树预览用,翻页由请求层 offset/limit 承担) */
function buildSelectSql(driver: string | undefined, db: string, table: string, limit: number | false) {
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;`;
return `SELECT * FROM ${ident}${limit === false ? "" : ` LIMIT ${limit}`};`;
}
if (d === "sqlserver" || d === "mssql") {
return `SELECT TOP 100 * FROM ${table};`;
return `SELECT ${limit === false ? "" : `TOP ${limit} `}* FROM ${table};`;
}
if (d === "oracle") {
return `SELECT * FROM ${table} FETCH FIRST 100 ROWS ONLY`;
return `SELECT * FROM ${table}${limit === false ? "" : ` FETCH FIRST ${limit} ROWS ONLY`}`;
}
if (d === "sqlite") {
return `SELECT * FROM "${table}" LIMIT 100;`;
return `SELECT * FROM "${table}"${limit === false ? "" : ` LIMIT ${limit}`};`;
}
const tbl = table.includes(".") ? table : `\`${table}\``;
if (db) return `SELECT * FROM \`${db}\`.${tbl} LIMIT 100;`;
return `SELECT * FROM ${tbl} LIMIT 100;`;
const lim = limit === false ? "" : ` LIMIT ${limit}`;
if (db) return `SELECT * FROM \`${db}\`.${tbl}${lim};`;
return `SELECT * FROM ${tbl}${lim};`;
}
export function sampleSelectSql(driver: string | undefined, db: string, table: string) {
return buildSelectSql(driver, db, table, 100);
}
/** 树表预览 SQL:不带行数上限,翻页由请求层 offset/limit 完成(否则第 2 页起恒为空) */
export function previewSelectSql(driver: string | undefined, db: string, table: string) {
return buildSelectSql(driver, db, table, false);
}
export function rowsToTSV(columns: string[], rows: Record<string, unknown>[], headers = true): string {
@@ -138,9 +162,17 @@ export function rowsToCSV(columns: string[], rows: Record<string, unknown>[]): 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(", ");
/** R2-F2:表名/列名按方言 quoteIdent(修 01-P1-1 恒反引号);库.表 形式逐段加引号 */
export function rowsToInsert(
driver: string | undefined,
table: string,
columns: string[],
rows: Record<string, unknown>[],
): string {
const ident = table
? table.split(".").map((p) => quoteIdent(driver, p)).join(".")
: "t";
const cols = columns.map((c) => quoteIdent(driver, c)).join(", ");
const lit = (v: unknown) => {
if (v == null) return "NULL";
if (typeof v === "number" && Number.isFinite(v)) return String(v);
@@ -192,7 +224,7 @@ export function buildUpdateSql(
const d = (driver || "mysql").toLowerCase();
const t = table.includes(".")
? table
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase"].includes(d)
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d)
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
: quoteIdent(driver, table);
const set = `${quoteIdent(driver, col)} = ${sqlLiteral(newVal === "" ? null : newVal)}`;
@@ -216,22 +248,8 @@ export function supportsExplain(driver: string | undefined): boolean {
].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}`;
}
// C3:方言 EXPLAIN 包装已下线 —— 改由服务端 POST /api/db/explain 按驱动完成
// (MSSQL SHOWPLAN_XML 需钉单连接,Oracle 需查 DBMS_XPLAN,前端拼 SQL 拿不到)。
/** Map OpsxDB driver id → sql-formatter language. */
export function sqlFormatterLanguage(driver?: string): string {
@@ -293,7 +311,7 @@ export function buildDeleteSql(
const d = (driver || "mysql").toLowerCase();
const t = table.includes(".")
? table
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase"].includes(d)
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d)
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
: quoteIdent(driver, table);
const where = pkCols
+145 -6
View File
@@ -3,6 +3,7 @@ import type { DriverId } from "./catalog";
import { driverById } from "./catalog";
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
import { decodeSecret, encodeSecret } from "./secrets";
import { dropSchemaCache } from "./api";
export type SavedConnection = {
id: string;
@@ -33,6 +34,12 @@ export type SavedConnection = {
/** Redis key 分隔符,默认 : */
keySeparator?: string;
redisMode?: "standalone" | "sentinel" | "cluster";
/** Sentinel/Cluster 节点列表 host:port(多行) */
redisNodes?: string;
/** Sentinel 主节点名 */
masterName?: string;
/** Sentinel 密码(可空) */
sentinelPassword?: string;
/** Oracle */
oracleConnType?: "basic";
oracleIdKind?: "service" | "sid";
@@ -76,6 +83,12 @@ export type QueryResult = {
error?: string;
truncated?: boolean;
limit?: number;
/** C5:服务端回传的分页偏移 */
offset?: number;
/** Redis 值游标分页:>0 还有更多(hash/set=SCAN 游标;list/zset=偏移) */
nextCursor?: number;
/** C1:命中危险语句,需确认后带 confirmDanger 重发 */
needConfirm?: boolean;
};
export type SchemaView = {
@@ -90,6 +103,9 @@ export type ResultMeta = {
database?: string;
table?: string;
pkCols?: string[];
/** Redis 键详情:打开键时带类型/TTL,命令结果不带 */
redisType?: string;
redisTtl?: number;
};
export type ConnState = "ok" | "down" | "reconnecting";
@@ -99,6 +115,17 @@ export type ResultSetItem = {
label: string;
sql: string;
result: QueryResult;
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
page?: number;
pageSize?: number;
/** 执行时使用的库(翻页重发需要) */
database?: string;
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
viewState?: {
colWidths?: Record<string, number>;
selectedIdx?: number | null;
selectedCol?: string | null;
};
};
export type Bookmark = {
@@ -119,8 +146,8 @@ export type ColumnInfo = {
export type TreeNode = {
name: string;
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key";
/** group id: tables|views|procedures|functions|keys */
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key" | "folder";
/** group id: tables|views|procedures|functions|keys|folder */
groupId?: string;
count?: number;
children?: TreeNode[];
@@ -129,6 +156,8 @@ export type TreeNode = {
type?: string;
/** Redis TTL seconds; -1 permanent; -2 missing */
ttl?: number;
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
fullName?: string;
nullable?: boolean;
key?: string;
};
@@ -148,6 +177,8 @@ export type ConfirmRequest = {
message: string;
okLabel?: string;
danger?: boolean;
/** C6:将执行的语句清单(危险确认二段流展示,前 3 条 + 折叠其余) */
items?: string[];
};
export type AiMessage = {
@@ -181,6 +212,10 @@ type State = {
bookmarks: Bookmark[];
showBookmarks: boolean;
connState: ConnState;
/** 左栏底部统一搜索:非 Redis=过滤库/表;Redis=过滤键(回车服务端模糊扫描) */
sidebarFilter: string;
/** Redis 类型筛选 */
sidebarTypeFilter: string;
running: boolean;
pickerSearch: string;
pickerCategory: string;
@@ -203,6 +238,16 @@ type State = {
showHelp: boolean;
showHistory: boolean;
confirm: ConfirmRequest | null;
/** R2-F1:多语句执行遇错即停(默认 true);false 时全部执行完再汇总 */
stopOnError: boolean;
/** R2-F8:语句执行超时(ms),0 = 不超时;持久化到 prefs */
execTimeoutMs: number;
/** 对象树最近聚焦的表/视图(编辑器右键「查看数据/DDL」用) */
focusedRelation: { database: string; table: string } | null;
/** 请求 ObjectTree 打开 DDL 弹层 */
ddlRequest: { database: string; table: string; token: number } | null;
/** 请求 ObjectTree 打开表数据预览 */
viewDataRequest: { database: string; table: string; token: number } | null;
set: <K extends keyof State>(key: K, value: State[K]) => void;
notify: (input: {
@@ -223,6 +268,8 @@ type State = {
removeConnection: (id: string) => void;
setDefaultConnection: (id: string) => void;
addConnGroup: (name: string) => string;
/** R2-F5:导入连接时并入分组(冲突/缺失 id 重新生成);默认分组不重复创建 */
importConnGroups: (groups: ConnectionGroup[]) => void;
renameConnGroup: (id: string, name: string) => void;
removeConnGroup: (id: string) => void;
moveConnection: (id: string, groupId: string, index: number) => void;
@@ -233,7 +280,15 @@ type State = {
newQueryTab: () => void;
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
setActiveTabTitle: (title: string) => void;
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
setTabDatabase: (database: string) => void;
closeTab: (id: string) => void;
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
closeOtherTabs: (id: string) => void;
closeRightTabs: (id: string) => void;
closeAllTabs: () => void;
setStopOnError: (v: boolean) => void;
setExecTimeoutMs: (v: number) => void;
updateActiveSql: (sql: string) => void;
pushAi: (role: AiMessage["role"], content: string) => void;
setSession: (connectionId: string, sessionId: string | null) => void;
@@ -241,6 +296,8 @@ type State = {
clearHistory: () => void;
setResultSets: (items: ResultSetItem[]) => void;
selectResultSet: (id: string) => void;
/** C5/G3:局部更新某个结果集(翻页结果、视图持久化);若为激活项同步 result */
updateResultSet: (id: string, patch: Partial<Omit<ResultSetItem, "id">>) => void;
loadBookmarks: () => void;
saveBookmark: (name: string, sql: string) => void;
renameBookmark: (id: string, name: string) => void;
@@ -260,7 +317,7 @@ const LS_BOOKMARKS = "opsxdb.bookmarks.v1";
let confirmResolver: ((ok: boolean) => void) | null = null;
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi">> {
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi" | "stopOnError" | "execTimeoutMs">> {
try {
const raw = localStorage.getItem(LS_PREFS);
if (!raw) return {};
@@ -271,6 +328,8 @@ function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "r
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
showAi: typeof p.showAi === "boolean" ? p.showAi : undefined,
stopOnError: typeof p.stopOnError === "boolean" ? p.stopOnError : undefined,
execTimeoutMs: typeof p.execTimeoutMs === "number" ? Math.min(3_600_000, Math.max(0, p.execTimeoutMs)) : undefined,
};
} catch {
return {};
@@ -348,8 +407,8 @@ function loadLs(): SavedConnection[] {
}
function defaultTab(): QueryTab {
const sql = "SELECT 1 AS ok;";
return { id: crypto.randomUUID(), title: "查询 1", sql, savedSql: sql, dirty: false };
// 新 Tab 默认空白(修 T3),不再预置 SELECT 1 AS ok;
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
}
export const useWorkspaceStore = create<State>((set, get) => ({
@@ -373,6 +432,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
bookmarks: [],
showBookmarks: false,
connState: "ok",
sidebarFilter: "",
sidebarTypeFilter: "all",
running: false,
pickerSearch: "",
pickerCategory: "全部",
@@ -395,10 +456,15 @@ export const useWorkspaceStore = create<State>((set, get) => ({
showHelp: false,
showHistory: false,
confirm: null,
stopOnError: true,
execTimeoutMs: 0,
focusedRelation: null,
ddlRequest: null,
viewDataRequest: null,
set: (key, value) => {
set({ [key]: value } as Partial<State>);
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi") {
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
get().persistPrefs();
}
if (key === "tabs" || key === "activeTabId") {
@@ -508,6 +574,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
removeConnection: (id) => {
get().saveConnections(get().connections.filter((c) => c.id !== id));
dropSchemaCache(id);
const sessions = { ...get().sessions };
delete sessions[id];
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
@@ -531,6 +598,23 @@ export const useWorkspaceStore = create<State>((set, get) => ({
return id;
},
/** R2-F5:导入连接时并入分组;与现有 id 冲突或缺失时重新生成,避免串组 */
importConnGroups: (groups) => {
if (!Array.isArray(groups) || !groups.length) return;
const cur = get().connGroups;
const ids = new Set(cur.map((g) => g.id));
const merged = [...cur];
for (const g of groups) {
if (!g || typeof g.name !== "string" || !g.name.trim()) continue;
if (g.id === DEFAULT_CONN_GROUP) continue;
const item = { id: g.id && !ids.has(g.id) ? g.id : crypto.randomUUID(), name: g.name.trim() };
ids.add(item.id);
merged.push(item);
}
saveGroups(merged);
set({ connGroups: merged });
},
renameConnGroup: (id, name) => {
if (id === DEFAULT_CONN_GROUP) return;
const n = name.trim();
@@ -659,6 +743,13 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
setTabDatabase: (database) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
closeTab: (id) => {
const tabs = get().tabs.filter((t) => t.id !== id);
const next = tabs.length ? tabs : [defaultTab()];
@@ -667,6 +758,42 @@ export const useWorkspaceStore = create<State>((set, get) => ({
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
},
/** R2-F6:右键菜单「关闭其他」——只保留指定 Tab */
closeOtherTabs: (id) => {
const tabs = get().tabs.filter((t) => t.id === id);
const next = tabs.length ? tabs : [defaultTab()];
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[0].id;
set({ tabs: next, activeTabId });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
},
/** R2-F6:右键菜单「关闭右侧」——保留指定 Tab 及其左侧全部 */
closeRightTabs: (id) => {
const idx = get().tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
const next = get().tabs.slice(0, idx + 1);
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[next.length - 1].id;
set({ tabs: next, activeTabId });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
},
/** R2-F6:右键菜单「关闭全部」——回落一个空白 Tab(与 closeTab 语义一致) */
closeAllTabs: () => {
const next = [defaultTab()];
set({ tabs: next, activeTabId: next[0].id });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId: next[0].id }));
},
setStopOnError: (v) => {
set({ stopOnError: v });
get().persistPrefs();
},
setExecTimeoutMs: (v) => {
set({ execTimeoutMs: Math.min(3_600_000, Math.max(0, v || 0)) });
get().persistPrefs();
},
updateActiveSql: (sql) => {
const id = get().activeTabId;
const tabs = get().tabs.map((t) => {
@@ -733,6 +860,16 @@ export const useWorkspaceStore = create<State>((set, get) => ({
set({ activeResultSetId: id, result: item.result, resultTab: "data" });
},
updateResultSet: (id, patch) => {
const items = get().resultSets.map((x) => (x.id === id ? { ...x, ...patch } : x));
const next: Partial<State> = { resultSets: items };
if (get().activeResultSetId === id) {
const item = items.find((x) => x.id === id);
if (item) next.result = item.result;
}
set(next);
},
loadBookmarks: () => {
try {
const raw = localStorage.getItem(LS_BOOKMARKS);
@@ -790,6 +927,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
aiWidth: s.aiWidth,
resultRatio: s.resultRatio,
showAi: s.showAi,
stopOnError: s.stopOnError,
execTimeoutMs: s.execTimeoutMs,
}),
);
},
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
File renamed without changes.