import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react'; import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd'; import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import Editor, { loader } from '@monaco-editor/react'; import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types'; import { useStore } from '../store'; import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App'; import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils'; import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; interface EditableColumn extends ColumnDefinition { _key: string; isNew?: boolean; isAutoIncrement?: boolean; // Virtual field for UI } interface IndexDisplayRow { key: string; name: string; indexType: string; nonUnique: number; columnNames: string[]; } interface ForeignKeyDisplayRow { key: string; name: string; constraintName: string; refTableName: string; columnNames: string[]; refColumnNames: string[]; } type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL'; interface IndexFormState { name: string; columnNames: string[]; kind: IndexKind; indexType: string; } interface ForeignKeyFormState { constraintName: string; columnNames: string[]; refTableName: string; refColumnNames: string[]; } interface SchemaExecutionResult { ok: boolean; message?: string; failedStatementIndex?: number; statementCount: number; } // 通用兜底类型列表 const COMMON_TYPES = [ { value: 'int' }, { value: 'varchar(255)' }, { value: 'text' }, { value: 'datetime' }, { value: 'tinyint(1)' }, { value: 'decimal(10,2)' }, { value: 'bigint' }, { value: 'json' }, ]; // 按数据库方言分组的完整字段类型列表 const DB_TYPE_OPTIONS: Record = { mysql: [ // 数值 { value: 'tinyint' }, { value: 'tinyint(1)' }, { value: 'smallint' }, { value: 'mediumint' }, { value: 'int' }, { value: 'bigint' }, { value: 'float' }, { value: 'double' }, { value: 'decimal(10,2)' }, // 字符串 { value: 'char(50)' }, { value: 'varchar(255)' }, { value: 'tinytext' }, { value: 'text' }, { value: 'mediumtext' }, { value: 'longtext' }, // 二进制 { value: 'binary(255)' }, { value: 'varbinary(255)' }, { value: 'tinyblob' }, { value: 'blob' }, { value: 'mediumblob' }, { value: 'longblob' }, // 日期时间 { value: 'date' }, { value: 'time' }, { value: 'datetime' }, { value: 'timestamp' }, { value: 'year' }, // 其他 { value: 'json' }, { value: 'enum' }, { value: 'set' }, { value: 'bit(1)' }, ], postgres: [ // 数值 { value: 'smallint' }, { value: 'integer' }, { value: 'bigint' }, { value: 'real' }, { value: 'double precision' }, { value: 'numeric(10,2)' }, { value: 'serial' }, { value: 'bigserial' }, // 字符串 { value: 'char(50)' }, { value: 'varchar(255)' }, { value: 'text' }, // 布尔 { value: 'boolean' }, // 日期时间 { value: 'date' }, { value: 'time' }, { value: 'timestamp' }, { value: 'timestamptz' }, { value: 'interval' }, // 二进制 { value: 'bytea' }, // JSON { value: 'json' }, { value: 'jsonb' }, // 其他 { value: 'uuid' }, { value: 'inet' }, { value: 'cidr' }, { value: 'macaddr' }, { value: 'xml' }, { value: 'int4range' }, { value: 'tsquery' }, { value: 'tsvector' }, ], sqlserver: [ // 数值 { value: 'tinyint' }, { value: 'smallint' }, { value: 'int' }, { value: 'bigint' }, { value: 'float' }, { value: 'real' }, { value: 'decimal(10,2)' }, { value: 'numeric(10,2)' }, { value: 'money' }, { value: 'smallmoney' }, // 字符串 { value: 'char(50)' }, { value: 'varchar(255)' }, { value: 'varchar(max)' }, { value: 'nchar(50)' }, { value: 'nvarchar(255)' }, { value: 'nvarchar(max)' }, { value: 'text' }, { value: 'ntext' }, // 日期时间 { value: 'date' }, { value: 'time' }, { value: 'datetime' }, { value: 'datetime2' }, { value: 'datetimeoffset' }, { value: 'smalldatetime' }, // 二进制 { value: 'binary(255)' }, { value: 'varbinary(255)' }, { value: 'varbinary(max)' }, { value: 'image' }, // 其他 { value: 'bit' }, { value: 'uniqueidentifier' }, { value: 'xml' }, ], sqlite: [ { value: 'INTEGER' }, { value: 'REAL' }, { value: 'TEXT' }, { value: 'BLOB' }, { value: 'NUMERIC' }, ], oracle: [ { value: 'NUMBER(10)' }, { value: 'NUMBER(10,2)' }, { value: 'FLOAT' }, { value: 'BINARY_FLOAT' }, { value: 'BINARY_DOUBLE' }, { value: 'CHAR(50)' }, { value: 'VARCHAR2(255)' }, { value: 'NVARCHAR2(255)' }, { value: 'CLOB' }, { value: 'NCLOB' }, { value: 'BLOB' }, { value: 'DATE' }, { value: 'TIMESTAMP' }, { value: 'TIMESTAMP WITH TIME ZONE' }, { value: 'RAW(255)' }, { value: 'LONG RAW' }, { value: 'XMLTYPE' }, ], }; const COMMON_DEFAULTS = [ { value: 'CURRENT_TIMESTAMP' }, { value: 'NULL' }, { value: '0' }, { value: "''" }, ]; const PGLIKE_INDEX_TYPE_OPTIONS = [ { label: '默认', value: 'DEFAULT' }, { label: 'BTREE', value: 'BTREE' }, { label: 'HASH', value: 'HASH' }, { label: 'GIN', value: 'GIN' }, { label: 'GIST', value: 'GIST' }, { label: 'BRIN', value: 'BRIN' }, { label: 'SPGIST', value: 'SPGIST' }, ]; const SQLSERVER_INDEX_TYPE_OPTIONS = [ { label: '默认', value: 'DEFAULT' }, { label: 'CLUSTERED', value: 'CLUSTERED' }, { label: 'NONCLUSTERED', value: 'NONCLUSTERED' }, ]; const CHARSETS = [ { label: 'utf8mb4 (Recommended)', value: 'utf8mb4' }, { label: 'utf8', value: 'utf8' }, { label: 'latin1', value: 'latin1' }, { label: 'ascii', value: 'ascii' }, ]; const COLLATIONS = { 'utf8mb4': [ { label: 'utf8mb4_unicode_ci (Default)', value: 'utf8mb4_unicode_ci' }, { label: 'utf8mb4_general_ci', value: 'utf8mb4_general_ci' }, { label: 'utf8mb4_bin', value: 'utf8mb4_bin' }, { label: 'utf8mb4_0900_ai_ci', value: 'utf8mb4_0900_ai_ci' }, ], 'utf8': [ { label: 'utf8_unicode_ci', value: 'utf8_unicode_ci' }, { label: 'utf8_general_ci', value: 'utf8_general_ci' }, { label: 'utf8_bin', value: 'utf8_bin' }, ] }; // --- Resizable Header Component (Native, same interaction as DataGrid) --- const ResizableTitle = (props: any) => { const { onResizeStart, width, ...restProps } = props; const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; if (width) { nextStyle.width = width; } if (!onResizeStart) { return ; } return ( {restProps.children} { e.stopPropagation(); if (typeof onResizeStart === 'function') { onResizeStart(e); } }} onClick={(e) => e.stopPropagation()} style={{ position: 'absolute', right: 0, bottom: 0, top: 0, width: 10, cursor: 'col-resize', zIndex: 10, touchAction: 'none', }} /> ); }; // --- Sortable Row Component --- interface RowProps extends React.HTMLAttributes { 'data-row-key': string; } const SortableRow = ({ children, ...props }: RowProps) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: props['data-row-key'], }); const style: React.CSSProperties = { ...props.style, transform: CSS.Transform.toString(transform), transition, cursor: 'move', ...(isDragging ? { position: 'relative', zIndex: 9999 } : {}), }; return ( {React.Children.map(children, child => { if ((child as React.ReactElement).key === 'sort') { return React.cloneElement(child as React.ReactElement, { children: ( ), }); } return child; })} ); }; const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const isNewTable = !tab.tableName; const [columns, setColumns] = useState([]); const [originalColumns, setOriginalColumns] = useState([]); const [indexes, setIndexes] = useState([]); const [fks, setFks] = useState([]); const [triggers, setTriggers] = useState([]); const [ddl, setDdl] = useState(''); // New Table State const [newTableName, setNewTableName] = useState(''); const [charset, setCharset] = useState('utf8mb4'); const [collation, setCollation] = useState('utf8mb4_unicode_ci'); const [loading, setLoading] = useState(false); const [previewSql, setPreviewSql] = useState(''); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [activeKey, setActiveKey] = useState(tab.initialTab || "columns"); const [selectedColumnRowKeys, setSelectedColumnRowKeys] = useState([]); const [isCopyColumnsModalOpen, setIsCopyColumnsModalOpen] = useState(false); const [copyTableName, setCopyTableName] = useState(''); const [copyCharset, setCopyCharset] = useState('utf8mb4'); const [copyCollation, setCopyCollation] = useState('utf8mb4_unicode_ci'); const [copyExecuting, setCopyExecuting] = useState(false); const [tableComment, setTableComment] = useState(''); const [tableCommentDraft, setTableCommentDraft] = useState(''); const [isTableCommentModalOpen, setIsTableCommentModalOpen] = useState(false); const [tableCommentSaving, setTableCommentSaving] = useState(false); const [selectedIndexKeys, setSelectedIndexKeys] = useState([]); const [isIndexModalOpen, setIsIndexModalOpen] = useState(false); const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create'); const [indexSaving, setIndexSaving] = useState(false); const [indexForm, setIndexForm] = useState({ name: '', columnNames: [], kind: 'NORMAL', indexType: 'DEFAULT', }); const [selectedForeignKey, setSelectedForeignKey] = useState(null); const [isForeignKeyModalOpen, setIsForeignKeyModalOpen] = useState(false); const [foreignKeyModalMode, setForeignKeyModalMode] = useState<'create' | 'edit'>('create'); const [foreignKeySaving, setForeignKeySaving] = useState(false); const [foreignKeyForm, setForeignKeyForm] = useState({ constraintName: '', columnNames: [], refTableName: '', refColumnNames: [], }); const [selectedTrigger, setSelectedTrigger] = useState(null); const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false); const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false); const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create'); const [triggerEditSql, setTriggerEditSql] = useState(''); const [triggerExecuting, setTriggerExecuting] = useState(false); const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); const [commentEditorColumnKey, setCommentEditorColumnKey] = useState(''); const [commentEditorColumnName, setCommentEditorColumnName] = useState(''); const [commentEditorValue, setCommentEditorValue] = useState(''); const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const darkMode = theme === 'dark'; const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff'; const readOnly = !!tab.readOnly; const panelRadius = 10; const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)'; const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)'; const panelToolbarBg = darkMode ? 'rgba(20, 20, 20, 0.35)' : 'rgba(255, 255, 255, 0.72)'; const panelBodyBg = darkMode ? 'rgba(0, 0, 0, 0.24)' : 'rgba(255, 255, 255, 0.82)'; const focusRowBg = darkMode ? 'rgba(246, 196, 83, 0.22)' : 'rgba(24, 144, 255, 0.12)'; const [tableHeight, setTableHeight] = useState(500); const containerRef = useRef(null); const shellRef = useRef(null); const pendingFocusColumnKeyRef = useRef(null); const focusHighlightTimerRef = useRef(null); const [focusColumnKey, setFocusColumnKey] = useState(''); const openCommentEditor = useCallback((record: EditableColumn) => { if (!record?._key) return; setCommentEditorColumnKey(record._key); setCommentEditorColumnName(record.name || ''); setCommentEditorValue(record.comment || ''); setIsCommentModalOpen(true); }, []); const closeCommentEditor = useCallback(() => { setIsCommentModalOpen(false); setCommentEditorColumnKey(''); setCommentEditorColumnName(''); setCommentEditorValue(''); }, []); // 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景) // 监听字段 Tab 容器高度,为所有 Tab 内表格计算 scroll.y // 当 Tab 切换时,字段 Tab 被 display:none 导致 height=0,跳过该次更新保持有效值 useEffect(() => { if (!containerRef.current) return; const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { const h = entry.contentRect.height; // 跳过零高度观测(Tab 面板被隐藏时) if (h <= 0) return; setTableHeight(Math.max(200, h - 40)); } }); resizeObserver.observe(containerRef.current); return () => resizeObserver.disconnect(); }, []); // 不依赖 activeKey,仅挂载一次,通过零高度守卫避免 Tab 切换异常 // --- Resizable Columns State --- const [tableColumns, setTableColumns] = useState([]); const [indexColumns, setIndexColumns] = useState([]); const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch> } | null>(null); const resizeRafRef = useRef(null); const latestResizeXRef = useRef(null); const ghostRef = useRef(null); const resizeListenerRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: ((e: MouseEvent) => void) | null }>({ move: null, up: null, }); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ); useEffect(() => { if (tab.initialTab) { setActiveKey(tab.initialTab); } }, [tab.initialTab]); useEffect(() => { setSelectedColumnRowKeys(prev => prev.filter(key => columns.some(c => c._key === key))); }, [columns]); useEffect(() => { return () => { if (focusHighlightTimerRef.current !== null) { window.clearTimeout(focusHighlightTimerRef.current); } }; }, []); const focusColumnRow = useCallback((targetKey: string): boolean => { if (activeKey !== 'columns') return false; const tableBody = containerRef.current?.querySelector('.ant-table-body') as HTMLElement | null; if (!tableBody) return false; const row = tableBody.querySelector(`tr[data-row-key="${targetKey}"]`) as HTMLTableRowElement | null; if (!row) return false; row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); setFocusColumnKey(targetKey); if (focusHighlightTimerRef.current !== null) { window.clearTimeout(focusHighlightTimerRef.current); } focusHighlightTimerRef.current = window.setTimeout(() => { setFocusColumnKey(prev => (prev === targetKey ? '' : prev)); }, 1600); if (!readOnly) { const firstInput = row.querySelector('input') as HTMLInputElement | null; if (firstInput) { firstInput.focus(); firstInput.select(); } } return true; }, [activeKey, readOnly]); useEffect(() => { const pendingKey = pendingFocusColumnKeyRef.current; if (!pendingKey || activeKey !== 'columns') return; let cancelled = false; const tryFocus = () => { if (cancelled) return; if (focusColumnRow(pendingKey)) { pendingFocusColumnKeyRef.current = null; } }; const timerA = window.setTimeout(tryFocus, 0); const timerB = window.setTimeout(tryFocus, 96); return () => { cancelled = true; window.clearTimeout(timerA); window.clearTimeout(timerB); }; }, [activeKey, columns, focusColumnRow]); // Initial Columns Definition useEffect(() => { const initialCols = [ { title: '名', dataIndex: 'name', key: 'name', width: 180, render: (text: string, record: EditableColumn) => readOnly ? text : ( handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" /> ) }, { title: '类型', dataIndex: 'type', key: 'type', width: 150, render: (text: string, record: EditableColumn) => readOnly ? text : ( handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" /> ) }, { title: '主键', dataIndex: 'key', key: 'key', width: 60, align: 'center', render: (text: string, record: EditableColumn) => ( handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} /> ) }, { title: '自增', dataIndex: 'isAutoIncrement', key: 'isAutoIncrement', width: 60, align: 'center', render: (val: boolean, record: EditableColumn) => ( handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} /> ) }, { title: '不是 Null', dataIndex: 'nullable', key: 'nullable', width: 80, align: 'center', render: (text: string, record: EditableColumn) => ( handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} /> ) }, { title: '默认', dataIndex: 'default', key: 'default', width: 180, // Increased default width render: (text: string, record: EditableColumn) => readOnly ? text : ( handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" /> ) }, { title: '注释', dataIndex: 'comment', key: 'comment', width: 200, render: (text: string, record: EditableColumn) => readOnly ? (
{text || ''}
) : (
handleColumnChange(record._key, 'comment', e.target.value)} onDoubleClick={() => openCommentEditor(record)} variant="borderless" />
) }, ...(readOnly ? [] : [{ title: '操作', key: 'action', width: 60, render: (_: any, record: EditableColumn) => (