feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收): - Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用) - ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/) - 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收 - 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle - 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点) 驱动管理: - 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口 - opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh) - 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退 UI/交互: - 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell) - 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角) - 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行) - 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理 质量: - Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥) - 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
This commit is contained in:
1 parent
26f459d1b8
commit
a9510235bb
156 files changed
+17200
-2520
No files matched your search
+245
-5
@@ -1,5 +1,5 @@
|
||||
import { callAi, type AiAction } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { useEffect, useState } from "react";
|
||||
import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const ACTIONS: { id: AiAction; label: string }[] = [
|
||||
@@ -11,6 +11,205 @@ const ACTIONS: { id: AiAction; label: string }[] = [
|
||||
{ id: "sample", label: "样例" },
|
||||
];
|
||||
|
||||
type ContentSeg = { kind: "text" | "code"; text: string };
|
||||
|
||||
/** R2-F7:拆分 AI 回复中的 ``` 代码块(通常 sql);块外文本维持纯文本 */
|
||||
function splitContent(content: string): ContentSeg[] {
|
||||
const segs: ContentSeg[] = [];
|
||||
const re = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content))) {
|
||||
if (m.index > last) segs.push({ kind: "text", text: content.slice(last, m.index) });
|
||||
segs.push({ kind: "code", text: m[1].replace(/\n$/, "") });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < content.length) segs.push({ kind: "text", text: content.slice(last) });
|
||||
return segs.length ? segs : [{ kind: "text", text: content }];
|
||||
}
|
||||
|
||||
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 });
|
||||
};
|
||||
const insertToEditor = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const tab = st.tabs.find((t) => t.id === st.activeTabId);
|
||||
const cur = tab?.sql ?? "";
|
||||
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${code}` : code);
|
||||
notify({ kind: "success", title: "已插入编辑器", message: "SQL 已追加到当前查询", mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div
|
||||
className="flex items-center justify-end gap-1.5 border-b px-1.5 py-0.5"
|
||||
style={{ borderColor: "var(--ops-border-soft)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={insertToEditor}
|
||||
>
|
||||
插入编辑器
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
className="m-0 overflow-auto whitespace-pre-wrap break-all px-2 py-1 font-mono"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
background: "var(--ops-editor-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** R2-F7:AI 消息渲染 —— 代码块走等宽块,其余纯文本(无代码块时整体不变) */
|
||||
function MessageContent({ content }: { content: string }) {
|
||||
const segs = splitContent(content);
|
||||
if (segs.length === 1 && segs[0].kind === "text") return <>{content}</>;
|
||||
return (
|
||||
<>
|
||||
{segs.map((s, i) =>
|
||||
s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
|
||||
function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (!alive) return;
|
||||
setProvider(cfg.provider || "stub");
|
||||
setBaseURL(cfg.baseURL || "");
|
||||
setModel(cfg.model || "");
|
||||
setHasKey(!!cfg.hasKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveAiConfig({
|
||||
provider,
|
||||
baseURL: baseURL.trim(),
|
||||
// 留空表示不改动已保存的 key(服务端掩码不回传明文)
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
model: model.trim(),
|
||||
});
|
||||
if (model.trim()) set("aiModel", model.trim());
|
||||
setHasKey(hasKey || !!apiKey.trim());
|
||||
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "AI 配置", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!(await save())) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await callAi({ action: "ask", prompt: "用一句话回答:连接正常", model: model.trim() || undefined });
|
||||
notify({
|
||||
kind: "success",
|
||||
title: "AI 连接正常",
|
||||
message: res.content.slice(0, 120) || "(空响应)",
|
||||
mode: "toast",
|
||||
durationMs: 4000,
|
||||
});
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
aria-label="Provider"
|
||||
>
|
||||
<option value="stub">stub(离线占位)</option>
|
||||
<option value="openai">openai(OpenAI 兼容)</option>
|
||||
</select>
|
||||
<input
|
||||
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
||||
placeholder="模型,如 gpt-4o-mini"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
aria-label="模型"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="ops-input w-full py-0.5 text-[11px]"
|
||||
placeholder="Base URL,如 https://api.openai.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
aria-label="Base URL"
|
||||
/>
|
||||
<input
|
||||
className="ops-input w-full py-0.5 text-[11px]"
|
||||
type="password"
|
||||
placeholder={hasKey ? "API Key(已配置,留空不改动)" : "API Key"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
aria-label="API Key"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void test()}>
|
||||
{busy ? "…" : "测试"}
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost ml-auto h-6 px-2 text-[11px]" onClick={onClose}>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiPanel() {
|
||||
const messages = useWorkspaceStore((s) => s.aiMessages);
|
||||
const ask = useWorkspaceStore((s) => s.aiAsk);
|
||||
@@ -24,6 +223,26 @@ export function AiPanel() {
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const pushAi = useWorkspaceStore((s) => s.pushAi);
|
||||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
/** C4:真实配置的模型清单(来自 /api/ai/config) */
|
||||
const [cfgModel, setCfgModel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (!alive) return;
|
||||
if (cfg.model) {
|
||||
setCfgModel(cfg.model);
|
||||
const cur = useWorkspaceStore.getState().aiModel;
|
||||
if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [set]);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
@@ -39,6 +258,8 @@ export function AiPanel() {
|
||||
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
||||
}
|
||||
try {
|
||||
// C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts)
|
||||
const schema = schemaContextFor(conn?.id, conn?.database || tab?.database);
|
||||
const res = await callAi({
|
||||
action,
|
||||
prompt: text,
|
||||
@@ -46,6 +267,8 @@ export function AiPanel() {
|
||||
driver: conn?.driver,
|
||||
database: conn?.database || tab?.database,
|
||||
error: result?.error,
|
||||
schema: schema || undefined,
|
||||
model: model && model !== "opsxdb-stub" ? model : undefined,
|
||||
});
|
||||
pushAi("assistant", res.content);
|
||||
if (res.sql) updateActiveSql(res.sql);
|
||||
@@ -57,6 +280,8 @@ export function AiPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean)));
|
||||
|
||||
return (
|
||||
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
||||
<div className="ops-section-label justify-between border-b gap-1">
|
||||
@@ -66,10 +291,23 @@ export function AiPanel() {
|
||||
className="ops-input h-6 py-0 text-[10px] normal-case tracking-normal"
|
||||
value={model}
|
||||
onChange={(e) => set("aiModel", e.target.value)}
|
||||
title="模型(来自 /api/ai/config,可在设置中修改)"
|
||||
>
|
||||
<option value="opsxdb-stub">opsxdb-stub</option>
|
||||
<option value="gpt-stub">gpt-stub</option>
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
||||
title="AI 设置"
|
||||
aria-label="AI 设置"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-close-faint"
|
||||
@@ -85,6 +323,8 @@ export function AiPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<select
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
@@ -155,7 +395,7 @@ export function AiPanel() {
|
||||
>
|
||||
{m.role === "user" ? "你" : "AI"} · {m.at}
|
||||
</div>
|
||||
{m.content}
|
||||
<MessageContent content={m.content} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -111,19 +111,88 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
}
|
||||
|
||||
if (kind === "redis") {
|
||||
const mode = form.redisMode ?? "standalone";
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "Redis 连接")}</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="部署模式">
|
||||
<div className="flex flex-wrap items-center gap-3 text-[12px]">
|
||||
{(
|
||||
[
|
||||
["standalone", "单机"],
|
||||
["sentinel", "Sentinel"],
|
||||
["cluster", "Cluster"],
|
||||
] as const
|
||||
).map(([v, label]) => (
|
||||
<label key={v} className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="redisMode"
|
||||
checked={mode === v}
|
||||
onChange={() => patch({ redisMode: v })}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
{mode === "standalone" && (
|
||||
<>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
</>
|
||||
)}
|
||||
{mode === "sentinel" && (
|
||||
<>
|
||||
<Row label="Sentinel 节点">
|
||||
<textarea
|
||||
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
|
||||
placeholder={"127.0.0.1:26379\n127.0.0.1:26380"}
|
||||
value={form.redisNodes ?? ""}
|
||||
onChange={(e) => patch({ redisNodes: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="主节点名">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="mymaster"
|
||||
value={form.masterName ?? ""}
|
||||
onChange={(e) => patch({ masterName: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Sentinel 密码">
|
||||
<PasswordInput
|
||||
value={form.sentinelPassword ?? ""}
|
||||
onChange={(v) => patch({ sentinelPassword: v })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
{mode === "cluster" && (
|
||||
<Row label="种子节点">
|
||||
<textarea
|
||||
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
|
||||
placeholder={"127.0.0.1:7000\n127.0.0.1:7001"}
|
||||
value={form.redisNodes ?? ""}
|
||||
onChange={(e) => patch({ redisNodes: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="密码">
|
||||
<PasswordInput
|
||||
value={form.password ?? ""}
|
||||
onChange={(v) => patch({ password: v })}
|
||||
/>
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
</Row>
|
||||
<Row label="用户名">{text("user", "ACL ≥6 可选")}</Row>
|
||||
<Row label="分隔符">
|
||||
{mode !== "cluster" && (
|
||||
<Row label="DB 索引">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="0"
|
||||
value={form.database ?? "0"}
|
||||
onChange={(e) => patch({ database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="键分隔符">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder=":"
|
||||
@@ -131,72 +200,17 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
onChange={(e) => patch({ keySeparator: e.target.value || ":" })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="模式">
|
||||
<div className="flex flex-wrap items-center gap-3 text-[12px]">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.redisMode ?? "standalone") === "standalone"}
|
||||
onChange={() => patch({ redisMode: "standalone" })}
|
||||
/>
|
||||
单机
|
||||
</label>
|
||||
<label className="flex items-center gap-1 opacity-50" title="本迭代未做">
|
||||
<input type="checkbox" disabled checked={false} readOnly />
|
||||
Sentinel
|
||||
</label>
|
||||
<label className="flex items-center gap-1 opacity-50" title="本迭代未做">
|
||||
<input type="checkbox" disabled checked={false} readOnly />
|
||||
Cluster
|
||||
</label>
|
||||
<span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
Sentinel / Cluster 本迭代未做
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "oracle") {
|
||||
const idKind = form.oracleIdKind ?? "service";
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "Oracle 连接")}</Row>
|
||||
<Row label="连接类型">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.oracleConnType ?? "basic"}
|
||||
onChange={(e) => patch({ oracleConnType: e.target.value as "basic" })}
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="标识">
|
||||
<div className="flex items-center gap-3 text-[12px]">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="oracleIdKind"
|
||||
checked={idKind === "service"}
|
||||
onChange={() => patch({ oracleIdKind: "service" })}
|
||||
/>
|
||||
服务名
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="oracleIdKind"
|
||||
checked={idKind === "sid"}
|
||||
onChange={() => patch({ oracleIdKind: "sid" })}
|
||||
/>
|
||||
SID
|
||||
</label>
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={idKind === "sid" ? "SID" : "服务名"}>
|
||||
<Row label="服务名">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? "ORCL"}
|
||||
@@ -204,17 +218,6 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
onChange={(e) => patch({ service: e.target.value, database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="角色">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.oracleRole ?? "default"}
|
||||
onChange={(e) => patch({ oracleRole: e.target.value })}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="SYSDBA">SYSDBA</option>
|
||||
<option value="SYSOPER">SYSOPER</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="用户">{text("user", selected.hints?.user ?? "system")}</Row>
|
||||
<Row label="密码">
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
@@ -265,20 +268,6 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{isMssql && (
|
||||
<Row label="验证方式">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.authMode ?? "sql"}
|
||||
onChange={(e) => patch({ authMode: e.target.value as "sql" | "windows" })}
|
||||
>
|
||||
<option value="sql">SQL Server 身份验证</option>
|
||||
<option value="windows" disabled>
|
||||
Windows 身份验证(未实现)
|
||||
</option>
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -445,17 +434,23 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
|
||||
const ssh = () => (
|
||||
<>
|
||||
<Row label="SSH 隧道">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.sshEnabled}
|
||||
onChange={(e) => patch({ sshEnabled: e.target.checked })}
|
||||
/>
|
||||
使用 SSH 隧道
|
||||
</label>
|
||||
</Row>
|
||||
{form.sshEnabled && (
|
||||
{kind === "file" ? (
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
文件库(SQLite)为本机文件,无网络端点,无需 SSH 隧道。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Row label="SSH 隧道">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.sshEnabled}
|
||||
onChange={(e) => patch({ sshEnabled: e.target.checked })}
|
||||
/>
|
||||
使用 SSH 隧道
|
||||
</label>
|
||||
</Row>
|
||||
{form.sshEnabled && (
|
||||
<>
|
||||
<Row label="SSH 主机">
|
||||
<input
|
||||
@@ -522,6 +517,8 @@ export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDs
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { DriverManager } from "./DriverManager";
|
||||
import { useWorkspaceStore, type ConnectionGroup, type SavedConnection, DEFAULT_CONN_GROUP } from "./store";
|
||||
|
||||
function hostSummary(c: SavedConnection) {
|
||||
const d = driverById(c.driver);
|
||||
@@ -118,7 +119,11 @@ export function ConnectionList() {
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showDrivers, setShowDrivers] = useState(false);
|
||||
/** R2-F5:导入文件选择 */
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
|
||||
@@ -163,6 +168,58 @@ export function ConnectionList() {
|
||||
set("status", `已选择 ${c.name}`);
|
||||
};
|
||||
|
||||
/** R2-F5:导出全部连接 —— 原样导出存储形态(含分组结构与加密态 secrets),文件头带版本 */
|
||||
const exportAll = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
connections: st.connections,
|
||||
connGroups: st.connGroups,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `opsxdb-connections-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
notify({ kind: "success", title: "已导出", message: `${st.connections.length} 条连接`, mode: "toast", durationMs: 1600 });
|
||||
};
|
||||
|
||||
/** R2-F5:导入连接 —— 校验数组结构;id 冲突重新生成后追加;持久化走 store 现有 action */
|
||||
const onImportFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as { connections?: unknown; connGroups?: unknown } | unknown[];
|
||||
const arr = Array.isArray(parsed) ? parsed : (parsed as { connections?: unknown }).connections;
|
||||
if (!Array.isArray(arr)) throw new Error("不是有效的连接导出文件(缺少 connections 数组)");
|
||||
const st = useWorkspaceStore.getState();
|
||||
const existingIds = new Set(st.connections.map((c) => c.id));
|
||||
const groupIds = new Set(st.connGroups.map((g) => g.id));
|
||||
let n = 0;
|
||||
for (const raw of arr) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const item = { ...(raw as SavedConnection) };
|
||||
if (!item.name || !item.driver) continue; // 跳过非法条目
|
||||
if (!item.id || existingIds.has(item.id)) item.id = crypto.randomUUID();
|
||||
existingIds.add(item.id);
|
||||
if (!item.groupId || !groupIds.has(item.groupId)) item.groupId = DEFAULT_CONN_GROUP;
|
||||
st.upsertConnection(item);
|
||||
n++;
|
||||
}
|
||||
if (!n) throw new Error("文件中没有可导入的连接");
|
||||
if (!Array.isArray(parsed) && Array.isArray((parsed as { connGroups?: unknown }).connGroups)) {
|
||||
st.importConnGroups((parsed as { connGroups: ConnectionGroup[] }).connGroups);
|
||||
}
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${n} 条连接`, mode: "toast", durationMs: 2000 });
|
||||
} catch (err) {
|
||||
notifyError(err, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const rowProps = { onOpen, onDelete, setDefaultConnection, editConnection, notify };
|
||||
|
||||
return (
|
||||
@@ -174,13 +231,51 @@ export function ConnectionList() {
|
||||
选择或新建数据库连接
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
|
||||
新建连接
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
|
||||
title="驱动管理(agent 驱动:MongoDB / ZooKeeper / 达梦)"
|
||||
aria-label="驱动管理"
|
||||
onClick={() => setShowDrivers(true)}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
{/* R2-F5:连接配置导入/导出 */}
|
||||
{connections.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
|
||||
title="导出全部连接(JSON,含分组与密码存储形态)"
|
||||
aria-label="导出全部连接"
|
||||
onClick={exportAll}
|
||||
>
|
||||
⤓
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
|
||||
title="导入连接(JSON)"
|
||||
aria-label="导入连接"
|
||||
onClick={() => importFileRef.current?.click()}
|
||||
>
|
||||
⤒
|
||||
</button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
aria-label="选择连接导出文件"
|
||||
onChange={(e) => void onImportFile(e)}
|
||||
/>
|
||||
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
|
||||
新建
|
||||
</button>
|
||||
{connections.length > 0 && (
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => set("view", "workbench")}>
|
||||
进入工作台
|
||||
<button type="button" className="ops-btn-secondary px-3" onClick={() => set("view", "workbench")} title="返回工作台">
|
||||
工作台
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -196,8 +291,8 @@ export function ConnectionList() {
|
||||
className="h-7 w-full max-w-md rounded border px-2 text-[12px] outline-none"
|
||||
style={{
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-bg, transparent)",
|
||||
color: "var(--ops-fg, inherit)",
|
||||
background: "var(--ops-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
placeholder="搜索名称 / 主机 / 驱动…"
|
||||
value={search}
|
||||
@@ -216,8 +311,8 @@ export function ConnectionList() {
|
||||
<div className="ops-empty">
|
||||
<h2>还没有连接</h2>
|
||||
<p>新建一个连接,保存后会出现在这里。</p>
|
||||
<button type="button" className="ops-btn-primary" onClick={() => openPicker()}>
|
||||
新建连接
|
||||
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
@@ -244,6 +339,7 @@ export function ConnectionList() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showDrivers && <DriverManager onClose={() => setShowDrivers(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,24 +6,12 @@ import { parseDSN } from "./dsn";
|
||||
import { sanitizeSecret } from "./secrets";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { openSession, testConnection } from "./api";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function LocalDriverIcon({ d, size = 20 }: { d: DriverDef; size?: number }) {
|
||||
if (d.icon) {
|
||||
return <img src={d.icon} alt="" style={{ width: size, height: size }} className="shrink-0 object-contain" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="grid shrink-0 place-items-center rounded text-[9px] font-bold text-white"
|
||||
style={{ width: size, height: size, background: d.color }}
|
||||
>
|
||||
{d.name.slice(0, 2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionPicker() {
|
||||
const search = useWorkspaceStore((s) => s.pickerSearch);
|
||||
@@ -64,7 +52,7 @@ export function ConnectionPicker() {
|
||||
name: form.name.trim(),
|
||||
driver: form.driver as DriverId,
|
||||
host: form.host || "127.0.0.1",
|
||||
port: Number(form.port || selected?.defaultPort || 3306),
|
||||
port: Number(form.port ?? selected?.defaultPort ?? 3306),
|
||||
user: form.user || "",
|
||||
password: sanitizeSecret(form.password || ""),
|
||||
database: form.database || "",
|
||||
@@ -85,6 +73,9 @@ export function ConnectionPicker() {
|
||||
savePassword,
|
||||
keySeparator: form.keySeparator,
|
||||
redisMode: form.redisMode,
|
||||
redisNodes: form.redisNodes,
|
||||
masterName: form.masterName,
|
||||
sentinelPassword: sanitizeSecret(form.sentinelPassword || ""),
|
||||
oracleConnType: form.oracleConnType,
|
||||
oracleIdKind: form.oracleIdKind,
|
||||
oracleRole: form.oracleRole,
|
||||
@@ -161,7 +152,7 @@ export function ConnectionPicker() {
|
||||
<div className="ops-shell flex h-screen">
|
||||
<aside className="ops-panel flex w-44 shrink-0 flex-col border-r">
|
||||
<div className="border-b px-2.5 py-2" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="text-[14px] font-semibold">新建连接</div>
|
||||
<div className="text-[14px] font-semibold">新建</div>
|
||||
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
选择引擎
|
||||
</div>
|
||||
@@ -199,10 +190,32 @@ export function ConnectionPicker() {
|
||||
<div className="ops-section-label border-b">{category === "全部" ? "全部引擎" : category}</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{filtered.map((d) => (
|
||||
<button key={d.id} type="button" className="ops-driver-row" onClick={() => openPicker(d.id)}>
|
||||
<LocalDriverIcon d={d} />
|
||||
<button key={d.id} type="button" className={cx("ops-driver-row", !(d.ready || d.mysqlCompat) && "opacity-60")} onClick={() => openPicker(d.id)} title={!(d.ready || d.mysqlCompat) ? "驱动未接入" : d.verified === false ? "已接入,待验收" : undefined}>
|
||||
<DriverIcon driver={d.id} size={20} />
|
||||
<span className="min-w-0 truncate font-medium">{d.name}</span>
|
||||
<span className="ops-driver-meta">{d.ready || d.mysqlCompat ? "可用" : "未就绪"}</span>
|
||||
{d.verified === false && (
|
||||
<span
|
||||
className="shrink-0 rounded px-1 text-[10px]"
|
||||
style={{
|
||||
color: "var(--ops-warning)",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 12%, transparent)",
|
||||
}}
|
||||
title={d.notes?.trim() || "已接入,待验收"}
|
||||
>
|
||||
未验收
|
||||
</span>
|
||||
)}
|
||||
{d.ready || d.mysqlCompat ? (
|
||||
<span className="ops-driver-meta">可用</span>
|
||||
) : (
|
||||
<span
|
||||
className="ops-driver-meta"
|
||||
style={{ color: "var(--ops-muted)", opacity: 0.85 }}
|
||||
title="驱动未接入"
|
||||
>
|
||||
未就绪
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
@@ -247,7 +260,7 @@ export function ConnectionPicker() {
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => { setTestMsg(null); set("pickerDriver", null); set("pickerMode", "drivers"); }}>
|
||||
返回
|
||||
</button>
|
||||
<LocalDriverIcon d={selected} />
|
||||
<DriverIcon driver={selected.id} size={20} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-semibold leading-tight">{selected.name}</div>
|
||||
<div className="text-[11px]" style={{ color: ready ? "var(--ops-accent)" : "var(--ops-muted)" }}>
|
||||
@@ -255,7 +268,7 @@ export function ConnectionPicker() {
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="ops-btn-ghost ml-auto" onClick={backToList}>
|
||||
连接列表
|
||||
返回
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -290,9 +303,9 @@ export function ConnectionPicker() {
|
||||
<div
|
||||
className="mx-3 mb-2 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-warn) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warn) 8%, transparent)",
|
||||
color: "var(--ops-warn)",
|
||||
borderColor: "color-mix(in srgb, var(--ops-warning) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 8%, transparent)",
|
||||
color: "var(--ops-warning)",
|
||||
}}
|
||||
>
|
||||
{selected.name} 驱动未就绪,可先保存配置。
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { closeSession } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { askInput } from "./dialogs";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { ObjectTree } from "./ObjectTree";
|
||||
import { TreeChevron } from "./TreeChevron";
|
||||
import {
|
||||
DEFAULT_CONN_GROUP,
|
||||
useWorkspaceStore,
|
||||
@@ -32,6 +36,23 @@ export function ConnectionSidebar({
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; conn: SavedConnection } | null>(null);
|
||||
/** 断开连接:关会话 + 清树 + 清激活 */
|
||||
const disconnect = (c: SavedConnection) => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const sid = st.sessions[c.id];
|
||||
if (sid) {
|
||||
void closeSession(sid).catch(() => {});
|
||||
}
|
||||
st.setSession(c.id, null);
|
||||
if (st.activeConnectionId === c.id) {
|
||||
st.set("activeConnectionId", null);
|
||||
st.set("tree", []);
|
||||
st.set("connState", "down");
|
||||
st.set("status", `已断开 ${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dropHint, setDropHint] = useState<DropHint>(null);
|
||||
const dragIdRef = useRef<string | null>(null);
|
||||
@@ -51,24 +72,37 @@ export function ConnectionSidebar({
|
||||
}, [connections, groups]);
|
||||
|
||||
const onAddGroup = () => {
|
||||
const name = window.prompt("分组名称", "新分组");
|
||||
if (name == null) return;
|
||||
const id = addConnGroup(name);
|
||||
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
|
||||
void id;
|
||||
void (async () => {
|
||||
// C6:askInput 替代 window.prompt(WKWebView 下 prompt 返回 null)
|
||||
const name = await askInput({ title: "分组名称", defaultValue: "新分组" });
|
||||
if (name == null) return;
|
||||
const id = addConnGroup(name);
|
||||
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
|
||||
void id;
|
||||
})();
|
||||
};
|
||||
|
||||
const onRenameGroup = (id: string, cur: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const name = window.prompt("重命名分组", cur);
|
||||
if (name == null || !name.trim()) return;
|
||||
renameConnGroup(id, name);
|
||||
void (async () => {
|
||||
const name = await askInput({ title: "重命名分组", defaultValue: cur });
|
||||
if (name == null || !name.trim()) return;
|
||||
renameConnGroup(id, name);
|
||||
})();
|
||||
};
|
||||
|
||||
const onRemoveGroup = (id: string, name: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
if (!window.confirm(`删除分组「${name}」?连接将移回默认。`)) return;
|
||||
removeConnGroup(id);
|
||||
void (async () => {
|
||||
const ok = await useWorkspaceStore.getState().askConfirm({
|
||||
title: "删除分组",
|
||||
message: `删除分组「${name}」?连接将移回默认。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
removeConnGroup(id);
|
||||
})();
|
||||
};
|
||||
|
||||
const setDrop = (groupId: string, index: number) => {
|
||||
@@ -88,10 +122,10 @@ export function ConnectionSidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[42%] min-h-[120px] flex-col border-b" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<div className="flex items-center justify-between px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<span>连接</span>
|
||||
<button type="button" className="normal-case tracking-normal hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||||
+ 分组
|
||||
</button>
|
||||
</div>
|
||||
@@ -189,15 +223,49 @@ export function ConnectionSidebar({
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<button type="button" className="ops-tree-item min-w-0 flex-1" onClick={() => onSelect(c.id)}>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-tree-item min-w-0 flex-1"
|
||||
onClick={() => onSelect(c.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, conn: c });
|
||||
}}
|
||||
>
|
||||
{/* 实例级展开/收起箭头(行首树形 chevron,SVG 保证方向清晰) */}
|
||||
<span
|
||||
className="grid w-3.5 shrink-0 cursor-pointer place-items-center opacity-60 hover:opacity-100"
|
||||
title={activeId === c.id ? "点击收起对象树" : "点击展开对象树"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeId === c.id) {
|
||||
// 已激活 → 收起(清空树)
|
||||
useWorkspaceStore.getState().set("tree", []);
|
||||
useWorkspaceStore.getState().set("activeConnectionId", null);
|
||||
} else {
|
||||
// 未激活 → 展开(激活并加载树)
|
||||
onSelect(c.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TreeChevron open={activeId === c.id} size={10} />
|
||||
</span>
|
||||
{c.color && (
|
||||
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
|
||||
)}
|
||||
<DriverIcon driver={c.driver} size={14} />
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="mr-1 text-[10px] opacity-50">{driverById(c.driver)?.name}</span>
|
||||
<span className="min-w-0 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: activeId === c.id ? '#22c55e' : 'var(--ops-muted)',
|
||||
opacity: activeId === c.id ? 1 : 0.4,
|
||||
}}
|
||||
title={activeId === c.id ? '已连接' : '未激活'}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -217,6 +285,7 @@ export function ConnectionSidebar({
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{activeId === c.id && <ObjectTree />}
|
||||
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
|
||||
<div className="ops-drop-line" />
|
||||
)}
|
||||
@@ -228,6 +297,53 @@ export function ConnectionSidebar({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<div
|
||||
className="ops-ctx"
|
||||
style={{ position: "fixed", left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000 }}
|
||||
>
|
||||
{activeId === ctxMenu.conn.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
disconnect(ctxMenu.conn);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
断开连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(ctxMenu.conn.id);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editConnection(ctxMenu.conn);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => {
|
||||
onDelete(ctxMenu.conn.id);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getDdl } from "./api";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
|
||||
/** 超长 DDL 截断阈值(R2-F3):超过只展示前 5000 字符并提示 */
|
||||
const DDL_CAP = 5000;
|
||||
|
||||
type Props = {
|
||||
conn: SavedConnection;
|
||||
database: string;
|
||||
table: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* R2-F3:DDL 查看 —— OpsModal(info 变体)展示服务端返回的建表语句。
|
||||
* 等宽 pre + 加载态 + 错误重试;「复制」「插入编辑器」把 DDL 带到剪贴板/当前 Tab。
|
||||
*/
|
||||
export function DdlModal({ conn, database, table, onClose }: Props) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const [ddl, setDdl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await getDdl(conn, table, { database, sessionId: sid });
|
||||
if (res.error) throw new Error(res.error);
|
||||
setDdl(res.ddl ?? "(空)");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setDdl(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conn, database, table]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const truncated = ddl != null && ddl.length > DDL_CAP;
|
||||
const text = truncated ? (ddl ?? "").slice(0, DDL_CAP) : (ddl ?? "");
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
notify({ kind: "success", title: "已复制", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
/** 插入编辑器:DDL 追加到当前激活 Tab 的 SQL 末尾 */
|
||||
const insertToEditor = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const tab = st.tabs.find((t) => t.id === st.activeTabId);
|
||||
const cur = tab?.sql ?? "";
|
||||
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${text}` : text);
|
||||
notify({ kind: "success", title: "已插入编辑器", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1600 });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" wide title={`DDL · ${database ? `${database}.` : ""}${table}`} okLabel="关闭" onOk={onClose}>
|
||||
{loading ? (
|
||||
<div style={{ color: "var(--ops-muted)" }}>加载 DDL…</div>
|
||||
) : error ? (
|
||||
<div>
|
||||
<div
|
||||
className="rounded border px-2.5 py-1.5"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
fontSize: "var(--ops-text-sm)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => void load()}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1.5 flex items-center justify-end gap-1.5">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => void copy()}>
|
||||
复制
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={insertToEditor}>
|
||||
插入编辑器
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
className="m-0 max-h-[50vh] overflow-auto whitespace-pre-wrap break-all rounded border px-2.5 py-2 font-mono leading-relaxed"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
borderColor: "var(--ops-border-soft)",
|
||||
background: "var(--ops-editor-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
{truncated && (
|
||||
<div className="mt-1.5" style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-warning)" }}>
|
||||
内容过长,仅显示前 {DDL_CAP} 字符(共 {ddl?.length} 字符)。
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { driverById, type DriverId } from "./catalog";
|
||||
import { resolveDriverIconUrl } from "./driverIconUrls";
|
||||
|
||||
function initials(name: string, id: string): string {
|
||||
const raw = (name || id).replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "");
|
||||
if (!raw) return "?";
|
||||
// Prefer 2-letter latin abbreviation from id
|
||||
if (/^[a-z]/i.test(id)) return id.slice(0, 2).toUpperCase();
|
||||
return raw.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function LetterFallback({
|
||||
label,
|
||||
size,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
size: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-grid shrink-0 place-items-center rounded-[3px] text-[9px] font-bold ${className ?? ""}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
background: "color-mix(in srgb, var(--ops-muted) 22%, transparent)",
|
||||
color: "var(--ops-muted)",
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DriverIcon({
|
||||
driver,
|
||||
@@ -10,27 +47,22 @@ export function DriverIcon({
|
||||
className?: string;
|
||||
}) {
|
||||
const d = driverById(driver);
|
||||
const label = (d?.name ?? driver).slice(0, 2).toUpperCase();
|
||||
const color = d?.color ?? "#64748b";
|
||||
if (d?.icon) {
|
||||
const label = initials(d?.name ?? String(driver), String(driver));
|
||||
const src = resolveDriverIconUrl(String(driver), d?.icon);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (src && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={d.icon}
|
||||
src={src}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className={`shrink-0 object-contain ${className}`}
|
||||
draggable={false}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-grid shrink-0 place-items-center rounded-[3px] text-[9px] font-bold text-white ${className}`}
|
||||
style={{ width: size, height: size, background: color }}
|
||||
title={d?.name ?? String(driver)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return <LetterFallback label={label} size={size} className={className} title={d?.name ?? String(driver)} />;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { driverImportOffline, driverInstall, driverStatus, type DriverStatusItem } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
mongodb: "MongoDB",
|
||||
zookeeper: "ZooKeeper",
|
||||
dameng: "达梦 DM8",
|
||||
};
|
||||
|
||||
function fmtSize(n: number): string {
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驱动管理弹框:agent 驱动(MongoDB / ZooKeeper / 达梦)的运行时状态与维护入口。
|
||||
* 在线环境首次连接会自动下载;这里提供预装、离线包导入与商用 jar 指引。
|
||||
*/
|
||||
export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [items, setItems] = useState<DriverStatusItem[] | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setItems(await driverStatus());
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const install = async (id: string) => {
|
||||
setBusy(id);
|
||||
try {
|
||||
const r = await driverInstall(id);
|
||||
if (r.drivers) setItems(r.drivers);
|
||||
if (r.ok) {
|
||||
notify({ kind: "success", title: "安装完成", message: `${LABELS[id] ?? id} 驱动已就绪`, mode: "toast" });
|
||||
} else {
|
||||
notify({ kind: "error", title: "安装失败", message: r.error ?? "未知错误", mode: "modal" });
|
||||
}
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const importZip = async (f: File) => {
|
||||
setBusy("import");
|
||||
try {
|
||||
const r = await driverImportOffline(f);
|
||||
if (r.drivers) setItems(r.drivers);
|
||||
if (r.ok) {
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${r.imported} 个文件`, mode: "toast" });
|
||||
} else {
|
||||
notify({ kind: "error", title: "导入失败", message: r.error ?? "包校验失败", mode: "modal" });
|
||||
}
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const jdk = items?.find((x) => x.jdkRequired);
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" title="驱动管理" onOk={onClose}>
|
||||
<div className="space-y-2" style={{ minWidth: 480 }}>
|
||||
{/* 环境状态行 */}
|
||||
<div
|
||||
className="flex items-center gap-3 rounded border px-2.5 py-1.5 text-[11.5px]"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||||
>
|
||||
<span style={{ color: "var(--ops-muted)" }}>JVM:</span>
|
||||
{jdk?.jdkOk ? (
|
||||
<span style={{ color: "var(--ops-accent)" }}>已检测到 Java {jdk.javaVersion}</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--ops-danger)" }}>未找到 Java(agent 驱动需 JDK 17+)</span>
|
||||
)}
|
||||
<span style={{ color: "var(--ops-muted)" }}>·</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>agent:</span>
|
||||
<span style={{ color: items?.[0]?.agentInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
|
||||
{items?.[0]?.agentInstalled ? "已安装" : "缺失(连接时自动下载)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 驱动行 */}
|
||||
{items === null && <div className="py-3 text-[12px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{items?.map((d) => {
|
||||
const done = d.allInstalled;
|
||||
const badge = !d.jdkOk
|
||||
? { t: "缺 JDK", c: "var(--ops-danger)" }
|
||||
: done
|
||||
? { t: "已安装", c: "var(--ops-accent)" }
|
||||
: d.license === "proprietary"
|
||||
? { t: "需自备 jar", c: "var(--ops-warning)" }
|
||||
: { t: "未安装", c: "var(--ops-muted)" };
|
||||
const total = d.jars.reduce((s, j) => s + (j.size || 0), 0);
|
||||
const got = d.jars.filter((j) => j.installed).length;
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center gap-2.5 rounded border px-2.5 py-2"
|
||||
style={{ borderColor: "var(--ops-border-soft)" }}
|
||||
>
|
||||
<DriverIcon driver={d.id} size={20} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12.5px] font-medium">{LABELS[d.id] ?? d.label}</span>
|
||||
<span
|
||||
className="rounded px-1 text-[10px]"
|
||||
style={{ color: badge.c, background: `color-mix(in srgb, ${badge.c} 12%, transparent)` }}
|
||||
>
|
||||
{badge.t}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{d.license === "proprietary"
|
||||
? "商用许可不代下载:把官方 DmJdbcDriver jar 放到 ~/.opsxdb/drivers/dameng/"
|
||||
: d.jars.length > 0
|
||||
? `驱动 jar ${got}/${d.jars.length}${total > 0 ? ` · ${fmtSize(total)}` : ""}${d.autoFetch ? " · 连接时自动下载" : ""}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
{d.autoFetch && !done && d.jdkOk && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void install(d.id)}
|
||||
>
|
||||
{busy === d.id ? "安装中…" : "安装"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 离线包导入 */}
|
||||
<div
|
||||
className="flex items-center gap-2 border-t pt-2 text-[11px]"
|
||||
style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
<span>内网/离线:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
disabled={busy !== null}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{busy === "import" ? "导入中…" : "导入离线驱动包…"}
|
||||
</button>
|
||||
<span className="truncate">(opsxdb-driver 仓库 pack.sh 产物 .zip)</span>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importZip(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
+152
-139
@@ -1,39 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
import type { ConfirmRequest } from "./store";
|
||||
import type { NotifyKind, NotifyPayload } from "./notify";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { settleInputRequest, useInputRequest } from "./dialogs";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
// C6:items 字段由 WS-C 在 store.ts 正式补充,此处本地类型扩展先行渲染
|
||||
type ConfirmRequestWithItems = ConfirmRequest & { items?: string[] };
|
||||
|
||||
const KIND_META: Record<
|
||||
NotifyKind,
|
||||
{ badge: string; ring: string; iconBg: string; label: string }
|
||||
> = {
|
||||
error: {
|
||||
badge: "text-red-300",
|
||||
ring: "border-red-800/70",
|
||||
iconBg: "bg-red-500/15 text-red-400",
|
||||
label: "错误",
|
||||
},
|
||||
warning: {
|
||||
badge: "text-amber-300",
|
||||
ring: "border-amber-800/70",
|
||||
iconBg: "bg-amber-500/15 text-amber-400",
|
||||
label: "提示",
|
||||
},
|
||||
info: {
|
||||
badge: "text-cyan-300",
|
||||
ring: "border-cyan-800/70",
|
||||
iconBg: "bg-cyan-500/15 text-cyan-400",
|
||||
label: "信息",
|
||||
},
|
||||
success: {
|
||||
badge: "text-emerald-300",
|
||||
ring: "border-emerald-800/70",
|
||||
iconBg: "bg-emerald-500/15 text-emerald-400",
|
||||
label: "成功",
|
||||
},
|
||||
/**
|
||||
* 分级语义色:统一走 --ops-* 令牌(修 V2,去除 slate 硬编码),亮暗主题随行
|
||||
* error→danger / warning→warning / info→accent / success→success
|
||||
*/
|
||||
const KIND_META: Record<NotifyKind, { color: string; label: string }> = {
|
||||
error: { color: "var(--ops-danger)", label: "错误" },
|
||||
warning: { color: "var(--ops-warning)", label: "提示" },
|
||||
info: { color: "var(--ops-accent)", label: "信息" },
|
||||
success: { color: "var(--ops-success)", label: "成功" },
|
||||
};
|
||||
|
||||
function KindIcon({ kind }: { kind: NotifyKind }) {
|
||||
@@ -41,93 +24,94 @@ function KindIcon({ kind }: { kind: NotifyKind }) {
|
||||
const glyph =
|
||||
kind === "error" ? "!" : kind === "warning" ? "!" : kind === "success" ? "✓" : "i";
|
||||
return (
|
||||
<div className={cx("grid h-9 w-9 shrink-0 place-items-center rounded-full text-sm font-bold", meta.iconBg)}>
|
||||
<div
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-sm font-bold"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${meta.color} 15%, transparent)`,
|
||||
color: meta.color,
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 通知模态:走 OpsModal info 变体(Esc/遮罩关闭、焦点管理由 OpsModal 负责) */
|
||||
function ModalCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
const meta = KIND_META[n.kind];
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`notify-title-${n.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"w-full max-w-md overflow-hidden rounded-xl border bg-slate-950 shadow-2xl shadow-black/50",
|
||||
meta.ring,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-slate-800 px-5 py-4">
|
||||
<OpsModal
|
||||
variant="info"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
<KindIcon kind={n.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cx("text-[11px] font-semibold uppercase tracking-wider", meta.badge)}>
|
||||
<span>
|
||||
<span
|
||||
className="block font-semibold uppercase"
|
||||
style={{ fontSize: "var(--ops-text-xs)", letterSpacing: "0.05em", color: meta.color }}
|
||||
>
|
||||
{meta.label}
|
||||
</div>
|
||||
<h2 id={`notify-title-${n.id}`} className="mt-0.5 text-base font-semibold text-slate-100">
|
||||
</span>
|
||||
<span className="block" style={{ fontSize: "var(--ops-text-lg)" }}>
|
||||
{n.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-2 py-1 text-slate-500 transition hover:bg-slate-800 hover:text-slate-300"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
onOk={onClose}
|
||||
>
|
||||
<p className="m-0">{n.message}</p>
|
||||
|
||||
{n.tips && n.tips.length > 0 && (
|
||||
<ul
|
||||
className="m-0 mt-2 space-y-1.5 rounded border px-3 py-2.5"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-sm)",
|
||||
borderColor: "var(--ops-border-soft)",
|
||||
background: "var(--ops-surface-2)",
|
||||
color: "var(--ops-muted)",
|
||||
listStyle: "none",
|
||||
}}
|
||||
>
|
||||
{n.tips.map((t) => (
|
||||
<li key={t} className="flex gap-2">
|
||||
<span className="mt-0.5" style={{ color: "var(--ops-accent)" }}>
|
||||
•
|
||||
</span>
|
||||
<span>{t}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{n.detail && (
|
||||
<details
|
||||
className="group mt-2 rounded border"
|
||||
style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-editor-bg)" }}
|
||||
>
|
||||
<summary
|
||||
className="cursor-pointer select-none px-3 py-2 hover:underline"
|
||||
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-5 py-4 text-sm">
|
||||
<p className="leading-relaxed text-slate-300">{n.message}</p>
|
||||
|
||||
{n.tips && n.tips.length > 0 && (
|
||||
<ul className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2.5 text-xs text-slate-400">
|
||||
{n.tips.map((t) => (
|
||||
<li key={t} className="flex gap-2">
|
||||
<span className="mt-0.5 text-cyan-500">•</span>
|
||||
<span>{t}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{n.detail && (
|
||||
<details className="group rounded-lg border border-slate-800 bg-[#070b11]">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-slate-500 hover:text-slate-300">
|
||||
技术详情
|
||||
</summary>
|
||||
<pre className="max-h-40 overflow-auto border-t border-slate-800 px-3 py-2 font-mono text-[11px] leading-5 text-slate-400 whitespace-pre-wrap break-all">
|
||||
{n.detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-slate-800 px-5 py-3">
|
||||
<button type="button" className="ops-btn-primary min-w-[88px]" onClick={onClose} autoFocus>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
技术详情
|
||||
</summary>
|
||||
<pre
|
||||
className="max-h-40 overflow-auto border-t px-3 py-2 font-mono leading-5 whitespace-pre-wrap break-all"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
borderColor: "var(--ops-border-soft)",
|
||||
color: "var(--ops-muted)",
|
||||
}}
|
||||
>
|
||||
{n.detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toast:保留分级图标 + 自动关闭,样式走令牌 */
|
||||
function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
const meta = KIND_META[n.kind];
|
||||
useEffect(() => {
|
||||
@@ -138,20 +122,23 @@ function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-auto flex w-[360px] max-w-[calc(100vw-2rem)] items-start gap-3 rounded-xl border bg-slate-950/95 px-4 py-3 shadow-xl backdrop-blur",
|
||||
meta.ring,
|
||||
)}
|
||||
className="ops-toast"
|
||||
style={{ borderColor: `color-mix(in srgb, ${meta.color} 45%, var(--ops-border))` }}
|
||||
role="status"
|
||||
>
|
||||
<KindIcon kind={n.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-slate-100">{n.title}</div>
|
||||
<div className="mt-0.5 text-xs leading-relaxed text-slate-400">{n.message}</div>
|
||||
<div className="font-semibold" style={{ fontSize: "var(--ops-text-md)", color: "var(--ops-text)" }}>
|
||||
{n.title}
|
||||
</div>
|
||||
<div className="mt-0.5 leading-relaxed" style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}>
|
||||
{n.message}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 text-slate-500 hover:text-slate-300"
|
||||
className="ops-btn-ghost"
|
||||
style={{ height: "auto", padding: "2px 6px" }}
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -161,39 +148,61 @@ function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 危险/普通确认框:走 OpsModal confirm 变体;items 语句清单前 3 条 + 折叠其余(修 C2) */
|
||||
function ConfirmCard() {
|
||||
const confirm = useWorkspaceStore((s) => s.confirm);
|
||||
const confirm = useWorkspaceStore((s) => s.confirm) as ConfirmRequestWithItems | null;
|
||||
const resolveConfirm = useWorkspaceStore((s) => s.resolveConfirm);
|
||||
useEffect(() => {
|
||||
if (!confirm) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") resolveConfirm(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [confirm, resolveConfirm]);
|
||||
if (!confirm) return null;
|
||||
const items = confirm.items;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] grid place-items-center bg-black/55 p-4" role="alertdialog">
|
||||
<div className="ops-modal w-full max-w-md">
|
||||
<div className="ops-modal-header">{confirm.title}</div>
|
||||
<div className="ops-modal-body">{confirm.message}</div>
|
||||
<div className="ops-modal-footer">
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => resolveConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary"
|
||||
style={confirm.danger ? { background: "var(--ops-danger)" } : undefined}
|
||||
onClick={() => resolveConfirm(true)}
|
||||
autoFocus
|
||||
>
|
||||
{confirm.okLabel || "确定"}
|
||||
</button>
|
||||
<OpsModal
|
||||
variant="confirm"
|
||||
title={confirm.title}
|
||||
danger={confirm.danger}
|
||||
okLabel={confirm.okLabel || "确定"}
|
||||
zIndex="var(--ops-z-confirm)"
|
||||
onOk={() => resolveConfirm(true)}
|
||||
onCancel={() => resolveConfirm(false)}
|
||||
>
|
||||
<p className="m-0">{confirm.message}</p>
|
||||
{items && items.length > 0 && (
|
||||
<div className="ops-modal-items">
|
||||
{items.slice(0, 3).map((s, i) => (
|
||||
<div key={i}>{s}</div>
|
||||
))}
|
||||
{items.length > 3 && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer select-none" style={{ color: "var(--ops-accent)" }}>
|
||||
还有 {items.length - 3} 条,展开查看
|
||||
</summary>
|
||||
{items.slice(3).map((s, i) => (
|
||||
<div key={i + 3} className="mt-1">
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
|
||||
/** askInput(C6)渲染宿主:队列中的输入请求逐个以 OpsModal input 变体展示 */
|
||||
function DialogHost() {
|
||||
const req = useInputRequest();
|
||||
if (!req) return null;
|
||||
return (
|
||||
<OpsModal
|
||||
key={req.id}
|
||||
variant="input"
|
||||
title={req.title}
|
||||
label={req.label}
|
||||
defaultValue={req.defaultValue}
|
||||
placeholder={req.placeholder}
|
||||
onOk={(v) => settleInputRequest(v ?? null)}
|
||||
onCancel={() => settleInputRequest(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,7 +215,11 @@ export function NotifyHost() {
|
||||
<>
|
||||
{modal && <ModalCard n={modal} onClose={() => dismissNotify(modal.id)} />}
|
||||
<ConfirmCard />
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[90] flex flex-col-reverse gap-2">
|
||||
<DialogHost />
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 flex flex-col-reverse gap-2"
|
||||
style={{ zIndex: "var(--ops-z-toast)" }}
|
||||
>
|
||||
{toasts.map((n) => (
|
||||
<ToastCard key={n.id} n={n} onClose={() => dismissNotify(n.id)} />
|
||||
))}
|
||||
|
||||
+1185
-458
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* OpsModal — 统一模态基件(设计规范 v2 §2.2)
|
||||
*
|
||||
* - variant: info(仅"确定")/ confirm(取消+确定)/ input(带输入框,返回字符串)
|
||||
* - danger: 主按钮渲染为红色(危险操作确认)
|
||||
* - 可访问性:焦点陷阱(Tab 在模态内循环)、关闭后焦点归还触发元素、
|
||||
* Esc 关闭、遮罩点击关闭
|
||||
* - 样式全部走 styles.css 的 --ops-* 令牌,亮暗主题随行
|
||||
*/
|
||||
export type OpsModalVariant = "info" | "confirm" | "input";
|
||||
|
||||
export type OpsModalProps = {
|
||||
title: ReactNode;
|
||||
variant?: OpsModalVariant;
|
||||
/** 主按钮红色(删除/截断等危险确认) */
|
||||
danger?: boolean;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** input 变体:输入框标签 */
|
||||
label?: string;
|
||||
/** input 变体:默认值 */
|
||||
defaultValue?: string;
|
||||
/** input 变体:占位符 */
|
||||
placeholder?: string;
|
||||
/** 层级,默认 var(--ops-z-modal);确认框传 var(--ops-z-confirm) */
|
||||
zIndex?: string | number;
|
||||
/** 宽版(max-w-2xl),DDL 等长文本展示用 */
|
||||
wide?: boolean;
|
||||
/** 确认回调;input 变体携带输入值 */
|
||||
onOk: (value?: string) => void;
|
||||
/** 取消回调(Esc / 遮罩点击 / 取消按钮),info 变体缺省时回落到 onOk */
|
||||
onCancel?: () => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function OpsModal(props: OpsModalProps) {
|
||||
const {
|
||||
title,
|
||||
variant = "info",
|
||||
danger = false,
|
||||
okLabel = "确定",
|
||||
cancelLabel = "取消",
|
||||
label,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
zIndex = "var(--ops-z-modal)",
|
||||
wide = false,
|
||||
onOk,
|
||||
onCancel,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
// 回调放 ref,避免键盘监听因回调变化反复挂载
|
||||
const cbRef = useRef({ onOk, onCancel });
|
||||
cbRef.current = { onOk, onCancel };
|
||||
|
||||
const cancel = () => (cbRef.current.onCancel ?? cbRef.current.onOk)();
|
||||
const submit = () => cbRef.current.onOk(variant === "input" ? value : undefined);
|
||||
|
||||
// 焦点管理:打开时聚焦输入框/主按钮,Tab 在模态内循环,Esc 关闭,卸载时焦点归还触发元素
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
const initial =
|
||||
variant === "input" && inputRef.current ? inputRef.current : focusables().pop();
|
||||
initial?.focus();
|
||||
if (variant === "input") inputRef.current?.select();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// 模态可叠加(如确认框盖在通知模态上):仅最上层响应键盘
|
||||
const overlays = document.querySelectorAll(".ops-modal-overlay");
|
||||
if (overlays.length && overlays[overlays.length - 1] !== overlayRef.current) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (!active || !dialog.contains(active) || active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (!active || !dialog.contains(active) || active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
// 焦点归还触发元素
|
||||
prev?.focus?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="ops-modal-overlay"
|
||||
style={{ zIndex }}
|
||||
onMouseDown={(e) => {
|
||||
// 仅点击遮罩本体(非对话框内容)才关闭
|
||||
if (e.target === overlayRef.current) cancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`ops-modal w-full ${wide ? "max-w-2xl" : "max-w-md"}`}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
>
|
||||
<div className="ops-modal-header">{title}</div>
|
||||
<div className="ops-modal-body">
|
||||
{children}
|
||||
{variant === "input" && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor="ops-modal-input"
|
||||
className="mb-1 block"
|
||||
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id="ops-modal-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="ops-input w-full"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="ops-modal-footer">
|
||||
{variant !== "info" && (
|
||||
<button type="button" className="ops-btn-secondary min-w-[72px]" onClick={cancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${danger ? "ops-btn-danger" : "ops-btn-primary"} min-w-[72px]`}
|
||||
onClick={submit}
|
||||
>
|
||||
{okLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { execSql, getRedisKey } from "./api";
|
||||
import { ensureSession, withSessionRetry } from "./ObjectTree";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import {
|
||||
displayRedisValue,
|
||||
formatRedisTtl,
|
||||
redisCmd,
|
||||
redisTypeColor,
|
||||
tryPrettyJson,
|
||||
tsLabel,
|
||||
} from "./redis";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
type ValueMode = "table" | "json";
|
||||
/** 表格首屏渲染行数,超出走「加载更多」 */
|
||||
const INITIAL_VISIBLE = 100;
|
||||
const PAGE_STEP = 200;
|
||||
|
||||
/** 单元格单行摘要:JSON 压回单行、空白折叠、超长截断 —— 表格保持紧凑,完整 pretty 留给查看弹层 */
|
||||
function cellSummary(raw: string, pretty: string | null): { text: string; more: boolean } {
|
||||
const s = pretty ? JSON.stringify(JSON.parse(pretty)) : raw;
|
||||
const one = s.replace(/\s+/g, " ").trim();
|
||||
if (one.length > 160) return { text: `${one.slice(0, 160)}…`, more: true };
|
||||
return { text: one, more: false };
|
||||
}
|
||||
type LineEdit = {
|
||||
field: string;
|
||||
value: string;
|
||||
score: string;
|
||||
isNew: boolean;
|
||||
oldField?: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function cellString(v: unknown): string {
|
||||
if (v == null) return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export function RedisKeyPane() {
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const resultMeta = useWorkspaceStore((s) => s.resultMeta);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
|
||||
const keyName = resultMeta?.table ?? "";
|
||||
const dbName = resultMeta?.database ?? conn?.database ?? "0";
|
||||
const type = resultMeta?.redisType || "string";
|
||||
const [ttlDraft, setTtlDraft] = useState(String(resultMeta?.redisTtl ?? -1));
|
||||
const [keyDraft, setKeyDraft] = useState(keyName);
|
||||
const [mode, setMode] = useState<ValueMode>(type === "string" ? "json" : "table");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lineEdit, setLineEdit] = useState<LineEdit | null>(null);
|
||||
/** 行内 JSON 放大查看(pretty 全文) */
|
||||
const [cellViewer, setCellViewer] = useState<string | null>(null);
|
||||
/** string 的 JSON 视图可编辑草稿(pretty);保存时校验并压缩回写 */
|
||||
const [prettyDraft, setPrettyDraft] = useState<string | null>(null);
|
||||
/** 列排序:null=默认序;dir 1 升 / -1 降 */
|
||||
const [sort, setSort] = useState<{ col: string; dir: 1 | -1 } | null>(null);
|
||||
/** 双击单元格就地编辑:i=行号(filtered 内)、col=列 */
|
||||
const [inline, setInline] = useState<{ i: number; col: string; draft: string } | null>(null);
|
||||
/** 表格分批渲染:首屏条数与当前已显示条数(宽/大键不全量铺 DOM) */
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
|
||||
const [stringDraft, setStringDraft] = useState(() => cellString(result?.rows?.[0]?.value));
|
||||
|
||||
useEffect(() => {
|
||||
setKeyDraft(keyName);
|
||||
setTtlDraft(String(resultMeta?.redisTtl ?? -1));
|
||||
setStringDraft(cellString(result?.rows?.[0]?.value));
|
||||
setFilter("");
|
||||
setMode(type === "string" ? "json" : "table");
|
||||
setVisibleCount(INITIAL_VISIBLE);
|
||||
setSort(null);
|
||||
setInline(null);
|
||||
}, [keyName, type, resultMeta?.redisTtl, result]);
|
||||
|
||||
const rows = result?.rows ?? [];
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(q)));
|
||||
}, [rows, filter]);
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort) return filtered;
|
||||
const { col, dir } = sort;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = a[col];
|
||||
const bv = b[col];
|
||||
if (col === "score") return ((Number(av) || 0) - (Number(bv) || 0)) * dir;
|
||||
return String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [filtered, sort]);
|
||||
|
||||
const jsonText = useMemo(() => {
|
||||
if (type === "string") {
|
||||
const raw = cellString(rows[0]?.value);
|
||||
return tryPrettyJson(raw) || raw;
|
||||
}
|
||||
if (type === "hash") {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const r of rows) {
|
||||
const f = cellString(r.field);
|
||||
const v = r.value;
|
||||
const parsed = tryPrettyJson(v);
|
||||
obj[f] = parsed ? JSON.parse(parsed) : v;
|
||||
}
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}, [rows, type]);
|
||||
|
||||
// string 的 JSON 草稿随数据刷新同步(保存/重载后回到干净态)
|
||||
useEffect(() => {
|
||||
if (type === "string") setPrettyDraft(jsonText);
|
||||
}, [jsonText, type]);
|
||||
|
||||
const reload = async () => {
|
||||
if (!conn || !keyName) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: 500 }));
|
||||
const qr = {
|
||||
columns: res.columns ?? [],
|
||||
rows: res.rows ?? [],
|
||||
rowCount: res.rowCount ?? 0,
|
||||
timeMs: res.timeMs ?? 0,
|
||||
error: res.error,
|
||||
truncated: res.truncated,
|
||||
limit: res.limit,
|
||||
};
|
||||
set("result", qr);
|
||||
set("resultMeta", {
|
||||
database: dbName,
|
||||
table: keyName,
|
||||
pkCols: [],
|
||||
redisType: res.type,
|
||||
redisTtl: res.ttl,
|
||||
});
|
||||
setTtlDraft(String(res.ttl ?? -1));
|
||||
setKeyDraft(keyName);
|
||||
if (res.type === "string") setStringDraft(cellString(res.rows?.[0]?.value));
|
||||
set("status", `${keyName} · ${res.type} · TTL ${formatRedisTtl(res.ttl)}`);
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runCmd = async (sql: string, okMsg?: string, opts?: { reload?: boolean }) => {
|
||||
if (!conn) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName }));
|
||||
if (res.error) throw new Error(res.error);
|
||||
if (okMsg) notify({ kind: "success", title: okMsg, message: keyName, mode: "toast", durationMs: 1400 });
|
||||
if (opts?.reload !== false) await reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTtl = async () => {
|
||||
const n = Number(ttlDraft);
|
||||
if (!Number.isFinite(n)) {
|
||||
notify({ kind: "warning", title: "TTL", message: "请输入秒数,-1 表示永久", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (n < 0) await runCmd(redisCmd("PERSIST", keyName), "已设为永久");
|
||||
else await runCmd(redisCmd("EXPIRE", keyName, String(Math.floor(n))), "已更新 TTL");
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
const next = keyDraft.trim();
|
||||
if (!next || next === keyName) return;
|
||||
const ok = await runCmd(redisCmd("RENAME", keyName, next), "已重命名");
|
||||
if (ok) {
|
||||
set("resultMeta", {
|
||||
database: dbName,
|
||||
table: next,
|
||||
pkCols: [],
|
||||
redisType: type,
|
||||
redisTtl: resultMeta?.redisTtl,
|
||||
});
|
||||
setKeyDraft(next);
|
||||
}
|
||||
};
|
||||
|
||||
/** 服务端游标续载:追加行而不是整键替换(hash/set=SCAN 游标;list/zset=偏移) */
|
||||
const loadMore = async () => {
|
||||
if (!conn || !keyName) return;
|
||||
const cur = result?.nextCursor ?? 0;
|
||||
if (!cur) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: result?.limit || 500, cursor: cur }));
|
||||
if (res.error) throw new Error(res.error);
|
||||
const merged = [...(result?.rows ?? []), ...(res.rows ?? [])];
|
||||
set("result", {
|
||||
columns: result?.columns ?? res.columns ?? [],
|
||||
rows: merged,
|
||||
rowCount: merged.length,
|
||||
timeMs: result?.timeMs ?? res.timeMs ?? 0,
|
||||
truncated: res.truncated,
|
||||
nextCursor: res.nextCursor,
|
||||
limit: result?.limit ?? res.limit,
|
||||
});
|
||||
setVisibleCount((v) => v + PAGE_STEP);
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 双击单元格就地编辑提交:按类型/列生成与「编辑」弹窗一致的命令 */
|
||||
const commitInline = async (row: Record<string, unknown>, col: string, draft: string) => {
|
||||
let sql = "";
|
||||
if (type === "hash") {
|
||||
if (col === "field") {
|
||||
const old = cellString(row.field);
|
||||
if (!draft.trim()) {
|
||||
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (draft !== old) sql = `${redisCmd("HDEL", keyName, old)}\n${redisCmd("HSET", keyName, draft, cellString(row.value))}`;
|
||||
else return;
|
||||
} else {
|
||||
if (draft === cellString(row.value)) return;
|
||||
sql = redisCmd("HSET", keyName, cellString(row.field), draft);
|
||||
}
|
||||
} else if (type === "zset") {
|
||||
if (col === "member") {
|
||||
const old = cellString(row.member);
|
||||
if (draft === old) return;
|
||||
sql = `${redisCmd("ZREM", keyName, old)}\n${redisCmd("ZADD", keyName, cellString(row.score), draft)}`;
|
||||
} else {
|
||||
if (String(draft) === cellString(row.score)) return;
|
||||
if (Number.isNaN(Number(draft))) {
|
||||
notify({ kind: "warning", title: "Score", message: "必须是数字", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
sql = redisCmd("ZADD", keyName, String(draft), cellString(row.member));
|
||||
}
|
||||
} else if (type === "set") {
|
||||
const old = cellString(row.member ?? row.value);
|
||||
if (draft === old) return;
|
||||
sql = `${redisCmd("SREM", keyName, old)}\n${redisCmd("SADD", keyName, draft)}`;
|
||||
} else if (type === "list") {
|
||||
if (draft === cellString(row.value)) return;
|
||||
sql = redisCmd("LSET", keyName, cellString(row.index ?? 0), draft);
|
||||
}
|
||||
if (!sql) return;
|
||||
setInline(null);
|
||||
for (const p of sql.split("\n")) {
|
||||
const ok = await runCmd(p, "已更新");
|
||||
if (!ok) break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
setSort((s0) => (!s0 || s0.col !== col ? { col, dir: 1 } : s0.dir === 1 ? { col, dir: -1 } : null));
|
||||
};
|
||||
|
||||
const sortTh = (col: string, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-0.5 hover:underline"
|
||||
onClick={() => toggleSort(col)}
|
||||
title="点击排序:升 → 降 → 默认"
|
||||
>
|
||||
{label}
|
||||
{sort?.col === col ? <span className="text-[9px]">{sort.dir === 1 ? "▲" : "▼"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 就地编辑输入框:Enter 提交 / Esc 取消 */
|
||||
const InlineInput = ({ row, i, col, value }: { row: Record<string, unknown>; i: number; col: string; value: string }) => (
|
||||
<input
|
||||
autoFocus
|
||||
className="ops-input h-6 w-full px-1 font-mono text-[11px]"
|
||||
value={inline?.i === i && inline.col === col ? inline.draft : value}
|
||||
onChange={(e) => setInline({ i, col, draft: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commitInline(row, col, inline?.draft ?? value);
|
||||
} else if (e.key === "Escape") setInline(null);
|
||||
}}
|
||||
onBlur={() => setInline(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
const saveString = async () => {
|
||||
let val = stringDraft;
|
||||
if (type === "string" && mode === "json") {
|
||||
// JSON 视图直接编辑:保存前校验并压回单行,避免把 pretty 缩进写进 Redis
|
||||
const draft = prettyDraft ?? jsonText;
|
||||
try {
|
||||
val = JSON.stringify(JSON.parse(draft));
|
||||
} catch (e) {
|
||||
notify({
|
||||
kind: "error",
|
||||
title: "JSON 解析失败",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
mode: "toast",
|
||||
durationMs: 2600,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runCmd(redisCmd("SET", keyName, val), "已保存");
|
||||
};
|
||||
|
||||
const deleteKey = async () => {
|
||||
const ok = await askConfirm({
|
||||
title: "删除键",
|
||||
message: `确认删除 Redis 键「${keyName}」?此操作不可恢复。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const done = await runCmd(redisCmd("DEL", keyName), "已删除", { reload: false });
|
||||
if (done) {
|
||||
set("result", null);
|
||||
set("resultMeta", { database: dbName, pkCols: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setLineEdit({
|
||||
field: type === "hash" ? "" : type === "zset" ? "" : "",
|
||||
value: "",
|
||||
score: "0",
|
||||
isNew: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openEditRow = (row: Record<string, unknown>, index: number) => {
|
||||
if (type === "hash") {
|
||||
setLineEdit({
|
||||
field: cellString(row.field),
|
||||
value: cellString(row.value),
|
||||
score: "0",
|
||||
isNew: false,
|
||||
oldField: cellString(row.field),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === "list") {
|
||||
setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index });
|
||||
return;
|
||||
}
|
||||
if (type === "set") {
|
||||
setLineEdit({
|
||||
field: "",
|
||||
value: cellString(row.member ?? row.value),
|
||||
score: "0",
|
||||
isNew: false,
|
||||
oldField: cellString(row.member ?? row.value),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === "zset") {
|
||||
setLineEdit({
|
||||
field: cellString(row.member),
|
||||
value: cellString(row.member),
|
||||
score: cellString(row.score),
|
||||
isNew: false,
|
||||
oldField: cellString(row.member),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const commitLine = async () => {
|
||||
if (!lineEdit) return;
|
||||
let sql = "";
|
||||
if (type === "hash") {
|
||||
if (!lineEdit.field.trim()) {
|
||||
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (!lineEdit.isNew && lineEdit.oldField && lineEdit.oldField !== lineEdit.field) {
|
||||
sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, lineEdit.value)}`;
|
||||
} else {
|
||||
sql = redisCmd("HSET", keyName, lineEdit.field, lineEdit.value);
|
||||
}
|
||||
} else if (type === "list") {
|
||||
if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, lineEdit.value);
|
||||
else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), lineEdit.value);
|
||||
} else if (type === "set") {
|
||||
if (lineEdit.isNew) sql = redisCmd("SADD", keyName, lineEdit.value);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== lineEdit.value) {
|
||||
sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, lineEdit.value)}`;
|
||||
} else sql = redisCmd("SADD", keyName, lineEdit.value);
|
||||
} else if (type === "zset") {
|
||||
const member = lineEdit.value || lineEdit.field;
|
||||
if (lineEdit.isNew) sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== member) {
|
||||
sql = `${redisCmd("ZREM", keyName, lineEdit.oldField)}\n${redisCmd("ZADD", keyName, lineEdit.score || "0", member)}`;
|
||||
} else sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
|
||||
}
|
||||
if (!sql) return;
|
||||
const parts = sql.split("\n");
|
||||
setLineEdit(null);
|
||||
for (const p of parts) {
|
||||
const ok = await runCmd(p, lineEdit.isNew ? "已新增" : "已更新");
|
||||
if (!ok) break;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLine = async (row: Record<string, unknown>, index: number) => {
|
||||
const ok = await askConfirm({
|
||||
title: "删除元素",
|
||||
message: "确认删除这一行?",
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
if (type === "hash") await runCmd(redisCmd("HDEL", keyName, cellString(row.field)), "已删除字段");
|
||||
else if (type === "set") await runCmd(redisCmd("SREM", keyName, cellString(row.member ?? row.value)), "已删除成员");
|
||||
else if (type === "zset") await runCmd(redisCmd("ZREM", keyName, cellString(row.member)), "已删除成员");
|
||||
else if (type === "list") {
|
||||
const val = cellString(row.value);
|
||||
await runCmd(redisCmd("LREM", keyName, "1", val), "已删除元素");
|
||||
void index;
|
||||
}
|
||||
};
|
||||
|
||||
/** 单行重建命令:复制后可直接在别处回放(zset→ZADD score member 等) */
|
||||
const rowCommand = (row: Record<string, unknown>): string => {
|
||||
switch (type) {
|
||||
case "hash":
|
||||
return redisCmd("HSET", keyName, cellString(row.field), cellString(row.value));
|
||||
case "zset":
|
||||
return redisCmd("ZADD", keyName, cellString(row.score), cellString(row.member));
|
||||
case "set":
|
||||
return redisCmd("SADD", keyName, cellString(row.member ?? row.value));
|
||||
case "list":
|
||||
return redisCmd("RPUSH", keyName, cellString(row.value));
|
||||
default:
|
||||
return cellString(row.value ?? row.member ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const dumpClipboard = async () => {
|
||||
const lines: string[] = [];
|
||||
if (type === "string") lines.push(redisCmd("SET", keyName, cellString(rows[0]?.value)));
|
||||
else if (type === "hash") {
|
||||
for (const r of rows) lines.push(redisCmd("HSET", keyName, cellString(r.field), cellString(r.value)));
|
||||
} else if (type === "list") {
|
||||
for (const r of rows) lines.push(redisCmd("RPUSH", keyName, cellString(r.value)));
|
||||
} else if (type === "set") {
|
||||
for (const r of rows) lines.push(redisCmd("SADD", keyName, cellString(r.member ?? r.value)));
|
||||
} else if (type === "zset") {
|
||||
for (const r of rows) lines.push(redisCmd("ZADD", keyName, cellString(r.score), cellString(r.member)));
|
||||
}
|
||||
const ttl = resultMeta?.redisTtl ?? -1;
|
||||
if (ttl >= 0) lines.push(redisCmd("EXPIRE", keyName, String(ttl)));
|
||||
await copyText(lines.join("\n") || redisCmd("GET", keyName));
|
||||
notify({ kind: "success", title: "已复制命令", message: `${lines.length} 条`, mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
const addLabel =
|
||||
type === "hash" ? "新增字段" : type === "list" ? "追加元素" : type === "zset" ? "新增成员" : type === "set" ? "新增成员" : null;
|
||||
|
||||
const colTitle =
|
||||
type === "hash" ? `ID(共 ${filtered.length})` : type === "list" ? "Index" : type === "zset" ? "#" : "#";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-2 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<span
|
||||
className="rounded px-2 py-0.5 text-[11px] font-semibold uppercase"
|
||||
style={{ background: `${redisTypeColor(type)}22`, color: redisTypeColor(type) }}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<input
|
||||
className="ops-input h-7 min-w-0 flex-1 font-mono text-[12px]"
|
||||
value={keyDraft}
|
||||
title="回车重命名"
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void rename();
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-7 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<span className="flex items-center px-2 text-[10px]" style={{ background: "var(--ops-surface-2)", color: "var(--ops-muted)" }}>
|
||||
TTL
|
||||
</span>
|
||||
<input
|
||||
className="w-20 bg-transparent px-2 text-[12px] outline-none"
|
||||
value={ttlDraft}
|
||||
title="秒;-1 永久。回车保存"
|
||||
onChange={(e) => setTtlDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveTtl();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="ops-btn-secondary h-7 px-2 text-[11px]" disabled={busy} onClick={() => void saveTtl()}>
|
||||
保存 TTL
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" disabled={busy} title="刷新" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" title="复制还原命令" onClick={() => void dumpClipboard()}>
|
||||
导出命令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost h-7 px-2 text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
disabled={busy}
|
||||
onClick={() => void deleteKey()}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-2 py-1" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{addLabel && (
|
||||
<button type="button" className="ops-btn-primary h-7 px-2 text-[11px]" disabled={busy} onClick={openAdd}>
|
||||
{addLabel}
|
||||
</button>
|
||||
)}
|
||||
{type === "string" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary h-7 px-2 text-[11px]"
|
||||
disabled={
|
||||
busy ||
|
||||
(mode === "json"
|
||||
? (prettyDraft ?? jsonText) === jsonText
|
||||
: stringDraft === cellString(rows[0]?.value))
|
||||
}
|
||||
onClick={() => void saveString()}
|
||||
>
|
||||
保存值
|
||||
</button>
|
||||
)}
|
||||
<div className="ops-seg">
|
||||
<button type="button" className={mode === "table" ? "ops-seg-active" : undefined} onClick={() => setMode("table")}>
|
||||
{type === "string" ? "原文" : "结构"}
|
||||
</button>
|
||||
<button type="button" className={mode === "json" ? "ops-seg-active" : undefined} onClick={() => setMode("json")}>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{type !== "string" && (
|
||||
<input
|
||||
className="ops-input h-7 w-48 text-[11px]"
|
||||
placeholder="搜索字段或值"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result?.truncated && (
|
||||
<div
|
||||
className="shrink-0 px-2 py-1 text-[11px]"
|
||||
style={{ color: "var(--ops-warning)", background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)" }}
|
||||
>
|
||||
{(result?.nextCursor ?? 0) > 0
|
||||
? `已分页加载前 ${rows.length} 行,底部可继续加载。`
|
||||
: `已截断,最多 ${result.limit || 500} 行。用过滤或命令缩小范围。`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{type === "string" || mode === "json" ? (
|
||||
type === "string" && mode === "table" ? (
|
||||
<textarea
|
||||
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
|
||||
value={stringDraft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setStringDraft(e.target.value)}
|
||||
/>
|
||||
) : type === "string" ? (
|
||||
<textarea
|
||||
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
|
||||
value={mode === "json" ? prettyDraft ?? jsonText : stringDraft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
if (mode === "json") setPrettyDraft(e.target.value);
|
||||
else setStringDraft(e.target.value);
|
||||
}}
|
||||
placeholder="JSON 视图可直接编辑;保存时自动校验并压缩回写"
|
||||
/>
|
||||
) : (
|
||||
<pre className="m-0 whitespace-pre-wrap break-all p-3 font-mono text-[12px] leading-relaxed">{jsonText}</pre>
|
||||
)
|
||||
) : (
|
||||
<table className="ops-grid" style={{ tableLayout: "fixed", width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 72 }}>{colTitle}</th>
|
||||
{type === "hash" && <th style={{ width: "28%" }}>{sortTh("field", "Key")}</th>}
|
||||
{type === "zset" && <th style={{ width: "56%" }}>{sortTh("member", "Member")}</th>}
|
||||
{type === "zset" && <th style={{ width: 120 }}>{sortTh("score", "Score")}</th>}
|
||||
{type !== "zset" && <th>{type === "list" ? "Value" : sortTh("value", "Value")}</th>}
|
||||
<th style={{ width: 108 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.slice(0, visibleCount).map((row, i) => {
|
||||
const valueRaw = type === "hash" ? row.value : type === "zset" ? row.member : row.member ?? row.value;
|
||||
const shown = displayRedisValue(valueRaw);
|
||||
const valuePretty = tryPrettyJson(cellString(valueRaw));
|
||||
const summary = cellSummary(cellString(valueRaw), valuePretty);
|
||||
const scoreTs = type === "zset" ? tsLabel(Number(row.score)) : null;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="ops-rownum">{type === "list" ? cellString(row.index ?? i) : i + 1}</td>
|
||||
{type === "hash" && (
|
||||
<td className="font-mono" title={cellString(row.field)}>
|
||||
{inline?.i === i && inline.col === "field" ? (
|
||||
<InlineInput row={row} i={i} col="field" value={cellString(row.field)} />
|
||||
) : (
|
||||
<span
|
||||
className="cursor-text"
|
||||
title="双击重命名"
|
||||
onDoubleClick={() => setInline({ i, col: "field", draft: cellString(row.field) })}
|
||||
>
|
||||
{cellString(row.field)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{type === "zset" && (
|
||||
<td className="font-mono">
|
||||
{valuePretty ? (
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
|
||||
style={{ cursor: "zoom-in" }}
|
||||
title="点击查看完整 JSON"
|
||||
onClick={() => setCellViewer(valuePretty)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
|
||||
{summary.more && (
|
||||
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
|
||||
更多
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : inline?.i === i && inline.col === "member" ? (
|
||||
<InlineInput row={row} i={i} col="member" value={cellString(row.member)} />
|
||||
) : (
|
||||
<span
|
||||
className="cursor-text"
|
||||
title="双击编辑"
|
||||
onDoubleClick={() => setInline({ i, col: "member", draft: cellString(row.member) })}
|
||||
>
|
||||
{cellString(row.member)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{type === "zset" && (
|
||||
<td className="font-mono" title={scoreTs ?? "双击改分"}>
|
||||
{inline?.i === i && inline.col === "score" ? (
|
||||
<InlineInput row={row} i={i} col="score" value={cellString(row.score)} />
|
||||
) : (
|
||||
<div
|
||||
className="cursor-text"
|
||||
onDoubleClick={() => setInline({ i, col: "score", draft: cellString(row.score) })}
|
||||
>
|
||||
{cellString(row.score)}
|
||||
{scoreTs && <div className="text-[10px] leading-3 opacity-60">{scoreTs}</div>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{type !== "zset" && (
|
||||
<td title={valuePretty ? "点击查看完整 JSON" : shown.pretty ?? cellString(valueRaw)}>
|
||||
{valuePretty || cellString(valueRaw).length > 180 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
|
||||
style={{ cursor: "zoom-in" }}
|
||||
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
|
||||
{summary.more && (
|
||||
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
|
||||
更多
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : inline?.i === i && inline.col === "value" ? (
|
||||
<InlineInput row={row} i={i} col="value" value={cellString(valueRaw)} />
|
||||
) : (
|
||||
<pre
|
||||
className="m-0 max-h-24 cursor-text overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-snug"
|
||||
title="双击编辑"
|
||||
onDoubleClick={() => setInline({ i, col: "value", draft: cellString(valueRaw) })}
|
||||
>
|
||||
{shown.text}
|
||||
</pre>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title="复制值(纯文本)"
|
||||
onClick={() => {
|
||||
const val = cellString(valueRaw);
|
||||
void copyText(val).then(() =>
|
||||
notify({ kind: "success", title: "已复制值", message: "", mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title={`复制重建命令(${type === "zset" ? "ZADD key score member" : type === "hash" ? "HSET key field value" : type === "set" ? "SADD key member" : "RPUSH key value"})`}
|
||||
onClick={() => {
|
||||
const cmd = rowCommand(row);
|
||||
void copyText(cmd).then(() =>
|
||||
notify({ kind: "success", title: "已复制命令", message: cmd, mode: "toast", durationMs: 1600 }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
⌘
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title={valuePretty ? "查看 JSON" : "查看文本"}
|
||||
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
|
||||
>
|
||||
{valuePretty ? "{}" : "☰"}
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost px-1 text-[11px]" title="编辑" onClick={() => openEditRow(row, i)}>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
title="删除此行"
|
||||
onClick={() => void deleteLine(row, i)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center" style={{ color: "var(--ops-muted)" }}>
|
||||
{rows.length === 0 ? "空值" : "无匹配"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(result?.nextCursor ?? 0) > 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
disabled={busy}
|
||||
onClick={() => void loadMore()}
|
||||
>
|
||||
加载更多(服务端续载 · 已加载 {rows.length} 行)
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : filtered.length > visibleCount ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
onClick={() => setVisibleCount((v) => v + PAGE_STEP)}
|
||||
>
|
||||
加载更多(已显示 {visibleCount} / 共 {filtered.length} 行)
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lineEdit && (
|
||||
<OpsModal
|
||||
title={lineEdit.isNew ? addLabel || "新增" : "编辑"}
|
||||
variant="confirm"
|
||||
okLabel="保存"
|
||||
onOk={() => void commitLine()}
|
||||
onCancel={() => setLineEdit(null)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{type === "hash" && (
|
||||
<label className="block text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>Field</span>
|
||||
<input
|
||||
className="ops-input mt-1 w-full font-mono"
|
||||
value={lineEdit.field}
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, field: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{type === "zset" && (
|
||||
<label className="block text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>Score</span>
|
||||
<input
|
||||
className="ops-input mt-1 w-full font-mono"
|
||||
value={lineEdit.score}
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, score: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>{type === "zset" ? "Member" : "Value"}</span>
|
||||
<textarea
|
||||
className="ops-input mt-1 min-h-[120px] w-full font-mono text-[12px]"
|
||||
value={lineEdit.value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, value: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</OpsModal>
|
||||
)}
|
||||
|
||||
{cellViewer != null && (
|
||||
<OpsModal
|
||||
title="JSON 查看"
|
||||
variant="confirm"
|
||||
okLabel="复制 JSON"
|
||||
onOk={() => {
|
||||
void copyText(cellViewer).then(() =>
|
||||
notify({ kind: "success", title: "已复制 JSON", message: "", mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
setCellViewer(null);
|
||||
}}
|
||||
onCancel={() => setCellViewer(null)}
|
||||
>
|
||||
<pre
|
||||
className="m-0 max-h-[56vh] overflow-auto whitespace-pre-wrap break-all rounded border p-2 font-mono text-[11px] leading-snug"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-bg-soft, transparent)" }}
|
||||
>
|
||||
{cellViewer}
|
||||
</pre>
|
||||
</OpsModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+555
-79
@@ -1,12 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { execSql } from "./api";
|
||||
import { execSql, exportData, type ExportError, type ExportRequest } from "./api";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver } from "./sql";
|
||||
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver, splitStatements } from "./sql";
|
||||
import { RedisKeyPane } from "./RedisKeyPane";
|
||||
import { SchemaPane } from "./SchemaPane";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const DISPLAY_CAP = 2000;
|
||||
|
||||
/** C5:仅单条 SELECT(含 WITH)启用分页 */
|
||||
function isSingleSelect(sql: string): boolean {
|
||||
const stmts = splitStatements(sql).map((r) => r.text.trim()).filter(Boolean);
|
||||
if (stmts.length !== 1) return false;
|
||||
const s = stmts[0].replace(/^(\s|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/g, "");
|
||||
return /^(select|with)\b/i.test(s);
|
||||
}
|
||||
|
||||
function formatCell(v: unknown) {
|
||||
if (v == null) return <span className="ops-null">NULL</span>;
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
@@ -15,6 +24,13 @@ function formatCell(v: unknown) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 值是否为被截断展示的长文本(修 G4:提供查看/复制全文 popover) */
|
||||
function longText(v: unknown): string | null {
|
||||
if (v == null || typeof v === "object") return null;
|
||||
const s = String(v);
|
||||
return s.length > 120 ? s : null;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
@@ -44,12 +60,12 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
const writable = isWritableDriver(conn?.driver);
|
||||
const canEdit = writable && !!resultMeta?.table && (resultMeta.pkCols?.length ?? 0) > 0;
|
||||
const redisInspect = (conn?.driver || "").toLowerCase() === "redis" && !!resultMeta?.redisType && !!resultMeta?.table;
|
||||
|
||||
const [sortCol, setSortCol] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | null>(null);
|
||||
@@ -57,18 +73,47 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
||||
const [selectedCol, setSelectedCol] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState<{ row: number; col: string; value: string; original: string } | null>(null);
|
||||
/** 长值 popover(修 G4) */
|
||||
const [peek, setPeek] = useState<{ text: string; x: number; y: number } | null>(null);
|
||||
const [paging, setPaging] = useState(false);
|
||||
/** R2-F2:导出中心下拉菜单 */
|
||||
const [expMenu, setExpMenu] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{ col: string; startX: number; startW: number } | null>(null);
|
||||
const editRef = useRef(edit);
|
||||
editRef.current = edit;
|
||||
|
||||
const activeRs = resultSets.find((r) => r.id === activeResultSetId) ?? null;
|
||||
const updateResultSet = useWorkspaceStore((s) => s.updateResultSet);
|
||||
|
||||
// 切换结果集时重置排序/编辑,并恢复该结果集持久化的列宽与选中态(修 G3)
|
||||
useEffect(() => {
|
||||
setSortCol(null);
|
||||
setSortDir(null);
|
||||
setSelectedIdx(null);
|
||||
setSelectedCol(null);
|
||||
setColWidths({});
|
||||
setEdit(null);
|
||||
}, [result]);
|
||||
setPeek(null);
|
||||
const cur = useWorkspaceStore.getState().resultSets.find((x) => x.id === activeResultSetId);
|
||||
const vs = cur?.viewState;
|
||||
setColWidths(vs?.colWidths ?? {});
|
||||
setSelectedIdx(vs?.selectedIdx ?? null);
|
||||
setSelectedCol(vs?.selectedCol ?? null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeResultSetId, result]);
|
||||
|
||||
// 列宽/选中态变化时写回 resultSet(内容相同则跳过,避免回环)
|
||||
useEffect(() => {
|
||||
if (!activeResultSetId) return;
|
||||
const cur = useWorkspaceStore.getState().resultSets.find((x) => x.id === activeResultSetId);
|
||||
if (!cur) return;
|
||||
const prev = cur.viewState;
|
||||
const same =
|
||||
JSON.stringify(prev?.colWidths ?? {}) === JSON.stringify(colWidths) &&
|
||||
(prev?.selectedIdx ?? null) === selectedIdx &&
|
||||
(prev?.selectedCol ?? null) === selectedCol;
|
||||
if (same) return;
|
||||
updateResultSet(activeResultSetId, { viewState: { colWidths, selectedIdx, selectedCol } });
|
||||
}, [colWidths, selectedIdx, selectedCol, activeResultSetId, updateResultSet]);
|
||||
|
||||
// discard pending edit when switching tabs
|
||||
useEffect(() => {
|
||||
@@ -309,8 +354,9 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
const n = selectedIdx + 1;
|
||||
const ok = await askConfirm({
|
||||
title: "删除行",
|
||||
message: `确认删除第 ${n} 行?不可恢复。`,
|
||||
message: `确认删除第 ${n} 行?此操作不可撤销。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const pk = resultMeta.pkCols || [];
|
||||
@@ -341,11 +387,118 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
|
||||
const onGridKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (edit) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && canEdit && selectedIdx != null) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
void deleteSelectedRow();
|
||||
return;
|
||||
}
|
||||
// 结果网格行导航(修 K3):↑↓/PageUp/PageDown/Home/End
|
||||
const moveSel = (next: number | null) => {
|
||||
setSelectedIdx(next);
|
||||
if (next != null) {
|
||||
requestAnimationFrame(() => {
|
||||
gridRef.current?.querySelector(`tr[data-row-idx="${next}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
};
|
||||
const max = visibleRows.length - 1;
|
||||
if (max < 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
moveSel(selectedIdx == null ? 0 : Math.min(max, selectedIdx + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveSel(selectedIdx == null ? 0 : Math.max(0, selectedIdx - 1));
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault();
|
||||
moveSel(selectedIdx == null ? 0 : Math.min(max, selectedIdx + 20));
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault();
|
||||
moveSel(selectedIdx == null ? 0 : Math.max(0, selectedIdx - 20));
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
moveSel(0);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveSel(max);
|
||||
} else if (e.key === "Enter" && selectedIdx != null && selectedCol && canEdit) {
|
||||
e.preventDefault();
|
||||
beginEdit(selectedIdx, selectedCol);
|
||||
}
|
||||
};
|
||||
|
||||
/** C5:翻页 —— 重发同一语句,带 offset/limit */
|
||||
const canPaginate =
|
||||
!!activeRs && resultSets.length === 1 && !result?.error && isSingleSelect(activeRs.sql);
|
||||
const page = activeRs?.page ?? 0;
|
||||
const pageSize = activeRs?.pageSize ?? result?.limit ?? 1000;
|
||||
const hasPrevPage = canPaginate && page > 0;
|
||||
const hasNextPage = canPaginate && !!result?.truncated;
|
||||
|
||||
const gotoPage = async (p: number) => {
|
||||
if (!conn || !activeRs || paging || p < 0) return;
|
||||
setPaging(true);
|
||||
set("status", `加载第 ${p + 1} 页…`);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await execSql(conn, activeRs.sql, {
|
||||
sessionId: sid,
|
||||
database: activeRs.database || conn.database,
|
||||
limit: pageSize,
|
||||
offset: p * pageSize,
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
updateResultSet(activeRs.id, { result: res, page: p, pageSize });
|
||||
set("connState", "ok");
|
||||
set("status", `第 ${p + 1} 页 · ${res.rowCount} 行`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setPaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** E1:错误框「重试」—— 重发当前结果集语句(危险语句仍走 C1 二段确认) */
|
||||
const retryRun = async () => {
|
||||
if (!conn || !activeRs || paging) return;
|
||||
setPaging(true);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
let res = await execSql(conn, activeRs.sql, {
|
||||
sessionId: sid,
|
||||
database: activeRs.database || conn.database,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
});
|
||||
if (res.needConfirm) {
|
||||
const ok = await askConfirm({
|
||||
title: "危险语句确认",
|
||||
message: res.error || "服务端检测到危险语句,确认后继续执行。",
|
||||
okLabel: "仍要执行",
|
||||
danger: true,
|
||||
items: [activeRs.sql],
|
||||
});
|
||||
if (!ok) return;
|
||||
res = await execSql(conn, activeRs.sql, {
|
||||
sessionId: sid,
|
||||
database: activeRs.database || conn.database,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
confirmDanger: true,
|
||||
});
|
||||
}
|
||||
updateResultSet(activeRs.id, { result: res });
|
||||
if (!res.error) set("connState", "ok");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setPaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -374,63 +527,166 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
kind === "csv"
|
||||
? rowsToCSV(result.columns, result.rows)
|
||||
: kind === "insert"
|
||||
? rowsToInsert(resultMeta?.table || tableHint || "t", result.columns, result.rows)
|
||||
? rowsToInsert(conn?.driver, resultMeta?.table || tableHint || "t", result.columns, result.rows)
|
||||
: rowsToTSV(result.columns, result.rows);
|
||||
await copyText(text);
|
||||
notify({ kind: "success", title: "已复制", message: kind === "insert" ? "INSERT 语句" : kind.toUpperCase(), mode: "toast" });
|
||||
};
|
||||
|
||||
const downloadCsv = () => {
|
||||
if (!result || result.error) return;
|
||||
const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" });
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `opsxdb-${Date.now()}.csv`;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
|
||||
/** R2-F2:当前结果本地导出(INSERT 走 quoteIdent 方言引号,修 01-P1-1) */
|
||||
const exportCurrent = (format: "csv" | "json" | "insert") => {
|
||||
if (!result || result.error) return;
|
||||
const stamp = Date.now();
|
||||
if (format === "csv") {
|
||||
const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" });
|
||||
downloadBlob(blob, `opsxdb-${stamp}.csv`);
|
||||
} else if (format === "json") {
|
||||
const blob = new Blob([JSON.stringify(result.rows, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
downloadBlob(blob, `opsxdb-${stamp}.json`);
|
||||
} else {
|
||||
const text = rowsToInsert(conn?.driver, resultMeta?.table || tableHint || "t", result.columns, result.rows);
|
||||
const blob = new Blob([text], { type: "application/sql;charset=utf-8" });
|
||||
downloadBlob(blob, `opsxdb-${stamp}.sql`);
|
||||
}
|
||||
notify({ kind: "success", title: "已导出", message: `当前结果 ${format.toUpperCase()}`, mode: "toast", durationMs: 1600 });
|
||||
setExpMenu(false);
|
||||
};
|
||||
|
||||
/** R2-F2/E7:服务端导出 —— needConfirm 时弹二段确认,带 confirmDanger 重发一次 */
|
||||
const serverExport = async (req: ExportRequest) => {
|
||||
if (!conn || exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const ext = req.format === "insert" ? "sql" : req.format;
|
||||
try {
|
||||
const { blob, filename } = await exportData(conn, { ...req, sessionId: sid });
|
||||
downloadBlob(blob, filename || `export.${ext}`);
|
||||
notify({ kind: "success", title: "导出完成", message: filename || `export.${ext}`, mode: "toast", durationMs: 1800 });
|
||||
setExpMenu(false);
|
||||
} catch (e) {
|
||||
const err = e as ExportError;
|
||||
if (!err.needConfirm || !req.sql) throw e;
|
||||
const ok = await askConfirm({
|
||||
title: "危险语句确认",
|
||||
message: err.message || "导出语句命中危险/非 SELECT,确认后继续导出?",
|
||||
okLabel: "仍要导出",
|
||||
danger: true,
|
||||
items: [req.sql],
|
||||
});
|
||||
if (!ok) return;
|
||||
const again = await exportData(conn, { ...req, sessionId: sid, confirmDanger: true });
|
||||
downloadBlob(again.blob, again.filename || `export.${ext}`);
|
||||
notify({ kind: "success", title: "导出完成", message: again.filename || `export.${ext}`, mode: "toast", durationMs: 1800 });
|
||||
setExpMenu(false);
|
||||
}
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 服务端导出可用性:整表(需表上下文) / 当前查询(需单条 SELECT) */
|
||||
const canExportTable = !!conn && !!resultMeta?.table;
|
||||
const canExportQuery = !!conn && canPaginate && !!activeRs;
|
||||
|
||||
const emptyResult = result && !result.error && result.rows.length === 0;
|
||||
|
||||
// 长值 popover:Esc / 点击外部关闭(修 G4)
|
||||
useEffect(() => {
|
||||
if (!peek) return;
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setPeek(null);
|
||||
}
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest?.("[data-ops-peek]")) setPeek(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [peek]);
|
||||
|
||||
// R2-F2:导出菜单 —— Esc / 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!expMenu) return;
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setExpMenu(false);
|
||||
}
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest?.("[data-ops-export]")) setExpMenu(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [expMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onContextMenu={(e) => {
|
||||
// 拦系统菜单(Look Up / Inspect);结果表 P0 暂不自绘
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="ops-section-label justify-between border-b gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto normal-case tracking-normal">
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "data" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
|
||||
onClick={() => {
|
||||
if (edit) {
|
||||
void askConfirm({ title: "未提交编辑", message: "切换将丢弃未提交的单元格改动。", okLabel: "丢弃并切换" }).then((ok) => {
|
||||
if (ok) {
|
||||
setEdit(null);
|
||||
set("resultTab", "data");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
set("resultTab", "data");
|
||||
}}
|
||||
>
|
||||
数据
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "schema" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
|
||||
onClick={() => set("resultTab", "schema")}
|
||||
>
|
||||
结构
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "explain" ? "ops-tab-active" : "ops-tab"}
|
||||
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
|
||||
onClick={() => set("resultTab", "explain")}
|
||||
>
|
||||
计划
|
||||
</button>
|
||||
<div className="ops-seg" role="tablist" aria-label="结果视图">
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "data" ? "ops-seg-active" : undefined}
|
||||
onClick={() => {
|
||||
if (edit) {
|
||||
void askConfirm({ title: "未提交编辑", message: "切换将丢弃未提交的单元格改动。", okLabel: "丢弃并切换" }).then((ok) => {
|
||||
if (ok) {
|
||||
setEdit(null);
|
||||
set("resultTab", "data");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
set("resultTab", "data");
|
||||
}}
|
||||
>
|
||||
数据
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "schema" ? "ops-seg-active" : undefined}
|
||||
onClick={() => set("resultTab", "schema")}
|
||||
>
|
||||
结构
|
||||
</button>
|
||||
{!redisInspect && (
|
||||
<button
|
||||
type="button"
|
||||
className={resultTab === "explain" ? "ops-seg-active" : undefined}
|
||||
onClick={() => set("resultTab", "explain")}
|
||||
>
|
||||
计划
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{resultSets.length > 1 && (
|
||||
<>
|
||||
<span className="mx-1 opacity-30">|</span>
|
||||
@@ -457,7 +713,7 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{resultTab === "data" && result && !result.error && (
|
||||
{resultTab === "data" && result && !result.error && !redisInspect && (
|
||||
<span className="flex items-center gap-2 font-normal normal-case tracking-normal">
|
||||
<span>
|
||||
{result.rowCount} 行 · {result.timeMs} ms
|
||||
@@ -492,29 +748,123 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
>
|
||||
删除行
|
||||
</button>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={downloadCsv}>
|
||||
CSV
|
||||
</button>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy("insert")}>
|
||||
INSERT
|
||||
</button>
|
||||
{/* R2-F2:导出中心 —— 本地三格式 + 服务端整表/当前查询(自绘下拉) */}
|
||||
<div className="relative" data-ops-export>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline disabled:opacity-50"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
disabled={exporting}
|
||||
onClick={() => setExpMenu((v) => !v)}
|
||||
>
|
||||
导出{exporting ? "…" : " ▾"}
|
||||
</button>
|
||||
{expMenu && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-10 mt-1 w-44 rounded border p-1"
|
||||
style={{
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface)",
|
||||
boxShadow: "var(--ops-shadow-pop)",
|
||||
}}
|
||||
>
|
||||
<div className="px-2 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
当前结果
|
||||
</div>
|
||||
{(["csv", "json", "insert"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
onClick={() => exportCurrent(f)}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
onClick={() => {
|
||||
void copy("insert");
|
||||
setExpMenu(false);
|
||||
}}
|
||||
>
|
||||
复制 INSERT
|
||||
</button>
|
||||
{canExportTable && (
|
||||
<>
|
||||
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
|
||||
<div className="px-2 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
整表(服务端)
|
||||
</div>
|
||||
{(["csv", "json", "insert"] as const).map((f) => (
|
||||
<button
|
||||
key={`table-${f}`}
|
||||
type="button"
|
||||
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
onClick={() =>
|
||||
void serverExport({
|
||||
format: f,
|
||||
table: resultMeta!.table,
|
||||
database: resultMeta!.database,
|
||||
})
|
||||
}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{canExportQuery && (
|
||||
<>
|
||||
<div className="my-0.5 border-t" style={{ borderColor: "var(--ops-border)" }} />
|
||||
<div className="px-2 py-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
当前查询(服务端)
|
||||
</div>
|
||||
{(["csv", "json", "insert"] as const).map((f) => (
|
||||
<button
|
||||
key={`query-${f}`}
|
||||
type="button"
|
||||
className="block w-full rounded px-2 py-1 text-left hover:bg-[var(--ops-bg-active)]"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
onClick={() =>
|
||||
void serverExport({
|
||||
format: f,
|
||||
sql: activeRs!.sql,
|
||||
database: activeRs!.database,
|
||||
})
|
||||
}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resultTab === "schema" ? (
|
||||
<SchemaPane />
|
||||
) : resultTab === "data" && redisInspect && result && !result.error ? (
|
||||
<RedisKeyPane />
|
||||
) : resultTab === "explain" ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{!explainText ? (
|
||||
<div className="ops-empty" style={{ minHeight: 100 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>执行计划</h2>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>执行计划</h2>
|
||||
<p>点工具栏「执行计划」获取(部分引擎可用)。</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="m-0 whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed"
|
||||
style={{ color: "var(--ops-text)" }}
|
||||
className="m-0 whitespace-pre-wrap font-mono leading-relaxed"
|
||||
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-text)" }}
|
||||
>
|
||||
{explainText}
|
||||
</pre>
|
||||
@@ -526,9 +876,9 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
<div
|
||||
className="shrink-0 border-b px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-warn) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warn) 10%, transparent)",
|
||||
color: "var(--ops-warn)",
|
||||
borderColor: "color-mix(in srgb, var(--ops-warning) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)",
|
||||
color: "var(--ops-warning)",
|
||||
}}
|
||||
>
|
||||
已截断:界面最多显示 {result?.limit || DISPLAY_CAP} 行
|
||||
@@ -539,39 +889,62 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{result?.error && (
|
||||
<div
|
||||
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
className="m-1.5 rounded border px-2.5 py-1.5"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-sm)",
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
}}
|
||||
>
|
||||
{result.error}
|
||||
<div className="whitespace-pre-wrap break-all">{result.error}</div>
|
||||
<div className="mt-1.5 flex gap-3" style={{ fontSize: "var(--ops-text-xs)" }}>
|
||||
{activeRs && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline disabled:opacity-50"
|
||||
disabled={paging}
|
||||
onClick={() => void retryRun()}
|
||||
>
|
||||
{paging ? "重试中…" : "重试"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
onClick={() =>
|
||||
void copyText(activeRs ? `${activeRs.sql}\n\n${result.error ?? ""}` : (result.error ?? "")).then(() =>
|
||||
notify({ kind: "success", title: "已复制", message: "错误详情已复制", mode: "toast", durationMs: 1400 }),
|
||||
)
|
||||
}
|
||||
>
|
||||
复制错误详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>还没有结果</h2>
|
||||
<p>写一条 SQL,⌘/Ctrl+Enter 运行;或点左侧表/键预览数据。</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
|
||||
去写查询
|
||||
</button>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>还没有结果</h2>
|
||||
<p>
|
||||
{(conn?.driver || "").toLowerCase() === "redis"
|
||||
? "写一条 Redis 命令,⌘/Ctrl+Enter 运行;或点左侧键查看值。"
|
||||
: "写一条 SQL,⌘/Ctrl+Enter 运行;或点左侧表/键预览数据。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emptyResult && (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>空结果</h2>
|
||||
<p>查询成功,但没有返回行。</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
|
||||
去写查询
|
||||
</button>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>空结果</h2>
|
||||
<p>查询成功,但没有返回行。试试改写 SQL 或放宽条件。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && result.rows.length > 0 && (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="outline-none"
|
||||
tabIndex={0}
|
||||
onPaste={(e) => void pasteGrid(e)}
|
||||
@@ -592,10 +965,19 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
className="ops-sortable"
|
||||
style={{ width: w, minWidth: w, maxWidth: w }}
|
||||
onClick={() => cycleSort(c)}
|
||||
title="点击排序:升 → 降 → 取消"
|
||||
title="点击排序:升 → 降 → 取消(仅当前页数据)"
|
||||
>
|
||||
{c}
|
||||
{mark}
|
||||
{sortCol === c && sortDir && (
|
||||
<span
|
||||
className="ml-0.5 rounded px-0.5 font-normal"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-muted)", background: "var(--ops-surface-2)" }}
|
||||
title="排序仅作用于当前页已加载数据"
|
||||
>
|
||||
本页
|
||||
</span>
|
||||
)}
|
||||
<span className="ops-col-resize" onMouseDown={(e) => onResizeStart(c, e)} />
|
||||
</th>
|
||||
);
|
||||
@@ -606,13 +988,15 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
{visibleRows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
data-row-idx={i}
|
||||
className={selectedIdx === i ? "ops-row-selected" : undefined}
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
>
|
||||
<td className="ops-rownum">{i + 1}</td>
|
||||
<td className="ops-rownum">{(result.offset ?? 0) + i + 1}</td>
|
||||
{result.columns.map((c) => {
|
||||
const editing = edit && edit.row === i && edit.col === c;
|
||||
const cellSelected = selectedIdx === i && selectedCol === c;
|
||||
const long = longText(row[c]);
|
||||
return (
|
||||
<td
|
||||
key={c}
|
||||
@@ -645,7 +1029,24 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
formatCell(row[c])
|
||||
<>
|
||||
{formatCell(row[c])}
|
||||
{long && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 align-middle opacity-50 hover:opacity-100"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-accent)" }}
|
||||
title="查看全文"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPeek({ text: long, x: Math.min(r.left, window.innerWidth - 460), y: r.bottom + 4 });
|
||||
}}
|
||||
>
|
||||
展开
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
@@ -657,8 +1058,83 @@ export function ResultPane({ tableHint }: { tableHint?: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* C5 分页条:仅单条 SELECT 结果集启用;truncated = 还有下一页 */}
|
||||
{resultTab === "data" && canPaginate && result && !result.error && (hasPrevPage || hasNextPage || page > 0) && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-t px-2 py-1"
|
||||
style={{ borderColor: "var(--ops-border)", fontSize: "var(--ops-text-xs)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-5 px-1.5"
|
||||
disabled={!hasPrevPage || paging}
|
||||
onClick={() => void gotoPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
第 {page + 1} 页 · 本页 {result.rows.length} 行(自第 {(result.offset ?? 0) + 1} 行起)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-5 px-1.5"
|
||||
disabled={!hasNextPage || paging}
|
||||
onClick={() => void gotoPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
{result.truncated && <span style={{ color: "var(--ops-warning)" }}>还有更多行</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* G4:长值查看/复制全文 popover */}
|
||||
{peek && (
|
||||
<div
|
||||
data-ops-peek
|
||||
className="fixed flex max-w-[440px] flex-col gap-1.5 rounded border p-2 shadow-lg"
|
||||
style={{
|
||||
left: peek.x,
|
||||
top: peek.y,
|
||||
zIndex: "var(--ops-z-ctx)",
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface)",
|
||||
boxShadow: "var(--ops-shadow-pop)",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className="m-0 max-h-56 overflow-auto whitespace-pre-wrap break-all font-mono"
|
||||
style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-text)" }}
|
||||
>
|
||||
{peek.text}
|
||||
</pre>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-5 px-2"
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() =>
|
||||
void copyText(peek.text).then(() => {
|
||||
notify({ kind: "success", title: "已复制", message: "全文已复制", mode: "toast", durationMs: 1200 });
|
||||
setPeek(null);
|
||||
})
|
||||
}
|
||||
>
|
||||
复制全文
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost h-5 px-2"
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() => setPeek(null)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { execSql } from "./api";
|
||||
import { execSql, listColumns } from "./api";
|
||||
import { DdlModal } from "./DdlModal";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
@@ -26,7 +27,11 @@ export function SchemaPane() {
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
|
||||
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
|
||||
/** R2-F3:DDL 查看弹窗 */
|
||||
const [showDdl, setShowDdl] = useState(false);
|
||||
const mysql = isMysqlFamily(conn?.driver);
|
||||
/** Redis 键的结构页不展示 DDL(服务端仅支持表/视图) */
|
||||
const ddlEnabled = !!conn && (conn.driver || "").toLowerCase() !== "redis";
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -62,12 +67,30 @@ export function SchemaPane() {
|
||||
if (!schema) {
|
||||
return (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>表结构</h2>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>表结构</h2>
|
||||
<p>在对象树点表,或右键「查看结构」。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** E1:结构加载失败重试 —— 重新拉列元数据 */
|
||||
const retryLoad = async () => {
|
||||
if (!conn || !schema) return;
|
||||
set("schemaView", { ...schema, loading: true, error: undefined });
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const { columns } = await listColumns(conn, schema.database, schema.table, sid);
|
||||
set("schemaView", { database: schema.database, table: schema.table, columns, loading: false });
|
||||
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
|
||||
set("resultMeta", { database: schema.database, table: schema.table, pkCols });
|
||||
set("connState", "ok");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("schemaView", { database: schema.database, table: schema.table, columns: [], loading: false, error: msg });
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
}
|
||||
};
|
||||
|
||||
if (schema.loading) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
@@ -86,7 +109,15 @@ export function SchemaPane() {
|
||||
color: "var(--ops-danger)",
|
||||
}}
|
||||
>
|
||||
{schema.error}
|
||||
<div>{schema.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 hover:underline"
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() => void retryLoad()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,14 +131,27 @@ export function SchemaPane() {
|
||||
结构 · {schema.database ? `${schema.database}.` : ""}
|
||||
{schema.table}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="normal-case tracking-normal hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => set("resultTab", "data")}
|
||||
>
|
||||
回结果
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2 normal-case tracking-normal">
|
||||
{ddlEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
title="查看建表语句(DDL)"
|
||||
onClick={() => setShowDdl(true)}
|
||||
>
|
||||
DDL
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => set("resultTab", "data")}
|
||||
>
|
||||
回结果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{schema.columns.length === 0 ? (
|
||||
@@ -121,6 +165,7 @@ export function SchemaPane() {
|
||||
<th style={{ width: 36 }}>#</th>
|
||||
<th>列名</th>
|
||||
<th>类型</th>
|
||||
<th style={{ width: 52 }}>键</th>
|
||||
<th style={{ width: 64 }}>可空</th>
|
||||
<th>默认值</th>
|
||||
</tr>
|
||||
@@ -134,6 +179,22 @@ export function SchemaPane() {
|
||||
{c.name}
|
||||
</td>
|
||||
<td className="font-mono text-[12px]">{c.type}</td>
|
||||
<td>
|
||||
{c.key === "PRI" ? (
|
||||
<span
|
||||
className="rounded px-1 font-semibold"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
color: "var(--ops-warning)",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
PRI
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</td>
|
||||
<td>{c.nullable ? "YES" : "NO"}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{c.default == null ? <span className="ops-null">NULL</span> : String(c.default)}
|
||||
@@ -146,8 +207,7 @@ export function SchemaPane() {
|
||||
|
||||
{mysql && (
|
||||
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<details open className="px-3 py-1.5">
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
<details open className="px-3 py-1.5"> <summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
索引({meta.indexes.length})
|
||||
</summary>
|
||||
{meta.indexes.length === 0 ? (
|
||||
@@ -234,6 +294,9 @@ export function SchemaPane() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDdl && conn && (
|
||||
<DdlModal conn={conn} database={schema.database} table={schema.table} onClose={() => setShowDdl(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { driverStatus, health, type DriverStatusItem } from "./api";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const IS_MAC = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
const MOD = IS_MAC ? "⌘" : "Ctrl";
|
||||
|
||||
type TabId = "general" | "execution" | "results" | "drivers" | "shortcuts" | "about";
|
||||
|
||||
const TABS: Array<{ id: TabId; icon: string; label: string; desc: string }> = [
|
||||
{ id: "general", icon: "🎨", label: "常规", desc: "主题与界面" },
|
||||
{ id: "execution", icon: "⚡", label: "执行", desc: "超时与多语句" },
|
||||
{ id: "results", icon: "📊", label: "结果", desc: "面板与分页" },
|
||||
{ id: "drivers", icon: "🧩", label: "驱动", desc: "agent 驱动状态" },
|
||||
{ id: "shortcuts", icon: "⌨️", label: "快捷键", desc: "键盘操作一览" },
|
||||
{ id: "about", icon: "ℹ️", label: "关于", desc: "版本与数据目录" },
|
||||
];
|
||||
|
||||
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12.5px]" style={{ color: "var(--ops-text)" }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
{hint && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
className="h-4.5 w-9 rounded-full border transition"
|
||||
style={{
|
||||
height: 18,
|
||||
borderColor: "var(--ops-border)",
|
||||
background: checked ? "var(--ops-accent)" : "var(--ops-surface-2)",
|
||||
}}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className="block rounded-full bg-white transition-all"
|
||||
style={{ width: 12, height: 12, marginLeft: checked ? 22 : 3, boxShadow: "0 1px 2px rgba(0,0,0,.3)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: Array<{ v: T; label: string }>; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="ops-seg flex">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={String(o.v)}
|
||||
type="button"
|
||||
className={value === o.v ? "ops-tree-item-active" : ""}
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() => onChange(o.v)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
const theme = useWorkspaceStore((s) => s.theme);
|
||||
const showAi = useWorkspaceStore((s) => s.showAi);
|
||||
const stopOnError = useWorkspaceStore((s) => s.stopOnError);
|
||||
const execTimeoutMs = useWorkspaceStore((s) => s.execTimeoutMs);
|
||||
const resultRatio = useWorkspaceStore((s) => s.resultRatio);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList);
|
||||
|
||||
const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null);
|
||||
const [version, setVersion] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
void driverStatus().then(setDrivers).catch(() => setDrivers([]));
|
||||
void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const resetLayout = () => {
|
||||
set("leftWidth", 260);
|
||||
set("aiWidth", 280);
|
||||
set("resultRatio", 0.38);
|
||||
notify({ kind: "success", title: "已重置", message: "面板布局恢复默认", mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" title="设置" onOk={onClose}>
|
||||
<div className="flex" style={{ minWidth: 560, minHeight: 340 }}>
|
||||
{/* 左导航 */}
|
||||
<nav className="w-36 shrink-0 space-y-px border-r pr-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] transition ${
|
||||
tab === t.id ? "ops-tree-item-active font-medium" : "opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
<span className="w-4 text-center">{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 右面板 */}
|
||||
<div className="min-w-0 flex-1 space-y-4 pl-4">
|
||||
{tab === "general" && (
|
||||
<>
|
||||
<Row label="主题">
|
||||
<Seg
|
||||
value={theme}
|
||||
options={[{ v: "dark" as const, label: "深色" }, { v: "light" as const, label: "浅色" }]}
|
||||
onChange={(v) => set("theme", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="显示 AI 面板" hint="工作台右侧的 AI 助手面板">
|
||||
<Toggle checked={showAi} onChange={(v) => set("showAi", v)} />
|
||||
</Row>
|
||||
<Row label="面板布局" hint="侧栏宽度、AI 面板宽度、结果面板占比">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}>
|
||||
恢复默认
|
||||
</button>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "execution" && (
|
||||
<>
|
||||
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
|
||||
<Seg
|
||||
value={execTimeoutMs}
|
||||
options={[
|
||||
{ v: 0, label: "无" },
|
||||
{ v: 10000, label: "10s" },
|
||||
{ v: 30000, label: "30s" },
|
||||
{ v: 60000, label: "60s" },
|
||||
{ v: 300000, label: "5min" },
|
||||
]}
|
||||
onChange={(v) => set("execTimeoutMs", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="多语句遇错即停" hint="一次执行多条时,某条失败即停止后续">
|
||||
<Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} />
|
||||
</Row>
|
||||
<Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)">
|
||||
<span className="text-[11px]" style={{ color: "var(--ops-accent)" }}>始终开启</span>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "results" && (
|
||||
<>
|
||||
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`} hint="编辑器与结果面板的高度分配">
|
||||
<input
|
||||
type="range"
|
||||
min={25}
|
||||
max={60}
|
||||
value={Math.round(resultRatio * 100)}
|
||||
onChange={(e) => set("resultRatio", Number(e.target.value) / 100)}
|
||||
style={{ accentColor: "var(--ops-accent)", width: 120 }}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="行数上限" hint="查询默认 1000 行、硬上限 10000 行(超出截断提示),由服务端强制执行">
|
||||
<span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>1000 / 10000</span>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "drivers" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{drivers?.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between rounded border px-2 py-1.5 text-[11.5px]" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<span>{d.id}</span>
|
||||
<span style={{ color: d.allInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
|
||||
{d.allInstalled ? "已安装" : d.license === "proprietary" ? "需自备 jar" : "未安装"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Row label="驱动管理" hint="预装下载、离线包导入、JDK 检测">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openConnectionList();
|
||||
notify({ kind: "info", title: "驱动管理", message: "连接列表页右上角 ⚙ 打开驱动管理", mode: "toast", durationMs: 2600 });
|
||||
}}
|
||||
>
|
||||
去管理
|
||||
</button>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "shortcuts" && (
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
[`${MOD} + Enter`, "执行当前语句或选区"],
|
||||
[`${MOD} + T`, "新建查询"],
|
||||
[`${MOD} + W`, "关闭查询"],
|
||||
[`${MOD} + 1-9`, "切换到第 N 个查询"],
|
||||
["Ctrl + Tab", "循环切换查询"],
|
||||
[`${MOD} + ⇧ + F`, "聚焦对象树过滤框"],
|
||||
[`${MOD} + B`, "显示/隐藏 AI"],
|
||||
[`${MOD} + /`, "快捷键帮助"],
|
||||
["双击表名", "打开前 100 行"],
|
||||
["双击单元格", "进入行编辑"],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 text-[12px]">
|
||||
<span className="font-mono" style={{ color: "var(--ops-accent)" }}>{k}</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "about" && (
|
||||
<div className="space-y-2 text-[12px]" style={{ color: "var(--ops-text)" }}>
|
||||
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>版本</span><span className="font-mono">{version || "—"}</span></div>
|
||||
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>形态</span><span>桌面客户端(Tauri 2)</span></div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span style={{ color: "var(--ops-muted)" }}>驱动目录</span>
|
||||
<span className="truncate font-mono" title="~/.opsxdb/drivers">~/.opsxdb/drivers</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span style={{ color: "var(--ops-muted)" }}>驱动商店</span>
|
||||
<a
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
href="https://gitee.com/attacker/opsxdb-driver"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
gitee.com/attacker/opsxdb-driver
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,59 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } fro
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { autocompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { getCachedSchema } from "./api";
|
||||
import { REDIS_COMMANDS } from "./redis";
|
||||
import { formatSqlText, statementAt } from "./sql";
|
||||
|
||||
/** §3.10:SQL 关键字静态补全表(通用 + MySQL/PG 常用) */
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "VIEW",
|
||||
"JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ON", "AS", "DISTINCT",
|
||||
"GROUP", "BY", "ORDER", "ASC", "DESC", "LIMIT", "OFFSET", "HAVING", "UNION", "ALL",
|
||||
"CASE", "WHEN", "THEN", "ELSE", "END", "IN", "EXISTS", "BETWEEN", "LIKE", "IS",
|
||||
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "CAST", "WITH", "EXPLAIN",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "DEFAULT", "UNIQUE", "CHECK",
|
||||
"BEGIN", "COMMIT", "ROLLBACK", "GRANT", "REVOKE", "TRUNCATE", "RETURNING",
|
||||
].map((kw) => ({ label: kw, type: "keyword" }) satisfies Completion);
|
||||
|
||||
/**
|
||||
* §3.10/C8:补全数据源 = 关键字 + schemaCache(对象树懒加载时写入的库/表/列)。
|
||||
* 列名携带类型信息(detail),表/库/列分别标注类型。
|
||||
*/
|
||||
function schemaOptions(connId?: string): Completion[] {
|
||||
const cache = getCachedSchema(connId);
|
||||
if (!cache) return [];
|
||||
const out: Completion[] = [];
|
||||
for (const [db, tables] of cache) {
|
||||
out.push({ label: db, type: "namespace", detail: "库" });
|
||||
for (const [table, cols] of tables) {
|
||||
out.push({ label: table, type: "table", detail: db });
|
||||
for (const col of cols) {
|
||||
out.push({ label: col.name, type: "property", detail: `${table} · ${col.type}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sqlComplete(connIdRef: React.RefObject<string | undefined>) {
|
||||
return (ctx: CompletionContext): CompletionResult | null => {
|
||||
const word = ctx.matchBefore(/[\w$一-龥]*$/);
|
||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||
return { from: word.from, options: [...SQL_KEYWORDS, ...schemaOptions(connIdRef.current)] };
|
||||
};
|
||||
}
|
||||
|
||||
function redisComplete(ctx: CompletionContext): CompletionResult | null {
|
||||
const word = ctx.matchBefore(/[\w$]*$/);
|
||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||
return { from: word.from, options: REDIS_COMMANDS };
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 高亮走克制的四色方案(VS Code Dark+ 观感):
|
||||
* 关键字+数字 = 蓝,字符串 = 橙,注释 = 绿,标识符/运算符 = 前景浅白。
|
||||
@@ -38,6 +87,18 @@ export type SqlEditorHandle = {
|
||||
/** Format selection (if prefer + nonempty) or whole doc; single history-friendly change. */
|
||||
format: (selectionPrefer?: boolean) => void;
|
||||
focus: () => void;
|
||||
hasSelection: () => boolean;
|
||||
selectedText: () => string;
|
||||
selectAll: () => void;
|
||||
cutSelection: () => Promise<void>;
|
||||
copySelection: () => Promise<void>;
|
||||
pasteClipboard: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type SqlEditorContextInfo = {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
};
|
||||
|
||||
export const SqlEditor = forwardRef<
|
||||
@@ -49,18 +110,26 @@ export const SqlEditor = forwardRef<
|
||||
onRun: (sql: string) => void;
|
||||
/** Optional driver for dialect-aware format. */
|
||||
driver?: string;
|
||||
/** §3.10:schema 补全按连接隔离(C8 schemaCache) */
|
||||
connectionId?: string;
|
||||
/** 自绘右键:拦住系统菜单后回调 */
|
||||
onContextMenu?: (info: SqlEditorContextInfo) => void;
|
||||
}
|
||||
>(function SqlEditor({ value, theme, onChange, onRun, driver }, ref) {
|
||||
>(function SqlEditor({ value, theme, onChange, onRun, driver, connectionId, onContextMenu }, ref) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onRunRef = useRef(onRun);
|
||||
const valueRef = useRef(value);
|
||||
const driverRef = useRef(driver);
|
||||
const connIdRef = useRef(connectionId);
|
||||
const onContextMenuRef = useRef(onContextMenu);
|
||||
onChangeRef.current = onChange;
|
||||
onRunRef.current = onRun;
|
||||
valueRef.current = value;
|
||||
driverRef.current = driver;
|
||||
connIdRef.current = connectionId;
|
||||
onContextMenuRef.current = onContextMenu;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getRunSql: () => {
|
||||
@@ -68,7 +137,7 @@ export const SqlEditor = forwardRef<
|
||||
if (!view) return valueRef.current.trim();
|
||||
const sel = view.state.selection.main;
|
||||
const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to);
|
||||
return statementAt(view.state.doc.toString(), sel.head, selected);
|
||||
return statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current);
|
||||
},
|
||||
format: (selectionPrefer = true) => {
|
||||
const view = viewRef.current;
|
||||
@@ -90,6 +159,67 @@ export const SqlEditor = forwardRef<
|
||||
view.focus();
|
||||
},
|
||||
focus: () => viewRef.current?.focus(),
|
||||
hasSelection: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return false;
|
||||
return !view.state.selection.main.empty;
|
||||
},
|
||||
selectedText: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return "";
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return "";
|
||||
return view.state.sliceDoc(sel.from, sel.to);
|
||||
},
|
||||
selectAll: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) });
|
||||
view.focus();
|
||||
},
|
||||
cutSelection: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
view.dispatch({ changes: { from: sel.from, to: sel.to, insert: "" } });
|
||||
view.focus();
|
||||
},
|
||||
copySelection: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
view.focus();
|
||||
},
|
||||
pasteClipboard: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
let text = "";
|
||||
try {
|
||||
text = await navigator.clipboard.readText();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: text },
|
||||
selection: EditorSelection.cursor(sel.from + text.length),
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,10 +229,11 @@ export const SqlEditor = forwardRef<
|
||||
run: (view: EditorView) => {
|
||||
const sel = view.state.selection.main;
|
||||
const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to);
|
||||
onRunRef.current(statementAt(view.state.doc.toString(), sel.head, selected));
|
||||
onRunRef.current(statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const redis = (driverRef.current || "").toLowerCase() === "redis";
|
||||
const state = EditorState.create({
|
||||
doc: valueRef.current,
|
||||
extensions: [
|
||||
@@ -110,14 +241,30 @@ export const SqlEditor = forwardRef<
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
sql(),
|
||||
placeholder("写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
|
||||
// §3.10:关键字 + schemaCache 补全(override 覆盖 lang-sql 默认源)
|
||||
autocompletion({ override: [redis ? redisComplete : sqlComplete(connIdRef)] }),
|
||||
placeholder(redis ? "写 Redis 命令,⌘/Ctrl+Enter 执行当前行或选区" : "写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
|
||||
keymap.of([runKey, indentWithTab, ...historyKeymap, ...defaultKeymap]),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
contextmenu: (e, view) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sel = view.state.selection.main;
|
||||
onContextMenuRef.current?.({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
hasSelection: !sel.empty,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "12.5px" },
|
||||
".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: "1.45" },
|
||||
// 字体栈走外层 .ops-code 的 tailwind font-mono 令牌(§2.1 修 V6),不再内联硬编码
|
||||
".cm-scroller": { lineHeight: "1.45" },
|
||||
".cm-content": { padding: "8px 0" },
|
||||
".cm-gutters": { border: "none" },
|
||||
}),
|
||||
@@ -131,9 +278,9 @@ export const SqlEditor = forwardRef<
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// recreate on theme change only
|
||||
// recreate on theme / Redis↔SQL 方言切换(占位与补全不同)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [theme]);
|
||||
}, [theme, driver === "redis"]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 树形方向箭头(SVG 描边 chevron,替代 ▸/▾ 字符 —— Unicode 小三角在部分字体下糊成圆点,
|
||||
* 填充三角又显臃肿)。细长 ">" 造型,open 时旋转 90° 朝下。 */
|
||||
export function TreeChevron({
|
||||
open,
|
||||
size = 10,
|
||||
className = "",
|
||||
}: {
|
||||
/** undefined = 静态朝右(details 折叠组内由浏览器管开合) */
|
||||
open?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 ${open === undefined ? "" : `transition-transform duration-150 ${open ? "rotate-90" : ""}`} ${className}`}
|
||||
>
|
||||
<path d="M4.4 2.6 L8.4 6 L4.4 9.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+768
-141
File diff suppressed because it is too large.
Load diff
+363
-9
@@ -1,8 +1,29 @@
|
||||
import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store";
|
||||
import { decodeSecret } from "./secrets";
|
||||
|
||||
/** 开发态走 Vite 代理(空串);桌面生产包直连本机 Gin。 */
|
||||
/** C2:桌面 sidecar 注入的运行时配置(缺省时行为与现状一致) */
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPSXDB_API__?: string;
|
||||
__OPSXDB_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function injectedApi(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const v = window.__OPSXDB_API__;
|
||||
return v ? v.replace(/\/$/, "") : undefined;
|
||||
}
|
||||
|
||||
function injectedToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.__OPSXDB_TOKEN__ ?? "";
|
||||
}
|
||||
|
||||
/** 开发态走 Vite 代理(空串);桌面生产包优先 sidecar 注入地址,回落默认 8788。 */
|
||||
function resolveApiBase(): string {
|
||||
const injected = injectedApi();
|
||||
if (injected) return injected;
|
||||
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_OPSXDB_API;
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||
if (typeof window === "undefined") return "";
|
||||
@@ -15,11 +36,16 @@ function resolveApiBase(): string {
|
||||
|
||||
const API = resolveApiBase();
|
||||
|
||||
/** C2:token 存在时统一带 Authorization 头;token 为空(本机 dev)行为不变 */
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = injectedToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
@@ -30,6 +56,15 @@ async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promi
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, { headers: { ...authHeaders() }, signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export type ConnBody = {
|
||||
driver?: string;
|
||||
host?: string;
|
||||
@@ -47,8 +82,25 @@ export type ConnBody = {
|
||||
sql?: string;
|
||||
table?: string;
|
||||
limit?: number;
|
||||
/** E1:执行请求标识,配合 POST /api/db/cancel 取消在途语句 */
|
||||
requestId?: string;
|
||||
/** E2:单语句执行超时(毫秒),0/缺省 = 服务端默认不额外限制 */
|
||||
timeoutMs?: number;
|
||||
/** E6:对象搜索关键字 */
|
||||
keyword?: string;
|
||||
/** E7:服务端导出格式 */
|
||||
format?: string;
|
||||
/** C5:结果分页,跳过前 offset 行 */
|
||||
offset?: number;
|
||||
/** C1:前端已完成危险语句确认后重发时置 true */
|
||||
confirmDanger?: boolean;
|
||||
key?: string;
|
||||
pattern?: string;
|
||||
cursor?: number;
|
||||
redisMode?: string;
|
||||
redisNodes?: string;
|
||||
masterName?: string;
|
||||
sentinelPassword?: string;
|
||||
sshEnabled?: boolean;
|
||||
sshHost?: string;
|
||||
sshPort?: number;
|
||||
@@ -74,6 +126,10 @@ export function fullConnPayload(c: SavedConnection): ConnBody {
|
||||
sslCA: c.sslCA || "",
|
||||
sslCert: c.sslCert || "",
|
||||
sslKey: c.sslKey || "",
|
||||
redisMode: c.redisMode || "standalone",
|
||||
redisNodes: c.redisNodes || "",
|
||||
masterName: c.masterName || "",
|
||||
sentinelPassword: c.sentinelPassword ? decodeSecret(c.sentinelPassword) : "",
|
||||
sshEnabled: !!c.sshEnabled,
|
||||
sshHost: c.sshHost,
|
||||
sshPort: c.sshPort,
|
||||
@@ -92,9 +148,15 @@ export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Pa
|
||||
sql: extra?.sql,
|
||||
table: extra?.table,
|
||||
limit: extra?.limit,
|
||||
offset: extra?.offset,
|
||||
confirmDanger: extra?.confirmDanger,
|
||||
key: extra?.key,
|
||||
pattern: extra?.pattern,
|
||||
service: extra?.service ?? c.service,
|
||||
requestId: extra?.requestId,
|
||||
timeoutMs: extra?.timeoutMs,
|
||||
keyword: extra?.keyword,
|
||||
format: extra?.format,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,22 +181,239 @@ export async function listDatabases(c: SavedConnection, sessionId?: string) {
|
||||
return post<{ databases: string[] }>("/api/db/databases", body(c, sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* C8:schema 缓存 —— connId → database → table → columns。
|
||||
* listTables/listColumns 成功时写入,供 SqlEditor 补全与 AiPanel 上下文注入共用。
|
||||
*/
|
||||
const schemaCache = new Map<string, Map<string, Map<string, ColumnInfo[]>>>();
|
||||
|
||||
function cacheTables(connId: string, db: string, tables: string[]) {
|
||||
let dbMap = schemaCache.get(connId);
|
||||
if (!dbMap) {
|
||||
dbMap = new Map();
|
||||
schemaCache.set(connId, dbMap);
|
||||
}
|
||||
let tblMap = dbMap.get(db);
|
||||
if (!tblMap) {
|
||||
tblMap = new Map();
|
||||
dbMap.set(db, tblMap);
|
||||
}
|
||||
for (const t of tables) {
|
||||
if (!tblMap.has(t)) tblMap.set(t, []);
|
||||
}
|
||||
}
|
||||
|
||||
function cacheColumns(connId: string, db: string, table: string, columns: ColumnInfo[]) {
|
||||
let dbMap = schemaCache.get(connId);
|
||||
if (!dbMap) {
|
||||
dbMap = new Map();
|
||||
schemaCache.set(connId, dbMap);
|
||||
}
|
||||
let tblMap = dbMap.get(db);
|
||||
if (!tblMap) {
|
||||
tblMap = new Map();
|
||||
dbMap.set(db, tblMap);
|
||||
}
|
||||
tblMap.set(table, columns);
|
||||
}
|
||||
|
||||
/** 连接关闭/删除后丢弃其缓存,避免跨连接串数据 */
|
||||
export function dropSchemaCache(connId: string) {
|
||||
schemaCache.delete(connId);
|
||||
}
|
||||
|
||||
/** SqlEditor 补全数据源:已加载的表与列 */
|
||||
export function getCachedSchema(
|
||||
connId: string | null | undefined,
|
||||
): Map<string, Map<string, ColumnInfo[]>> | null {
|
||||
if (!connId) return null;
|
||||
return schemaCache.get(connId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AiPanel 上下文注入:把已加载的库表列拼成文本,按 connId 隔离,截断 4000 字符。
|
||||
* 优先输出当前库,其余库只列库.表名。
|
||||
*/
|
||||
export function schemaContextFor(connId: string | null | undefined, currentDb?: string): string {
|
||||
const dbMap = connId ? schemaCache.get(connId) : undefined;
|
||||
if (!dbMap || dbMap.size === 0) return "";
|
||||
const MAX = 4000;
|
||||
const lines: string[] = [];
|
||||
const dbs = [...dbMap.keys()].sort((a, b) => {
|
||||
if (a === currentDb) return -1;
|
||||
if (b === currentDb) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
for (const db of dbs) {
|
||||
const tblMap = dbMap.get(db)!;
|
||||
for (const [table, cols] of tblMap) {
|
||||
const colText = cols.length
|
||||
? `(${cols.map((c) => `${c.name} ${c.type}`.trim()).join(", ")})`
|
||||
: "";
|
||||
lines.push(`${db}.${table}${colText}`);
|
||||
if (lines.join("\n").length > MAX) {
|
||||
return lines.join("\n").slice(0, MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n").slice(0, MAX);
|
||||
}
|
||||
|
||||
export async function listTables(c: SavedConnection, database: string, sessionId?: string) {
|
||||
return post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
|
||||
const res = await post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
|
||||
if (Array.isArray(res.tables)) cacheTables(c.id, database, res.tables);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) {
|
||||
return post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
|
||||
const res = await post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
|
||||
if (Array.isArray(res.columns)) cacheColumns(c.id, database, table, res.columns);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** E1:最近一次 execSql 的 requestId,供 cancelExec() 取消在途语句 */
|
||||
let lastExecRequestId: string | undefined;
|
||||
|
||||
export async function execSql(
|
||||
c: SavedConnection,
|
||||
sql: string,
|
||||
opts?: { database?: string; sessionId?: string; limit?: number; signal?: AbortSignal },
|
||||
opts?: {
|
||||
database?: string;
|
||||
sessionId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
confirmDanger?: boolean;
|
||||
/** E2:单语句执行超时(毫秒),0/缺省 = 不额外限制 */
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
// E1:每次执行生成独立 requestId(模块级记录最后一次),取消端点按 id 精准取消
|
||||
const requestId = crypto.randomUUID();
|
||||
lastExecRequestId = requestId;
|
||||
return post<QueryResult>(
|
||||
"/api/db/exec",
|
||||
body(c, opts?.sessionId, { sql, database: opts?.database || c.database, limit: opts?.limit }),
|
||||
body(c, opts?.sessionId, {
|
||||
sql,
|
||||
database: opts?.database || c.database,
|
||||
limit: opts?.limit,
|
||||
offset: opts?.offset,
|
||||
confirmDanger: opts?.confirmDanger,
|
||||
requestId,
|
||||
timeoutMs: opts?.timeoutMs,
|
||||
}),
|
||||
opts?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* E1:取消最近一次执行 —— fire-and-forget 调 POST /api/db/cancel。
|
||||
* 与前端 abort 互补:服务端收到取消后释放连接/杀查询,错误一律吞掉。
|
||||
*/
|
||||
export function cancelExec(): void {
|
||||
const rid = lastExecRequestId;
|
||||
if (!rid) return;
|
||||
void fetch(`${API}/api/db/cancel`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ requestId: rid }),
|
||||
}).catch(() => {
|
||||
/* 取消失败不影响本地中止流程 */
|
||||
});
|
||||
}
|
||||
|
||||
/** E5:查看表/视图 DDL(方言差异由服务端处理) */
|
||||
export async function getDdl(
|
||||
c: SavedConnection,
|
||||
table: string,
|
||||
opts?: { database?: string; sessionId?: string },
|
||||
) {
|
||||
return post<{ ddl?: string; error?: string }>(
|
||||
"/api/db/ddl",
|
||||
body(c, opts?.sessionId, { table, database: opts?.database || c.database }),
|
||||
);
|
||||
}
|
||||
|
||||
export type SearchObjectHit = { schema: string; name: string; type: "table" | "view" };
|
||||
|
||||
/** E6:跨库对象搜索(服务端 LIKE,大小写策略由驱动决定) */
|
||||
export async function searchObjects(
|
||||
c: SavedConnection,
|
||||
keyword: string,
|
||||
opts?: { database?: string; sessionId?: string },
|
||||
) {
|
||||
return post<{ results: SearchObjectHit[]; error?: string }>(
|
||||
"/api/db/search",
|
||||
body(c, opts?.sessionId, { keyword, database: opts?.database || c.database }),
|
||||
);
|
||||
}
|
||||
|
||||
export type ExportRequest = {
|
||||
format: "csv" | "json" | "insert";
|
||||
/** 自定义查询导出(单条 SELECT);命中危险/非 SELECT 时服务端返回 needConfirm */
|
||||
sql?: string;
|
||||
/** 整表导出 */
|
||||
table?: string;
|
||||
limit?: number;
|
||||
database?: string;
|
||||
sessionId?: string;
|
||||
/** C1:危险确认二段流重发时置 true */
|
||||
confirmDanger?: boolean;
|
||||
};
|
||||
|
||||
/** E7 服务端导出失败:Error 上挂 needConfirm,由调用侧弹二段确认后重发 */
|
||||
export type ExportError = Error & { needConfirm?: boolean };
|
||||
|
||||
/**
|
||||
* E7:服务端导出 —— 文件流下载(Content-Disposition attachment)。
|
||||
* 返回 blob 与解析出的文件名;失败时抛 ExportError(needConfirm=true 表示需二段确认)。
|
||||
*/
|
||||
export async function exportData(c: SavedConnection, opts: ExportRequest): Promise<{ blob: Blob; filename?: string }> {
|
||||
const res = await fetch(`${API}/api/db/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(
|
||||
body(c, opts.sessionId, {
|
||||
format: opts.format,
|
||||
sql: opts.sql,
|
||||
table: opts.table,
|
||||
limit: opts.limit,
|
||||
database: opts.database || c.database,
|
||||
confirmDanger: opts.confirmDanger,
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error((data as { error?: string }).error || res.statusText || "导出失败") as ExportError;
|
||||
if ((data as { needConfirm?: boolean }).needConfirm) err.needConfirm = true;
|
||||
throw err;
|
||||
}
|
||||
const cd = res.headers.get("Content-Disposition") || "";
|
||||
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
|
||||
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
}
|
||||
|
||||
/** C3:服务端专用执行计划端点(方言包装在服务端完成,前端不再拼 EXPLAIN) */
|
||||
export type ExplainResult = {
|
||||
columns?: string[];
|
||||
rows?: Record<string, unknown>[];
|
||||
/** MSSQL SHOWPLAN_XML / Oracle 文本计划 */
|
||||
text?: string;
|
||||
timeMs: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function explainSql(
|
||||
c: SavedConnection,
|
||||
sql: string,
|
||||
opts?: { database?: string; sessionId?: string; signal?: AbortSignal },
|
||||
) {
|
||||
return post<ExplainResult>(
|
||||
"/api/db/explain",
|
||||
body(c, opts?.sessionId, { sql, database: opts?.database || c.database }),
|
||||
opts?.signal,
|
||||
);
|
||||
}
|
||||
@@ -159,6 +438,8 @@ export type RedisKeyValue = {
|
||||
timeMs: number;
|
||||
truncated?: boolean;
|
||||
limit?: number;
|
||||
/** >0 表示还有更多;续载时作为 cursor 回传 */
|
||||
nextCursor?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -177,11 +458,11 @@ export async function getRedisKey(
|
||||
c: SavedConnection,
|
||||
database: string,
|
||||
key: string,
|
||||
opts?: { sessionId?: string; limit?: number },
|
||||
opts?: { sessionId?: string; limit?: number; cursor?: number },
|
||||
) {
|
||||
return post<RedisKeyValue>(
|
||||
"/api/db/redis/key",
|
||||
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, driver: c.driver }),
|
||||
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, cursor: opts?.cursor, driver: c.driver }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,6 +478,20 @@ export async function deleteRedisKey(
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录(前缀)批量删除:pattern 如 `agent:*`;后端 SCAN+UNLINK,拒全库与超上限 */
|
||||
export async function deleteRedisPattern(
|
||||
c: SavedConnection,
|
||||
database: string,
|
||||
pattern: string,
|
||||
opts?: { sessionId?: string },
|
||||
) {
|
||||
return post<{ deleted: number; scanned: number; truncated: boolean; error?: string; timeMs?: number }>(
|
||||
"/api/db/redis/del-pattern",
|
||||
body(c, opts?.sessionId, { database, pattern, driver: c.driver }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export async function loadTree(c: SavedConnection, sessionId?: string): Promise<TreeNode[]> {
|
||||
const { databases } = await listDatabases(c, sessionId);
|
||||
return databases.map((name) => ({ name, kind: "database" as const }));
|
||||
@@ -218,12 +513,32 @@ export async function callAi(payload: {
|
||||
driver?: string;
|
||||
database?: string;
|
||||
error?: string;
|
||||
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
|
||||
schema?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
|
||||
}
|
||||
|
||||
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置) */
|
||||
export type AiConfig = {
|
||||
provider: string;
|
||||
baseURL: string;
|
||||
model: string;
|
||||
hasKey: boolean;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export async function getAiConfig(signal?: AbortSignal) {
|
||||
return get<AiConfig>("/api/ai/config", signal);
|
||||
}
|
||||
|
||||
export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string }) {
|
||||
return post<{ ok: boolean }>("/api/ai/config", cfg);
|
||||
}
|
||||
|
||||
export async function health() {
|
||||
const res = await fetch(`${API}/api/health`);
|
||||
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
|
||||
if (!res.ok) throw new Error(`health ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -236,3 +551,42 @@ export function isAbortError(e: unknown): boolean {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return /aborted|abort/i.test(msg) && !/session/i.test(msg);
|
||||
}
|
||||
|
||||
// ---------- 驱动管理(agent 驱动) ----------
|
||||
|
||||
export type DriverJarStatus = { file: string; size: number; installed: boolean };
|
||||
|
||||
export type DriverStatusItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
runtime: string;
|
||||
license: string;
|
||||
autoFetch: boolean;
|
||||
jars: DriverJarStatus[];
|
||||
allInstalled: boolean;
|
||||
jdkRequired: boolean;
|
||||
jdkOk: boolean;
|
||||
javaVersion: string;
|
||||
agentInstalled: boolean;
|
||||
offlinePack: boolean;
|
||||
};
|
||||
|
||||
export async function driverStatus() {
|
||||
const res = await fetch(`${API}/api/drivers/status`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
|
||||
return (data as { drivers: DriverStatusItem[] }).drivers;
|
||||
}
|
||||
|
||||
export async function driverInstall(driver: string) {
|
||||
return post<{ ok: boolean; error?: string; drivers?: DriverStatusItem[] }>("/api/drivers/install", { driver });
|
||||
}
|
||||
|
||||
export async function driverImportOffline(file: File) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`${API}/api/drivers/import`, { method: "POST", body: form });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
|
||||
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
|
||||
}
|
||||
+131
-20
@@ -1,3 +1,9 @@
|
||||
import registry from "../../../connection-types/connection-types.json";
|
||||
|
||||
/**
|
||||
* 驱动目录 §3.11:单一事实来源是 connection-types/connection-types.json(运行时 import),
|
||||
* 本文件只做类型收敛与查询辅助。新增驱动请先改 JSON(Go 侧 open.go 分发表仍需手工同步)。
|
||||
*/
|
||||
export type DriverId =
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
@@ -14,6 +20,7 @@ export type DriverId =
|
||||
| "dameng"
|
||||
| "oceanbase"
|
||||
| "gaussdb"
|
||||
| "opengauss" // 服务端族接受此 id(老数据/URL 粘贴兼容),选择器与 gaussdb 合并展示
|
||||
| "polardb"
|
||||
| "tdsql"
|
||||
| "kingbase";
|
||||
@@ -31,30 +38,43 @@ export type DriverDef = {
|
||||
icon?: string;
|
||||
mysqlCompat?: boolean;
|
||||
ready?: boolean;
|
||||
/** 是否已在 docker 验收矩阵验收;false 时引擎列表显示「未验收」徽标 */
|
||||
verified?: boolean;
|
||||
/** 未验收/未就绪说明 */
|
||||
notes?: string;
|
||||
formKind?: FormKind;
|
||||
hints?: { host?: string; port?: string; user?: string; database?: string };
|
||||
};
|
||||
|
||||
export const DRIVERS: DriverDef[] = [
|
||||
{ id: "mysql", name: "MySQL", category: "关系型", defaultPort: 3306, color: "#00618A", icon: "/icons/databases/mysql.svg", mysqlCompat: true, ready: true, hints: { host: "127.0.0.1", port: "3306", user: "root", database: "" } },
|
||||
{ id: "mariadb", name: "MariaDB", category: "关系型", defaultPort: 3307, color: "#C0765A", icon: "/icons/databases/mariadb.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "tidb", name: "TiDB", category: "关系型", defaultPort: 4000, color: "#E11D48", icon: "/icons/databases/tidb.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "postgresql", name: "PostgreSQL", category: "关系型", defaultPort: 5432, color: "#336791", icon: "/icons/databases/postgresql.svg", ready: true, hints: { host: "127.0.0.1", port: "5432", user: "postgres", database: "postgres" } },
|
||||
{ id: "sqlserver", name: "SQL Server", category: "关系型", defaultPort: 1433, color: "#CC2927", icon: "/icons/databases/sqlserver.svg", ready: true, hints: { host: "127.0.0.1", port: "1433", user: "sa", database: "master" } },
|
||||
{ id: "oracle", name: "Oracle", category: "关系型", defaultPort: 1521, color: "#F80000", icon: "/icons/databases/oracle.svg", ready: true, formKind: "oracle", hints: { host: "127.0.0.1", port: "1521", user: "system", database: "ORCL" } },
|
||||
{ id: "sqlite", name: "SQLite", category: "关系型", defaultPort: 0, color: "#003B57", icon: "/icons/databases/sqlite.svg", ready: true, formKind: "file", hints: { database: "tests/tmp/demo.sqlite" } },
|
||||
{ id: "mongodb", name: "MongoDB", category: "NoSQL", defaultPort: 27017, color: "#00ED64", icon: "/icons/databases/mongodb.svg" },
|
||||
{ id: "redis", name: "Redis", category: "NoSQL", defaultPort: 6379, color: "#DC382D", icon: "/icons/databases/redis.svg", ready: true, formKind: "redis", hints: { host: "127.0.0.1", port: "6379", user: "", database: "" } },
|
||||
{ id: "elasticsearch", name: "Elasticsearch", category: "时序/搜索", defaultPort: 9200, color: "#FEC514", icon: "/icons/databases/elasticsearch.svg" },
|
||||
{ id: "zookeeper", name: "ZooKeeper", category: "时序/搜索", defaultPort: 2181, color: "#2C5F8A", icon: "/icons/databases/zookeeper.svg" },
|
||||
{ id: "influxdb", name: "InfluxDB", category: "时序/搜索", defaultPort: 8086, color: "#22ADF6", icon: "/icons/databases/influxdb.svg" },
|
||||
{ id: "dameng", name: "达梦", category: "国产库", defaultPort: 5236, color: "#1E40AF", icon: "/icons/databases/dameng.svg" },
|
||||
{ id: "oceanbase", name: "OceanBase", category: "国产库", defaultPort: 2881, color: "#EE0A24", icon: "/icons/databases/oceanbase.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "gaussdb", name: "GaussDB / openGauss", category: "国产库", defaultPort: 5432, color: "#C7000B", icon: "/icons/databases/gaussdb.svg", ready: true, hints: { host: "127.0.0.1", port: "5432", user: "gaussdb", database: "postgres" } },
|
||||
{ id: "polardb", name: "PolarDB", category: "国产库", defaultPort: 3306, color: "#FF6A00", icon: "/icons/databases/polardb.png", mysqlCompat: true, ready: true },
|
||||
{ id: "tdsql", name: "TDSQL", category: "国产库", defaultPort: 3306, color: "#0052D9", icon: "/icons/databases/tdsql.svg", mysqlCompat: true, ready: true },
|
||||
{ id: "kingbase", name: "Kingbase", category: "国产库", defaultPort: 54321, color: "#0B5FFF", icon: "/icons/databases/kingbase.svg", ready: true, hints: { host: "127.0.0.1", port: "54321", user: "system", database: "test" } },
|
||||
];
|
||||
type RegistryEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: Exclude<DriverCategory, "全部">;
|
||||
form?: FormKind;
|
||||
ready?: boolean;
|
||||
verified?: boolean;
|
||||
notes?: string;
|
||||
defaultPort?: number;
|
||||
mysqlCompat?: boolean;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
hints?: { host?: string; port?: string; user?: string; database?: string };
|
||||
};
|
||||
|
||||
export const DRIVERS: DriverDef[] = (registry.types as RegistryEntry[]).map((t) => ({
|
||||
id: t.id as DriverId,
|
||||
name: t.label,
|
||||
category: t.category,
|
||||
defaultPort: t.defaultPort ?? 0,
|
||||
color: t.color ?? "#64748b",
|
||||
icon: t.icon,
|
||||
mysqlCompat: t.mysqlCompat,
|
||||
ready: t.ready,
|
||||
verified: t.verified,
|
||||
notes: t.notes,
|
||||
formKind: t.form,
|
||||
hints: t.hints,
|
||||
}));
|
||||
|
||||
export const CATEGORIES: DriverCategory[] = ["全部", "关系型", "NoSQL", "国产库", "时序/搜索"];
|
||||
|
||||
@@ -70,3 +90,94 @@ export function isDriverReady(id: string) {
|
||||
export function formKindOf(id: string): FormKind {
|
||||
return driverById(id)?.formKind ?? "standard";
|
||||
}
|
||||
|
||||
// ---------- 驱动能力模型 ----------
|
||||
// 对象树分组 / 右键菜单 / 行编辑等按驱动开关,禁止名不副实的功能展示
|
||||
// (如 ES 挂「存储过程」、ZK 节点提供「删除表」)。
|
||||
|
||||
export type TreeGroupKind = "tables" | "views" | "procedures" | "functions";
|
||||
|
||||
export type DriverCaps = {
|
||||
/** 库节点下展示哪些分组;空数组 = 该驱动无分组概念(走特殊树) */
|
||||
treeGroups: TreeGroupKind[];
|
||||
/** 分组显示别名(如 ES 的 tables →「索引」) */
|
||||
groupLabel: Partial<Record<TreeGroupKind, string>>;
|
||||
/** 对象节点可展开查看字段(列) */
|
||||
columns: boolean;
|
||||
/** 右键「查看 DDL」 */
|
||||
ddl: boolean;
|
||||
/** 右键「清空/截断」 */
|
||||
truncate: boolean;
|
||||
/** 右键「删除对象」(表/集合/索引…) */
|
||||
dropObject: boolean;
|
||||
/** 库节点「设为默认库」 */
|
||||
setDefaultDb: boolean;
|
||||
};
|
||||
|
||||
const ALL_GROUPS: TreeGroupKind[] = ["tables", "views", "procedures", "functions"];
|
||||
|
||||
/** 全能力 SQL 库(含达梦:JDBC 元数据全支持) */
|
||||
const SQL_CAPS: DriverCaps = {
|
||||
treeGroups: ALL_GROUPS,
|
||||
groupLabel: {},
|
||||
columns: true,
|
||||
ddl: true,
|
||||
truncate: true,
|
||||
dropObject: true,
|
||||
setDefaultDb: true,
|
||||
};
|
||||
|
||||
/** 只读 flat 对象树(无分组包装,对象直挂库节点)的 NoSQL 驱动 */
|
||||
function readonlyCaps(columns: boolean): DriverCaps {
|
||||
return {
|
||||
treeGroups: [], // flat:ES 索引/Mongo 集合/Influx measurement/ZK 子节点 直挂库节点
|
||||
groupLabel: {},
|
||||
columns,
|
||||
ddl: false,
|
||||
truncate: false,
|
||||
dropObject: false,
|
||||
setDefaultDb: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function driverCaps(driver: string): DriverCaps {
|
||||
const d = (driver || "").toLowerCase();
|
||||
switch (d) {
|
||||
case "mysql":
|
||||
case "mariadb":
|
||||
case "tidb":
|
||||
case "oceanbase":
|
||||
case "polardb":
|
||||
case "tdsql":
|
||||
case "postgresql":
|
||||
case "postgres":
|
||||
case "gaussdb":
|
||||
case "opengauss":
|
||||
case "kingbase":
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
case "oracle":
|
||||
case "dameng":
|
||||
return SQL_CAPS;
|
||||
case "sqlite":
|
||||
// SQLite 无存储过程/函数/TRUNCATE;有视图与 DROP TABLE
|
||||
return { ...SQL_CAPS, treeGroups: ["tables", "views"], truncate: false };
|
||||
case "elasticsearch":
|
||||
// ES SQL 只读:索引≈表;无 DDL/写操作/默认库
|
||||
return readonlyCaps(true);
|
||||
case "influxdb":
|
||||
// bucket→measurement;字段可探(Flux fieldKeys),只读
|
||||
return readonlyCaps(true);
|
||||
case "mongodb":
|
||||
// 库→集合;抽样字段;写操作走 DSL 编辑器而非树右键
|
||||
return readonlyCaps(true);
|
||||
case "zookeeper":
|
||||
// 路径→子节点;节点数据靠双击 GET;无列概念
|
||||
return readonlyCaps(false);
|
||||
case "redis":
|
||||
// Redis 走专属键树(buildRedisKeyTree),这里全关兜底
|
||||
return { treeGroups: [], groupLabel: {}, columns: false, ddl: false, truncate: false, dropObject: false, setDefaultDb: false };
|
||||
default:
|
||||
return readonlyCaps(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 模块级输入对话框(C6 契约)
|
||||
*
|
||||
* 独立于 store.ts(避免与 WS-C 文件冲突):内部维护一个微型订阅队列,
|
||||
* 由 NotifyHost 内嵌的 DialogHost 订阅并以 OpsModal input 变体渲染。
|
||||
* 业务侧只需 `const name = await askInput({ title: "分组名称" })`,
|
||||
* 确认返回输入字符串,Esc/取消返回 null(替代 WKWebView 下返回 null 的 window.prompt)。
|
||||
*/
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type AskInputOptions = {
|
||||
title: string;
|
||||
label?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type InputRequest = AskInputOptions & {
|
||||
/** 递增 id,供渲染侧作 React key(连续请求时强制重挂,重置输入框状态) */
|
||||
id: number;
|
||||
resolve: (value: string | null) => void;
|
||||
};
|
||||
|
||||
// 请求队列:允许连续发起多个 askInput,逐个展示
|
||||
let queue: InputRequest[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** C6 契约:弹出令牌化输入框,确认返回值 / Esc 或取消返回 null */
|
||||
export function askInput(opts: AskInputOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
queue = [...queue, { ...opts, id: nextId++, resolve }];
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): InputRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
/** 内部:NotifyHost 的 DialogHost 订阅当前待处理的输入请求 */
|
||||
export function useInputRequest(): InputRequest | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/** 内部:消费当前请求(确认传值,取消传 null),并出队下一个 */
|
||||
export function settleInputRequest(value: string | null) {
|
||||
const [cur, ...rest] = queue;
|
||||
queue = rest;
|
||||
cur?.resolve(value);
|
||||
emit();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Vite-bundled driver icons — works in Tauri/web without depending on /public path. */
|
||||
const modules = import.meta.glob("../assets/databases/*.{svg,png}", {
|
||||
eager: true,
|
||||
query: "?url",
|
||||
import: "default",
|
||||
}) as Record<string, string>;
|
||||
|
||||
function fileKey(path: string): string {
|
||||
const base = path.split("/").pop() ?? path;
|
||||
return base.replace(/\.(svg|png)$/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** id → bundled asset URL */
|
||||
export const DRIVER_ICON_URLS: Record<string, string> = {};
|
||||
for (const [path, url] of Object.entries(modules)) {
|
||||
DRIVER_ICON_URLS[fileKey(path)] = url;
|
||||
}
|
||||
|
||||
/** Resolve catalog icon path or id to a loadable URL. */
|
||||
export function resolveDriverIconUrl(id: string, icon?: string): string | undefined {
|
||||
const byId = DRIVER_ICON_URLS[id.toLowerCase()];
|
||||
if (byId) return byId;
|
||||
if (!icon) return undefined;
|
||||
const m = icon.match(/\/([^/]+)\.(svg|png)$/i);
|
||||
if (m) {
|
||||
const byFile = DRIVER_ICON_URLS[m[1].toLowerCase()];
|
||||
if (byFile) return byFile;
|
||||
}
|
||||
// last resort: absolute/public path (web dev)
|
||||
return icon;
|
||||
}
|
||||
@@ -15,6 +15,13 @@ const PROTO: Record<string, DriverId> = {
|
||||
tidb: "tidb",
|
||||
mongodb: "mongodb",
|
||||
mongo: "mongodb",
|
||||
oceanbase: "oceanbase",
|
||||
gaussdb: "gaussdb",
|
||||
opengauss: "gaussdb",
|
||||
polardb: "polardb",
|
||||
tdsql: "tdsql",
|
||||
kingbase: "kingbase",
|
||||
dameng: "dameng",
|
||||
};
|
||||
|
||||
export type ParsedDSN = {
|
||||
|
||||
@@ -7,6 +7,10 @@ export { DRIVERS, CATEGORIES, driverById, isDriverReady } from "./catalog";
|
||||
export type { DriverId, DriverDef, DriverCategory, FormKind } from "./catalog";
|
||||
export { NotifyHost } from "./NotifyHost";
|
||||
export { formatDbError } from "./notify";
|
||||
export { OpsModal } from "./OpsModal";
|
||||
export type { OpsModalProps, OpsModalVariant } from "./OpsModal";
|
||||
export { askInput } from "./dialogs";
|
||||
export type { AskInputOptions } from "./dialogs";
|
||||
export { DriverIcon } from "./DriverIcon";
|
||||
|
||||
export { SchemaPane } from "./SchemaPane";
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRedisKeyTree,
|
||||
createKeyCommand,
|
||||
isDangerousRedis,
|
||||
quoteRedisArg,
|
||||
redisCmd,
|
||||
redisLeafLabel,
|
||||
removeRedisKeyFromTree,
|
||||
splitRedisCommands,
|
||||
tryPrettyJson,
|
||||
tsLabel,
|
||||
} from "./redis";
|
||||
|
||||
describe("buildRedisKeyTree", () => {
|
||||
it("按 : 编文件夹,叶子保留全名", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "CEX:FOLLOW:FOLLOWERS:FUTURE:97865651", type: "hash", ttl: -1 },
|
||||
{ name: "CEX:FOLLOW:TRADERS:1", type: "hash", ttl: -1 },
|
||||
{ name: "LANG", type: "string", ttl: 12 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
expect(tree.map((n) => n.name)).toEqual(["CEX", "LANG"]);
|
||||
const cex = tree[0];
|
||||
expect(cex.kind).toBe("folder");
|
||||
expect(cex.count).toBe(2);
|
||||
expect(cex.fullName).toBe("CEX:");
|
||||
const follow = cex.children?.[0];
|
||||
expect(follow?.name).toBe("FOLLOW");
|
||||
const leaves = (follow?.children ?? []).flatMap((n) =>
|
||||
n.kind === "folder" ? n.children ?? [] : [n],
|
||||
);
|
||||
expect(leaves.some((n) => n.name === "CEX:FOLLOW:TRADERS:1")).toBe(true);
|
||||
});
|
||||
|
||||
it("无分隔符时保持扁平", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "alpha", type: "string", ttl: -1 },
|
||||
{ name: "beta", type: "hash", ttl: 3 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
expect(tree.every((n) => n.kind === "key")).toBe(true);
|
||||
expect(tree.map((n) => n.name)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("删除键后收起空文件夹", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "a:b:c", type: "string", ttl: -1 },
|
||||
{ name: "a:x", type: "string", ttl: -1 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
const next = removeRedisKeyFromTree(tree, "a:b:c");
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].kind).toBe("folder");
|
||||
expect(next[0].count).toBe(1);
|
||||
expect(next[0].children?.[0].name).toBe("a:x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quote / cmd", () => {
|
||||
it("空白与引号转义", () => {
|
||||
expect(quoteRedisArg("abc")).toBe("abc");
|
||||
expect(quoteRedisArg("a b")).toBe('"a b"');
|
||||
expect(quoteRedisArg('say "hi"')).toBe('"say \\"hi\\""');
|
||||
expect(redisCmd("SET", "k", "v w")).toBe('SET k "v w"');
|
||||
});
|
||||
it("新建键命令", () => {
|
||||
expect(createKeyCommand("string", "demo", "x")).toBe("SET demo x");
|
||||
expect(createKeyCommand("hash", "h", "v")).toBe("HSET h field v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRedisCommands", () => {
|
||||
it("按行切,跳过空行和 # 注释", () => {
|
||||
const parts = splitRedisCommands("GET a\n\n# skip\nHGETALL b");
|
||||
expect(parts.map((p) => p.text)).toEqual(["GET a", "HGETALL b"]);
|
||||
});
|
||||
it("不把分号当分隔", () => {
|
||||
const parts = splitRedisCommands('SET k "a;b"');
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].text).toBe('SET k "a;b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("misc", () => {
|
||||
it("危险命令", () => {
|
||||
expect(isDangerousRedis("FLUSHALL")).toBe(true);
|
||||
expect(isDangerousRedis("GET k")).toBe(false);
|
||||
expect(isDangerousRedis("keys *")).toBe(true);
|
||||
});
|
||||
it("JSON 美化", () => {
|
||||
expect(tryPrettyJson('{"a":1}')).toBe('{\n "a": 1\n}');
|
||||
expect(tryPrettyJson("not-json")).toBeNull();
|
||||
});
|
||||
it("leaf label", () => {
|
||||
expect(redisLeafLabel("a:b:c", ":")).toBe("c");
|
||||
expect(redisLeafLabel("plain", ":")).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tsLabel", () => {
|
||||
it("毫秒时间戳给可读时间", () => {
|
||||
expect(tsLabel(1782230400000)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
it("秒时间戳给可读时间", () => {
|
||||
expect(tsLabel(1782230400)).toMatch(/^\d{4}-/);
|
||||
});
|
||||
it("普通数字/非法值不误判", () => {
|
||||
expect(tsLabel(10)).toBeNull();
|
||||
expect(tsLabel(77777777777777)).toBeNull();
|
||||
expect(tsLabel(Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { TreeNode } from "./store";
|
||||
|
||||
export type RedisKeyMeta = { name: string; type: string; ttl: number };
|
||||
|
||||
/** 类型点颜色,对齐常见 Redis GUI */
|
||||
export function redisTypeColor(type?: string): string {
|
||||
switch ((type || "").toLowerCase()) {
|
||||
case "string":
|
||||
return "#22c55e";
|
||||
case "hash":
|
||||
return "#3b82f6";
|
||||
case "list":
|
||||
return "#f59e0b";
|
||||
case "set":
|
||||
return "#a855f7";
|
||||
case "zset":
|
||||
return "#06b6d4";
|
||||
case "stream":
|
||||
return "#ec4899";
|
||||
case "rejson":
|
||||
case "json":
|
||||
case "rejson-rl":
|
||||
return "#14b8a6";
|
||||
default:
|
||||
return "#ef4444";
|
||||
}
|
||||
}
|
||||
|
||||
/** TTL 可读:永久 / 不存在 / Ns */
|
||||
export function formatRedisTtl(ttl: number | undefined): string {
|
||||
if (ttl == null) return "";
|
||||
if (ttl === -1) return "永久";
|
||||
if (ttl === -2) return "不存在";
|
||||
if (ttl < 0) return String(ttl);
|
||||
if (ttl < 60) return `${ttl}s`;
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m${ttl % 60 ? `${ttl % 60}s` : ""}`;
|
||||
if (ttl < 86400) {
|
||||
const h = Math.floor(ttl / 3600);
|
||||
const m = Math.floor((ttl % 3600) / 60);
|
||||
return m ? `${h}h${m}m` : `${h}h`;
|
||||
}
|
||||
const d = Math.floor(ttl / 86400);
|
||||
const h = Math.floor((ttl % 86400) / 3600);
|
||||
return h ? `${d}d${h}h` : `${d}d`;
|
||||
}
|
||||
|
||||
/** 数值若形如 Unix 时间戳(秒/毫秒)则给可读时间,否则 null —— zset Score 常存毫秒时间戳(如 K 线) */
|
||||
export function tsLabel(n: number): string | null {
|
||||
if (!Number.isFinite(n)) return null;
|
||||
let d: Date | null = null;
|
||||
if (n > 1e11 && n < 9e12) d = new Date(n); // 毫秒
|
||||
else if (n > 1e8 && n < 9e9) d = new Date(n * 1000); // 秒
|
||||
if (!d || Number.isNaN(d.getTime())) return null;
|
||||
const p = (x: number) => String(x).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function redisLeafLabel(full: string, sep: string): string {
|
||||
if (!sep) return full;
|
||||
const i = full.lastIndexOf(sep);
|
||||
if (i < 0) return full;
|
||||
const leaf = full.slice(i + sep.length);
|
||||
return leaf || full;
|
||||
}
|
||||
|
||||
type Bucket = {
|
||||
folders: Map<string, Bucket>;
|
||||
keys: RedisKeyMeta[];
|
||||
};
|
||||
|
||||
function keyCount(n: TreeNode): number {
|
||||
if (n.kind === "key") return 1;
|
||||
return n.count ?? (n.children ?? []).reduce((a, c) => a + keyCount(c), 0);
|
||||
}
|
||||
|
||||
/** 按分隔符把 SCAN 结果编成文件夹树(默认 `:`,对标 Another Redis Desktop Manager)。 */
|
||||
export function buildRedisKeyTree(keys: RedisKeyMeta[], separator = ":"): TreeNode[] {
|
||||
const sep = separator || ":";
|
||||
const root: Bucket = { folders: new Map(), keys: [] };
|
||||
for (const k of keys) {
|
||||
const parts = k.name.split(sep);
|
||||
if (parts.length <= 1) {
|
||||
root.keys.push(k);
|
||||
continue;
|
||||
}
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const p = parts[i] ?? "";
|
||||
let next = cur.folders.get(p);
|
||||
if (!next) {
|
||||
next = { folders: new Map(), keys: [] };
|
||||
cur.folders.set(p, next);
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
cur.keys.push(k);
|
||||
}
|
||||
return bucketToNodes(root, "", sep);
|
||||
}
|
||||
|
||||
function bucketToNodes(b: Bucket, prefix: string, sep: string): TreeNode[] {
|
||||
const folderNames = [...b.folders.keys()].sort((a, c) => a.localeCompare(c, undefined, { numeric: true }));
|
||||
const folders: TreeNode[] = folderNames.map((name) => {
|
||||
const child = b.folders.get(name)!;
|
||||
const fullName = prefix + name + sep;
|
||||
const children = bucketToNodes(child, fullName, sep);
|
||||
return {
|
||||
name,
|
||||
kind: "folder" as const,
|
||||
groupId: "folder",
|
||||
fullName,
|
||||
loaded: true,
|
||||
count: children.reduce((a, n) => a + keyCount(n), 0),
|
||||
children,
|
||||
};
|
||||
});
|
||||
const keys: TreeNode[] = [...b.keys]
|
||||
.sort((a, c) => a.name.localeCompare(c.name, undefined, { numeric: true }))
|
||||
.map((k) => ({
|
||||
name: k.name,
|
||||
kind: "key" as const,
|
||||
type: k.type,
|
||||
ttl: k.ttl,
|
||||
}));
|
||||
return [...folders, ...keys];
|
||||
}
|
||||
|
||||
export function removeRedisKeyFromTree(nodes: TreeNode[], key: string): TreeNode[] {
|
||||
const out: TreeNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.kind === "key" && n.name === key) continue;
|
||||
if (n.kind === "folder") {
|
||||
const children = removeRedisKeyFromTree(n.children ?? [], key);
|
||||
if (!children.length) continue;
|
||||
out.push({
|
||||
...n,
|
||||
children,
|
||||
count: children.reduce((a, c) => a + keyCount(c), 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 前端过滤键树:子串匹配键名/路径 + 类型筛选;空文件夹剪枝,计数同步 */
|
||||
export function filterRedisTree(nodes: TreeNode[], q: string, type: string): TreeNode[] {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const typeOn = type && type !== "all";
|
||||
if (!needle && !typeOn) return nodes;
|
||||
const keep = (n: TreeNode): boolean =>
|
||||
!needle || n.name.toLowerCase().includes(needle) || (n.fullName || "").toLowerCase().includes(needle);
|
||||
const typeOk = (n: TreeNode): boolean => !typeOn || (n.type || "").toLowerCase() === type;
|
||||
const walk = (list: TreeNode[]): TreeNode[] => {
|
||||
const out: TreeNode[] = [];
|
||||
for (const n of list) {
|
||||
if (n.kind === "folder") {
|
||||
const children = walk(n.children ?? []);
|
||||
if (!children.length) continue;
|
||||
out.push({ ...n, children, count: children.reduce((a, c) => a + keyCount(c), 0) });
|
||||
continue;
|
||||
}
|
||||
if (n.kind === "key") {
|
||||
if (keep(n) && typeOk(n)) out.push(n);
|
||||
continue;
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(nodes);
|
||||
}
|
||||
|
||||
export function quoteRedisArg(s: string): string {
|
||||
if (s === "") return '""';
|
||||
if (/^[\w\-.:/@+=]+$/.test(s)) return s;
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function redisCmd(...parts: string[]): string {
|
||||
return parts.map(quoteRedisArg).join(" ");
|
||||
}
|
||||
|
||||
export function tryPrettyJson(raw: unknown): string | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === "object") {
|
||||
try {
|
||||
return JSON.stringify(raw, null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const s = String(raw).trim();
|
||||
if (!s || (s[0] !== "{" && s[0] !== "[" && s[0] !== '"')) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayRedisValue(raw: unknown, max = 180): { text: string; pretty: string | null } {
|
||||
const pretty = tryPrettyJson(raw);
|
||||
const full = pretty ?? (raw == null ? "" : String(raw));
|
||||
if (full.length > max) return { text: `${full.slice(0, max)}…`, pretty };
|
||||
return { text: full, pretty };
|
||||
}
|
||||
|
||||
export const REDIS_COMMANDS = [
|
||||
"PING", "GET", "SET", "DEL", "EXISTS", "TTL", "PTTL", "EXPIRE", "PERSIST", "TYPE", "RENAME",
|
||||
"KEYS", "SCAN", "INFO", "DBSIZE", "SELECT", "ECHO",
|
||||
"HGET", "HGETALL", "HSET", "HDEL", "HKEYS", "HVALS", "HLEN", "HEXISTS", "HSCAN",
|
||||
"LPUSH", "RPUSH", "LRANGE", "LLEN", "LSET", "LPOP", "RPOP", "LINDEX",
|
||||
"SADD", "SREM", "SMEMBERS", "SCARD", "SISMEMBER", "SSCAN",
|
||||
"ZADD", "ZREM", "ZRANGE", "ZREVRANGE", "ZCARD", "ZSCORE", "ZSCAN",
|
||||
"MGET", "MSET", "INCR", "DECR", "INCRBY", "DECRBY", "APPEND", "STRLEN",
|
||||
"FLUSHDB", "FLUSHALL", "CONFIG", "CLIENT", "SLOWLOG", "MEMORY",
|
||||
].map((label) => ({ label, type: "keyword" as const }));
|
||||
|
||||
/** Redis 按行切命令(不是 SQL 分号)。空行与 # 注释跳过。 */
|
||||
export function splitRedisCommands(sql: string): { start: number; end: number; text: string }[] {
|
||||
const out: { start: number; end: number; text: string }[] = [];
|
||||
let i = 0;
|
||||
while (i <= sql.length) {
|
||||
const nl = sql.indexOf("\n", i);
|
||||
const end = nl < 0 ? sql.length : nl;
|
||||
const line = sql.slice(i, end);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const lead = line.search(/\S/);
|
||||
const from = i + (lead < 0 ? 0 : lead);
|
||||
out.push({ start: from, end: from + trimmed.length, text: trimmed });
|
||||
}
|
||||
if (nl < 0) break;
|
||||
i = end + 1;
|
||||
}
|
||||
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
|
||||
}
|
||||
|
||||
export function isDangerousRedis(sql: string): boolean {
|
||||
const cmd = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? "";
|
||||
return ["FLUSHALL", "FLUSHDB", "KEYS", "CONFIG", "SHUTDOWN", "DEBUG", "REPLICAOF", "SLAVEOF"].includes(cmd);
|
||||
}
|
||||
|
||||
export function redisHintForType(key: string, type?: string): string {
|
||||
const k = quoteRedisArg(key);
|
||||
switch (type) {
|
||||
case "hash":
|
||||
return `HGETALL ${k}`;
|
||||
case "list":
|
||||
return `LRANGE ${k} 0 499`;
|
||||
case "set":
|
||||
return `SMEMBERS ${k}`;
|
||||
case "zset":
|
||||
return `ZRANGE ${k} 0 499 WITHSCORES`;
|
||||
default:
|
||||
return `GET ${k}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyCommand(type: string, name: string, value: string): string {
|
||||
switch (type) {
|
||||
case "hash":
|
||||
return redisCmd("HSET", name, "field", value || "");
|
||||
case "list":
|
||||
return redisCmd("RPUSH", name, value || "");
|
||||
case "set":
|
||||
return redisCmd("SADD", name, value || "");
|
||||
case "zset":
|
||||
return redisCmd("ZADD", name, "0", value || "");
|
||||
default:
|
||||
return redisCmd("SET", name, value || "");
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否 Redis 连接(按驱动名) */
|
||||
export function isRedisConn(driver?: string): boolean {
|
||||
return (driver || "").toLowerCase() === "redis";
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isDangerousSQL,
|
||||
parseClipboardGrid,
|
||||
quoteIdent,
|
||||
rowsToCSV,
|
||||
rowsToInsert,
|
||||
splitRunnable,
|
||||
splitStatements,
|
||||
statementAt,
|
||||
} from "./sql";
|
||||
|
||||
describe("rowsToCSV", () => {
|
||||
it("转义引号/逗号/换行", () => {
|
||||
const out = rowsToCSV(
|
||||
["a", "b"],
|
||||
[{ a: 'x"y', b: "l1\nl2" }, { a: "c,d", b: null }],
|
||||
);
|
||||
expect(out).toBe('a,b\n"x""y","l1\nl2"\n"c,d",');
|
||||
});
|
||||
|
||||
it("空值输出空串并保留表头", () => {
|
||||
expect(rowsToCSV(["a"], [{ a: null }])).toBe("a\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowsToInsert(方言引号)", () => {
|
||||
const rows = [{ id: 1, name: "O'Neil" }, { id: 2, name: null }];
|
||||
|
||||
it("mysql 族反引号 + 单引号字面量翻倍", () => {
|
||||
expect(rowsToInsert("mysql", "users", ["id", "name"], rows)).toBe(
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'O''Neil');\n" +
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (2, NULL);",
|
||||
);
|
||||
});
|
||||
|
||||
it("postgresql/sqlite 双引号", () => {
|
||||
expect(rowsToInsert("postgresql", "users", ["id"], [{ id: 1 }])).toBe(
|
||||
'INSERT INTO "users" ("id") VALUES (1);',
|
||||
);
|
||||
expect(rowsToInsert("sqlite", "users", ["id"], [{ id: 1 }])).toBe(
|
||||
'INSERT INTO "users" ("id") VALUES (1);',
|
||||
);
|
||||
});
|
||||
|
||||
it("mssql 中括号", () => {
|
||||
expect(rowsToInsert("sqlserver", "users", ["id"], [{ id: 1 }])).toBe(
|
||||
"INSERT INTO [users] ([id]) VALUES (1);",
|
||||
);
|
||||
});
|
||||
|
||||
it("oracle 裸名(大写惯例不加引号)", () => {
|
||||
expect(rowsToInsert("oracle", "USERS", ["ID"], [{ ID: 1 }])).toBe(
|
||||
"INSERT INTO USERS (ID) VALUES (1);",
|
||||
);
|
||||
});
|
||||
|
||||
it("库.表 全限定逐段加引号;内部反引号/双引号/中括号转义", () => {
|
||||
expect(rowsToInsert("mysql", "app.users", ["id"], [{ id: 1 }])).toBe(
|
||||
"INSERT INTO `app`.`users` (`id`) VALUES (1);",
|
||||
);
|
||||
expect(quoteIdent("mysql", "a`b")).toBe("`a``b`");
|
||||
expect(quoteIdent("postgresql", 'a"b')).toBe('"a""b"');
|
||||
expect(quoteIdent("sqlserver", "a]b")).toBe("[a]]b]");
|
||||
});
|
||||
|
||||
it("缺省 driver 按 mysql 处理", () => {
|
||||
expect(rowsToInsert(undefined, "t", ["c"], [{ c: "v" }])).toBe("INSERT INTO `t` (`c`) VALUES ('v');");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteIdent", () => {
|
||||
it("各 family 引号风格", () => {
|
||||
expect(quoteIdent("mysql", "t")).toBe("`t`");
|
||||
expect(quoteIdent("mariadb", "t")).toBe("`t`");
|
||||
expect(quoteIdent("postgresql", "t")).toBe('"t"');
|
||||
expect(quoteIdent("gaussdb", "t")).toBe('"t"');
|
||||
expect(quoteIdent("sqlserver", "t")).toBe("[t]");
|
||||
expect(quoteIdent("mssql", "t")).toBe("[t]");
|
||||
expect(quoteIdent("oracle", "EMP")).toBe("EMP");
|
||||
expect(quoteIdent("sqlite", "t")).toBe('"t"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitStatements", () => {
|
||||
it("引号内分号不截断", () => {
|
||||
const parts = splitStatements("SELECT ';' AS x; SELECT 2");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].text).toBe("SELECT ';' AS x");
|
||||
expect(parts[1].text.trim()).toBe("SELECT 2");
|
||||
});
|
||||
|
||||
it("行注释内分号不截断", () => {
|
||||
const parts = splitStatements("SELECT 1 -- c;\n; SELECT 2");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[1].text.trim()).toBe("SELECT 2");
|
||||
});
|
||||
|
||||
it("块注释内分号不截断", () => {
|
||||
const parts = splitStatements("SELECT /* ; */ 1; SELECT 2");
|
||||
expect(parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("双引号字符串内分号不截断", () => {
|
||||
const parts = splitStatements('SELECT "a;b" FROM t; SELECT 2');
|
||||
expect(parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("单语句无分号原样返回", () => {
|
||||
const parts = splitStatements("SELECT 1");
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].text).toBe("SELECT 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClipboardGrid", () => {
|
||||
it("Tab 分隔", () => {
|
||||
expect(parseClipboardGrid("a\tb\nc\td")).toEqual([
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("literal NULL(任意大小写)→ null", () => {
|
||||
expect(parseClipboardGrid("x,NULL,y")).toEqual([["x", null, "y"]]);
|
||||
expect(parseClipboardGrid("null")).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it("无 Tab 的简单 CSV 按逗号拆", () => {
|
||||
expect(parseClipboardGrid('a,"b,c"')).toEqual([["a", "b,c"]]);
|
||||
});
|
||||
|
||||
it("CRLF 与末尾换行归一", () => {
|
||||
expect(parseClipboardGrid("a\r\nb\n")).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
it("空串返回空数组", () => {
|
||||
expect(parseClipboardGrid("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDangerousSQL", () => {
|
||||
it("基本危险动词", () => {
|
||||
expect(isDangerousSQL("DROP TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL("TRUNCATE TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL("ALTER TABLE t ADD c INT")).toBe(true);
|
||||
expect(isDangerousSQL("GRANT ALL ON a TO b")).toBe(true);
|
||||
expect(isDangerousSQL("REVOKE SELECT ON a FROM b")).toBe(true);
|
||||
});
|
||||
|
||||
it("DELETE/UPDATE 无 WHERE 才算危险", () => {
|
||||
expect(isDangerousSQL("DELETE FROM t")).toBe(true);
|
||||
expect(isDangerousSQL("DELETE FROM t WHERE id=1")).toBe(false);
|
||||
expect(isDangerousSQL("UPDATE t SET a=1")).toBe(true);
|
||||
expect(isDangerousSQL("UPDATE t SET a=1 WHERE id=2")).toBe(false);
|
||||
});
|
||||
|
||||
it("注释开头的语句仍判定", () => {
|
||||
expect(isDangerousSQL("-- hi\nDROP TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL("/* c */ DROP TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL(" -- hi\n /* c */ TRUNCATE TABLE t")).toBe(true);
|
||||
});
|
||||
|
||||
it("普通查询与纯注释不误伤", () => {
|
||||
expect(isDangerousSQL("SELECT 1")).toBe(false);
|
||||
expect(isDangerousSQL("WITH x AS (SELECT 1) SELECT * FROM x")).toBe(false);
|
||||
expect(isDangerousSQL("-- only comment")).toBe(false);
|
||||
expect(isDangerousSQL("")).toBe(false);
|
||||
});
|
||||
|
||||
it("Redis 危险命令按 driver 判定", () => {
|
||||
expect(isDangerousSQL("FLUSHALL", "redis")).toBe(true);
|
||||
expect(isDangerousSQL("GET k", "redis")).toBe(false);
|
||||
expect(isDangerousSQL("FLUSHALL")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRunnable / statementAt redis", () => {
|
||||
it("Redis 按行而不是分号", () => {
|
||||
const parts = splitRunnable("GET a\nHGETALL b", "redis");
|
||||
expect(parts.map((p) => p.text)).toEqual(["GET a", "HGETALL b"]);
|
||||
expect(statementAt('SET k "a;b"\nGET k', 0, undefined, "redis")).toBe('SET k "a;b"');
|
||||
});
|
||||
});
|
||||
+50
-32
@@ -1,6 +1,13 @@
|
||||
import { format as sqlFormat } from "sql-formatter";
|
||||
import { isDangerousRedis, splitRedisCommands } from "./redis";
|
||||
|
||||
export function isDangerousSQL(sql: string): boolean {
|
||||
/**
|
||||
* 危险语句 UX 预检(免一次往返)。
|
||||
* 注意:服务端 CheckDangerousSQL 为权威判定(逐语句/词边界/注释处理),
|
||||
* 此处只做宽松预提示,判定冲突时以服务端为准(needConfirm 二段流)。
|
||||
*/
|
||||
export function isDangerousSQL(sql: string, driver?: string): boolean {
|
||||
if ((driver || "").toLowerCase() === "redis") return isDangerousRedis(sql);
|
||||
const s = stripLead(sql);
|
||||
if (!s) return false;
|
||||
const up = s.toUpperCase();
|
||||
@@ -30,16 +37,22 @@ function stripLead(sql: string): string {
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 选区优先;否则取光标所在语句(分号分隔,忽略引号内)。 */
|
||||
export function statementAt(sql: string, cursor: number, selection?: string): string {
|
||||
/** 选区优先;否则取光标所在语句。Redis 按行,SQL 按分号。 */
|
||||
export function statementAt(sql: string, cursor: number, selection?: string, driver?: string): string {
|
||||
const sel = selection?.trim();
|
||||
if (sel) return sel;
|
||||
const ranges = splitStatements(sql);
|
||||
const ranges = splitRunnable(sql, driver);
|
||||
const pos = Math.max(0, Math.min(cursor, sql.length));
|
||||
const hit = ranges.find((r) => pos >= r.start && pos <= r.end) ?? ranges.find((r) => pos < r.start);
|
||||
return (hit?.text || sql).trim();
|
||||
}
|
||||
|
||||
/** Redis 按行;其它引擎按 SQL 分号。 */
|
||||
export function splitRunnable(sql: string, driver?: string): { start: number; end: number; text: string }[] {
|
||||
if ((driver || "").toLowerCase() === "redis") return splitRedisCommands(sql);
|
||||
return splitStatements(sql);
|
||||
}
|
||||
|
||||
export function splitStatements(sql: string): { start: number; end: number; text: string }[] {
|
||||
const out: { start: number; end: number; text: string }[] = [];
|
||||
let start = 0;
|
||||
@@ -97,25 +110,36 @@ export function splitStatements(sql: string): { start: number; end: number; text
|
||||
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
|
||||
}
|
||||
|
||||
export function sampleSelectSql(driver: string | undefined, db: string, table: string) {
|
||||
/** limit:false 时不拼行数上限(树预览用,翻页由请求层 offset/limit 承担) */
|
||||
function buildSelectSql(driver: string | undefined, db: string, table: string, limit: number | false) {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "redis") return `GET ${table}`;
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
const ident = table.includes(".") ? table : `"${table}"`;
|
||||
return `SELECT * FROM ${ident} LIMIT 100;`;
|
||||
return `SELECT * FROM ${ident}${limit === false ? "" : ` LIMIT ${limit}`};`;
|
||||
}
|
||||
if (d === "sqlserver" || d === "mssql") {
|
||||
return `SELECT TOP 100 * FROM ${table};`;
|
||||
return `SELECT ${limit === false ? "" : `TOP ${limit} `}* FROM ${table};`;
|
||||
}
|
||||
if (d === "oracle") {
|
||||
return `SELECT * FROM ${table} FETCH FIRST 100 ROWS ONLY`;
|
||||
return `SELECT * FROM ${table}${limit === false ? "" : ` FETCH FIRST ${limit} ROWS ONLY`}`;
|
||||
}
|
||||
if (d === "sqlite") {
|
||||
return `SELECT * FROM "${table}" LIMIT 100;`;
|
||||
return `SELECT * FROM "${table}"${limit === false ? "" : ` LIMIT ${limit}`};`;
|
||||
}
|
||||
const tbl = table.includes(".") ? table : `\`${table}\``;
|
||||
if (db) return `SELECT * FROM \`${db}\`.${tbl} LIMIT 100;`;
|
||||
return `SELECT * FROM ${tbl} LIMIT 100;`;
|
||||
const lim = limit === false ? "" : ` LIMIT ${limit}`;
|
||||
if (db) return `SELECT * FROM \`${db}\`.${tbl}${lim};`;
|
||||
return `SELECT * FROM ${tbl}${lim};`;
|
||||
}
|
||||
|
||||
export function sampleSelectSql(driver: string | undefined, db: string, table: string) {
|
||||
return buildSelectSql(driver, db, table, 100);
|
||||
}
|
||||
|
||||
/** 树表预览 SQL:不带行数上限,翻页由请求层 offset/limit 完成(否则第 2 页起恒为空) */
|
||||
export function previewSelectSql(driver: string | undefined, db: string, table: string) {
|
||||
return buildSelectSql(driver, db, table, false);
|
||||
}
|
||||
|
||||
export function rowsToTSV(columns: string[], rows: Record<string, unknown>[], headers = true): string {
|
||||
@@ -138,9 +162,17 @@ export function rowsToCSV(columns: string[], rows: Record<string, unknown>[]): s
|
||||
return [columns.map(esc).join(","), ...rows.map((r) => columns.map((c) => esc(r[c])).join(","))].join("\n");
|
||||
}
|
||||
|
||||
export function rowsToInsert(table: string, columns: string[], rows: Record<string, unknown>[]): string {
|
||||
const ident = table || "t";
|
||||
const cols = columns.map((c) => `\`${c}\``).join(", ");
|
||||
/** R2-F2:表名/列名按方言 quoteIdent(修 01-P1-1 恒反引号);库.表 形式逐段加引号 */
|
||||
export function rowsToInsert(
|
||||
driver: string | undefined,
|
||||
table: string,
|
||||
columns: string[],
|
||||
rows: Record<string, unknown>[],
|
||||
): string {
|
||||
const ident = table
|
||||
? table.split(".").map((p) => quoteIdent(driver, p)).join(".")
|
||||
: "t";
|
||||
const cols = columns.map((c) => quoteIdent(driver, c)).join(", ");
|
||||
const lit = (v: unknown) => {
|
||||
if (v == null) return "NULL";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
@@ -192,7 +224,7 @@ export function buildUpdateSql(
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
const t = table.includes(".")
|
||||
? table
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase"].includes(d)
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d)
|
||||
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
|
||||
: quoteIdent(driver, table);
|
||||
const set = `${quoteIdent(driver, col)} = ${sqlLiteral(newVal === "" ? null : newVal)}`;
|
||||
@@ -216,22 +248,8 @@ export function supportsExplain(driver: string | undefined): boolean {
|
||||
].includes(d);
|
||||
}
|
||||
|
||||
/** Wrap a single SELECT (etc.) with EXPLAIN for the driver. */
|
||||
export function buildExplainSql(driver: string | undefined, sql: string): string | null {
|
||||
if (!supportsExplain(driver)) return null;
|
||||
const s = sql.trim().replace(/;+\s*$/, "");
|
||||
if (!s) return null;
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "sqlserver" || d === "mssql") {
|
||||
return `-- SQL Server: approximate plan via SET STATISTICS\nSET STATISTICS PROFILE ON;\n${s};\nSET STATISTICS PROFILE OFF;`;
|
||||
}
|
||||
if (d === "oracle") return `EXPLAIN PLAN FOR ${s}`;
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
return `EXPLAIN (FORMAT TEXT) ${s}`;
|
||||
}
|
||||
if (d === "sqlite") return `EXPLAIN QUERY PLAN ${s}`;
|
||||
return `EXPLAIN ${s}`;
|
||||
}
|
||||
// C3:方言 EXPLAIN 包装已下线 —— 改由服务端 POST /api/db/explain 按驱动完成
|
||||
// (MSSQL SHOWPLAN_XML 需钉单连接,Oracle 需查 DBMS_XPLAN,前端拼 SQL 拿不到)。
|
||||
|
||||
/** Map OpsxDB driver id → sql-formatter language. */
|
||||
export function sqlFormatterLanguage(driver?: string): string {
|
||||
@@ -293,7 +311,7 @@ export function buildDeleteSql(
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
const t = table.includes(".")
|
||||
? table
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase"].includes(d)
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d)
|
||||
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
|
||||
: quoteIdent(driver, table);
|
||||
const where = pkCols
|
||||
|
||||
+145
-6
@@ -3,6 +3,7 @@ import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
|
||||
import { decodeSecret, encodeSecret } from "./secrets";
|
||||
import { dropSchemaCache } from "./api";
|
||||
|
||||
export type SavedConnection = {
|
||||
id: string;
|
||||
@@ -33,6 +34,12 @@ export type SavedConnection = {
|
||||
/** Redis key 分隔符,默认 : */
|
||||
keySeparator?: string;
|
||||
redisMode?: "standalone" | "sentinel" | "cluster";
|
||||
/** Sentinel/Cluster 节点列表 host:port(多行) */
|
||||
redisNodes?: string;
|
||||
/** Sentinel 主节点名 */
|
||||
masterName?: string;
|
||||
/** Sentinel 密码(可空) */
|
||||
sentinelPassword?: string;
|
||||
/** Oracle */
|
||||
oracleConnType?: "basic";
|
||||
oracleIdKind?: "service" | "sid";
|
||||
@@ -76,6 +83,12 @@ export type QueryResult = {
|
||||
error?: string;
|
||||
truncated?: boolean;
|
||||
limit?: number;
|
||||
/** C5:服务端回传的分页偏移 */
|
||||
offset?: number;
|
||||
/** Redis 值游标分页:>0 还有更多(hash/set=SCAN 游标;list/zset=偏移) */
|
||||
nextCursor?: number;
|
||||
/** C1:命中危险语句,需确认后带 confirmDanger 重发 */
|
||||
needConfirm?: boolean;
|
||||
};
|
||||
|
||||
export type SchemaView = {
|
||||
@@ -90,6 +103,9 @@ export type ResultMeta = {
|
||||
database?: string;
|
||||
table?: string;
|
||||
pkCols?: string[];
|
||||
/** Redis 键详情:打开键时带类型/TTL,命令结果不带 */
|
||||
redisType?: string;
|
||||
redisTtl?: number;
|
||||
};
|
||||
|
||||
export type ConnState = "ok" | "down" | "reconnecting";
|
||||
@@ -99,6 +115,17 @@ export type ResultSetItem = {
|
||||
label: string;
|
||||
sql: string;
|
||||
result: QueryResult;
|
||||
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** 执行时使用的库(翻页重发需要) */
|
||||
database?: string;
|
||||
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
|
||||
viewState?: {
|
||||
colWidths?: Record<string, number>;
|
||||
selectedIdx?: number | null;
|
||||
selectedCol?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
@@ -119,8 +146,8 @@ export type ColumnInfo = {
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key";
|
||||
/** group id: tables|views|procedures|functions|keys */
|
||||
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key" | "folder";
|
||||
/** group id: tables|views|procedures|functions|keys|folder */
|
||||
groupId?: string;
|
||||
count?: number;
|
||||
children?: TreeNode[];
|
||||
@@ -129,6 +156,8 @@ export type TreeNode = {
|
||||
type?: string;
|
||||
/** Redis TTL seconds; -1 permanent; -2 missing */
|
||||
ttl?: number;
|
||||
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
|
||||
fullName?: string;
|
||||
nullable?: boolean;
|
||||
key?: string;
|
||||
};
|
||||
@@ -148,6 +177,8 @@ export type ConfirmRequest = {
|
||||
message: string;
|
||||
okLabel?: string;
|
||||
danger?: boolean;
|
||||
/** C6:将执行的语句清单(危险确认二段流展示,前 3 条 + 折叠其余) */
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type AiMessage = {
|
||||
@@ -181,6 +212,10 @@ type State = {
|
||||
bookmarks: Bookmark[];
|
||||
showBookmarks: boolean;
|
||||
connState: ConnState;
|
||||
/** 左栏底部统一搜索:非 Redis=过滤库/表;Redis=过滤键(回车服务端模糊扫描) */
|
||||
sidebarFilter: string;
|
||||
/** Redis 类型筛选 */
|
||||
sidebarTypeFilter: string;
|
||||
running: boolean;
|
||||
pickerSearch: string;
|
||||
pickerCategory: string;
|
||||
@@ -203,6 +238,16 @@ type State = {
|
||||
showHelp: boolean;
|
||||
showHistory: boolean;
|
||||
confirm: ConfirmRequest | null;
|
||||
/** R2-F1:多语句执行遇错即停(默认 true);false 时全部执行完再汇总 */
|
||||
stopOnError: boolean;
|
||||
/** R2-F8:语句执行超时(ms),0 = 不超时;持久化到 prefs */
|
||||
execTimeoutMs: number;
|
||||
/** 对象树最近聚焦的表/视图(编辑器右键「查看数据/DDL」用) */
|
||||
focusedRelation: { database: string; table: string } | null;
|
||||
/** 请求 ObjectTree 打开 DDL 弹层 */
|
||||
ddlRequest: { database: string; table: string; token: number } | null;
|
||||
/** 请求 ObjectTree 打开表数据预览 */
|
||||
viewDataRequest: { database: string; table: string; token: number } | null;
|
||||
|
||||
set: <K extends keyof State>(key: K, value: State[K]) => void;
|
||||
notify: (input: {
|
||||
@@ -223,6 +268,8 @@ type State = {
|
||||
removeConnection: (id: string) => void;
|
||||
setDefaultConnection: (id: string) => void;
|
||||
addConnGroup: (name: string) => string;
|
||||
/** R2-F5:导入连接时并入分组(冲突/缺失 id 重新生成);默认分组不重复创建 */
|
||||
importConnGroups: (groups: ConnectionGroup[]) => void;
|
||||
renameConnGroup: (id: string, name: string) => void;
|
||||
removeConnGroup: (id: string) => void;
|
||||
moveConnection: (id: string, groupId: string, index: number) => void;
|
||||
@@ -233,7 +280,15 @@ type State = {
|
||||
newQueryTab: () => void;
|
||||
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
|
||||
setActiveTabTitle: (title: string) => void;
|
||||
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
|
||||
setTabDatabase: (database: string) => void;
|
||||
closeTab: (id: string) => void;
|
||||
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
|
||||
closeOtherTabs: (id: string) => void;
|
||||
closeRightTabs: (id: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setStopOnError: (v: boolean) => void;
|
||||
setExecTimeoutMs: (v: number) => void;
|
||||
updateActiveSql: (sql: string) => void;
|
||||
pushAi: (role: AiMessage["role"], content: string) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
@@ -241,6 +296,8 @@ type State = {
|
||||
clearHistory: () => void;
|
||||
setResultSets: (items: ResultSetItem[]) => void;
|
||||
selectResultSet: (id: string) => void;
|
||||
/** C5/G3:局部更新某个结果集(翻页结果、视图持久化);若为激活项同步 result */
|
||||
updateResultSet: (id: string, patch: Partial<Omit<ResultSetItem, "id">>) => void;
|
||||
loadBookmarks: () => void;
|
||||
saveBookmark: (name: string, sql: string) => void;
|
||||
renameBookmark: (id: string, name: string) => void;
|
||||
@@ -260,7 +317,7 @@ const LS_BOOKMARKS = "opsxdb.bookmarks.v1";
|
||||
|
||||
let confirmResolver: ((ok: boolean) => void) | null = null;
|
||||
|
||||
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi">> {
|
||||
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi" | "stopOnError" | "execTimeoutMs">> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_PREFS);
|
||||
if (!raw) return {};
|
||||
@@ -271,6 +328,8 @@ function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "r
|
||||
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
|
||||
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
|
||||
showAi: typeof p.showAi === "boolean" ? p.showAi : undefined,
|
||||
stopOnError: typeof p.stopOnError === "boolean" ? p.stopOnError : undefined,
|
||||
execTimeoutMs: typeof p.execTimeoutMs === "number" ? Math.min(3_600_000, Math.max(0, p.execTimeoutMs)) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -348,8 +407,8 @@ function loadLs(): SavedConnection[] {
|
||||
}
|
||||
|
||||
function defaultTab(): QueryTab {
|
||||
const sql = "SELECT 1 AS ok;";
|
||||
return { id: crypto.randomUUID(), title: "查询 1", sql, savedSql: sql, dirty: false };
|
||||
// 新 Tab 默认空白(修 T3),不再预置 SELECT 1 AS ok;
|
||||
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
@@ -373,6 +432,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
bookmarks: [],
|
||||
showBookmarks: false,
|
||||
connState: "ok",
|
||||
sidebarFilter: "",
|
||||
sidebarTypeFilter: "all",
|
||||
running: false,
|
||||
pickerSearch: "",
|
||||
pickerCategory: "全部",
|
||||
@@ -395,10 +456,15 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
showHelp: false,
|
||||
showHistory: false,
|
||||
confirm: null,
|
||||
stopOnError: true,
|
||||
execTimeoutMs: 0,
|
||||
focusedRelation: null,
|
||||
ddlRequest: null,
|
||||
viewDataRequest: null,
|
||||
|
||||
set: (key, value) => {
|
||||
set({ [key]: value } as Partial<State>);
|
||||
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi") {
|
||||
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
|
||||
get().persistPrefs();
|
||||
}
|
||||
if (key === "tabs" || key === "activeTabId") {
|
||||
@@ -508,6 +574,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
|
||||
removeConnection: (id) => {
|
||||
get().saveConnections(get().connections.filter((c) => c.id !== id));
|
||||
dropSchemaCache(id);
|
||||
const sessions = { ...get().sessions };
|
||||
delete sessions[id];
|
||||
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
|
||||
@@ -531,6 +598,23 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
return id;
|
||||
},
|
||||
|
||||
/** R2-F5:导入连接时并入分组;与现有 id 冲突或缺失时重新生成,避免串组 */
|
||||
importConnGroups: (groups) => {
|
||||
if (!Array.isArray(groups) || !groups.length) return;
|
||||
const cur = get().connGroups;
|
||||
const ids = new Set(cur.map((g) => g.id));
|
||||
const merged = [...cur];
|
||||
for (const g of groups) {
|
||||
if (!g || typeof g.name !== "string" || !g.name.trim()) continue;
|
||||
if (g.id === DEFAULT_CONN_GROUP) continue;
|
||||
const item = { id: g.id && !ids.has(g.id) ? g.id : crypto.randomUUID(), name: g.name.trim() };
|
||||
ids.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
saveGroups(merged);
|
||||
set({ connGroups: merged });
|
||||
},
|
||||
|
||||
renameConnGroup: (id, name) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const n = name.trim();
|
||||
@@ -659,6 +743,13 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
setTabDatabase: (database) => {
|
||||
const s = get();
|
||||
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id !== id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
@@ -667,6 +758,42 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭其他」——只保留指定 Tab */
|
||||
closeOtherTabs: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id === id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[0].id;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭右侧」——保留指定 Tab 及其左侧全部 */
|
||||
closeRightTabs: (id) => {
|
||||
const idx = get().tabs.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
const next = get().tabs.slice(0, idx + 1);
|
||||
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[next.length - 1].id;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭全部」——回落一个空白 Tab(与 closeTab 语义一致) */
|
||||
closeAllTabs: () => {
|
||||
const next = [defaultTab()];
|
||||
set({ tabs: next, activeTabId: next[0].id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId: next[0].id }));
|
||||
},
|
||||
|
||||
setStopOnError: (v) => {
|
||||
set({ stopOnError: v });
|
||||
get().persistPrefs();
|
||||
},
|
||||
|
||||
setExecTimeoutMs: (v) => {
|
||||
set({ execTimeoutMs: Math.min(3_600_000, Math.max(0, v || 0)) });
|
||||
get().persistPrefs();
|
||||
},
|
||||
|
||||
updateActiveSql: (sql) => {
|
||||
const id = get().activeTabId;
|
||||
const tabs = get().tabs.map((t) => {
|
||||
@@ -733,6 +860,16 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
set({ activeResultSetId: id, result: item.result, resultTab: "data" });
|
||||
},
|
||||
|
||||
updateResultSet: (id, patch) => {
|
||||
const items = get().resultSets.map((x) => (x.id === id ? { ...x, ...patch } : x));
|
||||
const next: Partial<State> = { resultSets: items };
|
||||
if (get().activeResultSetId === id) {
|
||||
const item = items.find((x) => x.id === id);
|
||||
if (item) next.result = item.result;
|
||||
}
|
||||
set(next);
|
||||
},
|
||||
|
||||
loadBookmarks: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_BOOKMARKS);
|
||||
@@ -790,6 +927,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
aiWidth: s.aiWidth,
|
||||
resultRatio: s.resultRatio,
|
||||
showAi: s.showAi,
|
||||
stopOnError: s.stopOnError,
|
||||
execTimeoutMs: s.execTimeoutMs,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in new issue
Block a user