) => {
+ 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,
+ }));
+};