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, TableOutlined } 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 from './MonacoEditor'; 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 { buildIndexCreateSqlPreview } from './tableDesignerIndexSql'; import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges, type StarRocksCreateTableOptions, type StarRocksDistributionType, type StarRocksKeyModel, type StarRocksTableKind } from './tableDesignerSchemaSql'; import { summarizeDuckDbPrimaryKeyChange } from './tableDesignerDuckDbPrimaryKey'; import { normalizeSchemaStatementForExecution, parseTableCommentFromDDL, splitSchemaExecutionStatements } from './tableDesignerExecutionSql'; import TableDesignerSqlPreview from './TableDesignerSqlPreview'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { getColumnDefinitionExtra, normalizeColumnDefinition, } from '../utils/columnDefinition'; import { isMysqlFamilyDialect as isMysqlFamilySqlDialect, isOracleLikeDialect as isOracleLikeSqlDialect, isPgLikeDialect as isPgLikeSqlDialect, isSqlServerDialect as isSqlServerSqlDialect, quoteSqlIdentifierPart, quoteSqlIdentifierPath, resolveColumnTypeOptions, resolveSqlDialect, } from '../utils/sqlDialect'; import { splitQualifiedNameLast, stripIdentifierQuotes } from '../utils/qualifiedName'; 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 renderDesignerCellField = (content: React.ReactNode, className?: string) => (
{content}
); const renderDesignerCellCheck = (content: React.ReactNode, className?: string) => (
{content}
); const renderDesignerHeaderTitle = (title: string) => ( {title} ); const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, embedded = false }) => { 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 [starRocksTableKind, setStarRocksTableKind] = useState('olap'); const [starRocksKeyModel, setStarRocksKeyModel] = useState('DUPLICATE'); const [starRocksKeyColumns, setStarRocksKeyColumns] = useState([]); const [starRocksPartitionClause, setStarRocksPartitionClause] = useState(''); const [starRocksDistributionType, setStarRocksDistributionType] = useState('HASH'); const [starRocksDistributionColumns, setStarRocksDistributionColumns] = useState([]); const [starRocksBucketMode, setStarRocksBucketMode] = useState<'AUTO' | 'NUMBER'>('AUTO'); const [starRocksBucketCount, setStarRocksBucketCount] = useState(''); const [starRocksProperties, setStarRocksProperties] = useState(''); const [starRocksRollups, setStarRocksRollups] = useState(''); const [starRocksExternalEngine, setStarRocksExternalEngine] = useState('hive'); const [starRocksExternalProperties, setStarRocksExternalProperties] = useState('"resource" = "hive0"\n"database" = "raw_db"\n"table" = "raw_table"'); 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 [inlineCommentEditingKey, setInlineCommentEditingKey] = useState(''); const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const darkMode = theme === 'dark'; const isV2Ui = appearance.uiVersion === 'v2'; 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; setInlineCommentEditingKey(''); setCommentEditorColumnKey(record._key); setCommentEditorColumnName(record.name || ''); setCommentEditorValue(record.comment || ''); setIsCommentModalOpen(true); }, []); const closeCommentEditor = useCallback(() => { setIsCommentModalOpen(false); setCommentEditorColumnKey(''); setCommentEditorColumnName(''); setCommentEditorValue(''); }, []); // 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 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(() => { setInlineCommentEditingKey(prev => (prev && columns.some(c => c._key === prev) ? prev : '')); }, [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]); const startInlineCommentEdit = useCallback((record: EditableColumn) => { if (readOnly || !record?._key) return; setInlineCommentEditingKey(record._key); }, [readOnly]); const finishInlineCommentEdit = useCallback(() => { setInlineCommentEditingKey(''); }, []); 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 columnTypeOptions = resolveColumnTypeOptions(getDbType()); const initialCols = [ { title: renderDesignerHeaderTitle('名'), dataIndex: 'name', key: 'name', width: 180, render: (text: string, record: EditableColumn) => readOnly ? text : ( renderDesignerCellField( handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" /> ) ) }, { title: renderDesignerHeaderTitle('类型'), dataIndex: 'type', key: 'type', width: 150, render: (text: string, record: EditableColumn) => readOnly ? text : ( renderDesignerCellField( handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />, 'is-compact' ) ) }, { title: renderDesignerHeaderTitle('主键'), dataIndex: 'key', key: 'key', width: 60, align: 'center', render: (text: string, record: EditableColumn) => ( renderDesignerCellCheck( handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} />, 'is-left-aligned' ) ) }, { title: renderDesignerHeaderTitle('自增'), dataIndex: 'isAutoIncrement', key: 'isAutoIncrement', width: 60, align: 'center', render: (val: boolean, record: EditableColumn) => ( renderDesignerCellCheck( handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} />, 'is-left-aligned' ) ) }, { title: renderDesignerHeaderTitle('不是 Null'), dataIndex: 'nullable', key: 'nullable', width: 80, align: 'center', render: (text: string, record: EditableColumn) => ( renderDesignerCellCheck( handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} />, 'is-left-aligned' ) ) }, { title: renderDesignerHeaderTitle('默认'), dataIndex: 'default', key: 'default', width: 180, // Increased default width render: (text: string, record: EditableColumn) => readOnly ? text : ( renderDesignerCellField( handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" /> ) ) }, { title: renderDesignerHeaderTitle('注释'), dataIndex: 'comment', key: 'comment', width: 200, render: (text: string, record: EditableColumn) => readOnly ? (
{text || ''}
) : (
{inlineCommentEditingKey !== record._key ? (
startInlineCommentEdit(record)} > {text || '\u00A0'}
) : ( handleColumnChange(record._key, 'comment', e.target.value)} onBlur={finishInlineCommentEdit} onPressEnter={finishInlineCommentEdit} autoFocus={inlineCommentEditingKey === record._key} variant="borderless" /> )}
) }, ...(readOnly ? [] : [{ title: renderDesignerHeaderTitle('操作'), key: 'action', width: 92, className: 'table-designer-action-column', onHeaderCell: () => ({ className: 'table-designer-action-column' }), render: (_: any, record: EditableColumn) => (
) }]) ]; setTableColumns(initialCols); }, [connections, embedded, finishInlineCommentEdit, inlineCommentEditingKey, openCommentEditor, readOnly, startInlineCommentEdit, tab.connectionId]); // Re-create when datasource dialect or readonly state changes const flushResizeGhost = useCallback(() => { resizeRafRef.current = null; if (!resizeDragRef.current || !ghostRef.current) return; if (latestResizeXRef.current === null) return; const relativeLeft = latestResizeXRef.current - resizeDragRef.current.containerLeft; ghostRef.current.style.transform = `translateX(${relativeLeft}px)`; }, []); const detachResizeListeners = useCallback(() => { if (resizeListenerRef.current.move) { document.removeEventListener('mousemove', resizeListenerRef.current.move); resizeListenerRef.current.move = null; } if (resizeListenerRef.current.up) { document.removeEventListener('mouseup', resizeListenerRef.current.up); resizeListenerRef.current.up = null; } }, []); const cleanupResizeState = useCallback(() => { if (resizeRafRef.current !== null) { cancelAnimationFrame(resizeRafRef.current); resizeRafRef.current = null; } latestResizeXRef.current = null; resizeDragRef.current = null; if (ghostRef.current) { ghostRef.current.style.display = 'none'; } document.body.style.cursor = ''; document.body.style.userSelect = ''; }, []); const createResizeStartHandler = useCallback((columns: any[], setter: React.Dispatch>) => (index: number) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const currentWidth = Number(columns[index]?.width || 200); const containerLeft = shellRef.current?.getBoundingClientRect().left ?? 0; resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft, setter }; latestResizeXRef.current = startX; if (ghostRef.current && shellRef.current) { const relativeLeft = startX - containerLeft; ghostRef.current.style.transform = `translateX(${relativeLeft}px)`; ghostRef.current.style.display = 'block'; } detachResizeListeners(); const onMove = (event: MouseEvent) => { if (!resizeDragRef.current) return; latestResizeXRef.current = event.clientX; if (resizeRafRef.current !== null) return; resizeRafRef.current = requestAnimationFrame(flushResizeGhost); }; const onUp = (event: MouseEvent) => { if (resizeDragRef.current) { const { startX: dragStartX, startWidth, index: dragIndex, setter: dragSetter } = resizeDragRef.current; const deltaX = event.clientX - dragStartX; const newWidth = Math.max(50, startWidth + deltaX); dragSetter((prevColumns) => { if (!prevColumns[dragIndex]) return prevColumns; const nextColumns = [...prevColumns]; nextColumns[dragIndex] = { ...nextColumns[dragIndex], width: newWidth, }; return nextColumns; }); } detachResizeListeners(); cleanupResizeState(); }; resizeListenerRef.current = { move: onMove, up: onUp }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, [cleanupResizeState, detachResizeListeners, flushResizeGhost]); const handleResizeStart = useMemo(() => createResizeStartHandler(tableColumns, setTableColumns), [createResizeStartHandler, tableColumns]); const handleIndexResizeStart = useMemo(() => createResizeStartHandler(indexColumns, setIndexColumns), [createResizeStartHandler, indexColumns]); useEffect(() => { return () => { detachResizeListeners(); cleanupResizeState(); }; }, [cleanupResizeState, detachResizeListeners]); const fetchData = async () => { if (isNewTable) return; // Don't fetch for new table setLoading(true); const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { message.error("Connection not found"); setLoading(false); return; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const promises: Promise[] = [ DBGetColumns(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''), DBGetIndexes(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''), DBGetForeignKeys(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || ''), DBGetTriggers(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || '') ]; if (!isNewTable) { promises.push(DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tab.tableName || '')); } const results = await Promise.all(promises); const colsRes = results[0]; const idxRes = results[1]; const fkRes = results[2]; const trigRes = results[3]; const ddlRes = !isNewTable ? results[4] : null; if (colsRes.success) { const colsWithKey = (colsRes.data as ColumnDefinition[]).map((c, index) => ({ ...normalizeColumnDefinition(c), _key: `col-${index}-${Date.now()}`, isAutoIncrement: getColumnDefinitionExtra(c).toLowerCase().includes('auto_increment') })); setColumns(JSON.parse(JSON.stringify(colsWithKey))); setOriginalColumns(JSON.parse(JSON.stringify(colsWithKey))); setSelectedColumnRowKeys([]); } else { message.error("Failed to load columns: " + colsRes.message); } if (idxRes.success) { setIndexes(Array.isArray(idxRes.data) ? idxRes.data : []); } else { setIndexes([]); } if (fkRes.success) { setFks(Array.isArray(fkRes.data) ? fkRes.data : []); } else { setFks([]); } if (trigRes.success) { setTriggers(Array.isArray(trigRes.data) ? trigRes.data : []); } else { setTriggers([]); } if (ddlRes && ddlRes.success) { const ddlText = String(ddlRes.data || ''); setDdl(ddlText); const parsedTableComment = parseTableCommentFromDDL(ddlText); setTableComment(parsedTableComment); if (!isTableCommentModalOpen) { setTableCommentDraft(parsedTableComment); } } setLoading(false); }; useEffect(() => { fetchData(); }, [tab]); // --- Trigger Handlers --- const normalizeDbType = (rawType: string): string => { const normalized = String(rawType || '').trim().toLowerCase(); if (normalized === 'postgresql' || normalized === 'pg') return 'postgres'; if (normalized === 'mssql' || normalized === 'sql_server' || normalized === 'sql-server') return 'sqlserver'; if (normalized === 'doris') return 'diros'; if (normalized === 'open_gauss' || normalized === 'open-gauss') return 'opengauss'; return normalized; }; const inferDialectFromCustomDriver = (driver: string): string => { const customDriver = normalizeDbType(driver); if (!customDriver) return 'custom'; if ( customDriver === 'mariadb' || customDriver === 'diros' || customDriver === 'sphinx' || customDriver === 'tidb' || customDriver === 'oceanbase' || customDriver.includes('mysql') ) { return 'mysql'; } if (customDriver === 'starrocks') return 'starrocks'; if (customDriver === 'dameng') return 'dm'; return customDriver; }; const getDbType = (): string => { const conn = connections.find(c => c.id === tab.connectionId); const rawType = String(conn?.config?.type || '').trim(); if (!rawType) return ''; return resolveSqlDialect(rawType, String(conn?.config?.driver || ''), { oceanBaseProtocol: conn?.config?.oceanBaseProtocol, }); }; const generateTriggerTemplate = (): string => { const dbType = getDbType(); const tblName = tab.tableName || 'table_name'; switch (dbType) { case 'mysql': case 'mariadb': case 'oceanbase': case 'diros': case 'starrocks': return `CREATE TRIGGER trigger_name BEFORE INSERT ON \`${tblName}\` FOR EACH ROW BEGIN -- 触发器逻辑 END;`; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': return `CREATE OR REPLACE FUNCTION trigger_function_name() RETURNS TRIGGER AS $$ BEGIN -- 触发器逻辑 RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_name BEFORE INSERT ON "${tblName}" FOR EACH ROW EXECUTE FUNCTION trigger_function_name();`; case 'sqlserver': return `CREATE TRIGGER trigger_name ON [${tblName}] AFTER INSERT AS BEGIN SET NOCOUNT ON; -- 触发器逻辑 END;`; case 'oracle': case 'dameng': case 'dm': return `CREATE OR REPLACE TRIGGER trigger_name BEFORE INSERT ON "${tblName}" FOR EACH ROW BEGIN -- 触发器逻辑 NULL; END;`; case 'sqlite': return `CREATE TRIGGER trigger_name AFTER INSERT ON "${tblName}" BEGIN -- 触发器逻辑 END;`; default: return `-- 请输入 CREATE TRIGGER 语句`; } }; const buildDropTriggerSql = (triggerName: string): string => { const dbType = getDbType(); const tblName = tab.tableName || ''; switch (dbType) { case 'mysql': case 'mariadb': case 'oceanbase': case 'diros': case 'starrocks': return `DROP TRIGGER IF EXISTS \`${triggerName}\``; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`; case 'sqlserver': return `DROP TRIGGER IF EXISTS [${triggerName}]`; case 'oracle': case 'dameng': case 'dm': return `DROP TRIGGER "${triggerName}"`; case 'sqlite': return `DROP TRIGGER IF EXISTS "${triggerName}"`; default: return `DROP TRIGGER ${triggerName}`; } }; const handleCreateTrigger = () => { setTriggerEditMode('create'); setTriggerEditSql(generateTriggerTemplate()); setIsTriggerEditModalOpen(true); }; const handleEditTrigger = () => { if (!selectedTrigger) return; setTriggerEditMode('edit'); // 构建完整的 CREATE TRIGGER 语句 const dbType = getDbType(); const tblName = tab.tableName || ''; let createSql = ''; if (dbType === 'mysql') { createSql = `CREATE TRIGGER \`${selectedTrigger.name}\` ${selectedTrigger.timing} ${selectedTrigger.event} ON \`${tblName}\` FOR EACH ROW ${selectedTrigger.statement}`; } else { createSql = selectedTrigger.statement || '-- 无法获取完整的触发器定义'; } setTriggerEditSql(createSql); setIsTriggerEditModalOpen(true); }; const handleDeleteTrigger = () => { if (!selectedTrigger) return; Modal.confirm({ title: '确认删除触发器', icon: , content: `确定要删除触发器 "${selectedTrigger.name}" 吗?此操作不可撤销。`, okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { message.error('未找到连接'); return; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const dropSql = buildDropTriggerSql(selectedTrigger.name); try { const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql); if (res.success) { message.success('触发器删除成功'); setSelectedTrigger(null); fetchData(); // 刷新列表 } else { message.error('删除失败: ' + res.message); } } catch (e: any) { message.error('删除失败: ' + (e?.message || String(e))); } } }); }; const handleExecuteTriggerSql = async () => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { message.error('未找到连接'); return; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; setTriggerExecuting(true); try { // 如果是编辑模式,先删除旧触发器 if (triggerEditMode === 'edit' && selectedTrigger) { const dropSql = buildDropTriggerSql(selectedTrigger.name); const dropRes = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql); if (!dropRes.success) { message.error('删除旧触发器失败: ' + dropRes.message); setTriggerExecuting(false); return; } } // 执行创建语句 const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql); if (res.success) { message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功'); setIsTriggerEditModalOpen(false); setSelectedTrigger(null); fetchData(); // 刷新列表 } else { message.error('执行失败: ' + res.message); } } catch (e: any) { message.error('执行失败: ' + (e?.message || String(e))); } finally { setTriggerExecuting(false); } }; // --- Handlers --- const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => { setColumns(prev => prev.map(col => { if (col._key === key) { const newCol = { ...col, [field]: value }; if (field === 'key' && value === 'PRI') newCol.nullable = 'NO'; if (field === 'isAutoIncrement' && value === true) { newCol.key = 'PRI'; newCol.nullable = 'NO'; newCol.type = 'int'; // Suggest INT } return newCol; } return col; })); }; const createNewColumn = useCallback((indexHint: number): EditableColumn => ({ name: isNewTable ? 'new_column' : `new_col_${indexHint}`, type: 'varchar(255)', nullable: 'YES', key: '', extra: '', comment: '', default: '', _key: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, isNew: true, isAutoIncrement: false }), [isNewTable]); const handleAddColumn = useCallback((insertAfterKey?: string) => { const newCol = createNewColumn(columns.length + 1); setColumns(prev => { const next = [...prev]; if (insertAfterKey) { const insertIndex = next.findIndex(col => col._key === insertAfterKey); if (insertIndex >= 0) { next.splice(insertIndex + 1, 0, newCol); return next; } } next.push(newCol); return next; }); setSelectedColumnRowKeys([newCol._key]); pendingFocusColumnKeyRef.current = newCol._key; }, [columns.length, createNewColumn]); const handleAddColumnAfterSelected = useCallback(() => { const selectedSet = new Set(selectedColumnRowKeys); const anchor = columns.find(col => selectedSet.has(col._key)); if (!anchor) { message.warning('请先选择一个字段,再执行插入。'); return; } handleAddColumn(anchor._key); }, [columns, handleAddColumn, selectedColumnRowKeys]); const handleDeleteColumn = (key: string) => { setColumns(prev => prev.filter(c => c._key !== key)); }; const selectedColumns = useMemo(() => { if (selectedColumnRowKeys.length === 0) return []; const selectedSet = new Set(selectedColumnRowKeys); return columns.filter(col => selectedSet.has(col._key)); }, [columns, selectedColumnRowKeys]); const groupedIndexes = useMemo(() => { type IndexFieldItem = { name: string; seq: number; order: number; }; type IndexBucket = { key: string; name: string; indexType: string; nonUnique: number; order: number; fields: IndexFieldItem[]; }; const buckets = new Map(); const safeIndexes = Array.isArray(indexes) ? indexes : []; safeIndexes.forEach((idx, order) => { const rawName = String(idx.name || '').trim(); const key = rawName || `__unnamed_${order}`; const indexType = String(idx.indexType || '').trim() || '-'; const displayName = rawName || '(未命名索引)'; if (!buckets.has(key)) { buckets.set(key, { key, name: displayName, indexType, nonUnique: idx.nonUnique === 0 ? 0 : 1, order, fields: [], }); } const bucket = buckets.get(key); if (!bucket) return; if (bucket.indexType === '-' && indexType !== '-') { bucket.indexType = indexType; } if (idx.nonUnique === 0) { bucket.nonUnique = 0; } const columnName = String(idx.columnName || '').trim(); if (!columnName) return; const rawSeq = Number(idx.seqInIndex); const seq = Number.isFinite(rawSeq) ? rawSeq : 0; bucket.fields.push({ name: columnName, seq, order, }); }); return Array.from(buckets.values()) .sort((a, b) => a.order - b.order) .map((bucket) => { const sortedFieldNames = bucket.fields .slice() .sort((a, b) => { const aSeq = a.seq > 0 ? a.seq : Number.MAX_SAFE_INTEGER; const bSeq = b.seq > 0 ? b.seq : Number.MAX_SAFE_INTEGER; if (aSeq !== bSeq) return aSeq - bSeq; return a.order - b.order; }) .map(field => field.name); const uniqueFieldNames = Array.from(new Set(sortedFieldNames)); return { key: bucket.key, name: bucket.name, indexType: bucket.indexType, nonUnique: bucket.nonUnique, columnNames: uniqueFieldNames, }; }); }, [indexes]); const selectedIndex = useMemo(() => { if (selectedIndexKeys.length === 0) return null; return groupedIndexes.find(idx => selectedIndexKeys.includes(idx.key)) || null; }, [selectedIndexKeys, groupedIndexes]); const groupedIndexFieldCount = useMemo( () => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0), [groupedIndexes] ); const groupedForeignKeys = useMemo(() => { type FieldItem = { name: string; order: number }; type FkBucket = { key: string; constraintName: string; refTableName: string; order: number; columns: FieldItem[]; refColumns: FieldItem[]; }; const buckets = new Map(); const safeFks = Array.isArray(fks) ? fks : []; safeFks.forEach((fk, order) => { const rawConstraint = String(fk.constraintName || fk.name || '').trim(); const key = rawConstraint || `__unnamed_fk_${order}`; const constraintName = rawConstraint || '(未命名外键)'; const refTableName = String(fk.refTableName || '').trim() || '-'; if (!buckets.has(key)) { buckets.set(key, { key, constraintName, refTableName, order, columns: [], refColumns: [], }); } const bucket = buckets.get(key); if (!bucket) return; if (bucket.refTableName === '-' && refTableName !== '-') { bucket.refTableName = refTableName; } const colName = String(fk.columnName || '').trim(); const refColName = String(fk.refColumnName || '').trim(); if (colName) bucket.columns.push({ name: colName, order }); if (refColName) bucket.refColumns.push({ name: refColName, order }); }); return Array.from(buckets.values()) .sort((a, b) => a.order - b.order) .map((bucket) => { const columnNames = bucket.columns .slice() .sort((a, b) => a.order - b.order) .map(item => item.name); const refColumnNames = bucket.refColumns .slice() .sort((a, b) => a.order - b.order) .map(item => item.name); return { key: bucket.key, name: bucket.constraintName, constraintName: bucket.constraintName, refTableName: bucket.refTableName, columnNames: Array.from(new Set(columnNames)), refColumnNames: Array.from(new Set(refColumnNames)), }; }); }, [fks]); const localColumnOptions = useMemo( () => columns.map(col => ({ label: col.name, value: col.name })), [columns] ); const isStarRocksNewTable = isNewTable && getDbType() === 'starrocks'; const parseStarRocksRollupOptions = (raw: string): StarRocksCreateTableOptions['rollups'] => ( String(raw || '') .split(/\r?\n/) .map(line => line.trim()) .filter(Boolean) .map(line => { const [namePart, columnsPart] = line.split(':'); const name = String(namePart || '').trim(); const columnNames = String(columnsPart || '') .split(',') .map(item => item.trim()) .filter(Boolean); return { name, columnNames }; }) .filter(item => item.name && item.columnNames.length > 0) ); const buildStarRocksCreateOptions = (): StarRocksCreateTableOptions | undefined => { if (!isStarRocksNewTable) return undefined; return { tableKind: starRocksTableKind, keyModel: starRocksKeyModel, keyColumnNames: starRocksKeyColumns, partitionClause: starRocksPartitionClause, distributionType: starRocksDistributionType, distributionColumnNames: starRocksDistributionColumns, bucketMode: starRocksBucketMode, bucketCount: Number(starRocksBucketCount) || undefined, properties: starRocksProperties, rollups: parseStarRocksRollupOptions(starRocksRollups), externalEngine: starRocksExternalEngine, externalProperties: starRocksExternalProperties, }; }; useEffect(() => { if (selectedIndexKeys.length === 0) return; const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key)); if (validKeys.length !== selectedIndexKeys.length) { setSelectedIndexKeys(validKeys); } }, [groupedIndexes, selectedIndexKeys]); useEffect(() => { if (!selectedForeignKey) return; if (!groupedForeignKeys.some(fk => fk.key === selectedForeignKey.key)) { setSelectedForeignKey(null); } }, [groupedForeignKeys, selectedForeignKey]); const escapeBacktickIdentifier = (name: string) => String(name || '').replace(/`/g, '``'); const escapeBracketIdentifier = (name: string) => String(name || '').replace(/]/g, ']]'); const escapeDoubleQuoteIdentifier = (name: string) => String(name || '').replace(/"/g, '""'); const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { const parsed = splitQualifiedNameLast(qualifiedName); return { schemaName: parsed.parentPath, objectName: parsed.objectName, }; }; const isPgLikeDialect = (dbType: string): boolean => isPgLikeSqlDialect(dbType); const isOracleLikeDialect = (dbType: string): boolean => isOracleLikeSqlDialect(dbType); const isSqlServerDialect = (dbType: string): boolean => isSqlServerSqlDialect(dbType); const isMysqlLikeDialect = (dbType: string): boolean => isMysqlFamilySqlDialect(dbType); const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb' || dbType === 'elasticsearch'; const lacksAlterForeignKeySupport = (dbType: string): boolean => dbType === 'sqlite' || dbType === 'duckdb' || dbType === 'tdengine'; const lacksTableCommentSupport = (dbType: string): boolean => dbType === 'sqlite'; const quoteIdentifierPartByDialect = (part: string, dbType: string): string => { return quoteSqlIdentifierPart(dbType, part); }; const quoteIdentifierPathByDialect = (path: string, dbType: string): string => { return quoteSqlIdentifierPath(dbType, path); }; const resolveTableInfo = () => { const dbType = getDbType(); const rawTable = String(tab.tableName || '').trim(); const rawDb = String(tab.dbName || '').trim(); const parsed = splitQualifiedName(rawTable); const table = parsed.objectName || stripIdentifierQuotes(rawTable); let schema = parsed.schemaName; if (!schema) { if (isPgLikeDialect(dbType)) { schema = rawDb || 'public'; } else if (isSqlServerDialect(dbType)) { schema = 'dbo'; } else if (isOracleLikeDialect(dbType)) { schema = rawDb; } else { schema = rawDb; } } const qualifiedName = schema ? `${schema}.${table}` : table; return { dbType, schema: stripIdentifierQuotes(schema), table: stripIdentifierQuotes(table), qualifiedName, tableRef: quoteIdentifierPathByDialect(qualifiedName, dbType), }; }; const hasUnsavedDraftChanges = useMemo(() => { if (isNewTable || readOnly) { return false; } const tableInfo = resolveTableInfo(); return hasAlterTableDraftChanges({ dbType: tableInfo.dbType, tableName: tableInfo.qualifiedName, originalColumns, columns, }); }, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]); const supportsIndexSchemaOps = (): boolean => { const dbType = getDbType(); if (!dbType) return false; if (isNonRelationalDialect(dbType)) return false; return true; }; const supportsForeignKeySchemaOps = (): boolean => { const dbType = getDbType(); if (!dbType) return false; if (isNonRelationalDialect(dbType)) return false; if (lacksAlterForeignKeySupport(dbType)) return false; return true; }; const supportsTableCommentOps = (): boolean => { const dbType = getDbType(); if (!dbType) return false; if (isNonRelationalDialect(dbType)) return false; if (lacksTableCommentSupport(dbType)) return false; return true; }; const getIndexKindOptions = () => { const dbType = getDbType(); if (isMysqlLikeDialect(dbType)) { return [ { label: '普通索引(非聚合)', value: 'NORMAL' }, { label: '唯一索引', value: 'UNIQUE' }, { label: '主键索引(聚合)', value: 'PRIMARY' }, { label: '全文索引', value: 'FULLTEXT' }, { label: '空间索引', value: 'SPATIAL' }, ]; } return [ { label: '普通索引', value: 'NORMAL' }, { label: '唯一索引', value: 'UNIQUE' }, ]; }; const getIndexTypeOptions = (kind?: IndexKind) => { const dbType = getDbType(); const k = kind || 'NORMAL'; if (isMysqlLikeDialect(dbType)) { // MySQL InnoDB: 所有索引均为固定方法类型 if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }]; if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }]; return [{ label: 'BTREE', value: 'BTREE' }]; } if (isPgLikeDialect(dbType)) { if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }]; return PGLIKE_INDEX_TYPE_OPTIONS; } if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS; return [{ label: '默认', value: 'DEFAULT' }]; }; /** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */ const getFixedIndexType = (kind: IndexKind): string | undefined => { const dbType = getDbType(); if (isMysqlLikeDialect(dbType)) { if (kind === 'PRIMARY') return 'BTREE'; if (kind === 'FULLTEXT') return 'FULLTEXT'; if (kind === 'SPATIAL') return 'RTREE'; } if (isPgLikeDialect(dbType)) { if (kind === 'PRIMARY') return 'BTREE'; } return undefined; }; const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { return buildCreateTablePreviewSql({ dbType: getDbType(), tableName: targetTableName, columns: targetColumns, charset: targetCharset, collation: targetCollation, starRocksOptions: buildStarRocksCreateOptions(), }); }; const openCopySelectedColumnsModal = () => { if (selectedColumns.length === 0) { message.warning('请先勾选要复制的字段'); return; } const sourceName = (tab.tableName || 'new_table').trim(); setCopyTableName(`${sourceName}_copy`); setCopyCharset(charset); const charsetCollations = (COLLATIONS as any)[charset] || []; setCopyCollation( charsetCollations.some((item: any) => item.value === collation) ? collation : (charsetCollations[0]?.value || 'utf8mb4_unicode_ci') ); setIsCopyColumnsModalOpen(true); }; const handleExecuteCopySelectedColumns = async () => { if (!copyTableName.trim()) { message.error('请输入目标表名'); return; } if (selectedColumns.length === 0) { message.error('未选择可复制字段'); return; } const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { message.error('Connection not found'); return; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const sql = buildCreateTableSql(copyTableName.trim(), selectedColumns, copyCharset, copyCollation); setCopyExecuting(true); try { const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql); if (res.success) { message.success(`已将 ${selectedColumns.length} 个字段复制到新表 ${copyTableName.trim()}`); setIsCopyColumnsModalOpen(false); } else { message.error("执行失败: " + res.message); } } finally { setCopyExecuting(false); } }; const executeSchemaStatements = async (sqlText: string): Promise => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { return { ok: false, message: '未找到连接', statementCount: 0 }; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const dbType = resolveTableInfo().dbType; const statements = splitSchemaExecutionStatements(sqlText); for (let i = 0; i < statements.length; i++) { const stmt = normalizeSchemaStatementForExecution(statements[i], dbType); const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt); if (!res.success) { const prefix = statements.length > 1 ? `第 ${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: '; return { ok: false, message: prefix + res.message, failedStatementIndex: i, statementCount: statements.length, }; } } return { ok: true, statementCount: statements.length }; }; const buildIndexFormFromRow = (row: IndexDisplayRow): IndexFormState => { return normalizeIndexFormFromRow( row as IndexDisplaySnapshot, getIndexKindOptions().map(item => item.value as IndexKind), ); }; const executeIndexEditSql = async (dropSql: string, addSql: string, previousIndex: IndexDisplayRow): Promise => { const result = await executeSchemaStatements(`${dropSql}\n${addSql}`); if (result.ok) { message.success('索引修改成功'); await fetchData(); return true; } const oldCreateSql = buildIndexCreateSql(buildIndexFormFromRow(previousIndex)); if (!oldCreateSql) { message.error((result.message || '执行失败') + ';且无法自动恢复原索引,请尽快检查'); await fetchData(); return false; } if (!shouldRestoreOriginalIndex(result)) { message.error(result.message || '执行失败'); return false; } const restoreResult = await executeSchemaStatements(oldCreateSql); if (restoreResult.ok) { message.error((result.message || '执行失败') + ';已自动恢复原索引'); } else { message.error((result.message || '执行失败') + `;恢复原索引失败: ${restoreResult.message || '未知错误'}`); } await fetchData(); return false; }; const executeSchemaSql = async (sql: string, successMessage: string): Promise => { try { const result = await executeSchemaStatements(sql); if (!result.ok) { message.error(result.message || '执行失败'); if ((result.failedStatementIndex ?? 0) > 0) await fetchData(); return false; } message.success(successMessage); await fetchData(); return true; } catch (e: any) { message.error('执行失败: ' + (e?.message || String(e))); return false; } }; const openTableCommentModal = () => { setTableCommentDraft(tableComment || ''); setIsTableCommentModalOpen(true); }; const buildTableCommentSql = (nextComment: string): string | null => { const tableInfo = resolveTableInfo(); const dbType = tableInfo.dbType; const escapedComment = escapeSqlString(nextComment); if (isNonRelationalDialect(dbType)) return null; if (isMysqlLikeDialect(dbType)) { return `ALTER TABLE ${tableInfo.tableRef} COMMENT = '${escapedComment}';`; } if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) { return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`; } if (isSqlServerDialect(dbType)) { const schemaName = escapeSqlString(tableInfo.schema || 'dbo'); const tableName = escapeSqlString(tableInfo.table); return `IF EXISTS ( SELECT 1 FROM sys.extended_properties ep JOIN sys.tables t ON ep.major_id = t.object_id AND ep.minor_id = 0 JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE ep.name = N'MS_Description' AND s.name = N'${schemaName}' AND t.name = N'${tableName}' ) BEGIN EXEC sp_updateextendedproperty @name = N'MS_Description', @value = N'${escapedComment}', @level0type = N'SCHEMA', @level0name = N'${schemaName}', @level1type = N'TABLE', @level1name = N'${tableName}'; END ELSE BEGIN EXEC sp_addextendedproperty @name = N'MS_Description', @value = N'${escapedComment}', @level0type = N'SCHEMA', @level0name = N'${schemaName}', @level1type = N'TABLE', @level1name = N'${tableName}'; END;`; } return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`; }; const handleSaveTableComment = async () => { if (!supportsTableCommentOps()) { message.warning('当前数据库暂不支持在此修改表备注'); return; } if (!tab.tableName) return; const sql = buildTableCommentSql(tableCommentDraft); if (!sql) { message.warning('当前数据库暂不支持在此修改表备注'); return; } setTableCommentSaving(true); const ok = await executeSchemaSql(sql, '表备注更新成功'); setTableCommentSaving(false); if (ok) { setTableComment(tableCommentDraft); setIsTableCommentModalOpen(false); } }; const openCreateIndexModal = () => { setIndexModalMode('create'); setIndexForm({ name: '', columnNames: [], kind: 'NORMAL', indexType: 'DEFAULT', }); setIsIndexModalOpen(true); }; const openEditIndexModal = () => { if (!selectedIndex) { message.warning('请先选择一个索引'); return; } setIndexModalMode('edit'); setIndexForm(buildIndexFormFromRow(selectedIndex)); setIsIndexModalOpen(true); }; const getIndexCreateSqlResult = (form: IndexFormState) => { const tableInfo = resolveTableInfo(); return buildIndexCreateSqlPreview({ dbType: tableInfo.dbType, tableRef: tableInfo.tableRef, name: form.name, columnNames: form.columnNames, kind: form.kind, indexType: form.indexType, }); }; const buildIndexCreateSql = (form: IndexFormState): string | null => { const result = getIndexCreateSqlResult(form); if (!result.sql) { if (result.severity === 'warning') { message.warning(result.message || '无法生成索引创建语句'); } else { message.error(result.message || '无法生成索引创建语句'); } return null; } return result.sql; }; const indexCreatePreviewSql = useMemo(() => { if (!isIndexModalOpen) return ''; const result = getIndexCreateSqlResult(indexForm); return result.sql || `-- ${result.message || '填写索引信息后生成创建语句'}`; }, [connections, indexForm, isIndexModalOpen, tab.connectionId, tab.dbName, tab.tableName]); const selectedIndexCreateSql = useMemo(() => { if (!selectedIndex || selectedIndexKeys.length !== 1) return ''; const result = getIndexCreateSqlResult(buildIndexFormFromRow(selectedIndex)); return result.sql || `-- ${result.message || '无法生成索引创建语句'}`; }, [connections, selectedIndex, selectedIndexKeys.length, tab.connectionId, tab.dbName, tab.tableName]); const indexTableHeight = selectedIndexCreateSql ? Math.max(180, tableHeight - 220) : tableHeight; const buildIndexDropSql = (indexName: string): string | null => { const tableInfo = resolveTableInfo(); const dbType = tableInfo.dbType; const name = String(indexName || '').trim(); if (!name) return null; if (isMysqlLikeDialect(dbType)) { if (name.toUpperCase() === 'PRIMARY') { return `ALTER TABLE ${tableInfo.tableRef}\nDROP PRIMARY KEY;`; } const indexRef = quoteIdentifierPartByDialect(name, dbType); return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`; } if (isSqlServerDialect(dbType)) { const indexRef = quoteIdentifierPartByDialect(name, dbType); return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`; } if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType) || dbType === 'sqlite') { const fullIndexName = name.includes('.') || !tableInfo.schema ? name : `${tableInfo.schema}.${name}`; const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType); return `DROP INDEX ${indexRef};`; } if (isNonRelationalDialect(dbType)) { return null; } const fullIndexName = name.includes('.') || !tableInfo.schema ? name : `${tableInfo.schema}.${name}`; const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType); return `DROP INDEX ${indexRef};`; }; const handleSubmitIndex = async () => { if (!supportsIndexSchemaOps()) { message.warning('当前数据库暂不支持在此维护索引'); return; } if (!tab.tableName) return; const supportedKinds = new Set(getIndexKindOptions().map(item => item.value)); if (!supportedKinds.has(indexForm.kind)) { message.warning('当前数据库不支持该索引类型'); return; } const nextName = indexForm.kind === 'PRIMARY' ? 'PRIMARY' : String(indexForm.name || '').trim(); if (indexForm.kind !== 'PRIMARY' && !nextName) { message.error('请输入索引名'); return; } if (indexForm.columnNames.length === 0) { message.error('请至少选择一个字段'); return; } const upperName = nextName.toUpperCase(); const duplicate = groupedIndexes.some(idx => { if (indexModalMode === 'edit' && selectedIndex && idx.key === selectedIndex.key) return false; return idx.name.toUpperCase() === upperName; }); if (duplicate) { message.error(`索引名已存在:${nextName}`); return; } setIndexSaving(true); const addSql = buildIndexCreateSql({ ...indexForm, name: nextName }); if (!addSql) { setIndexSaving(false); return; } let sql = addSql; if (indexModalMode === 'edit' && selectedIndex) { const previousForm = buildIndexFormFromRow(selectedIndex); const nextForm: IndexFormState = { name: indexForm.kind === 'PRIMARY' ? 'PRIMARY' : nextName, columnNames: [...indexForm.columnNames], kind: indexForm.kind, indexType: indexForm.kind === 'NORMAL' || indexForm.kind === 'UNIQUE' ? (String(indexForm.indexType || '').trim().toUpperCase() || 'DEFAULT') : 'DEFAULT', }; if (!hasIndexFormChanged(previousForm, nextForm)) { setIndexSaving(false); message.info('没有检测到索引变更'); return; } const dropSql = buildIndexDropSql(selectedIndex.name); if (!dropSql) { setIndexSaving(false); message.warning('当前数据库暂不支持删除该索引'); return; } const ok = await executeIndexEditSql(dropSql, addSql, selectedIndex); setIndexSaving(false); if (ok) { setIsIndexModalOpen(false); } return; } const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功'); setIndexSaving(false); if (ok) { setIsIndexModalOpen(false); } }; const handleDeleteIndex = () => { if (selectedIndexKeys.length === 0) { message.warning('请先选择要删除的索引'); return; } if (!supportsIndexSchemaOps()) { message.warning('当前数据库暂不支持在此维护索引'); return; } // 根据选中的 key 找到对应的索引对象 const toDelete = groupedIndexes.filter(idx => selectedIndexKeys.includes(idx.key)); if (toDelete.length === 0) { message.warning('请先选择要删除的索引'); return; } const names = toDelete.map(idx => `"${idx.name}"`).join('、'); Modal.confirm({ title: '确认删除索引', icon: , content: toDelete.length === 1 ? `确定删除索引 ${names} 吗?` : `确定删除以下 ${toDelete.length} 个索引吗?\n${names}`, okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { const sqls: string[] = []; for (const idx of toDelete) { const sql = buildIndexDropSql(idx.name); if (!sql) { message.warning(`当前数据库暂不支持删除索引 "${idx.name}"`); return; } sqls.push(sql); } const ok = await executeSchemaSql(sqls.join('\n'), toDelete.length === 1 ? '索引删除成功' : `${toDelete.length} 个索引删除成功`); if (ok) { setSelectedIndexKeys([]); } } }); }; const openCreateForeignKeyModal = () => { setForeignKeyModalMode('create'); setForeignKeyForm({ constraintName: '', columnNames: [], refTableName: '', refColumnNames: [], }); setIsForeignKeyModalOpen(true); }; const openEditForeignKeyModal = () => { if (!selectedForeignKey) { message.warning('请先选择一个外键'); return; } setForeignKeyModalMode('edit'); setForeignKeyForm({ constraintName: selectedForeignKey.constraintName, columnNames: [...selectedForeignKey.columnNames], refTableName: selectedForeignKey.refTableName === '-' ? '' : selectedForeignKey.refTableName, refColumnNames: [...selectedForeignKey.refColumnNames], }); setIsForeignKeyModalOpen(true); }; const buildForeignKeyAddSql = (form: ForeignKeyFormState): string | null => { const tableInfo = resolveTableInfo(); const dbType = tableInfo.dbType; if (!supportsForeignKeySchemaOps()) return null; const localColsSql = form.columnNames .map(col => quoteIdentifierPartByDialect(col, dbType)) .join(', '); const refColsSql = form.refColumnNames .map(col => quoteIdentifierPartByDialect(col, dbType)) .join(', '); const refParts = splitQualifiedName(form.refTableName); const refObjectName = refParts.objectName || String(form.refTableName || '').trim(); const refTableName = !refParts.schemaName && tableInfo.schema && (isPgLikeDialect(dbType) || isSqlServerDialect(dbType) || isOracleLikeDialect(dbType)) ? `${tableInfo.schema}.${refObjectName}` : String(form.refTableName || '').trim(); const refTableSql = quoteIdentifierPathByDialect(refTableName, dbType); const constraintSql = quoteIdentifierPartByDialect(form.constraintName, dbType); return `ALTER TABLE ${tableInfo.tableRef}\nADD CONSTRAINT ${constraintSql} FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql});`; }; const buildForeignKeyDropSql = (constraintName: string): string | null => { const tableInfo = resolveTableInfo(); const dbType = tableInfo.dbType; if (!supportsForeignKeySchemaOps()) return null; const constraintSql = quoteIdentifierPartByDialect(constraintName, dbType); if (isMysqlLikeDialect(dbType)) { return `ALTER TABLE ${tableInfo.tableRef}\nDROP FOREIGN KEY ${constraintSql};`; } return `ALTER TABLE ${tableInfo.tableRef}\nDROP CONSTRAINT ${constraintSql};`; }; const handleSubmitForeignKey = async () => { if (!supportsForeignKeySchemaOps()) { message.warning('当前数据库暂不支持在此维护外键'); return; } if (!tab.tableName) return; const nextConstraint = String(foreignKeyForm.constraintName || '').trim(); const refTable = String(foreignKeyForm.refTableName || '').trim(); const refCols = foreignKeyForm.refColumnNames.map(v => String(v || '').trim()).filter(Boolean); const localCols = foreignKeyForm.columnNames.map(v => String(v || '').trim()).filter(Boolean); if (!nextConstraint) { message.error('请输入外键约束名'); return; } if (localCols.length === 0) { message.error('请至少选择一个本表字段'); return; } if (!refTable) { message.error('请输入参考表'); return; } if (refCols.length === 0) { message.error('请至少填写一个参考字段'); return; } if (localCols.length !== refCols.length) { message.error('本表字段数量与参考字段数量必须一致'); return; } const duplicate = groupedForeignKeys.some(item => { if (foreignKeyModalMode === 'edit' && selectedForeignKey && item.key === selectedForeignKey.key) return false; return item.constraintName.toUpperCase() === nextConstraint.toUpperCase(); }); if (duplicate) { message.error(`外键约束名已存在:${nextConstraint}`); return; } setForeignKeySaving(true); const addSql = buildForeignKeyAddSql({ ...foreignKeyForm, constraintName: nextConstraint, columnNames: localCols, refTableName: refTable, refColumnNames: refCols, }); if (!addSql) { setForeignKeySaving(false); message.warning('当前数据库暂不支持在此维护外键'); return; } let sql = addSql; if (foreignKeyModalMode === 'edit' && selectedForeignKey) { const dropSql = buildForeignKeyDropSql(selectedForeignKey.constraintName); if (!dropSql) { setForeignKeySaving(false); message.warning('当前数据库暂不支持删除该外键'); return; } sql = `${dropSql}\n${addSql}`; } const ok = await executeSchemaSql(sql, foreignKeyModalMode === 'create' ? '外键新增成功' : '外键修改成功'); setForeignKeySaving(false); if (ok) { setIsForeignKeyModalOpen(false); } }; const handleDeleteForeignKey = () => { if (!selectedForeignKey) { message.warning('请先选择一个外键'); return; } if (!supportsForeignKeySchemaOps()) { message.warning('当前数据库暂不支持在此维护外键'); return; } Modal.confirm({ title: '确认删除外键', icon: , content: `确定删除外键约束 "${selectedForeignKey.constraintName}" 吗?`, okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { const sql = buildForeignKeyDropSql(selectedForeignKey.constraintName); if (!sql) { message.warning('当前数据库暂不支持删除该外键'); return; } await executeSchemaSql(sql, '外键删除成功'); } }); }; const onDragEnd = ({ active, over }: any) => { if (active.id !== over?.id) { setColumns((previous) => { const activeIndex = previous.findIndex((i) => i._key === active.id); const overIndex = previous.findIndex((i) => i._key === over?.id); return arrayMove(previous, activeIndex, overIndex); }); } }; const generateDDL = () => { if (isNewTable && !newTableName.trim()) { message.error("请输入表名"); return; } if (columns.length === 0) { message.error("请至少添加一个字段"); return; } if (isNewTable) { // CREATE TABLE const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation); setPreviewSql(sql); setIsPreviewOpen(true); } else { const tableInfo = resolveTableInfo(); if (tableInfo.dbType === 'duckdb') { const pkChange = summarizeDuckDbPrimaryKeyChange(originalColumns, columns); if (pkChange.isUnsupportedChange) { message.warning('DuckDB 当前仅支持为无主键表新增主键;已有主键的修改或删除需要通过重建表完成。'); return; } } const sql = buildAlterTablePreviewSql({ dbType: tableInfo.dbType, tableName: tableInfo.qualifiedName, originalColumns, columns, }); if (!sql.trim()) { message.info("没有检测到变更"); return; } setPreviewSql(sql); setIsPreviewOpen(true); } }; const handleRefreshDesigner = () => { if (!hasUnsavedDraftChanges) { void fetchData(); return; } Modal.confirm({ title: '存在未保存的字段变更', icon: , content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?', okText: '仍然刷新', cancelText: '取消', onOk: async () => { await fetchData(); }, }); }; const handleExecuteSave = async () => { const result = await executeSchemaStatements(previewSql); if (!result.ok) { message.error(result.message || "执行失败"); return; } message.success(isNewTable ? "表创建成功!" : "表结构修改成功!"); setIsPreviewOpen(false); if (!isNewTable) { fetchData(); } else { // TODO: Close tab or reload sidebar? // Ideally, refresh sidebar node. } }; // Merge columns with resize handler const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({ ...col, onHeaderCell: (column: any) => ({ width: column.width, onResizeStart: handleResizeStart(index), }), })), [tableColumns]); // 字段表 Checkbox 选择列(不参与 resize,支持全选) const allColumnKeys = useMemo(() => columns.map(c => c._key), [columns]); const isAllColumnsSelected = allColumnKeys.length > 0 && selectedColumnRowKeys.length === allColumnKeys.length; const isColumnsIndeterminate = selectedColumnRowKeys.length > 0 && selectedColumnRowKeys.length < allColumnKeys.length; const columnSelectCol = useMemo(() => ({ title: () => (
setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])} style={{ margin: 0 }} />
), dataIndex: '_select', key: '_select', width: 44, className: 'table-designer-select-column', onHeaderCell: () => ({ className: 'table-designer-select-column' }), onCell: () => ({ className: 'table-designer-select-column' }), render: (_: any, record: any) => (
{ e.stopPropagation(); setSelectedColumnRowKeys((prev: string[]) => e.target.checked ? [...prev, record._key] : prev.filter((k: string) => k !== record._key) ); }} style={{ margin: 0 }} />
), }), [selectedColumnRowKeys, allColumnKeys, isAllColumnsSelected, isColumnsIndeterminate]); // sort 拖拽列(不参与 resize) const sortColumn = useMemo(() => ({ key: 'sort', width: 40, render: () => , }), []); const columnsWithSelect = useMemo(() => readOnly ? resizableColumns : [columnSelectCol, sortColumn, ...resizableColumns], [readOnly, columnSelectCol, sortColumn, resizableColumns] ); // --- Index Columns Init --- useEffect(() => { setIndexColumns([ { title: '索引名', dataIndex: 'name', key: 'name', width: 240, render: (text: string) => ( {text} ), }, { title: '字段', dataIndex: 'columnNames', key: 'columnNames', width: 320, render: (columnNames: string[]) => { if (!columnNames || columnNames.length === 0) { return '-'; } return (
{columnNames.map((columnName: string, idx: number) => ( {columnName} ))}
); } }, { title: '索引类型', dataIndex: 'indexType', key: 'indexType', width: 140, render: (text: string) => text || '-', }, { title: '唯一性', dataIndex: 'nonUnique', key: 'nonUnique', width: 110, render: (v: number) => ( {v === 0 ? '唯一' : '普通'} ), }, ]); }, []); // Checkbox 选择列(不参与 resize,支持全选) const allIndexKeys = groupedIndexes.map(idx => idx.key); const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length; const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length; const toggleIndexSelection = (key: string, checked?: boolean) => { setSelectedIndexKeys(prev => getNextIndexSelection(prev, key, checked)); }; const selectColumn = { title: () => ( e.stopPropagation()} onChange={(e) => { setSelectedIndexKeys(e.target.checked ? allIndexKeys : []); }} style={{ margin: 0 }} /> ), dataIndex: '_select', key: '_select', width: 48, className: 'table-designer-select-column', onHeaderCell: () => ({ className: 'table-designer-select-column' }), onCell: () => ({ className: 'table-designer-select-column' }), render: (_: any, record: any) => ( { e.stopPropagation(); toggleIndexSelection(record.key); }} style={{ display: 'inline-flex' }} > undefined} style={{ margin: 0, pointerEvents: 'none' }} /> ), }; const resizableIndexColumns = [ selectColumn, ...indexColumns.map((col, index) => ({ ...col, onHeaderCell: (column: any) => ({ width: column.width, onResizeStart: handleIndexResizeStart(index), }), })), ]; const starRocksAdvancedTabContent = (
setStarRocksTableKind(e.target.value)} optionType="button" buttonStyle="solid" options={[ { label: 'OLAP 表', value: 'olap' }, { label: '外部表', value: 'external' }, ]} /> {starRocksTableKind === 'olap' ? ( <> setStarRocksPartitionClause(e.target.value)} autoSize={{ minRows: 3, maxRows: 8 }} placeholder={'PARTITION BY date_trunc(\'day\', `event_time`)\n-- 或按业务需要填写完整 PARTITION BY 子句'} /> setStarRocksBucketCount(e.target.value.replace(/[^\d]/g, ''))} placeholder="Buckets" style={{ width: 120 }} /> setStarRocksProperties(e.target.value)} autoSize={{ minRows: 3, maxRows: 8 }} placeholder={'"replication_num" = "1"\n"storage_medium" = "SSD"'} /> setStarRocksRollups(e.target.value)} autoSize={{ minRows: 3, maxRows: 8 }} placeholder={'rollup_name: column1, column2\nrollup_daily: dt, user_id'} /> ) : ( <> setNewTableName(e.target.value)} style={{ width: 150 }} /> )} {!readOnly && } {!isNewTable && } {!isNewTable && !readOnly && supportsTableCommentOps() && ( )} {!readOnly && } {!readOnly && ( )} {!readOnly && ( )}
React.startTransition(() => setActiveKey(key))} style={{ flex: 1, minHeight: 0, padding: embedded ? 0 : '0 10px 10px 10px', borderBottomLeftRadius: embedded ? 0 : panelRadius, borderBottomRightRadius: embedded ? 0 : panelRadius, borderLeft: `1px solid ${panelFrameColor}`, borderRight: `1px solid ${panelFrameColor}`, borderBottom: `1px solid ${panelFrameColor}`, background: panelBodyBg }} items={[ { key: 'columns', label: '字段', children: columnsTabContent }, ...(isStarRocksNewTable ? [ { key: 'starrocks', label: 'StarRocks', children: starRocksAdvancedTabContent, }, ] : []), ...(!isNewTable ? [ { key: 'indexes', label: '索引', children: (
{!readOnly && (
{!supportsIndexSchemaOps() && ( 当前数据库暂不支持索引编辑,仅支持查看 )} {supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && ( 已选择:{selectedIndexKeys.length} 个索引 )}
)}
索引数:{groupedIndexes.length},索引字段:{groupedIndexFieldCount}
({ onClick: () => { toggleIndexSelection(record.key); }, style: { cursor: 'pointer' } })} /> {selectedIndexCreateSql && selectedIndex && (
创建语句:{selectedIndex.name}
)} ) }, { key: 'foreignKeys', label: '外键', children: (
{!readOnly && (
{!supportsForeignKeySchemaOps() && ( 当前数据库暂不支持外键编辑,仅支持查看 )} {supportsForeignKeySchemaOps() && selectedForeignKey && ( 已选择:{selectedForeignKey.constraintName} )}
)}
vals?.length ? vals.join(', ') : '-', }, { title: '参考表', dataIndex: 'refTableName', key: 'refTableName', width: 220 }, { title: '参考字段', dataIndex: 'refColumnNames', key: 'refColumnNames', render: (vals: string[]) => vals?.length ? vals.join(', ') : '-', }, ]} rowKey="key" size="small" pagination={false} loading={loading} scroll={{ x: 980, y: tableHeight }} rowSelection={{ type: 'radio', selectedRowKeys: selectedForeignKey ? [selectedForeignKey.key] : [], onChange: (_, selectedRows) => setSelectedForeignKey((selectedRows[0] as ForeignKeyDisplayRow) || null), }} onRow={(record) => ({ onClick: () => { if (selectedForeignKey?.key === record.key) { setSelectedForeignKey(null); } else { setSelectedForeignKey(record); } }, style: { cursor: 'pointer' } })} /> ) }, { key: 'triggers', label: '触发器', children: (
{!readOnly && ( <> )} {selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'}
}} rowSelection={{ type: 'radio', selectedRowKeys: selectedTrigger ? [selectedTrigger.name] : [], onChange: (_, selectedRows) => setSelectedTrigger(selectedRows[0] || null), onSelect: (record, selected) => { // 点击单选按钮时,如果已选中则取消 if (selectedTrigger?.name === record.name) { setSelectedTrigger(null); } else { setSelectedTrigger(record); } }, }} onRow={(record) => ({ onClick: () => { // 点击已选中的行时取消选择 if (selectedTrigger?.name === record.name) { setSelectedTrigger(null); } else { setSelectedTrigger(record); } }, style: { cursor: 'pointer' } })} /> ) } ] : []), ...(!isNewTable ? [{ key: 'ddl', label: 'DDL', icon: , children: (
) }] : []) ]} /> { if (commentEditorColumnKey) { handleColumnChange(commentEditorColumnKey, 'comment', commentEditorValue); } closeCommentEditor(); }} okText="应用" cancelText="取消" width={640} destroyOnHidden > setCommentEditorValue(e.target.value)} autoSize={{ minRows: 8, maxRows: 18 }} placeholder="请输入字段注释" maxLength={2000} /> setIsCopyColumnsModalOpen(false)} onOk={handleExecuteCopySelectedColumns} okText="创建新表" cancelText="取消" confirmLoading={copyExecuting} width={560} >
已选择字段:{selectedColumns.length}
setCopyTableName(e.target.value)} maxLength={128} />
setIsTableCommentModalOpen(false)} onOk={handleSaveTableComment} okText="保存" cancelText="取消" confirmLoading={tableCommentSaving} width={640} > setTableCommentDraft(e.target.value)} autoSize={{ minRows: 5, maxRows: 12 }} placeholder="请输入表备注" maxLength={2048} />
当前备注:{tableComment || '(空)'}
setIsIndexModalOpen(false)} onOk={handleSubmitIndex} okText={indexModalMode === 'create' ? '创建' : '保存'} cancelText="取消" confirmLoading={indexSaving} width={620} > setIndexForm(prev => ({ ...prev, name: e.target.value }))} maxLength={128} disabled={indexForm.kind === 'PRIMARY'} /> { const fixedType = getFixedIndexType(val); if (fixedType) { // 固定类型(PRIMARY/FULLTEXT/SPATIAL)直接设置对应的索引方法 setIndexForm(prev => ({ ...prev, kind: val, name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name), indexType: fixedType, })); } else { const nextTypeOptions = getIndexTypeOptions(val); const currentType = indexForm.indexType || 'DEFAULT'; const isCurrentTypeValid = nextTypeOptions.some(opt => opt.value === currentType); setIndexForm(prev => ({ ...prev, kind: val, name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name), indexType: isCurrentTypeValid ? currentType : 'DEFAULT', })); } }} style={{ width: 220 }} /> setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))} maxLength={128} /> setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))} maxLength={256} />