feat: OpsxDB 桌面数据库客户端首版入库

含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
logan committed 2026-09-19 22:43:45 +08:00
commit f8740d27f7
240 files changed
+21625

No files matched your search

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