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

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

No files matched your search

+664
View File
@@ -0,0 +1,664 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type KeyboardEvent, type ClipboardEvent } from "react";
import { execSql } from "./api";
import { ensureSession } from "./ObjectTree";
import { rowsToCSV, rowsToInsert, rowsToTSV, buildUpdateSql, buildDeleteSql, parseClipboardGrid, isWritableDriver } from "./sql";
import { SchemaPane } from "./SchemaPane";
import { useWorkspaceStore } from "./store";
const DISPLAY_CAP = 2000;
function formatCell(v: unknown) {
if (v == null) return <span className="ops-null">NULL</span>;
if (typeof v === "object") return JSON.stringify(v);
const s = String(v);
if (s.length > 120) return s.slice(0, 120) + "…";
return s;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}
function cmpVal(a: unknown, b: unknown): number {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === "number" && typeof b === "number") return a - b;
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb) && String(a).trim() !== "" && String(b).trim() !== "") {
return na - nb;
}
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
}
export function ResultPane({ tableHint }: { tableHint?: string }) {
const result = useWorkspaceStore((s) => s.result);
const resultSets = useWorkspaceStore((s) => s.resultSets);
const activeResultSetId = useWorkspaceStore((s) => s.activeResultSetId);
const resultTab = useWorkspaceStore((s) => s.resultTab);
const explainText = useWorkspaceStore((s) => s.explainText);
const resultMeta = useWorkspaceStore((s) => s.resultMeta);
const connections = useWorkspaceStore((s) => s.connections);
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const set = useWorkspaceStore((s) => s.set);
const newQueryTab = useWorkspaceStore((s) => s.newQueryTab);
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
const conn = connections.find((c) => c.id === activeConnectionId);
const writable = isWritableDriver(conn?.driver);
const canEdit = writable && !!resultMeta?.table && (resultMeta.pkCols?.length ?? 0) > 0;
const [sortCol, setSortCol] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc" | null>(null);
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
const [selectedCol, setSelectedCol] = useState<string | null>(null);
const [edit, setEdit] = useState<{ row: number; col: string; value: string; original: string } | null>(null);
const dragRef = useRef<{ col: string; startX: number; startW: number } | null>(null);
const editRef = useRef(edit);
editRef.current = edit;
useEffect(() => {
setSortCol(null);
setSortDir(null);
setSelectedIdx(null);
setSelectedCol(null);
setColWidths({});
setEdit(null);
}, [result]);
// discard pending edit when switching tabs
useEffect(() => {
if (resultTab !== "data" && editRef.current) {
notify({ kind: "info", title: "已丢弃未提交编辑", message: "切换标签时未保存的单元格改动已丢弃。", mode: "toast", durationMs: 1800 });
setEdit(null);
}
}, [resultTab, notify]);
const sortedRows = useMemo(() => {
if (!result || result.error) return [];
let rows = result.rows;
if (sortCol && sortDir) {
rows = [...rows].sort((ra, rb) => {
const c = cmpVal(ra[sortCol], rb[sortCol]);
return sortDir === "asc" ? c : -c;
});
}
return rows;
}, [result, sortCol, sortDir]);
const truncatedLocal = sortedRows.length > DISPLAY_CAP;
const visibleRows = truncatedLocal ? sortedRows.slice(0, DISPLAY_CAP) : sortedRows;
useEffect(() => {
if (!result || result.error) return;
const parts: string[] = [];
parts.push(`${result.rowCount} 行`);
if (result.timeMs != null) parts.push(`${result.timeMs} ms`);
if (selectedIdx != null) parts.push(`选中第 ${selectedIdx + 1} 行`);
if (result.truncated || truncatedLocal) parts.push("已截断");
set("status", parts.join(" · "));
}, [result, selectedIdx, truncatedLocal, set]);
const cycleSort = (col: string) => {
if (sortCol !== col) {
setSortCol(col);
setSortDir("asc");
return;
}
if (sortDir === "asc") setSortDir("desc");
else if (sortDir === "desc") {
setSortCol(null);
setSortDir(null);
} else setSortDir("asc");
};
const onResizeStart = (col: string, e: ReactMouseEvent) => {
e.preventDefault();
e.stopPropagation();
const startW = colWidths[col] ?? 140;
dragRef.current = { col, startX: e.clientX, startW };
const move = (ev: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const w = Math.max(64, Math.min(480, d.startW + (ev.clientX - d.startX)));
setColWidths((prev) => ({ ...prev, [d.col]: w }));
};
const up = () => {
dragRef.current = null;
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
};
const beginEdit = (rowIdx: number, col: string) => {
if (!result || result.error) return;
if (!writable) {
notify({ kind: "warning", title: "只读", message: "当前引擎不支持结果内联写回。", mode: "toast" });
return;
}
if (!resultMeta?.table) {
notify({ kind: "warning", title: "无法编辑", message: "请从对象树打开表后再编辑(需表上下文)。", mode: "toast" });
return;
}
if (!(resultMeta.pkCols?.length ?? 0)) {
notify({ kind: "warning", title: "无法编辑", message: "未识别主键,暂不支持写回。", mode: "toast" });
return;
}
if (resultMeta.pkCols!.includes(col)) {
notify({ kind: "warning", title: "主键", message: "暂不支持直接改主键列。", mode: "toast" });
return;
}
const row = visibleRows[rowIdx];
const v = row?.[col];
const s = v == null ? "" : String(v);
setEdit({ row: rowIdx, col, value: s, original: s });
};
const commitEdit = async () => {
if (!edit || !result || !conn || !resultMeta?.table) {
setEdit(null);
return;
}
const row = visibleRows[edit.row];
if (!row) {
setEdit(null);
return;
}
const oldVal = row[edit.col];
const newRaw = edit.value;
const newVal: unknown = newRaw === "" ? null : newRaw;
if (String(oldVal ?? "") === String(newVal ?? "")) {
setEdit(null);
return;
}
const sql = buildUpdateSql(conn.driver, resultMeta.database, resultMeta.table, resultMeta.pkCols || [], row, edit.col, newVal);
if (!sql) {
notify({ kind: "warning", title: "无法写回", message: "缺少主键,无法生成 UPDATE。", mode: "toast" });
setEdit(null);
return;
}
const prev = edit;
setEdit(null);
// optimistic
const nextRows = result.rows.map((r, i) => {
// map via visible index carefully — use identity on sorted view
if (visibleRows[prev.row] !== r && sortedRows[prev.row] !== r) return r;
// find by reference in original
return r === row ? { ...r, [prev.col]: newVal } : r;
});
// safer: update by matching PK
const pk = resultMeta.pkCols || [];
const patched = result.rows.map((r) => {
const same = pk.every((k) => String(r[k] ?? "") === String(row[k] ?? ""));
return same ? { ...r, [prev.col]: newVal } : r;
});
set("result", { ...result, rows: patched });
try {
const sid = await ensureSession(conn);
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
if (res.error) throw new Error(res.error);
set("connState", "ok");
notify({ kind: "success", title: "已写回", message: `${prev.col} 已更新`, mode: "toast", durationMs: 1400 });
} catch (e) {
// rollback
set("result", { ...result, rows: result.rows });
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
notifyError(e, "modal");
}
};
const cancelEdit = () => setEdit(null);
const pasteGrid = async (e: ClipboardEvent) => {
if (edit) return; // let input handle paste
if (!result || result.error) return;
if (!canEdit) {
e.preventDefault();
notify({ kind: "warning", title: "只读", message: "当前结果不可写回,无法粘贴。", mode: "toast" });
return;
}
if (!conn || !resultMeta?.table) return;
const text = e.clipboardData?.getData("text/plain") ?? "";
if (!text) return;
e.preventDefault();
const matrix = parseClipboardGrid(text);
if (!matrix.length) return;
const pk = resultMeta.pkCols || [];
const cols = result.columns;
const startRow = selectedIdx ?? 0;
let startColName = selectedCol;
if (!startColName || !cols.includes(startColName)) {
startColName = cols.find((c) => !pk.includes(c)) ?? cols[0] ?? null;
}
if (!startColName) return;
let startColIdx = cols.indexOf(startColName);
if (startColIdx < 0) startColIdx = 0;
type CellChange = { row: Record<string, unknown>; col: string; newVal: unknown; oldVal: unknown };
const changes: CellChange[] = [];
for (let ri = 0; ri < matrix.length; ri++) {
const rowIdx = startRow + ri;
if (rowIdx >= visibleRows.length) break;
const row = visibleRows[rowIdx];
if (!row) break;
const line = matrix[ri];
let colCursor = startColIdx;
for (let ci = 0; ci < line.length; ci++) {
while (colCursor < cols.length && pk.includes(cols[colCursor])) colCursor++;
if (colCursor >= cols.length) break;
const col = cols[colCursor];
const cell = line[ci];
// empty → null (match commitEdit); literal NULL already null from parser
const newVal: unknown = cell == null || cell === "" ? null : cell;
const oldVal = row[col];
if (String(oldVal ?? "") !== String(newVal ?? "")) {
changes.push({ row, col, newVal, oldVal });
}
colCursor++;
}
}
if (!changes.length) {
notify({ kind: "info", title: "粘贴", message: "没有可写入的单元格(可能全是主键或未越界)。", mode: "toast", durationMs: 1600 });
return;
}
const snapshotRows = result.rows.map((r) => ({ ...r }));
// optimistic batch
let patched = result.rows;
for (const ch of changes) {
patched = patched.map((r) => {
const same = pk.every((k) => String(r[k] ?? "") === String(ch.row[k] ?? ""));
return same ? { ...r, [ch.col]: ch.newVal } : r;
});
}
set("result", { ...result, rows: patched });
try {
const sid = await ensureSession(conn);
for (const ch of changes) {
const sql = buildUpdateSql(conn.driver, resultMeta.database, resultMeta.table, pk, ch.row, ch.col, ch.newVal);
if (!sql) throw new Error("缺少主键,无法生成 UPDATE");
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
if (res.error) throw new Error(res.error);
}
set("connState", "ok");
notify({ kind: "success", title: "已粘贴", message: `已粘贴 ${changes.length} 格`, mode: "toast", durationMs: 1600 });
} catch (err) {
set("result", { ...result, rows: snapshotRows });
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
notifyError(err, "modal");
}
};
const deleteSelectedRow = async () => {
if (!canEdit || selectedIdx == null || !result || result.error || !conn || !resultMeta?.table) return;
const row = visibleRows[selectedIdx];
if (!row) return;
const n = selectedIdx + 1;
const ok = await askConfirm({
title: "删除行",
message: `确认删除第 ${n} 行?不可恢复。`,
okLabel: "删除",
});
if (!ok) return;
const pk = resultMeta.pkCols || [];
const sql = buildDeleteSql(conn.driver, resultMeta.database, resultMeta.table, pk, row);
if (!sql) {
notify({ kind: "warning", title: "无法删除", message: "缺少主键,无法生成 DELETE。", mode: "toast" });
return;
}
const snapshot = result.rows;
const snapshotCount = result.rowCount;
const filtered = result.rows.filter((r) => !pk.every((k) => String(r[k] ?? "") === String(row[k] ?? "")));
set("result", { ...result, rows: filtered, rowCount: Math.max(0, (result.rowCount ?? filtered.length) - 1) });
setSelectedIdx(null);
setSelectedCol(null);
try {
const sid = await ensureSession(conn);
const res = await execSql(conn, sql, { database: resultMeta.database, sessionId: sid });
if (res.error) throw new Error(res.error);
set("connState", "ok");
notify({ kind: "success", title: "已删除", message: `第 ${n} 行已删除`, mode: "toast", durationMs: 1400 });
} catch (err) {
set("result", { ...result, rows: snapshot, rowCount: snapshotCount });
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
notifyError(err, "modal");
}
};
const onGridKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (edit) return;
if ((e.key === "Delete" || e.key === "Backspace") && canEdit && selectedIdx != null) {
const t = e.target as HTMLElement;
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
e.preventDefault();
void deleteSelectedRow();
}
};
const onEditKey = (e: KeyboardEvent<HTMLInputElement>) => {
// 未提交前 ⌘/Ctrl+Z:恢复进入编辑时的值(不提交、不关编辑)
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (!edit) return;
if (edit.value === edit.original) return;
setEdit({ ...edit, value: edit.original });
return;
}
if (e.key === "Enter") {
e.preventDefault();
void commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
};
const copy = async (kind: "tsv" | "csv" | "insert") => {
if (!result || result.error) return;
const text =
kind === "csv"
? rowsToCSV(result.columns, result.rows)
: kind === "insert"
? rowsToInsert(resultMeta?.table || tableHint || "t", result.columns, result.rows)
: rowsToTSV(result.columns, result.rows);
await copyText(text);
notify({ kind: "success", title: "已复制", message: kind === "insert" ? "INSERT 语句" : kind.toUpperCase(), mode: "toast" });
};
const downloadCsv = () => {
if (!result || result.error) return;
const blob = new Blob(["\ufeff" + rowsToCSV(result.columns, result.rows)], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `opsxdb-${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(a.href);
};
const emptyResult = result && !result.error && result.rows.length === 0;
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="ops-section-label justify-between border-b gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto normal-case tracking-normal">
<button
type="button"
className={resultTab === "data" ? "ops-tab-active" : "ops-tab"}
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
onClick={() => {
if (edit) {
void askConfirm({ title: "未提交编辑", message: "切换将丢弃未提交的单元格改动。", okLabel: "丢弃并切换" }).then((ok) => {
if (ok) {
setEdit(null);
set("resultTab", "data");
}
});
return;
}
set("resultTab", "data");
}}
>
数据
</button>
<button
type="button"
className={resultTab === "schema" ? "ops-tab-active" : "ops-tab"}
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
onClick={() => set("resultTab", "schema")}
>
结构
</button>
<button
type="button"
className={resultTab === "explain" ? "ops-tab-active" : "ops-tab"}
style={{ borderBottomWidth: 2, padding: "2px 6px", height: 28 }}
onClick={() => set("resultTab", "explain")}
>
计划
</button>
{resultSets.length > 1 && (
<>
<span className="mx-1 opacity-30">|</span>
{resultSets.map((rs) => (
<button
key={rs.id}
type="button"
className={activeResultSetId === rs.id && resultTab === "data" ? "ops-tab-active" : "ops-tab"}
style={{
borderBottomWidth: 2,
padding: "2px 6px",
height: 28,
color: rs.result.error ? "var(--ops-danger)" : undefined,
}}
title={rs.sql.slice(0, 120)}
onClick={() => {
useWorkspaceStore.getState().selectResultSet(rs.id);
}}
>
{rs.label}
{rs.result.error ? " !" : ""}
</button>
))}
</>
)}
</div>
{resultTab === "data" && result && !result.error && (
<span className="flex items-center gap-2 font-normal normal-case tracking-normal">
<span>
{result.rowCount} 行 · {result.timeMs} ms
{selectedIdx != null ? ` · #${selectedIdx + 1}` : ""}
{canEdit ? "" : writable ? "" : " · 只读"}
</span>
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy("tsv")}>
复制全部
</button>
<button
type="button"
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
disabled={selectedIdx == null}
onClick={() => {
if (selectedIdx == null || !result) return;
const row = visibleRows[selectedIdx];
if (!row) return;
void copyText(result.columns.map((c) => (row[c] == null ? "" : String(row[c]))).join("\t")).then(() =>
notify({ kind: "success", title: "已复制行", message: `#${selectedIdx + 1}`, mode: "toast", durationMs: 1200 }),
);
}}
>
复制行
</button>
<button
type="button"
className="hover:underline"
style={{ color: canEdit && selectedIdx != null ? "var(--ops-danger)" : "var(--ops-accent)", opacity: canEdit && selectedIdx != null ? 1 : 0.45 }}
disabled={!canEdit || selectedIdx == null}
onClick={() => void deleteSelectedRow()}
>
删除行
</button>
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={downloadCsv}>
CSV
</button>
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy("insert")}>
INSERT
</button>
</span>
)}
</div>
{resultTab === "schema" ? (
<SchemaPane />
) : resultTab === "explain" ? (
<div className="min-h-0 flex-1 overflow-auto p-2">
{!explainText ? (
<div className="ops-empty" style={{ minHeight: 100 }}>
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>执行计划</h2>
<p>点工具栏「执行计划」获取(部分引擎可用)。</p>
</div>
) : (
<pre
className="m-0 whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed"
style={{ color: "var(--ops-text)" }}
>
{explainText}
</pre>
)}
</div>
) : (
<>
{(result?.truncated || truncatedLocal) && (
<div
className="shrink-0 border-b px-2 py-1 text-[11px]"
style={{
borderColor: "color-mix(in srgb, var(--ops-warn) 40%, var(--ops-border))",
background: "color-mix(in srgb, var(--ops-warn) 10%, transparent)",
color: "var(--ops-warn)",
}}
>
已截断:界面最多显示 {result?.limit || DISPLAY_CAP} 行
{truncatedLocal && sortedRows.length > DISPLAY_CAP ? `(共 ${sortedRows.length})` : ""}。加 LIMIT 或导出 CSV。
</div>
)}
<div className="min-h-0 flex-1 overflow-auto">
{result?.error && (
<div
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
style={{
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
color: "var(--ops-danger)",
}}
>
{result.error}
</div>
)}
{!result && (
<div className="ops-empty" style={{ minHeight: 120 }}>
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>还没有结果</h2>
<p>写一条 SQL,⌘/Ctrl+Enter 运行;或点左侧表/键预览数据。</p>
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
去写查询
</button>
</div>
)}
{emptyResult && (
<div className="ops-empty" style={{ minHeight: 120 }}>
<h2 style={{ fontSize: "var(--ops-font-sm)" }}>空结果</h2>
<p>查询成功,但没有返回行。</p>
<button type="button" className="ops-btn-secondary" onClick={() => newQueryTab()}>
去写查询
</button>
</div>
)}
{result && !result.error && result.rows.length > 0 && (
<div
className="outline-none"
tabIndex={0}
onPaste={(e) => void pasteGrid(e)}
onKeyDown={onGridKeyDown}
>
<table className="ops-grid" style={{ tableLayout: "fixed", width: "max-content", minWidth: "100%" }}>
<thead>
<tr>
<th className="ops-rownum" style={{ width: 40 }}>
#
</th>
{result.columns.map((c) => {
const w = colWidths[c] ?? 140;
const mark = sortCol === c ? (sortDir === "asc" ? " ↑" : sortDir === "desc" ? " ↓" : "") : "";
return (
<th
key={c}
className="ops-sortable"
style={{ width: w, minWidth: w, maxWidth: w }}
onClick={() => cycleSort(c)}
title="点击排序:升 → 降 → 取消"
>
{c}
{mark}
<span className="ops-col-resize" onMouseDown={(e) => onResizeStart(c, e)} />
</th>
);
})}
</tr>
</thead>
<tbody>
{visibleRows.map((row, i) => (
<tr
key={i}
className={selectedIdx === i ? "ops-row-selected" : undefined}
onClick={() => setSelectedIdx(i)}
>
<td className="ops-rownum">{i + 1}</td>
{result.columns.map((c) => {
const editing = edit && edit.row === i && edit.col === c;
const cellSelected = selectedIdx === i && selectedCol === c;
return (
<td
key={c}
className={cellSelected ? "ops-cell-selected" : undefined}
style={{
width: colWidths[c] ?? 140,
maxWidth: colWidths[c] ?? 140,
overflow: "hidden",
textOverflow: "ellipsis",
outline: cellSelected ? "1px solid var(--ops-accent)" : undefined,
outlineOffset: cellSelected ? -1 : undefined,
}}
title={row[c] == null ? "NULL" : String(row[c])}
onClick={(e) => {
e.stopPropagation();
setSelectedIdx(i);
setSelectedCol(c);
}}
onDoubleClick={() => beginEdit(i, c)}
>
{editing ? (
<input
autoFocus
className="ops-input w-full py-0"
style={{ boxShadow: "0 0 0 1px var(--ops-accent)" }}
value={edit!.value}
onChange={(e) => setEdit({ ...edit!, value: e.target.value })}
onBlur={() => void commitEdit()}
onKeyDown={onEditKey}
onClick={(e) => e.stopPropagation()}
/>
) : (
formatCell(row[c])
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</div>
);
}