Files
opsxdb/packages/ui/src/ResultPane.tsx
T
logan f8740d27f7 feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
2026-09-19 22:43:45 +08:00

665 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}