From 7f4feef3d6372e9534c6a48bd15b11f736585ace Mon Sep 17 00:00:00 2001 From: mango <1711456624@qq.com> Date: Sat, 25 Jul 2026 21:02:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(table-designer):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=96=E9=83=A8=20SQL=20=E6=81=A2=E5=A4=8D=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AD=97=E6=AE=B5=E5=A4=8D=E5=88=B6=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QueryEditor.external-sql-save.test.tsx | 42 ++++++++ frontend/src/components/QueryEditor.tsx | 13 ++- frontend/src/components/TableDesigner.tsx | 29 +++++ .../tableDesignerColumnClipboard.test.ts | 64 +++++++++++ .../tableDesignerColumnClipboard.ts | 100 ++++++++++++++++++ 5 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/tableDesignerColumnClipboard.test.ts create mode 100644 frontend/src/components/tableDesignerColumnClipboard.ts diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 018bd074..785ebf79 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -97,6 +97,7 @@ const storeState = vi.hoisted(() => ({ }, }, activeTabId: 'tab-1', + tabs: [] as TabData[], aiPanelVisible: false, setAIPanelVisible: vi.fn(), sqlSnippets: [] as any[], @@ -792,6 +793,7 @@ describe('QueryEditor external SQL save', () => { storeState.saveQuery.mockImplementation(async (query: SavedQuery) => query); storeState.savedQueries = []; storeState.activeTabId = 'tab-1'; + storeState.tabs = []; storeState.aiPanelVisible = false; storeState.setAIPanelVisible.mockReset(); storeState.appearance.uiVersion = 'legacy'; @@ -7334,6 +7336,46 @@ describe('QueryEditor external SQL save', () => { expect(editorState.editor.setValue).toHaveBeenCalledWith(''); }); + it('does not restore a closed external SQL file after unmount cleanup', async () => { + const filePath = '/Users/me/Documents/gonavi-queries/closed.sql'; + const tab = createTab({ filePath, query: 'select 1;' }); + storeState.tabs = [tab]; + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + editorState.value = 'select 2;'; + editorState.latestOnChange?.(editorState.value); + }); + expect(getSQLFileTabDraft('tab-1')).toBe('select 2;'); + + storeState.tabs = []; + await act(async () => { + renderer.unmount(); + }); + + expect(getSQLFileTabDraft('tab-1')).toBe(''); + }); + + it('writes the latest external SQL draft when the tab still exists on unmount', async () => { + const filePath = '/Users/me/Documents/gonavi-queries/open.sql'; + const tab = createTab({ filePath, query: 'select 1;' }); + storeState.tabs = [tab]; + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + editorState.value = 'select 2;'; + + await act(async () => { + renderer.unmount(); + }); + + expect(getSQLFileTabDraft('tab-1')).toBe('select 2;'); + }); it('writes external SQL file tabs back to disk without creating saved queries', async () => { let renderer!: ReactTestRenderer; const filePath = '/Users/me/Documents/gonavi-queries/report.sql'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index f30f7eca..bf4a95a5 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -2291,10 +2291,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbName: currentDbRef.current, }); return () => { - persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), { - connectionId: currentConnectionIdRef.current, - dbName: currentDbRef.current, - }); + const tabStillExists = useStore.getState().tabs.some((item) => item.id === draftSnapshotTab.id); + if (tabStillExists) { + persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), { + connectionId: currentConnectionIdRef.current, + dbName: currentDbRef.current, + }); + } else { + clearQueryTabDraft(draftSnapshotTab.id); + } }; }, [draftSnapshotTab, getCurrentQuery, isExternalSQLFileTab]); diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index f50f56e7..7ce85866 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -35,6 +35,11 @@ import { resolveSqlDialect, } from '../utils/sqlDialect'; import { splitQualifiedNameLast, stripIdentifierQuotes } from '../utils/qualifiedName'; +import { + cloneTableDesignerColumnsForPaste, + parseTableDesignerColumns, + serializeTableDesignerColumns, +} from './tableDesignerColumnClipboard'; interface EditableColumn extends ColumnDefinition { _key: string; @@ -1435,6 +1440,28 @@ ${selectedTrigger.statement}`; setColumns(prev => prev.filter(c => c._key !== key)); }; + const isNativeColumnEditorTarget = (target: EventTarget | null): boolean => { + const element = target instanceof HTMLElement ? target : null; + return !!element?.closest('input:not([type="checkbox"]):not([type="radio"]), textarea, select, [contenteditable="true"]'); + }; + + const handleColumnClipboardCopy = (event: React.ClipboardEvent) => { + if (readOnly || selectedColumns.length === 0 || isNativeColumnEditorTarget(event.target)) return; + event.clipboardData.setData('text/plain', serializeTableDesignerColumns(selectedColumns)); + event.preventDefault(); + }; + + const handleColumnClipboardPaste = (event: React.ClipboardEvent) => { + if (readOnly || isNativeColumnEditorTarget(event.target)) return; + const pastedColumns = parseTableDesignerColumns(event.clipboardData.getData('text/plain')); + if (!pastedColumns || pastedColumns.length === 0) return; + const nextColumns = cloneTableDesignerColumnsForPaste(pastedColumns, columns) as EditableColumn[]; + setColumns(prev => [...prev, ...nextColumns]); + setSelectedColumnRowKeys(nextColumns.map(column => column._key)); + pendingFocusColumnKeyRef.current = nextColumns[0]._key || null; + event.preventDefault(); + }; + const selectedColumns = useMemo(() => { if (selectedColumnRowKeys.length === 0) return []; const selectedSet = new Set(selectedColumnRowKeys); @@ -2810,6 +2837,8 @@ END;`;
= {}): TableDesignerClipboardColumn => ({ + _key: 'column-1', + name: 'created_at', + type: 'datetime', + nullable: 'NO', + key: '', + extra: 'DEFAULT_GENERATED', + comment: '创建时间', + default: 'CURRENT_TIMESTAMP', + hasDefault: true, + charset: 'utf8mb4', + collation: 'utf8mb4_bin', + isAutoIncrement: false, + ...overrides, +}); + +describe('tableDesignerColumnClipboard', () => { + it('serializes and parses column definitions without UI keys', () => { + const text = serializeTableDesignerColumns([column()]); + expect(text.startsWith(TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX)).toBe(true); + expect(text).not.toContain('column-1'); + expect(parseTableDesignerColumns(text)).toEqual([expect.objectContaining({ + name: 'created_at', + type: 'datetime', + default: 'CURRENT_TIMESTAMP', + charset: 'utf8mb4', + })]); + }); + + it('rejects ordinary, malformed, and incomplete clipboard text', () => { + expect(parseTableDesignerColumns('created_at')).toBeNull(); + expect(parseTableDesignerColumns(`${TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX}{`)).toBeNull(); + expect(parseTableDesignerColumns(`${TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX}${JSON.stringify({ version: 1, columns: [{ name: 'id' }] })}`)).toBeNull(); + }); + + it('clones columns at the end with preserved definitions and unique names', () => { + const pasted = cloneTableDesignerColumnsForPaste( + [column({ name: 'id' }), column({ name: 'ID' })], + [column({ name: 'id' }), column({ name: 'id_copy' })], + ); + + expect(pasted).toHaveLength(2); + expect(pasted.map(item => item.name)).toEqual(['id_copy_2', 'ID_copy_3']); + expect(pasted.every(item => item.isNew && item._key && item._key !== 'column-1')).toBe(true); + expect(pasted[0]).toEqual(expect.objectContaining({ + type: 'datetime', + nullable: 'NO', + default: 'CURRENT_TIMESTAMP', + hasDefault: true, + extra: 'DEFAULT_GENERATED', + comment: '创建时间', + })); + }); +}); diff --git a/frontend/src/components/tableDesignerColumnClipboard.ts b/frontend/src/components/tableDesignerColumnClipboard.ts new file mode 100644 index 00000000..66bea167 --- /dev/null +++ b/frontend/src/components/tableDesignerColumnClipboard.ts @@ -0,0 +1,100 @@ +export const TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX = 'gonavi-table-designer-columns-v1:'; + +export type TableDesignerClipboardColumn = { + _key?: string; + name: string; + type: string; + nullable: string; + key: string; + default?: string; + hasDefault?: boolean; + extra: string; + comment: string; + charset?: string; + collation?: string; + isAutoIncrement?: boolean; + isNew?: boolean; +}; + +const isRecord = (value: unknown): value is Record => ( + !!value && typeof value === 'object' && !Array.isArray(value) +); + +const readRequiredText = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + return value; +}; + +const normalizeColumn = (value: unknown): TableDesignerClipboardColumn | null => { + if (!isRecord(value)) return null; + const name = readRequiredText(value.name); + const type = readRequiredText(value.type); + const nullable = readRequiredText(value.nullable); + const key = readRequiredText(value.key); + const extra = readRequiredText(value.extra); + const comment = readRequiredText(value.comment); + if (name === null || type === null || nullable === null || key === null || extra === null || comment === null) { + return null; + } + + const column: TableDesignerClipboardColumn = { + name, + type, + nullable, + key, + extra, + comment, + }; + if (typeof value.default === 'string') column.default = value.default; + if (typeof value.hasDefault === 'boolean') column.hasDefault = value.hasDefault; + if (typeof value.charset === 'string') column.charset = value.charset; + if (typeof value.collation === 'string') column.collation = value.collation; + if (typeof value.isAutoIncrement === 'boolean') column.isAutoIncrement = value.isAutoIncrement; + return column; +}; + +export const serializeTableDesignerColumns = (columns: TableDesignerClipboardColumn[]): string => ( + `${TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX}${JSON.stringify({ + version: 1, + columns: columns.map(({ _key: _ignored, ...column }) => column), + })}` +); + +export const parseTableDesignerColumns = (text: string): TableDesignerClipboardColumn[] | null => { + if (!String(text || '').startsWith(TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX)) return null; + try { + const payload = JSON.parse(String(text).slice(TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX.length)); + if (!isRecord(payload) || payload.version !== 1 || !Array.isArray(payload.columns)) return null; + const columns = payload.columns.map(normalizeColumn); + return columns.every((column): column is TableDesignerClipboardColumn => column !== null) + ? columns + : null; + } catch { + return null; + } +}; + +const createUniqueColumnName = (sourceName: string, usedNames: Set): string => { + const baseName = sourceName.trim() || 'new_column'; + let candidate = `${baseName}_copy`; + let suffix = 2; + while (usedNames.has(candidate.toLowerCase())) { + candidate = `${baseName}_copy_${suffix}`; + suffix += 1; + } + usedNames.add(candidate.toLowerCase()); + return candidate; +}; + +export const cloneTableDesignerColumnsForPaste = ( + columns: TableDesignerClipboardColumn[], + existingColumns: TableDesignerClipboardColumn[], +): TableDesignerClipboardColumn[] => { + const usedNames = new Set(existingColumns.map((column) => String(column.name || '').trim().toLowerCase())); + return columns.map((column) => ({ + ...column, + name: createUniqueColumnName(column.name, usedNames), + _key: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + isNew: true, + })); +};