mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
✨ feat(db-ui): 修复金仓打开表报错并增强结果页编辑体验
- postgres/kingbase 查询前自动清洗 ""ident"" 形式的非法标识符 - 结果表支持单元格弹窗编辑,提升 JSON/长文本可编辑性 - 修复查询结果表头与数据列宽度不对齐问题
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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)
|
||||
|
||||
219
internal/app/sql_sanitize.go
Normal file
219
internal/app/sql_sanitize.go
Normal 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 ""
|
||||
}
|
||||
37
internal/app/sql_sanitize_test.go
Normal file
37
internal/app/sql_sanitize_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user