feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
commit
f8740d27f7
240 files changed
+21625
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user