feat(db-ui): 修复金仓打开表报错并增强结果页编辑体验

- postgres/kingbase 查询前自动清洗 ""ident"" 形式的非法标识符
  - 结果表支持单元格弹窗编辑,提升 JSON/长文本可编辑性
  - 修复查询结果表头与数据列宽度不对齐问题
This commit is contained in:
Syngnat
2026-02-04 10:13:02 +08:00
parent 34c494ce51
commit 4a0db185c0
4 changed files with 383 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback }
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal } from 'antd';
import type { SortOrder } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { ImportData, ExportTable, ExportData, ApplyChanges } from '../../wailsjs/go/app/App';
import { useStore } from '../store';
import { v4 as uuidv4 } from 'uuid';
@@ -27,11 +28,38 @@ const formatCellValue = (val: any) => {
return String(val);
};
const toEditableText = (val: any): string => {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
try {
return JSON.stringify(val, null, 2);
} catch {
return String(val);
}
};
const looksLikeJsonText = (text: string): boolean => {
const raw = (text || '').trim();
if (!raw) return false;
const first = raw[0];
const last = raw[raw.length - 1];
return (first === '{' && last === '}') || (first === '[' && last === ']');
};
const shouldUseModalEditorForValue = (val: any): boolean => {
if (val === null || val === undefined) return false;
if (typeof val === 'object') return true;
const s = toEditableText(val);
if (s.includes('\n') || s.includes('\r')) return true;
if (s.length >= 160) return true;
return looksLikeJsonText(s);
};
// --- Resizable Header (Native Implementation) ---
const ResizableTitle = (props: any) => {
const { onResizeStart, width, ...restProps } = props;
if (!width) {
if (!width || typeof onResizeStart !== 'function') {
return <th {...restProps} />;
}
@@ -85,6 +113,7 @@ interface EditableCellProps {
dataIndex: string;
record: Item;
handleSave: (record: Item) => void;
openEditor?: (record: Item, dataIndex: string, title: React.ReactNode) => void;
[key: string]: any;
}
@@ -95,6 +124,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
dataIndex,
record,
handleSave,
openEditor,
...restProps
}) => {
const [editing, setEditing] = useState(false);
@@ -139,7 +169,16 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
);
}
return <td {...restProps} onDoubleClick={editable ? toggleEdit : undefined}>{childNode}</td>;
const handleDoubleClick = () => {
if (!editable) return;
if (openEditor && shouldUseModalEditorForValue(record?.[dataIndex])) {
openEditor(record, dataIndex, title);
return;
}
toggleEdit();
};
return <td {...restProps} onDoubleClick={editable ? handleDoubleClick : undefined}>{childNode}</td>;
});
const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
@@ -221,9 +260,15 @@ const DataGrid: React.FC<DataGridProps> = ({
}) => {
const { connections } = useStore();
const addSqlLog = useStore(state => state.addSqlLog);
const darkMode = useStore(state => state.darkMode);
const selectionColumnWidth = 46;
const [form] = Form.useForm();
const [modal, contextHolder] = Modal.useModal();
const gridId = useMemo(() => `grid-${uuidv4()}`, []);
const [cellEditorOpen, setCellEditorOpen] = useState(false);
const [cellEditorValue, setCellEditorValue] = useState('');
const [cellEditorIsJson, setCellEditorIsJson] = useState(false);
const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null);
// Helper to export specific data
const exportData = async (rows: any[], format: string) => {
@@ -237,6 +282,26 @@ const DataGrid: React.FC<DataGridProps> = ({
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const closeCellEditor = useCallback(() => {
setCellEditorOpen(false);
setCellEditorMeta(null);
setCellEditorValue('');
setCellEditorIsJson(false);
}, []);
const openCellEditor = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
const isJson = looksLikeJsonText(text);
const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex));
setCellEditorMeta({ record, dataIndex, title: titleText });
setCellEditorValue(text);
setCellEditorIsJson(isJson);
setCellEditorOpen(true);
}, []);
// Dynamic Height
const [tableHeight, setTableHeight] = useState(500);
@@ -452,6 +517,23 @@ const DataGrid: React.FC<DataGridProps> = ({
}
}, [addedRows]);
const handleCellEditorSave = useCallback(() => {
if (!cellEditorMeta) return;
const nextRow: any = { ...cellEditorMeta.record, [cellEditorMeta.dataIndex]: cellEditorValue };
handleCellSave(nextRow);
closeCellEditor();
}, [cellEditorMeta, cellEditorValue, handleCellSave, closeCellEditor]);
const handleFormatJsonInEditor = useCallback(() => {
if (!cellEditorIsJson) return;
try {
const obj = JSON.parse(cellEditorValue);
setCellEditorValue(JSON.stringify(obj, null, 2));
} catch (e: any) {
message.error("JSON 格式无效:" + (e?.message || String(e)));
}
}, [cellEditorIsJson, cellEditorValue]);
// Merge Data for Display
// 'displayData' already merges addedRows.
// We need to merge modifiedRows into it for rendering.
@@ -493,9 +575,10 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex: col.dataIndex,
title: col.title,
handleSave: handleCellSave,
openEditor: openCellEditor,
}),
};
}), [columns, handleCellSave]);
}), [columns, handleCellSave, openCellEditor]);
const handleAddRow = () => {
const newKey = `new-${Date.now()}`;
@@ -735,7 +818,7 @@ const DataGrid: React.FC<DataGridProps> = ({
header: { cell: ResizableTitle }
}), []);
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
const enableVirtual = mergedDisplayData.length >= 200;
return (
@@ -803,6 +886,42 @@ const DataGrid: React.FC<DataGridProps> = ({
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0 }}>
{contextHolder}
<Modal
title={cellEditorMeta ? `编辑单元格:${cellEditorMeta.title}` : '编辑单元格'}
open={cellEditorOpen}
onCancel={closeCellEditor}
width={960}
destroyOnClose
maskClosable={false}
footer={[
<Button key="format" onClick={handleFormatJsonInEditor} disabled={!cellEditorIsJson}>
JSON
</Button>,
<Button key="cancel" onClick={closeCellEditor}></Button>,
<Button key="ok" type="primary" onClick={handleCellEditorSave}></Button>,
]}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
{cellEditorMeta ? `${tableName || ''}${tableName ? '.' : ''}${cellEditorMeta.dataIndex}` : ''}
</div>
{cellEditorOpen && (
<Editor
height="56vh"
language={cellEditorIsJson ? "json" : "plaintext"}
theme={darkMode ? "vs-dark" : "light"}
value={cellEditorValue}
onChange={(val) => setCellEditorValue(val || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
fontSize: 14,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
</Modal>
<Form component={false} form={form}>
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
<EditableContext.Provider value={form}>
@@ -811,6 +930,7 @@ const DataGrid: React.FC<DataGridProps> = ({
dataSource={mergedDisplayData}
columns={mergedColumns}
size="small"
tableLayout="fixed"
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
virtual={enableVirtual}
loading={loading}
@@ -821,6 +941,7 @@ const DataGrid: React.FC<DataGridProps> = ({
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
columnWidth: selectionColumnWidth,
}}
rowClassName={(record) => {
const k = record?.[GONAVI_ROW_KEY];
@@ -857,9 +978,6 @@ const DataGrid: React.FC<DataGridProps> = ({
<style>{`
.${gridId} .row-added td { background-color: #f6ffed !important; }
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
.${gridId} .ant-table-body {
max-height: ${tableHeight}px !important;
}
`}</style>
{/* Ghost Resize Line for Columns */}

View File

@@ -91,6 +91,8 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
return connection.QueryResult{Success: false, Message: err.Error()}
}
query = sanitizeSQLForPgLike(runConfig.Type, query)
lowerQuery := strings.TrimSpace(strings.ToLower(query))
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
data, columns, err := dbInst.Query(query)

View File

@@ -0,0 +1,219 @@
package app
import (
"strings"
"unicode"
)
func sanitizeSQLForPgLike(dbType string, query string) string {
switch strings.ToLower(strings.TrimSpace(dbType)) {
case "postgres", "kingbase":
return fixBrokenDoubleDoubleQuotedIdent(query)
default:
return query
}
}
// fixBrokenDoubleDoubleQuotedIdent fixes accidental identifiers like:
// SELECT * FROM ""schema"".""table""
// which can be produced when a quoted identifier gets wrapped by quotes again.
//
// It is intentionally conservative:
// - only runs outside strings/comments/dollar-quoted blocks
// - does not touch valid escaped-quote sequences inside quoted identifiers (e.g. "a""b")
func fixBrokenDoubleDoubleQuotedIdent(query string) string {
if !strings.Contains(query, `""`) {
return query
}
var b strings.Builder
b.Grow(len(query))
inSingle := false
inDoubleIdent := false
inLineComment := false
inBlockComment := false
dollarTag := ""
for i := 0; i < len(query); i++ {
ch := query[i]
next := byte(0)
if i+1 < len(query) {
next = query[i+1]
}
if inLineComment {
b.WriteByte(ch)
if ch == '\n' {
inLineComment = false
}
continue
}
if inBlockComment {
b.WriteByte(ch)
if ch == '*' && next == '/' {
b.WriteByte('/')
i++
inBlockComment = false
}
continue
}
if dollarTag != "" {
if strings.HasPrefix(query[i:], dollarTag) {
b.WriteString(dollarTag)
i += len(dollarTag) - 1
dollarTag = ""
continue
}
b.WriteByte(ch)
continue
}
if inSingle {
b.WriteByte(ch)
if ch == '\'' {
// escaped single quote
if next == '\'' {
b.WriteByte('\'')
i++
continue
}
inSingle = false
}
continue
}
if inDoubleIdent {
b.WriteByte(ch)
if ch == '"' {
// escaped quote inside identifier
if next == '"' {
b.WriteByte('"')
i++
continue
}
inDoubleIdent = false
}
continue
}
// --- Outside of all string/comment blocks ---
if ch == '-' && next == '-' {
b.WriteByte(ch)
b.WriteByte('-')
i++
inLineComment = true
continue
}
if ch == '/' && next == '*' {
b.WriteByte(ch)
b.WriteByte('*')
i++
inBlockComment = true
continue
}
if ch == '\'' {
b.WriteByte(ch)
inSingle = true
continue
}
if ch == '$' {
if tag := parseDollarTag(query[i:]); tag != "" {
b.WriteString(tag)
i += len(tag) - 1
dollarTag = tag
continue
}
}
// Fix: ""ident"" -> "ident" (only when it looks like a plain identifier)
if ch == '"' && next == '"' {
prevIsQuote := i > 0 && query[i-1] == '"'
nextIsQuote := i+2 < len(query) && query[i+2] == '"'
if !prevIsQuote && !nextIsQuote {
if replacement, advance, ok := tryFixDoubleDoubleQuotedIdent(query, i); ok {
b.WriteString(replacement)
i = advance - 1
continue
}
}
}
if ch == '"' {
b.WriteByte(ch)
inDoubleIdent = true
continue
}
b.WriteByte(ch)
}
return b.String()
}
func tryFixDoubleDoubleQuotedIdent(query string, start int) (replacement string, advance int, ok bool) {
// start points at the first quote of `""...""`
if start < 0 || start+1 >= len(query) {
return "", 0, false
}
if query[start] != '"' || query[start+1] != '"' {
return "", 0, false
}
if start > 0 && query[start-1] == '"' {
return "", 0, false
}
if start+2 < len(query) && query[start+2] == '"' {
return "", 0, false
}
contentStart := start + 2
j := contentStart
for j+1 < len(query) {
if query[j] == '"' && query[j+1] == '"' {
// ensure closing pair is not part of a triple quote
if j+2 < len(query) && query[j+2] == '"' {
j++
continue
}
content := strings.TrimSpace(query[contentStart:j])
if looksLikeIdentifierContent(content) {
return `"` + content + `"`, j + 2, true
}
return "", 0, false
}
// Fast abort: identifier-like content should not span lines.
if query[j] == '\n' || query[j] == '\r' {
break
}
j++
}
return "", 0, false
}
func looksLikeIdentifierContent(s string) bool {
if strings.TrimSpace(s) == "" {
return false
}
for _, r := range s {
if r == '_' || r == '$' || r == '-' || unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
return false
}
return true
}
func parseDollarTag(s string) string {
// Match: $tag$ where tag is [A-Za-z0-9_]* (can be empty => $$)
if len(s) < 2 || s[0] != '$' {
return ""
}
for i := 1; i < len(s); i++ {
c := s[i]
if c == '$' {
return s[:i+1]
}
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
return ""
}
}
return ""
}

View File

@@ -0,0 +1,37 @@
package app
import "testing"
func TestSanitizeSQLForPgLike_FixesBrokenDoubleDoubleQuotes(t *testing.T) {
in := `SELECT * FROM ""ldf_server"".""t_user"" LIMIT 1`
out := sanitizeSQLForPgLike("kingbase", in)
want := `SELECT * FROM "ldf_server"."t_user" LIMIT 1`
if out != want {
t.Fatalf("unexpected sanitize output:\nIN: %s\nOUT: %s\nWANT: %s", in, out, want)
}
}
func TestSanitizeSQLForPgLike_DoesNotTouchEscapedQuotesInsideIdentifier(t *testing.T) {
in := `SELECT "a""b" FROM "t""x"`
out := sanitizeSQLForPgLike("postgres", in)
if out != in {
t.Fatalf("should keep valid escaped quotes inside identifier:\nIN: %s\nOUT: %s", in, out)
}
}
func TestSanitizeSQLForPgLike_DoesNotTouchDollarQuotedStrings(t *testing.T) {
in := "SELECT $$\"\"ldf_server\"\"$$, \"\"ldf_server\"\""
out := sanitizeSQLForPgLike("postgres", in)
want := "SELECT $$\"\"ldf_server\"\"$$, \"ldf_server\""
if out != want {
t.Fatalf("unexpected sanitize output for dollar quoted string:\nIN: %s\nOUT: %s\nWANT: %s", in, out, want)
}
}
func TestSanitizeSQLForPgLike_DoesNotModifyOtherDBTypes(t *testing.T) {
in := `SELECT * FROM ""ldf_server""`
out := sanitizeSQLForPgLike("mysql", in)
if out != in {
t.Fatalf("non-PG-like db should not be sanitized:\nIN: %s\nOUT: %s", in, out)
}
}