Files
MyGoNavi/frontend/src/components/TableDesigner.tsx
Syngnat 6b67bb24b4 feat(tool-center): 优化工具中心分组交互与通用弹窗
- 重构工具中心为侧边分组导航,固定弹窗高度并支持内部滚动
- 新增通用可拖拽可缩放 Modal,统一主要弹窗打开体验
- 为工具中心内嵌入口补充返回上一步交互与底部操作区
- 补充多语言文案和工具中心/Modal/i18n 回归测试
2026-06-18 20:28:47 +08:00

3654 lines
156 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Modal from './common/ResizableDraggableModal';
import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, 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 { getCurrentLanguage, t } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import {
getColumnDefinitionExtra,
normalizeColumnDefinition,
} from '../utils/columnDefinition';
import { buildEditableTriggerSql } from '../utils/triggerEditSql';
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<string, { value: string }[]> = {
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: 'DEFAULT', 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: 'DEFAULT', value: 'DEFAULT' },
{ label: 'CLUSTERED', value: 'CLUSTERED' },
{ label: 'NONCLUSTERED', value: 'NONCLUSTERED' },
];
const CHARSETS = [
{ value: 'utf8mb4' },
{ value: 'utf8' },
{ value: 'latin1' },
{ value: 'ascii' },
];
const getCharsetOptions = (i18nLanguage: string) => CHARSETS.map(({ value }) => ({
label: value === 'utf8mb4'
? `${value} ${t('table_designer.option.recommended_suffix', undefined, i18nLanguage)}`
: value,
value,
}));
const COLLATIONS = {
'utf8mb4': [
{ label: 'utf8mb4_unicode_ci', 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' },
]
};
const getCollationOptions = (i18nLanguage: string) => Object.fromEntries(
Object.entries(COLLATIONS).map(([charset, options]) => [
charset,
options.map((option, index) => option.value === 'utf8mb4_unicode_ci' && index === 0
? { ...option, label: `${option.value} (${t('table_designer.option.default', undefined, i18nLanguage)})` }
: option),
]),
) as typeof COLLATIONS;
const useTableDesignerI18nLanguage = () => {
const i18n = useOptionalI18n();
return i18n?.language ?? getCurrentLanguage();
};
// --- 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 <th {...restProps} style={nextStyle} />;
}
return (
<th {...restProps} style={{ ...nextStyle, position: 'relative' }}>
{restProps.children}
<span
className="react-resizable-handle"
onMouseDown={(e) => {
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',
}}
/>
</th>
);
};
// --- Sortable Row Component ---
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
'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 (
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, child => {
if ((child as React.ReactElement).key === 'sort') {
return React.cloneElement(child as React.ReactElement, {
children: (
<MenuOutlined
style={{ cursor: 'grab', color: '#999' }}
{...listeners}
/>
),
});
}
return child;
})}
</tr>
);
};
const renderDesignerCellField = (content: React.ReactNode, className?: string) => (
<div className={`table-designer-cell-field${className ? ` ${className}` : ''}`}>
{content}
</div>
);
const renderDesignerCellCheck = (content: React.ReactNode, className?: string) => (
<div className={`table-designer-cell-check${className ? ` ${className}` : ''}`}>
{content}
</div>
);
const renderDesignerHeaderTitle = (title: string) => (
<span className="table-designer-header-title">{title}</span>
);
const TableDesigner: React.FC<{ tab: TabData; embedded?: boolean }> = ({ tab, embedded = false }) => {
const isNewTable = !tab.tableName;
const [columns, setColumns] = useState<EditableColumn[]>([]);
const [originalColumns, setOriginalColumns] = useState<EditableColumn[]>([]);
const [indexes, setIndexes] = useState<IndexDefinition[]>([]);
const [fks, setFks] = useState<ForeignKeyDefinition[]>([]);
const [triggers, setTriggers] = useState<TriggerDefinition[]>([]);
const [ddl, setDdl] = useState<string>('');
// New Table State
const [newTableName, setNewTableName] = useState('');
const [charset, setCharset] = useState('utf8mb4');
const [collation, setCollation] = useState('utf8mb4_unicode_ci');
const [starRocksTableKind, setStarRocksTableKind] = useState<StarRocksTableKind>('olap');
const [starRocksKeyModel, setStarRocksKeyModel] = useState<StarRocksKeyModel>('DUPLICATE');
const [starRocksKeyColumns, setStarRocksKeyColumns] = useState<string[]>([]);
const [starRocksPartitionClause, setStarRocksPartitionClause] = useState('');
const [starRocksDistributionType, setStarRocksDistributionType] = useState<StarRocksDistributionType>('HASH');
const [starRocksDistributionColumns, setStarRocksDistributionColumns] = useState<string[]>([]);
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<string>('');
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [activeKey, setActiveKey] = useState(tab.initialTab || "columns");
const [selectedColumnRowKeys, setSelectedColumnRowKeys] = useState<string[]>([]);
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<string[]>([]);
const [isIndexModalOpen, setIsIndexModalOpen] = useState(false);
const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create');
const [indexSaving, setIndexSaving] = useState(false);
const [indexForm, setIndexForm] = useState<IndexFormState>({
name: '',
columnNames: [],
kind: 'NORMAL',
indexType: 'DEFAULT',
});
const [selectedForeignKey, setSelectedForeignKey] = useState<ForeignKeyDisplayRow | null>(null);
const [isForeignKeyModalOpen, setIsForeignKeyModalOpen] = useState(false);
const [foreignKeyModalMode, setForeignKeyModalMode] = useState<'create' | 'edit'>('create');
const [foreignKeySaving, setForeignKeySaving] = useState(false);
const [foreignKeyForm, setForeignKeyForm] = useState<ForeignKeyFormState>({
constraintName: '',
columnNames: [],
refTableName: '',
refColumnNames: [],
});
const [selectedTrigger, setSelectedTrigger] = useState<TriggerDefinition | null>(null);
const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false);
const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false);
const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create');
const [triggerEditSql, setTriggerEditSql] = useState<string>('');
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 addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const i18nLanguage = useTableDesignerI18nLanguage();
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
const readOnly = !!tab.readOnly;
const designerTableTitle = tab.tableName || newTableName || t('table_designer.title.untitled_table', undefined, i18nLanguage);
const designerDbTitle = tab.dbName || t('table_designer.title.default_database', undefined, i18nLanguage);
const designerColumnSummary = t('table_designer.summary.columns', { count: columns.length }, i18nLanguage);
const charsetOptions = useMemo(() => getCharsetOptions(i18nLanguage), [i18nLanguage]);
const collationOptions = useMemo(() => getCollationOptions(i18nLanguage), [i18nLanguage]);
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<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const pendingFocusColumnKeyRef = useRef<string | null>(null);
const focusHighlightTimerRef = useRef<number | null>(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<any[]>([]);
const [indexColumns, setIndexColumns] = useState<any[]>([]);
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch<React.SetStateAction<any[]>> } | null>(null);
const resizeRafRef = useRef<number | null>(null);
const latestResizeXRef = useRef<number | null>(null);
const ghostRef = useRef<HTMLDivElement>(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(t('table_designer.column.name', undefined, i18nLanguage)),
dataIndex: 'name',
key: 'name',
width: 180,
render: (text: string, record: EditableColumn) => readOnly ? text : (
renderDesignerCellField(
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
)
)
},
{
title: renderDesignerHeaderTitle(t('table_designer.column.type', undefined, i18nLanguage)),
dataIndex: 'type',
key: 'type',
width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : (
renderDesignerCellField(
<AutoComplete options={columnTypeOptions} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />,
'is-compact'
)
)
},
{
title: renderDesignerHeaderTitle(t('table_designer.column.primary_key', undefined, i18nLanguage)),
dataIndex: 'key',
key: 'key',
width: 60,
align: 'center',
render: (text: string, record: EditableColumn) => (
renderDesignerCellCheck(
<Checkbox checked={text === 'PRI'} disabled={readOnly} onChange={e => handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} />,
'is-left-aligned'
)
)
},
{
title: renderDesignerHeaderTitle(t('table_designer.column.auto_increment', undefined, i18nLanguage)),
dataIndex: 'isAutoIncrement',
key: 'isAutoIncrement',
width: 60,
align: 'center',
render: (val: boolean, record: EditableColumn) => (
renderDesignerCellCheck(
<Checkbox checked={val} disabled={readOnly} onChange={e => handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} />,
'is-left-aligned'
)
)
},
{
title: renderDesignerHeaderTitle(t('table_designer.column.not_null', undefined, i18nLanguage)),
dataIndex: 'nullable',
key: 'nullable',
width: 80,
align: 'center',
render: (text: string, record: EditableColumn) => (
renderDesignerCellCheck(
<Checkbox checked={text === 'NO'} disabled={readOnly || record.key === 'PRI'} onChange={e => handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} />,
'is-left-aligned'
)
)
},
{
title: renderDesignerHeaderTitle(t('table_designer.column.default', undefined, i18nLanguage)),
dataIndex: 'default',
key: 'default',
width: 180, // Increased default width
render: (text: string, record: EditableColumn) => readOnly ? text : (
renderDesignerCellField(
<AutoComplete options={COMMON_DEFAULTS} value={text} onChange={val => handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" />
)
)
},
{
title: renderDesignerHeaderTitle(t('table_designer.column.comment', undefined, i18nLanguage)),
dataIndex: 'comment',
key: 'comment',
width: 200,
render: (text: string, record: EditableColumn) => readOnly ? (
<Tooltip title={text || ''}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text || ''}</div>
</Tooltip>
) : (
<div className="table-designer-cell-field table-designer-comment-field">
{inlineCommentEditingKey !== record._key ? (
<Tooltip title={text || ''}>
<div
className={`table-designer-comment-display${text ? '' : ' is-empty'}`}
onDoubleClick={() => startInlineCommentEdit(record)}
>
{text || '\u00A0'}
</div>
</Tooltip>
) : (
<Input
value={text}
onChange={e => handleColumnChange(record._key, 'comment', e.target.value)}
onBlur={finishInlineCommentEdit}
onPressEnter={finishInlineCommentEdit}
autoFocus={inlineCommentEditingKey === record._key}
variant="borderless"
/>
)}
<Tooltip title={t('table_designer.tooltip.edit_comment_popup', undefined, i18nLanguage)}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openCommentEditor(record)}
/>
</Tooltip>
</div>
)
},
...(readOnly ? [] : [{
title: renderDesignerHeaderTitle(t('table_designer.column.actions', undefined, i18nLanguage)),
key: 'action',
width: 92,
className: 'table-designer-action-column',
onHeaderCell: () => ({ className: 'table-designer-action-column' }),
render: (_: any, record: EditableColumn) => (
<div className="table-designer-action-cell">
<Tooltip title={t('table_designer.tooltip.edit_comment_popup', undefined, i18nLanguage)}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openCommentEditor(record)} />
</Tooltip>
<Tooltip title={t('table_designer.action.delete', undefined, i18nLanguage)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteColumn(record._key)} />
</Tooltip>
</div>
)
}])
];
setTableColumns(initialCols);
}, [connections, finishInlineCommentEdit, i18nLanguage, inlineCommentEditingKey, openCommentEditor, readOnly, startInlineCommentEdit, tab.connectionId]); // Re-create when datasource dialect, language, inline comment state, 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<React.SetStateAction<any[]>>) => (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(t('table_designer.message.connection_not_found', undefined, i18nLanguage));
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<any>[] = [
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(t('table_designer.message.load_columns_failed', { detail: colsRes.message }, i18nLanguage));
}
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';
if (normalized === 'gauss_db' || normalized === 'gauss-db') return 'gaussdb';
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
-- Trigger logic
END;`;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
case 'gaussdb':
return `CREATE OR REPLACE FUNCTION trigger_function_name()
RETURNS TRIGGER AS $$
BEGIN
-- Trigger logic
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;
-- Trigger logic
END;`;
case 'oracle':
case 'dameng':
case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
FOR EACH ROW
BEGIN
-- Trigger logic
NULL;
END;`;
case 'sqlite':
return `CREATE TRIGGER trigger_name
AFTER INSERT ON "${tblName}"
BEGIN
-- Trigger logic
END;`;
default:
return `-- Enter a CREATE TRIGGER statement`;
}
};
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':
case 'gaussdb':
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;
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 || '-- Trigger definition unavailable';
}
const dbName = String(tab.dbName || '').trim();
setActiveContext({ connectionId: tab.connectionId, dbName });
addTab({
id: `query-edit-trigger-${tab.connectionId}-${dbName}-${tab.tableName || ''}-${selectedTrigger.name}-${Date.now()}`,
title: `修改触发器: ${selectedTrigger.name}`,
type: 'query',
connectionId: tab.connectionId,
dbName,
query: buildEditableTriggerSql(selectedTrigger.name, createSql, {
dropSql: buildDropTriggerSql(selectedTrigger.name),
}),
queryMode: 'object-edit',
});
};
const handleDeleteTrigger = () => {
if (!selectedTrigger) return;
Modal.confirm({
title: t('table_designer.modal.delete_trigger_title', undefined, i18nLanguage),
icon: <ExclamationCircleOutlined />,
content: t('table_designer.modal.delete_trigger_content', { name: selectedTrigger.name }, i18nLanguage),
okText: t('table_designer.action.delete', undefined, i18nLanguage),
okType: 'danger',
cancelText: t('table_designer.action.cancel', undefined, i18nLanguage),
onOk: async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error(t('table_designer.message.connection_not_found', undefined, i18nLanguage));
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(t('table_designer.message.trigger_deleted', undefined, i18nLanguage));
setSelectedTrigger(null);
fetchData(); // 刷新列表
} else {
message.error(t('table_designer.message.delete_failed', { detail: res.message }, i18nLanguage));
}
} catch (e: any) {
message.error(t('table_designer.message.delete_failed', { detail: e?.message || String(e) }, i18nLanguage));
}
}
});
};
const handleExecuteTriggerSql = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error(t('table_designer.message.connection_not_found', undefined, i18nLanguage));
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(t('table_designer.message.drop_old_trigger_failed', { detail: dropRes.message }, i18nLanguage));
setTriggerExecuting(false);
return;
}
}
// 执行创建语句
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql);
if (res.success) {
message.success(triggerEditMode === 'create'
? t('table_designer.message.trigger_created', undefined, i18nLanguage)
: t('table_designer.message.trigger_updated', undefined, i18nLanguage));
setIsTriggerEditModalOpen(false);
setSelectedTrigger(null);
fetchData(); // 刷新列表
} else {
message.error(t('table_designer.message.execution_failed', { detail: res.message }, i18nLanguage));
}
} catch (e: any) {
message.error(t('table_designer.message.execution_failed', { detail: e?.message || String(e) }, i18nLanguage));
} 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(t('table_designer.message.select_column_before_insert', undefined, i18nLanguage));
return;
}
handleAddColumn(anchor._key);
}, [columns, handleAddColumn, i18nLanguage, 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<IndexDisplayRow[]>(() => {
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<string, IndexBucket>();
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 || t('table_designer.fallback.unnamed_index', undefined, i18nLanguage);
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,
};
});
}, [i18nLanguage, 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<ForeignKeyDisplayRow[]>(() => {
type FieldItem = { name: string; order: number };
type FkBucket = {
key: string;
constraintName: string;
refTableName: string;
order: number;
columns: FieldItem[];
refColumns: FieldItem[];
};
const buckets = new Map<string, FkBucket>();
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 || t('table_designer.fallback.unnamed_foreign_key', undefined, i18nLanguage);
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, i18nLanguage]);
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: t('table_designer.index.kind.normal_nonclustered', undefined, i18nLanguage), value: 'NORMAL' },
{ label: t('table_designer.index.kind.unique', undefined, i18nLanguage), value: 'UNIQUE' },
{ label: t('table_designer.index.kind.primary_clustered', undefined, i18nLanguage), value: 'PRIMARY' },
{ label: t('table_designer.index.kind.fulltext', undefined, i18nLanguage), value: 'FULLTEXT' },
{ label: t('table_designer.index.kind.spatial', undefined, i18nLanguage), value: 'SPATIAL' },
];
}
return [
{ label: t('table_designer.index.kind.normal', undefined, i18nLanguage), value: 'NORMAL' },
{ label: t('table_designer.index.kind.unique', undefined, i18nLanguage), 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.map(option => option.value === 'DEFAULT'
? { ...option, label: t('table_designer.option.default', undefined, i18nLanguage) }
: option);
}
if (isSqlServerDialect(dbType)) {
return SQLSERVER_INDEX_TYPE_OPTIONS.map(option => option.value === 'DEFAULT'
? { ...option, label: t('table_designer.option.default', undefined, i18nLanguage) }
: option);
}
return [{ label: t('table_designer.option.default', undefined, i18nLanguage), 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(t('table_designer.message.select_columns_to_copy', undefined, i18nLanguage));
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(t('table_designer.message.target_table_required', undefined, i18nLanguage));
return;
}
if (selectedColumns.length === 0) {
message.error(t('table_designer.message.no_copyable_columns', undefined, i18nLanguage));
return;
}
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error(t('table_designer.message.connection_not_found', undefined, i18nLanguage));
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(t('table_designer.message.columns_copied_to_new_table', { count: selectedColumns.length, table: copyTableName.trim() }, i18nLanguage));
setIsCopyColumnsModalOpen(false);
} else {
message.error(t('table_designer.message.execution_failed', { detail: res.message }, i18nLanguage));
}
} finally {
setCopyExecuting(false);
}
};
const executeSchemaStatements = async (sqlText: string): Promise<SchemaExecutionResult> => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
return { ok: false, message: t('table_designer.message.connection_not_found', undefined, i18nLanguage), 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
? t('table_designer.message.statement_execution_failed_prefix', { current: i + 1, total: statements.length }, i18nLanguage)
: t('table_designer.message.execution_failed_prefix', undefined, i18nLanguage);
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<boolean> => {
const result = await executeSchemaStatements(`${dropSql}\n${addSql}`);
if (result.ok) {
message.success(t('table_designer.message.index_updated', undefined, i18nLanguage));
await fetchData();
return true;
}
const oldCreateSql = buildIndexCreateSql(buildIndexFormFromRow(previousIndex));
if (!oldCreateSql) {
message.error(t('table_designer.message.index_restore_unavailable', { detail: result.message || t('table_designer.message.execution_failed_plain', undefined, i18nLanguage) }, i18nLanguage));
await fetchData();
return false;
}
if (!shouldRestoreOriginalIndex(result)) {
message.error(result.message || t('table_designer.message.execution_failed_plain', undefined, i18nLanguage));
return false;
}
const restoreResult = await executeSchemaStatements(oldCreateSql);
if (restoreResult.ok) {
message.error(t('table_designer.message.index_restored_after_failure', { detail: result.message || t('table_designer.message.execution_failed_plain', undefined, i18nLanguage) }, i18nLanguage));
} else {
message.error(t('table_designer.message.index_restore_failed', {
detail: result.message || t('table_designer.message.execution_failed_plain', undefined, i18nLanguage),
restoreDetail: restoreResult.message || t('table_designer.fallback.unknown_error', undefined, i18nLanguage),
}, i18nLanguage));
}
await fetchData();
return false;
};
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
try {
const result = await executeSchemaStatements(sql);
if (!result.ok) {
message.error(result.message || t('table_designer.message.execution_failed_plain', undefined, i18nLanguage));
if ((result.failedStatementIndex ?? 0) > 0) await fetchData();
return false;
}
message.success(successMessage);
await fetchData();
return true;
} catch (e: any) {
message.error(t('table_designer.message.execution_failed', { detail: e?.message || String(e) }, i18nLanguage));
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(t('table_designer.message.table_comment_unsupported', undefined, i18nLanguage));
return;
}
if (!tab.tableName) return;
const sql = buildTableCommentSql(tableCommentDraft);
if (!sql) {
message.warning(t('table_designer.message.table_comment_unsupported', undefined, i18nLanguage));
return;
}
setTableCommentSaving(true);
const ok = await executeSchemaSql(sql, t('table_designer.message.table_comment_updated', undefined, i18nLanguage));
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(t('table_designer.message.select_one_index', undefined, i18nLanguage));
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 || t('table_designer.message.index_create_sql_unavailable', undefined, i18nLanguage));
} else {
message.error(result.message || t('table_designer.message.index_create_sql_unavailable', undefined, i18nLanguage));
}
return null;
}
return result.sql;
};
const indexCreatePreviewSql = useMemo(() => {
if (!isIndexModalOpen) return '';
const result = getIndexCreateSqlResult(indexForm);
return result.sql || `-- ${result.message || 'Index CREATE SQL placeholder unavailable'}`;
}, [connections, i18nLanguage, 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 || 'Index CREATE SQL unavailable'}`;
}, [connections, i18nLanguage, 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(t('table_designer.message.index_maintenance_unsupported', undefined, i18nLanguage));
return;
}
if (!tab.tableName) return;
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
if (!supportedKinds.has(indexForm.kind)) {
message.warning(t('table_designer.message.index_kind_unsupported', undefined, i18nLanguage));
return;
}
const nextName = indexForm.kind === 'PRIMARY' ? 'PRIMARY' : String(indexForm.name || '').trim();
if (indexForm.kind !== 'PRIMARY' && !nextName) {
message.error(t('table_designer.message.index_name_required', undefined, i18nLanguage));
return;
}
if (indexForm.columnNames.length === 0) {
message.error(t('table_designer.message.select_at_least_one_column', undefined, i18nLanguage));
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(t('table_designer.message.index_name_exists', { name: nextName }, i18nLanguage));
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(t('table_designer.message.no_index_changes', undefined, i18nLanguage));
return;
}
const dropSql = buildIndexDropSql(selectedIndex.name);
if (!dropSql) {
setIndexSaving(false);
message.warning(t('table_designer.message.index_delete_unsupported', undefined, i18nLanguage));
return;
}
const ok = await executeIndexEditSql(dropSql, addSql, selectedIndex);
setIndexSaving(false);
if (ok) {
setIsIndexModalOpen(false);
}
return;
}
const ok = await executeSchemaSql(
sql,
indexModalMode === 'create'
? t('table_designer.message.index_created', undefined, i18nLanguage)
: t('table_designer.message.index_updated', undefined, i18nLanguage),
);
setIndexSaving(false);
if (ok) {
setIsIndexModalOpen(false);
}
};
const handleDeleteIndex = () => {
if (selectedIndexKeys.length === 0) {
message.warning(t('table_designer.message.select_index_to_delete', undefined, i18nLanguage));
return;
}
if (!supportsIndexSchemaOps()) {
message.warning(t('table_designer.message.index_maintenance_unsupported', undefined, i18nLanguage));
return;
}
// 根据选中的 key 找到对应的索引对象
const toDelete = groupedIndexes.filter(idx => selectedIndexKeys.includes(idx.key));
if (toDelete.length === 0) {
message.warning(t('table_designer.message.select_index_to_delete', undefined, i18nLanguage));
return;
}
const names = toDelete.map(idx => `"${idx.name}"`).join(', ');
Modal.confirm({
title: t('table_designer.modal.delete_index_title', undefined, i18nLanguage),
icon: <ExclamationCircleOutlined />,
content: toDelete.length === 1
? t('table_designer.modal.delete_index_one', { names }, i18nLanguage)
: t('table_designer.modal.delete_index_many', { count: toDelete.length, names }, i18nLanguage),
okText: t('table_designer.action.delete', undefined, i18nLanguage),
okType: 'danger',
cancelText: t('table_designer.action.cancel', undefined, i18nLanguage),
onOk: async () => {
const sqls: string[] = [];
for (const idx of toDelete) {
const sql = buildIndexDropSql(idx.name);
if (!sql) {
message.warning(t('table_designer.message.index_delete_named_unsupported', { name: idx.name }, i18nLanguage));
return;
}
sqls.push(sql);
}
const ok = await executeSchemaSql(
sqls.join('\n'),
toDelete.length === 1
? t('table_designer.message.index_deleted', undefined, i18nLanguage)
: t('table_designer.message.indexes_deleted', { count: toDelete.length }, i18nLanguage),
);
if (ok) {
setSelectedIndexKeys([]);
}
}
});
};
const openCreateForeignKeyModal = () => {
setForeignKeyModalMode('create');
setForeignKeyForm({
constraintName: '',
columnNames: [],
refTableName: '',
refColumnNames: [],
});
setIsForeignKeyModalOpen(true);
};
const openEditForeignKeyModal = () => {
if (!selectedForeignKey) {
message.warning(t('table_designer.message.select_one_foreign_key', undefined, i18nLanguage));
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(t('table_designer.message.foreign_key_maintenance_unsupported', undefined, i18nLanguage));
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(t('table_designer.message.foreign_key_name_required', undefined, i18nLanguage));
return;
}
if (localCols.length === 0) {
message.error(t('table_designer.message.select_local_columns', undefined, i18nLanguage));
return;
}
if (!refTable) {
message.error(t('table_designer.message.ref_table_required', undefined, i18nLanguage));
return;
}
if (refCols.length === 0) {
message.error(t('table_designer.message.ref_columns_required', undefined, i18nLanguage));
return;
}
if (localCols.length !== refCols.length) {
message.error(t('table_designer.message.foreign_key_column_count_mismatch', undefined, i18nLanguage));
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(t('table_designer.message.foreign_key_name_exists', { name: nextConstraint }, i18nLanguage));
return;
}
setForeignKeySaving(true);
const addSql = buildForeignKeyAddSql({
...foreignKeyForm,
constraintName: nextConstraint,
columnNames: localCols,
refTableName: refTable,
refColumnNames: refCols,
});
if (!addSql) {
setForeignKeySaving(false);
message.warning(t('table_designer.message.foreign_key_maintenance_unsupported', undefined, i18nLanguage));
return;
}
let sql = addSql;
if (foreignKeyModalMode === 'edit' && selectedForeignKey) {
const dropSql = buildForeignKeyDropSql(selectedForeignKey.constraintName);
if (!dropSql) {
setForeignKeySaving(false);
message.warning(t('table_designer.message.foreign_key_delete_unsupported', undefined, i18nLanguage));
return;
}
sql = `${dropSql}\n${addSql}`;
}
const ok = await executeSchemaSql(
sql,
foreignKeyModalMode === 'create'
? t('table_designer.message.foreign_key_created', undefined, i18nLanguage)
: t('table_designer.message.foreign_key_updated', undefined, i18nLanguage),
);
setForeignKeySaving(false);
if (ok) {
setIsForeignKeyModalOpen(false);
}
};
const handleDeleteForeignKey = () => {
if (!selectedForeignKey) {
message.warning(t('table_designer.message.select_one_foreign_key', undefined, i18nLanguage));
return;
}
if (!supportsForeignKeySchemaOps()) {
message.warning(t('table_designer.message.foreign_key_maintenance_unsupported', undefined, i18nLanguage));
return;
}
Modal.confirm({
title: t('table_designer.modal.delete_foreign_key_title', undefined, i18nLanguage),
icon: <ExclamationCircleOutlined />,
content: t('table_designer.modal.delete_foreign_key_content', { name: selectedForeignKey.constraintName }, i18nLanguage),
okText: t('table_designer.action.delete', undefined, i18nLanguage),
okType: 'danger',
cancelText: t('table_designer.action.cancel', undefined, i18nLanguage),
onOk: async () => {
const sql = buildForeignKeyDropSql(selectedForeignKey.constraintName);
if (!sql) {
message.warning(t('table_designer.message.foreign_key_delete_unsupported', undefined, i18nLanguage));
return;
}
await executeSchemaSql(sql, t('table_designer.message.foreign_key_deleted', undefined, i18nLanguage));
}
});
};
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(t('table_designer.message.table_name_required', undefined, i18nLanguage));
return;
}
if (columns.length === 0) {
message.error(t('table_designer.message.add_at_least_one_column', undefined, i18nLanguage));
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(t('table_designer.message.no_changes_detected', undefined, i18nLanguage));
return;
}
setPreviewSql(sql);
setIsPreviewOpen(true);
}
};
const handleRefreshDesigner = () => {
if (!hasUnsavedDraftChanges) {
void fetchData();
return;
}
Modal.confirm({
title: t('table_designer.modal.unsaved_changes_title', undefined, i18nLanguage),
icon: <ExclamationCircleOutlined />,
content: t('table_designer.modal.unsaved_changes_content', undefined, i18nLanguage),
okText: t('table_designer.action.refresh_anyway', undefined, i18nLanguage),
cancelText: t('table_designer.action.cancel', undefined, i18nLanguage),
onOk: async () => {
await fetchData();
},
});
};
const handleExecuteSave = async () => {
const result = await executeSchemaStatements(previewSql);
if (!result.ok) {
message.error(result.message || t('table_designer.message.execution_failed_plain', undefined, i18nLanguage));
return;
}
message.success(isNewTable
? t('table_designer.message.schema_saved_create', undefined, i18nLanguage)
: t('table_designer.message.schema_saved_alter', undefined, i18nLanguage));
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: () => (
<div className="table-designer-select-check">
<Checkbox
checked={isAllColumnsSelected}
indeterminate={isColumnsIndeterminate}
onChange={(e: any) => setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])}
style={{ margin: 0 }}
/>
</div>
),
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) => (
<div className="table-designer-select-check">
<Checkbox
checked={selectedColumnRowKeys.includes(record._key)}
onChange={(e: any) => {
e.stopPropagation();
setSelectedColumnRowKeys((prev: string[]) =>
e.target.checked
? [...prev, record._key]
: prev.filter((k: string) => k !== record._key)
);
}}
style={{ margin: 0 }}
/>
</div>
),
}), [selectedColumnRowKeys, allColumnKeys, isAllColumnsSelected, isColumnsIndeterminate]);
// sort 拖拽列(不参与 resize
const sortColumn = useMemo(() => ({
key: 'sort',
width: 40,
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
}), []);
const columnsWithSelect = useMemo(() =>
readOnly
? resizableColumns
: [columnSelectCol, sortColumn, ...resizableColumns],
[readOnly, columnSelectCol, sortColumn, resizableColumns]
);
// --- Index Columns Init ---
useEffect(() => {
setIndexColumns([
{
title: t('table_designer.index.column.name', undefined, i18nLanguage),
dataIndex: 'name',
key: 'name',
width: 240,
render: (text: string) => (
<Tooltip title={text}>
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{text}
</span>
</Tooltip>
),
},
{
title: t('table_designer.index.column.fields', undefined, i18nLanguage),
dataIndex: 'columnNames',
key: 'columnNames',
width: 320,
render: (columnNames: string[]) => {
if (!columnNames || columnNames.length === 0) {
return '-';
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{columnNames.map((columnName: string, idx: number) => (
<Tag key={`${columnName}-${idx}`}>
{columnName}
</Tag>
))}
</div>
);
}
},
{
title: t('table_designer.index.column.type', undefined, i18nLanguage),
dataIndex: 'indexType',
key: 'indexType',
width: 140,
render: (text: string) => text || '-',
},
{
title: t('table_designer.index.column.uniqueness', undefined, i18nLanguage),
dataIndex: 'nonUnique',
key: 'nonUnique',
width: 110,
render: (v: number) => (
<Tag color={v === 0 ? 'gold' : 'default'}>
{v === 0 ? t('table_designer.index.uniqueness.unique', undefined, i18nLanguage) : t('table_designer.index.uniqueness.normal', undefined, i18nLanguage)}
</Tag>
),
},
]);
}, [i18nLanguage]);
// 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: () => (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onClick={(e) => 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) => (
<span
onClick={(e) => {
e.stopPropagation();
toggleIndexSelection(record.key);
}}
style={{ display: 'inline-flex' }}
>
<Checkbox
checked={selectedIndexKeys.includes(record.key)}
onChange={() => undefined}
style={{ margin: 0, pointerEvents: 'none' }}
/>
</span>
),
};
const resizableIndexColumns = [
selectColumn,
...indexColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleIndexResizeStart(index),
}),
})),
];
const starRocksAdvancedTabContent = (
<div style={{ height: '100%', overflow: 'auto', padding: 12 }}>
<Space direction="vertical" size={14} style={{ width: '100%', maxWidth: 960 }}>
<Radio.Group
value={starRocksTableKind}
onChange={(e) => setStarRocksTableKind(e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{ label: t('table_designer.starrocks.table_kind.olap', undefined, i18nLanguage), value: 'olap' },
{ label: t('table_designer.starrocks.table_kind.external', undefined, i18nLanguage), value: 'external' },
]}
/>
{starRocksTableKind === 'olap' ? (
<>
<Space wrap>
<Select
value={starRocksKeyModel}
onChange={setStarRocksKeyModel}
options={[
{ label: t('table_designer.starrocks.key_model.duplicate', undefined, i18nLanguage), value: 'DUPLICATE' },
{ label: t('table_designer.column.primary_key', undefined, i18nLanguage), value: 'PRIMARY' },
{ label: t('table_designer.starrocks.key_model.unique', undefined, i18nLanguage), value: 'UNIQUE' },
{ label: t('table_designer.starrocks.key_model.aggregate', undefined, i18nLanguage), value: 'AGGREGATE' },
]}
style={{ width: 180 }}
/>
<Select
mode="multiple"
allowClear
placeholder={t('table_designer.starrocks.placeholder.key_columns', undefined, i18nLanguage)}
value={starRocksKeyColumns}
onChange={setStarRocksKeyColumns}
options={localColumnOptions}
style={{ minWidth: 280 }}
/>
</Space>
<Input.TextArea
value={starRocksPartitionClause}
onChange={(e) => setStarRocksPartitionClause(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={t('table_designer.starrocks.placeholder.partition_clause', undefined, i18nLanguage)}
/>
<Space wrap>
<Select
value={starRocksDistributionType}
onChange={setStarRocksDistributionType}
options={[
{ label: t('table_designer.starrocks.distribution.hash', undefined, i18nLanguage), value: 'HASH' },
{ label: t('table_designer.starrocks.distribution.random', undefined, i18nLanguage), value: 'RANDOM' },
{ label: t('table_designer.starrocks.distribution.none', undefined, i18nLanguage), value: 'NONE' },
]}
style={{ width: 180 }}
/>
<Select
mode="multiple"
allowClear
disabled={starRocksDistributionType !== 'HASH'}
placeholder={t('table_designer.starrocks.placeholder.distribution_columns', undefined, i18nLanguage)}
value={starRocksDistributionColumns}
onChange={setStarRocksDistributionColumns}
options={localColumnOptions}
style={{ minWidth: 260 }}
/>
<Select
value={starRocksBucketMode}
onChange={setStarRocksBucketMode}
options={[
{ label: t('table_designer.starrocks.bucket_mode.auto', undefined, i18nLanguage), value: 'AUTO' },
{ label: t('table_designer.starrocks.bucket_mode.number', undefined, i18nLanguage), value: 'NUMBER' },
]}
style={{ width: 160 }}
/>
<Input
{...noAutoCapInputProps}
disabled={starRocksBucketMode !== 'NUMBER'}
value={starRocksBucketCount}
onChange={(e) => setStarRocksBucketCount(e.target.value.replace(/[^\d]/g, ''))}
placeholder={t('table_designer.starrocks.placeholder.bucket_count', undefined, i18nLanguage)}
style={{ width: 120 }}
/>
</Space>
<Input.TextArea
value={starRocksProperties}
onChange={(e) => setStarRocksProperties(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'"replication_num" = "1"\n"storage_medium" = "SSD"'}
/>
<Input.TextArea
value={starRocksRollups}
onChange={(e) => setStarRocksRollups(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={'rollup_name: column1, column2\nrollup_daily: dt, user_id'}
/>
</>
) : (
<>
<Space wrap>
<Select
value={starRocksExternalEngine}
onChange={setStarRocksExternalEngine}
options={[
{ label: 'Hive', value: 'hive' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'Iceberg', value: 'iceberg' },
{ label: 'Hudi', value: 'hudi' },
{ label: 'JDBC', value: 'jdbc' },
]}
style={{ width: 180 }}
/>
</Space>
<Input.TextArea
value={starRocksExternalProperties}
onChange={(e) => setStarRocksExternalProperties(e.target.value)}
autoSize={{ minRows: 6, maxRows: 14 }}
placeholder={'"resource" = "hive0"\n"database" = "raw_db"\n"table" = "raw_table"'}
/>
</>
)}
</Space>
</div>
);
const columnsTabContent = (
<div
ref={containerRef}
className={`table-designer-wrapper${isV2Ui ? ' gn-v2-designer-table-shell' : ''}`}
style={{
height: '100%',
overflow: 'hidden',
position: 'relative',
background: panelBodyBg
}}
>
<style>{`
.table-designer-wrapper .ant-table-body {
max-height: ${tableHeight}px !important;
}
.table-designer-wrapper .table-designer-focus-row > .ant-table-cell {
background: ${focusRowBg} !important;
}
`}</style>
{readOnly ? (
<Table
dataSource={columns}
columns={columnsWithSelect}
rowKey="_key"
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
size="small"
pagination={false}
loading={loading}
scroll={{ y: tableHeight }}
bordered={false}
components={{
header: {
cell: ResizableTitle,
},
}}
/>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={columns.map(c => c._key)} strategy={verticalListSortingStrategy}>
<Table
dataSource={columns}
columns={columnsWithSelect}
rowKey="_key"
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
size="small"
pagination={false}
loading={loading}
scroll={{ y: tableHeight }}
bordered={false}
components={{
body: { row: SortableRow },
header: { cell: ResizableTitle }
}}
/>
</SortableContext>
</DndContext>
)}
</div>
);
return (
<div
ref={shellRef}
className={`table-designer-shell${isV2Ui ? ' gn-v2-table-designer' : ''}${embedded ? ' is-embedded' : ''}`}
style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: embedded ? 0 : '6px 0', position: 'relative' }}
>
<style>{`
.table-designer-shell .ant-table,
.table-designer-shell .ant-table-wrapper,
.table-designer-shell .ant-table-container {
background: transparent !important;
}
.table-designer-shell .ant-table-wrapper {
border: none !important;
overflow: hidden !important;
}
.table-designer-shell .ant-table-container {
border: none !important;
}
.table-designer-shell .ant-table-thead > tr > th {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody > tr > td,
.table-designer-shell .ant-table-tbody .ant-table-row > .ant-table-cell {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody td .ant-input {
padding-left: 0 !important;
padding-right: 0 !important;
}
.table-designer-shell .ant-table-tbody td .ant-select .ant-select-selector {
padding-left: 0 !important;
}
.table-designer-shell .table-designer-cell-field {
display: flex;
align-items: center;
min-height: 34px;
padding: 0 10px;
border: 1px solid ${darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)'};
border-radius: 10px;
background: ${darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'};
box-sizing: border-box;
}
.table-designer-shell .table-designer-cell-field .ant-input,
.table-designer-shell .table-designer-cell-field .ant-select,
.table-designer-shell .table-designer-cell-field .ant-select-selector,
.table-designer-shell .table-designer-cell-field .ant-select-selection-search,
.table-designer-shell .table-designer-cell-field .ant-select-selection-item {
background: transparent !important;
}
.table-designer-shell .table-designer-cell-field .ant-input,
.table-designer-shell .table-designer-cell-field .ant-select-selection-item,
.table-designer-shell .table-designer-cell-field input {
font-size: 13px;
line-height: 1.4;
}
.table-designer-shell .table-designer-cell-field .ant-select {
width: 100%;
}
.table-designer-shell .table-designer-cell-field .ant-select-selector,
.table-designer-shell .table-designer-cell-field .ant-input {
padding: 0 !important;
box-shadow: none !important;
}
.table-designer-shell .table-designer-cell-field.is-compact {
padding-right: 6px;
}
.table-designer-shell .table-designer-cell-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 34px;
}
.table-designer-shell .table-designer-cell-check .ant-checkbox-wrapper {
margin-inline-end: 0 !important;
}
.table-designer-shell .table-designer-cell-check.is-left-aligned {
justify-content: flex-start;
}
.table-designer-shell .table-designer-header-title {
display: inline-flex;
align-items: center;
justify-content: flex-start;
width: 100%;
line-height: 1.1;
white-space: nowrap;
}
.table-designer-shell .table-designer-select-check {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 28px;
}
.table-designer-shell .table-designer-select-check .ant-checkbox-wrapper {
margin-inline-end: 0 !important;
}
.table-designer-shell .table-designer-select-column {
text-align: center !important;
vertical-align: middle !important;
}
.table-designer-shell .table-designer-action-column {
text-align: left !important;
}
.table-designer-shell .table-designer-comment-field {
gap: 4px;
padding-right: 4px;
}
.table-designer-shell .table-designer-comment-field .ant-input {
flex: 1;
min-width: 0;
}
.table-designer-shell .table-designer-comment-display {
flex: 1;
min-width: 0;
min-height: 28px;
display: flex;
align-items: center;
font: inherit;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.table-designer-shell .table-designer-comment-display.is-empty {
color: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.28)'};
}
.table-designer-shell .table-designer-action-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
}
.table-designer-shell .table-designer-action-cell .ant-btn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 8px;
}
.table-designer-shell .ant-table-thead > tr > th::before {
display: none !important;
}
.table-designer-shell .ant-table-thead > tr > th {
cursor: default !important;
user-select: none !important;
-webkit-user-select: none !important;
}
.table-designer-shell .ant-table-tbody > tr:hover > td,
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
}
.table-designer-shell .ant-tabs-nav {
margin-bottom: 8px !important;
}
.table-designer-shell.gn-v2-table-designer .ant-tabs-nav {
margin-bottom: 0 !important;
}
.table-designer-shell.is-embedded .ant-tabs-nav {
margin-bottom: 0 !important;
}
.table-designer-shell .ant-tabs-nav::before {
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
}
.table-designer-shell .ant-tabs-ink-bar {
will-change: transform;
transition: width 0.15s ease, left 0.15s ease, transform 0.15s ease !important;
}
.table-designer-shell .ant-tabs-tab {
transition: color 0.15s ease !important;
}
.table-designer-shell.gn-v2-table-designer .ant-tabs-nav-wrap,
.table-designer-shell.gn-v2-table-designer .ant-tabs-nav-list {
width: auto !important;
min-height: 34px !important;
align-items: center !important;
}
.table-designer-shell.gn-v2-table-designer .ant-tabs-tab {
width: auto !important;
min-width: 0 !important;
max-width: none !important;
min-height: 34px !important;
margin: 0 !important;
padding: 0 12px !important;
border-right: 0 !important;
border-bottom: 0 !important;
white-space: nowrap !important;
}
.table-designer-shell.gn-v2-table-designer .ant-tabs-tab-btn {
width: auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.table-designer-shell.is-embedded .ant-tabs-nav-wrap,
.table-designer-shell.is-embedded .ant-tabs-nav-list {
width: auto !important;
min-height: 34px !important;
align-items: center !important;
}
.table-designer-shell.is-embedded .ant-tabs-tab {
width: auto !important;
min-width: 0 !important;
max-width: none !important;
min-height: 34px !important;
margin: 0 !important;
padding: 0 12px !important;
border-right: 0 !important;
border-bottom: 0 !important;
white-space: nowrap !important;
}
.table-designer-shell.is-embedded .ant-tabs-tab-btn {
width: auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field {
min-height: 28px;
padding-inline: 0;
border: none !important;
border-radius: 0;
background: transparent !important;
box-shadow: none !important;
}
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field .ant-input,
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field .ant-input:focus,
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field .ant-input-focused,
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field .ant-select-selector,
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field .ant-select-focused .ant-select-selector {
border: none !important;
box-shadow: none !important;
background: transparent !important;
}
.table-designer-shell.gn-v2-table-designer .table-designer-comment-display,
.table-designer-shell.gn-v2-table-designer .table-designer-comment-field .ant-input,
.table-designer-shell.gn-v2-table-designer .table-designer-comment-field .ant-input input {
font-size: 12px !important;
line-height: 1.4 !important;
font-family: inherit !important;
}
.table-designer-shell.gn-v2-table-designer .table-designer-cell-field.is-compact {
padding-right: 0;
}
.table-designer-shell.gn-v2-table-designer .table-designer-comment-field {
padding-right: 0;
}
.table-designer-shell.gn-v2-table-designer .table-designer-cell-check {
min-height: 30px;
}
.table-designer-shell.gn-v2-table-designer .table-designer-select-check {
min-height: 22px;
}
.table-designer-shell.is-embedded .table-designer-select-check {
min-height: 14px !important;
}
.table-designer-shell.gn-v2-table-designer .table-designer-action-cell {
justify-content: flex-start;
gap: 4px;
}
.table-designer-shell.gn-v2-table-designer .table-designer-action-cell .ant-btn {
width: 26px;
height: 26px;
border-radius: 7px;
}
.table-designer-shell .ant-tabs-content-holder,
.table-designer-shell .ant-tabs-content,
.table-designer-shell .ant-tabs-tabpane {
height: 100%;
}
.table-designer-shell .react-resizable-handle {
position: absolute !important;
right: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 10px !important;
height: auto !important;
background-position: top right !important;
cursor: col-resize !important;
z-index: 10;
touch-action: none;
}
`}</style>
<div
ref={ghostRef}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '2px',
background: resizeGuideColor,
zIndex: 9999,
display: 'none',
pointerEvents: 'none',
willChange: 'transform',
}}
/>
{isV2Ui && (
<div className="gn-v2-designer-header">
<div className="gn-v2-designer-title">
<span>{t('table_designer.title.schema_designer', undefined, i18nLanguage)}</span>
<strong>{designerTableTitle}</strong>
</div>
<div className="gn-v2-designer-meta">
<span><TableOutlined /> {designerDbTitle}</span>
<span>{designerColumnSummary}</span>
{readOnly && <span>{t('table_designer.status.read_only', undefined, i18nLanguage)}</span>}
</div>
</div>
)}
<div
className={isV2Ui ? 'gn-v2-designer-toolbar' : undefined}
style={{
padding: '10px 12px 8px 12px',
borderBottom: `1px solid ${panelToolbarBorder}`,
borderTopLeftRadius: embedded ? 0 : panelRadius,
borderTopRightRadius: embedded ? 0 : panelRadius,
borderLeft: `1px solid ${panelFrameColor}`,
borderRight: `1px solid ${panelFrameColor}`,
borderTop: embedded ? 'none' : `1px solid ${panelFrameColor}`,
background: panelToolbarBg,
display: 'flex',
gap: '8px',
alignItems: 'center'
}}
>
{isNewTable && (
<>
<Input
{...noAutoCapInputProps}
placeholder={t('table_designer.placeholder.table_name', undefined, i18nLanguage)}
value={newTableName}
onChange={e => setNewTableName(e.target.value)}
style={{ width: 150 }}
/>
<Select
value={charset}
onChange={v => {
setCharset(v);
// Set default collation
const cols = (COLLATIONS as any)[v];
if (cols && cols.length > 0) setCollation(cols[0].value);
}}
options={charsetOptions}
style={{ width: 120 }}
/>
<Select
value={collation}
onChange={setCollation}
options={(collationOptions as any)[charset] || []}
style={{ width: 150 }}
/>
</>
)}
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}>{t('table_designer.action.save', undefined, i18nLanguage)}</Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}>{t('table_designer.action.refresh', undefined, i18nLanguage)}</Button>}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}>{t('table_designer.action.table_comment', undefined, i18nLanguage)}</Button>
)}
{!readOnly && <Button size="small" icon={<PlusOutlined />} onClick={() => handleAddColumn()}>{t('table_designer.action.add_column', undefined, i18nLanguage)}</Button>}
{!readOnly && (
<Button
size="small"
icon={<PlusOutlined />}
onClick={handleAddColumnAfterSelected}
disabled={selectedColumnRowKeys.length === 0}
>
{t('table_designer.action.add_after_selected', undefined, i18nLanguage)}
</Button>
)}
{!readOnly && (
<Button
size="small"
icon={<CopyOutlined />}
onClick={openCopySelectedColumnsModal}
disabled={selectedColumns.length === 0}
>
{t('table_designer.action.copy_selected_to_new_table', undefined, i18nLanguage)}
</Button>
)}
<div style={{ flex: 1 }} />
</div>
<Tabs
className={isV2Ui ? 'gn-v2-designer-tabs' : undefined}
activeKey={activeKey}
onChange={(key) => 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: t('table_designer.tab.columns', undefined, i18nLanguage),
children: columnsTabContent
},
...(isStarRocksNewTable ? [
{
key: 'starrocks',
label: 'StarRocks',
children: starRocksAdvancedTabContent,
},
] : []),
...(!isNewTable ? [
{
key: 'indexes',
label: t('table_designer.tab.indexes', undefined, i18nLanguage),
children: (
<div className={`index-table-wrap${isV2Ui ? ' gn-v2-designer-tab-content gn-v2-designer-index-table' : ''}`} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}>{t('table_designer.action.add', undefined, i18nLanguage)}</Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length !== 1} onClick={openEditIndexModal}>{t('table_designer.action.edit', undefined, i18nLanguage)}</Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length === 0} onClick={handleDeleteIndex}>{t('table_designer.action.delete', undefined, i18nLanguage)}</Button>
{!supportsIndexSchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
{t('table_designer.notice.index_readonly', undefined, i18nLanguage)}
</span>
)}
{supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{t('table_designer.selection.indexes_selected', { count: selectedIndexKeys.length }, i18nLanguage)}
</span>
)}
</div>
)}
<div className={isV2Ui ? 'gn-v2-designer-section-note' : undefined} style={{ color: '#888', fontSize: 12 }}>
{t('table_designer.summary.indexes', { count: groupedIndexes.length, fields: groupedIndexFieldCount }, i18nLanguage)}
</div>
<Table
dataSource={groupedIndexes}
columns={resizableIndexColumns}
rowKey="key"
size="small"
pagination={false}
loading={loading}
scroll={{ x: 960, y: indexTableHeight }}
components={{
header: { cell: ResizableTitle },
}}
onRow={(record) => ({
onClick: () => {
toggleIndexSelection(record.key);
},
style: { cursor: 'pointer' }
})}
/>
{selectedIndexCreateSql && selectedIndex && (
<div style={{ width: '100%' }}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 6 }}>
{t('table_designer.label.create_statement', { name: selectedIndex.name }, i18nLanguage)}
</div>
<TableDesignerSqlPreview sql={selectedIndexCreateSql} darkMode={darkMode} height="160px" />
</div>
)}
</div>
)
},
{
key: 'foreignKeys',
label: t('table_designer.tab.foreign_keys', undefined, i18nLanguage),
children: (
<div className={isV2Ui ? 'gn-v2-designer-tab-content' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsForeignKeySchemaOps()} onClick={openCreateForeignKeyModal}>{t('table_designer.action.add', undefined, i18nLanguage)}</Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={openEditForeignKeyModal}>{t('table_designer.action.edit', undefined, i18nLanguage)}</Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={handleDeleteForeignKey}>{t('table_designer.action.delete', undefined, i18nLanguage)}</Button>
{!supportsForeignKeySchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
{t('table_designer.notice.foreign_key_readonly', undefined, i18nLanguage)}
</span>
)}
{supportsForeignKeySchemaOps() && selectedForeignKey && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{t('table_designer.selection.foreign_key_selected', { name: selectedForeignKey.constraintName }, i18nLanguage)}
</span>
)}
</div>
)}
<Table
dataSource={groupedForeignKeys}
columns={[
{ title: t('table_designer.foreign_key.column.constraint_name', undefined, i18nLanguage), dataIndex: 'constraintName', key: 'constraintName', width: 220 },
{
title: t('table_designer.foreign_key.column.fields', undefined, i18nLanguage),
dataIndex: 'columnNames',
key: 'columnNames',
render: (vals: string[]) => vals?.length ? vals.join(', ') : '-',
},
{ title: t('table_designer.foreign_key.column.ref_table', undefined, i18nLanguage), dataIndex: 'refTableName', key: 'refTableName', width: 220 },
{
title: t('table_designer.foreign_key.column.ref_fields', undefined, i18nLanguage),
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' }
})}
/>
</div>
)
},
{
key: 'triggers',
label: t('table_designer.tab.triggers', undefined, i18nLanguage),
children: (
<div className={isV2Ui ? 'gn-v2-designer-tab-content' : undefined}>
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<Button
size="small"
icon={<EyeOutlined />}
disabled={!selectedTrigger}
onClick={() => setIsTriggerModalOpen(true)}
>
{t('table_designer.action.view_statement', undefined, i18nLanguage)}
</Button>
{!readOnly && (
<>
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}>{t('table_designer.action.add', undefined, i18nLanguage)}</Button>
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}>{t('table_designer.action.edit', undefined, i18nLanguage)}</Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}>{t('table_designer.action.delete', undefined, i18nLanguage)}</Button>
</>
)}
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedTrigger
? t('table_designer.selection.trigger_selected', { name: selectedTrigger.name }, i18nLanguage)
: t('table_designer.selection.trigger_prompt', undefined, i18nLanguage)}
</span>
</div>
<Table
dataSource={triggers}
columns={[
{ title: t('table_designer.trigger.column.name', undefined, i18nLanguage), dataIndex: 'name', key: 'name' },
{ title: t('table_designer.trigger.column.timing', undefined, i18nLanguage), dataIndex: 'timing', key: 'timing', width: 100 },
{ title: t('table_designer.trigger.column.event', undefined, i18nLanguage), dataIndex: 'event', key: 'event', width: 100 },
]}
rowKey="name"
size="small"
pagination={false}
loading={loading}
scroll={{ y: tableHeight }}
locale={{ emptyText: <Empty description={t('table_designer.empty.triggers', undefined, i18nLanguage)} image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
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' }
})}
/>
</div>
)
}
] : []),
...(!isNewTable ? [{
key: 'ddl',
label: 'DDL',
icon: <FileTextOutlined />,
children: (
<div className={isV2Ui ? 'gn-v2-designer-ddl-shell' : undefined} style={{ height: '100%', minHeight: 320, border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={ddl}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: true,
wordWrap: 'on',
automaticLayout: true,
padding: { top: 8, bottom: 24 },
}}
/>
</div>
)
}] : [])
]}
/>
<Modal
title={commentEditorColumnName
? t('table_designer.modal.column_comment_title_named', { name: commentEditorColumnName }, i18nLanguage)
: t('table_designer.modal.column_comment_title', undefined, i18nLanguage)}
open={isCommentModalOpen}
onCancel={closeCommentEditor}
onOk={() => {
if (commentEditorColumnKey) {
handleColumnChange(commentEditorColumnKey, 'comment', commentEditorValue);
}
closeCommentEditor();
}}
okText={t('table_designer.action.apply', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
width={640}
destroyOnHidden
>
<Input.TextArea
value={commentEditorValue}
onChange={(e) => setCommentEditorValue(e.target.value)}
autoSize={{ minRows: 8, maxRows: 18 }}
placeholder={t('table_designer.placeholder.column_comment', undefined, i18nLanguage)}
maxLength={2000}
/>
</Modal>
<Modal
title={t('table_designer.modal.copy_columns_title', undefined, i18nLanguage)}
open={isCopyColumnsModalOpen}
onCancel={() => setIsCopyColumnsModalOpen(false)}
onOk={handleExecuteCopySelectedColumns}
okText={t('table_designer.action.create_table', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
confirmLoading={copyExecuting}
width={560}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div style={{ color: '#666' }}>
{t('table_designer.selection.columns_selected', { count: selectedColumns.length }, i18nLanguage)}
</div>
<Input
{...noAutoCapInputProps}
placeholder={t('table_designer.placeholder.target_table_name', undefined, i18nLanguage)}
value={copyTableName}
onChange={e => setCopyTableName(e.target.value)}
maxLength={128}
/>
<Space wrap>
<Select
value={copyCharset}
onChange={v => {
setCopyCharset(v);
const cols = (COLLATIONS as any)[v];
if (cols && cols.length > 0) setCopyCollation(cols[0].value);
}}
options={charsetOptions}
style={{ width: 160 }}
/>
<Select
value={copyCollation}
onChange={setCopyCollation}
options={(collationOptions as any)[copyCharset] || []}
style={{ width: 220 }}
/>
</Space>
</Space>
</Modal>
<Modal
title={t('table_designer.modal.table_comment_title', undefined, i18nLanguage)}
open={isTableCommentModalOpen}
onCancel={() => setIsTableCommentModalOpen(false)}
onOk={handleSaveTableComment}
okText={t('table_designer.action.save', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
confirmLoading={tableCommentSaving}
width={640}
>
<Input.TextArea
value={tableCommentDraft}
onChange={(e) => setTableCommentDraft(e.target.value)}
autoSize={{ minRows: 5, maxRows: 12 }}
placeholder={t('table_designer.placeholder.table_comment', undefined, i18nLanguage)}
maxLength={2048}
/>
<div style={{ marginTop: 8, color: '#888', fontSize: 12 }}>
{t('table_designer.table_comment.current', {
comment: tableComment || t('table_designer.fallback.empty', undefined, i18nLanguage),
}, i18nLanguage)}
</div>
</Modal>
<Modal
title={indexModalMode === 'create'
? t('table_designer.modal.index_create_title', undefined, i18nLanguage)
: t('table_designer.modal.index_edit_title', undefined, i18nLanguage)}
open={isIndexModalOpen}
onCancel={() => setIsIndexModalOpen(false)}
onOk={handleSubmitIndex}
okText={indexModalMode === 'create' ? t('table_designer.action.create', undefined, i18nLanguage) : t('table_designer.action.save', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
confirmLoading={indexSaving}
width={620}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder={indexForm.kind === 'PRIMARY'
? t('table_designer.placeholder.primary_index_name', undefined, i18nLanguage)
: t('table_designer.placeholder.index_name', undefined, i18nLanguage)}
value={indexForm.name}
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
maxLength={128}
disabled={indexForm.kind === 'PRIMARY'}
/>
<Select
mode="multiple"
allowClear
placeholder={t('table_designer.placeholder.index_columns', undefined, i18nLanguage)}
value={indexForm.columnNames}
onChange={(vals) => setIndexForm(prev => ({ ...prev, columnNames: vals }))}
options={localColumnOptions}
style={{ width: '100%' }}
/>
<Space wrap>
<Select
value={indexForm.kind}
options={getIndexKindOptions()}
onChange={(val: IndexKind) => {
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 }}
/>
<Select
value={indexForm.indexType}
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
options={getIndexTypeOptions(indexForm.kind)}
style={{ width: 160 }}
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
/>
</Space>
<div style={{ color: '#888', fontSize: 12 }}>
{t('table_designer.notice.index_restore_hint', undefined, i18nLanguage)}
</div>
<div style={{ width: '100%' }}>
<div style={{ color: '#666', fontSize: 12, marginBottom: 6 }}>{t('table_designer.label.create_statement_plain', undefined, i18nLanguage)}</div>
<TableDesignerSqlPreview sql={indexCreatePreviewSql} darkMode={darkMode} height="180px" />
</div>
</Space>
</Modal>
<Modal
title={foreignKeyModalMode === 'create'
? t('table_designer.modal.foreign_key_create_title', undefined, i18nLanguage)
: t('table_designer.modal.foreign_key_edit_title', undefined, i18nLanguage)}
open={isForeignKeyModalOpen}
onCancel={() => setIsForeignKeyModalOpen(false)}
onOk={handleSubmitForeignKey}
okText={foreignKeyModalMode === 'create' ? t('table_designer.action.create', undefined, i18nLanguage) : t('table_designer.action.save', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
confirmLoading={foreignKeySaving}
width={700}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
{...noAutoCapInputProps}
placeholder={t('table_designer.placeholder.foreign_key_name', undefined, i18nLanguage)}
value={foreignKeyForm.constraintName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
maxLength={128}
/>
<Select
mode="multiple"
allowClear
placeholder={t('table_designer.placeholder.local_columns', undefined, i18nLanguage)}
value={foreignKeyForm.columnNames}
onChange={(vals) => setForeignKeyForm(prev => ({ ...prev, columnNames: vals }))}
options={localColumnOptions}
style={{ width: '100%' }}
/>
<Input
{...noAutoCapInputProps}
placeholder={t('table_designer.placeholder.ref_table', undefined, i18nLanguage)}
value={foreignKeyForm.refTableName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
maxLength={256}
/>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder={t('table_designer.placeholder.ref_columns', undefined, i18nLanguage)}
value={foreignKeyForm.refColumnNames}
onChange={(vals) => setForeignKeyForm(prev => ({ ...prev, refColumnNames: vals }))}
style={{ width: '100%' }}
/>
<div style={{ color: '#888', fontSize: 12 }}>
{t('table_designer.notice.foreign_key_replace_hint', undefined, i18nLanguage)}
</div>
</Space>
</Modal>
<Modal
title={t('table_designer.modal.confirm_sql_title', undefined, i18nLanguage)}
open={isPreviewOpen}
onOk={handleExecuteSave}
onCancel={() => setIsPreviewOpen(false)}
width={700}
okText={t('table_designer.action.execute', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
>
<TableDesignerSqlPreview sql={previewSql} darkMode={darkMode} />
<p style={{ marginTop: 10, color: '#faad14' }}>{t('table_designer.notice.sql_irreversible', undefined, i18nLanguage)}</p>
</Modal>
<Modal
title={selectedTrigger
? t('table_designer.modal.trigger_detail_title_named', { name: selectedTrigger.name }, i18nLanguage)
: t('table_designer.modal.trigger_detail_title', undefined, i18nLanguage)}
open={isTriggerModalOpen}
onCancel={() => setIsTriggerModalOpen(false)}
footer={null}
width={700}
>
{selectedTrigger && (
<div>
<div style={{ marginBottom: 12, display: 'flex', gap: 24 }}>
<span><strong>{t('table_designer.trigger.field.timing', undefined, i18nLanguage)}:</strong> {selectedTrigger.timing}</span>
<span><strong>{t('table_designer.trigger.field.event', undefined, i18nLanguage)}:</strong> {selectedTrigger.event}</span>
</div>
<div style={{ border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
<Editor
height="350px"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={selectedTrigger.statement}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
)}
</Modal>
<Modal
title={triggerEditMode === 'create'
? t('table_designer.modal.trigger_create_title', undefined, i18nLanguage)
: t('table_designer.modal.trigger_edit_title', undefined, i18nLanguage)}
open={isTriggerEditModalOpen}
onCancel={() => setIsTriggerEditModalOpen(false)}
width={800}
okText={triggerEditMode === 'create' ? t('table_designer.action.create', undefined, i18nLanguage) : t('table_designer.action.save', undefined, i18nLanguage)}
cancelText={t('table_designer.action.cancel', undefined, i18nLanguage)}
confirmLoading={triggerExecuting}
onOk={handleExecuteTriggerSql}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
{triggerEditMode === 'edit' && selectedTrigger && (
<span>{t('table_designer.notice.trigger_replace_hint', undefined, i18nLanguage)}</span>
)}
</div>
<div style={{ border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
<Editor
height="350px"
language="sql"
theme={darkMode ? 'vs-dark' : 'light'}
value={triggerEditSql}
onChange={(val) => setTriggerEditSql(val || '')}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
<p style={{ marginTop: 10, color: '#faad14' }}>{t('table_designer.notice.sql_statement_irreversible', undefined, i18nLanguage)}</p>
</Modal>
</div>
);
};
export default TableDesigner;