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

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

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

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

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

No files matched your search

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