🐛 fix(table-designer): 修复外部 SQL 恢复并支持字段复制粘贴 (#717)

This commit is contained in:
mango
2026-07-25 21:02:25 +08:00
parent 6eff9eb854
commit 7f4feef3d6
5 changed files with 244 additions and 4 deletions

View File

@@ -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(<QueryEditor tab={tab} />);
});
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(<QueryEditor tab={tab} />);
});
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';

View File

@@ -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]);

View File

@@ -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<HTMLDivElement>) => {
if (readOnly || selectedColumns.length === 0 || isNativeColumnEditorTarget(event.target)) return;
event.clipboardData.setData('text/plain', serializeTableDesignerColumns(selectedColumns));
event.preventDefault();
};
const handleColumnClipboardPaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
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;`;
<div
ref={containerRef}
className={`table-designer-wrapper${isV2Ui ? ' gn-v2-designer-table-shell' : ''}`}
onCopy={handleColumnClipboardCopy}
onPaste={handleColumnClipboardPaste}
style={{
height: '100%',
overflow: 'hidden',

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
cloneTableDesignerColumnsForPaste,
parseTableDesignerColumns,
serializeTableDesignerColumns,
TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX,
type TableDesignerClipboardColumn,
} from './tableDesignerColumnClipboard';
const column = (overrides: Partial<TableDesignerClipboardColumn> = {}): 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: '创建时间',
}));
});
});

View File

@@ -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<string, unknown> => (
!!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>): 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,
}));
};