665 lines
27 KiB
TypeScript
665 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|