Files
MyGoNavi/frontend/src/components/TableDesigner.tsx
2026-04-11 21:53:51 +08:00

3061 lines
118 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 React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import Editor, { loader } from '@monaco-editor/react';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
interface EditableColumn extends ColumnDefinition {
_key: string;
isNew?: boolean;
isAutoIncrement?: boolean; // Virtual field for UI
}
interface IndexDisplayRow {
key: string;
name: string;
indexType: string;
nonUnique: number;
columnNames: string[];
}
interface ForeignKeyDisplayRow {
key: string;
name: string;
constraintName: string;
refTableName: string;
columnNames: string[];
refColumnNames: string[];
}
type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL';
interface IndexFormState {
name: string;
columnNames: string[];
kind: IndexKind;
indexType: string;
}
interface ForeignKeyFormState {
constraintName: string;
columnNames: string[];
refTableName: string;
refColumnNames: string[];
}
interface SchemaExecutionResult {
ok: boolean;
message?: string;
failedStatementIndex?: number;
statementCount: number;
}
// 通用兜底类型列表
const COMMON_TYPES = [
{ value: 'int' },
{ value: 'varchar(255)' },
{ value: 'text' },
{ value: 'datetime' },
{ value: 'tinyint(1)' },
{ value: 'decimal(10,2)' },
{ value: 'bigint' },
{ value: 'json' },
];
// 按数据库方言分组的完整字段类型列表
const DB_TYPE_OPTIONS: Record<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: '默认', value: 'DEFAULT' },
{ label: 'BTREE', value: 'BTREE' },
{ label: 'HASH', value: 'HASH' },
{ label: 'GIN', value: 'GIN' },
{ label: 'GIST', value: 'GIST' },
{ label: 'BRIN', value: 'BRIN' },
{ label: 'SPGIST', value: 'SPGIST' },
];
const SQLSERVER_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
{ label: 'CLUSTERED', value: 'CLUSTERED' },
{ label: 'NONCLUSTERED', value: 'NONCLUSTERED' },
];
const CHARSETS = [
{ label: 'utf8mb4 (Recommended)', value: 'utf8mb4' },
{ label: 'utf8', value: 'utf8' },
{ label: 'latin1', value: 'latin1' },
{ label: 'ascii', value: 'ascii' },
];
const COLLATIONS = {
'utf8mb4': [
{ label: 'utf8mb4_unicode_ci (Default)', value: 'utf8mb4_unicode_ci' },
{ label: 'utf8mb4_general_ci', value: 'utf8mb4_general_ci' },
{ label: 'utf8mb4_bin', value: 'utf8mb4_bin' },
{ label: 'utf8mb4_0900_ai_ci', value: 'utf8mb4_0900_ai_ci' },
],
'utf8': [
{ label: 'utf8_unicode_ci', value: 'utf8_unicode_ci' },
{ label: 'utf8_general_ci', value: 'utf8_general_ci' },
{ label: 'utf8_bin', value: 'utf8_bin' },
]
};
// --- Resizable Header Component (Native, same interaction as DataGrid) ---
const ResizableTitle = (props: any) => {
const { onResizeStart, width, ...restProps } = props;
const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties;
if (width) {
nextStyle.width = width;
}
if (!onResizeStart) {
return <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 TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
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 [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 connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
const readOnly = !!tab.readOnly;
const panelRadius = 10;
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)';
const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
const panelToolbarBg = darkMode ? 'rgba(20, 20, 20, 0.35)' : 'rgba(255, 255, 255, 0.72)';
const panelBodyBg = darkMode ? 'rgba(0, 0, 0, 0.24)' : 'rgba(255, 255, 255, 0.82)';
const focusRowBg = darkMode ? 'rgba(246, 196, 83, 0.22)' : 'rgba(24, 144, 255, 0.12)';
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<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;
setCommentEditorColumnKey(record._key);
setCommentEditorColumnName(record.name || '');
setCommentEditorValue(record.comment || '');
setIsCommentModalOpen(true);
}, []);
const closeCommentEditor = useCallback(() => {
setIsCommentModalOpen(false);
setCommentEditorColumnKey('');
setCommentEditorColumnName('');
setCommentEditorValue('');
}, []);
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
// 监听字段 Tab 容器高度,为所有 Tab 内表格计算 scroll.y
// 当 Tab 切换时,字段 Tab 被 display:none 导致 height=0跳过该次更新保持有效值
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
const h = entry.contentRect.height;
// 跳过零高度观测Tab 面板被隐藏时)
if (h <= 0) return;
setTableHeight(Math.max(200, h - 40));
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []); // 不依赖 activeKey仅挂载一次通过零高度守卫避免 Tab 切换异常
// --- Resizable Columns State ---
const [tableColumns, setTableColumns] = useState<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(() => {
return () => {
if (focusHighlightTimerRef.current !== null) {
window.clearTimeout(focusHighlightTimerRef.current);
}
};
}, []);
const focusColumnRow = useCallback((targetKey: string): boolean => {
if (activeKey !== 'columns') return false;
const tableBody = containerRef.current?.querySelector('.ant-table-body') as HTMLElement | null;
if (!tableBody) return false;
const row = tableBody.querySelector(`tr[data-row-key="${targetKey}"]`) as HTMLTableRowElement | null;
if (!row) return false;
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setFocusColumnKey(targetKey);
if (focusHighlightTimerRef.current !== null) {
window.clearTimeout(focusHighlightTimerRef.current);
}
focusHighlightTimerRef.current = window.setTimeout(() => {
setFocusColumnKey(prev => (prev === targetKey ? '' : prev));
}, 1600);
if (!readOnly) {
const firstInput = row.querySelector('input') as HTMLInputElement | null;
if (firstInput) {
firstInput.focus();
firstInput.select();
}
}
return true;
}, [activeKey, readOnly]);
useEffect(() => {
const pendingKey = pendingFocusColumnKeyRef.current;
if (!pendingKey || activeKey !== 'columns') return;
let cancelled = false;
const tryFocus = () => {
if (cancelled) return;
if (focusColumnRow(pendingKey)) {
pendingFocusColumnKeyRef.current = null;
}
};
const timerA = window.setTimeout(tryFocus, 0);
const timerB = window.setTimeout(tryFocus, 96);
return () => {
cancelled = true;
window.clearTimeout(timerA);
window.clearTimeout(timerB);
};
}, [activeKey, columns, focusColumnRow]);
// Initial Columns Definition
useEffect(() => {
const initialCols = [
{
title: '名',
dataIndex: 'name',
key: 'name',
width: 180,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
)
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 150,
render: (text: string, record: EditableColumn) => readOnly ? text : (
<AutoComplete options={DB_TYPE_OPTIONS[getDbType()] || COMMON_TYPES} value={text} onChange={val => handleColumnChange(record._key, 'type', val)} style={{ width: '100%' }} variant="borderless" />
)
},
{
title: '主键',
dataIndex: 'key',
key: 'key',
width: 60,
align: 'center',
render: (text: string, record: EditableColumn) => (
<Checkbox checked={text === 'PRI'} disabled={readOnly} onChange={e => handleColumnChange(record._key, 'key', e.target.checked ? 'PRI' : '')} />
)
},
{
title: '自增',
dataIndex: 'isAutoIncrement',
key: 'isAutoIncrement',
width: 60,
align: 'center',
render: (val: boolean, record: EditableColumn) => (
<Checkbox checked={val} disabled={readOnly} onChange={e => handleColumnChange(record._key, 'isAutoIncrement', e.target.checked)} />
)
},
{
title: '不是 Null',
dataIndex: 'nullable',
key: 'nullable',
width: 80,
align: 'center',
render: (text: string, record: EditableColumn) => (
<Checkbox checked={text === 'NO'} disabled={readOnly || record.key === 'PRI'} onChange={e => handleColumnChange(record._key, 'nullable', e.target.checked ? 'NO' : 'YES')} />
)
},
{
title: '默认',
dataIndex: 'default',
key: 'default',
width: 180, // Increased default width
render: (text: string, record: EditableColumn) => readOnly ? text : (
<AutoComplete options={COMMON_DEFAULTS} value={text} onChange={val => handleColumnChange(record._key, 'default', val)} style={{ width: '100%' }} variant="borderless" placeholder="NULL" />
)
},
{
title: '注释',
dataIndex: 'comment',
key: 'comment',
width: 200,
render: (text: string, record: EditableColumn) => readOnly ? (
<Tooltip title={text || ''}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text || ''}</div>
</Tooltip>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Input
value={text}
onChange={e => handleColumnChange(record._key, 'comment', e.target.value)}
onDoubleClick={() => openCommentEditor(record)}
variant="borderless"
/>
<Tooltip title="弹框编辑注释">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openCommentEditor(record)}
/>
</Tooltip>
</div>
)
},
...(readOnly ? [] : [{
title: '操作',
key: 'action',
width: 60,
render: (_: any, record: EditableColumn) => (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDeleteColumn(record._key)} />
)
}])
];
setTableColumns(initialCols);
}, [readOnly]); // Re-create if readOnly 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("Connection not found");
setLoading(false);
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const promises: Promise<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) => ({
...c,
_key: `col-${index}-${Date.now()}`,
isAutoIncrement: c.extra && c.extra.toLowerCase().includes('auto_increment')
}));
setColumns(JSON.parse(JSON.stringify(colsWithKey)));
setOriginalColumns(JSON.parse(JSON.stringify(colsWithKey)));
setSelectedColumnRowKeys([]);
} else {
message.error("Failed to load columns: " + colsRes.message);
}
if (idxRes.success) {
setIndexes(Array.isArray(idxRes.data) ? idxRes.data : []);
} else {
setIndexes([]);
}
if (fkRes.success) {
setFks(Array.isArray(fkRes.data) ? fkRes.data : []);
} else {
setFks([]);
}
if (trigRes.success) {
setTriggers(Array.isArray(trigRes.data) ? trigRes.data : []);
} else {
setTriggers([]);
}
if (ddlRes && ddlRes.success) {
const ddlText = String(ddlRes.data || '');
setDdl(ddlText);
const commentMatch = ddlText.replace(/\r?\n/g, ' ').match(/COMMENT\s*=\s*'((?:\\'|''|[^'])*)'/i);
const parsedTableComment = commentMatch ? commentMatch[1].replace(/\\'/g, "'").replace(/''/g, "'") : '';
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';
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 === 'starrocks'
|| customDriver.includes('mysql')
) {
return 'mysql';
}
if (customDriver === 'dameng') return 'dm';
return customDriver;
};
const getDbType = (): string => {
const conn = connections.find(c => c.id === tab.connectionId);
const type = normalizeDbType(String(conn?.config?.type || ''));
if (!type) return '';
if (type === 'custom') {
return inferDialectFromCustomDriver(String(conn?.config?.driver || ''));
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const generateTriggerTemplate = (): string => {
const dbType = getDbType();
const tblName = tab.tableName || 'table_name';
switch (dbType) {
case 'mysql':
return `CREATE TRIGGER trigger_name
BEFORE INSERT ON \`${tblName}\`
FOR EACH ROW
BEGIN
-- 触发器逻辑
END;`;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `CREATE OR REPLACE FUNCTION trigger_function_name()
RETURNS TRIGGER AS $$
BEGIN
-- 触发器逻辑
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
FOR EACH ROW
EXECUTE FUNCTION trigger_function_name();`;
case 'sqlserver':
return `CREATE TRIGGER trigger_name
ON [${tblName}]
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
-- 触发器逻辑
END;`;
case 'oracle':
case 'dm':
return `CREATE OR REPLACE TRIGGER trigger_name
BEFORE INSERT ON "${tblName}"
FOR EACH ROW
BEGIN
-- 触发器逻辑
NULL;
END;`;
case 'sqlite':
return `CREATE TRIGGER trigger_name
AFTER INSERT ON "${tblName}"
BEGIN
-- 触发器逻辑
END;`;
default:
return `-- 请输入 CREATE TRIGGER 语句`;
}
};
const buildDropTriggerSql = (triggerName: string): string => {
const dbType = getDbType();
const tblName = tab.tableName || '';
switch (dbType) {
case 'mysql':
return `DROP TRIGGER IF EXISTS \`${triggerName}\``;
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${tblName}"`;
case 'sqlserver':
return `DROP TRIGGER IF EXISTS [${triggerName}]`;
case 'oracle':
case 'dm':
return `DROP TRIGGER "${triggerName}"`;
case 'sqlite':
return `DROP TRIGGER IF EXISTS "${triggerName}"`;
default:
return `DROP TRIGGER ${triggerName}`;
}
};
const handleCreateTrigger = () => {
setTriggerEditMode('create');
setTriggerEditSql(generateTriggerTemplate());
setIsTriggerEditModalOpen(true);
};
const handleEditTrigger = () => {
if (!selectedTrigger) return;
setTriggerEditMode('edit');
// 构建完整的 CREATE TRIGGER 语句
const dbType = getDbType();
const tblName = tab.tableName || '';
let createSql = '';
if (dbType === 'mysql') {
createSql = `CREATE TRIGGER \`${selectedTrigger.name}\`
${selectedTrigger.timing} ${selectedTrigger.event} ON \`${tblName}\`
FOR EACH ROW
${selectedTrigger.statement}`;
} else {
createSql = selectedTrigger.statement || '-- 无法获取完整的触发器定义';
}
setTriggerEditSql(createSql);
setIsTriggerEditModalOpen(true);
};
const handleDeleteTrigger = () => {
if (!selectedTrigger) return;
Modal.confirm({
title: '确认删除触发器',
icon: <ExclamationCircleOutlined />,
content: `确定要删除触发器 "${selectedTrigger.name}" 吗?此操作不可撤销。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('未找到连接');
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const dropSql = buildDropTriggerSql(selectedTrigger.name);
try {
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
if (res.success) {
message.success('触发器删除成功');
setSelectedTrigger(null);
fetchData(); // 刷新列表
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
}
});
};
const handleExecuteTriggerSql = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('未找到连接');
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
setTriggerExecuting(true);
try {
// 如果是编辑模式,先删除旧触发器
if (triggerEditMode === 'edit' && selectedTrigger) {
const dropSql = buildDropTriggerSql(selectedTrigger.name);
const dropRes = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
if (!dropRes.success) {
message.error('删除旧触发器失败: ' + dropRes.message);
setTriggerExecuting(false);
return;
}
}
// 执行创建语句
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql);
if (res.success) {
message.success(triggerEditMode === 'create' ? '触发器创建成功' : '触发器修改成功');
setIsTriggerEditModalOpen(false);
setSelectedTrigger(null);
fetchData(); // 刷新列表
} else {
message.error('执行失败: ' + res.message);
}
} catch (e: any) {
message.error('执行失败: ' + (e?.message || String(e)));
} finally {
setTriggerExecuting(false);
}
};
// --- Handlers ---
const handleColumnChange = (key: string, field: keyof EditableColumn, value: any) => {
setColumns(prev => prev.map(col => {
if (col._key === key) {
const newCol = { ...col, [field]: value };
if (field === 'key' && value === 'PRI') newCol.nullable = 'NO';
if (field === 'isAutoIncrement' && value === true) {
newCol.key = 'PRI';
newCol.nullable = 'NO';
newCol.type = 'int'; // Suggest INT
}
return newCol;
}
return col;
}));
};
const createNewColumn = useCallback((indexHint: number): EditableColumn => ({
name: isNewTable ? 'new_column' : `new_col_${indexHint}`,
type: 'varchar(255)',
nullable: 'YES',
key: '',
extra: '',
comment: '',
default: '',
_key: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
isNew: true,
isAutoIncrement: false
}), [isNewTable]);
const handleAddColumn = useCallback((insertAfterKey?: string) => {
const newCol = createNewColumn(columns.length + 1);
setColumns(prev => {
const next = [...prev];
if (insertAfterKey) {
const insertIndex = next.findIndex(col => col._key === insertAfterKey);
if (insertIndex >= 0) {
next.splice(insertIndex + 1, 0, newCol);
return next;
}
}
next.push(newCol);
return next;
});
setSelectedColumnRowKeys([newCol._key]);
pendingFocusColumnKeyRef.current = newCol._key;
}, [columns.length, createNewColumn]);
const handleAddColumnAfterSelected = useCallback(() => {
const selectedSet = new Set(selectedColumnRowKeys);
const anchor = columns.find(col => selectedSet.has(col._key));
if (!anchor) {
message.warning('请先选择一个字段,再执行插入。');
return;
}
handleAddColumn(anchor._key);
}, [columns, handleAddColumn, selectedColumnRowKeys]);
const handleDeleteColumn = (key: string) => {
setColumns(prev => prev.filter(c => c._key !== key));
};
const selectedColumns = useMemo(() => {
if (selectedColumnRowKeys.length === 0) return [];
const selectedSet = new Set(selectedColumnRowKeys);
return columns.filter(col => selectedSet.has(col._key));
}, [columns, selectedColumnRowKeys]);
const groupedIndexes = useMemo<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 || '(未命名索引)';
if (!buckets.has(key)) {
buckets.set(key, {
key,
name: displayName,
indexType,
nonUnique: idx.nonUnique === 0 ? 0 : 1,
order,
fields: [],
});
}
const bucket = buckets.get(key);
if (!bucket) return;
if (bucket.indexType === '-' && indexType !== '-') {
bucket.indexType = indexType;
}
if (idx.nonUnique === 0) {
bucket.nonUnique = 0;
}
const columnName = String(idx.columnName || '').trim();
if (!columnName) return;
const rawSeq = Number(idx.seqInIndex);
const seq = Number.isFinite(rawSeq) ? rawSeq : 0;
bucket.fields.push({
name: columnName,
seq,
order,
});
});
return Array.from(buckets.values())
.sort((a, b) => a.order - b.order)
.map((bucket) => {
const sortedFieldNames = bucket.fields
.slice()
.sort((a, b) => {
const aSeq = a.seq > 0 ? a.seq : Number.MAX_SAFE_INTEGER;
const bSeq = b.seq > 0 ? b.seq : Number.MAX_SAFE_INTEGER;
if (aSeq !== bSeq) return aSeq - bSeq;
return a.order - b.order;
})
.map(field => field.name);
const uniqueFieldNames = Array.from(new Set(sortedFieldNames));
return {
key: bucket.key,
name: bucket.name,
indexType: bucket.indexType,
nonUnique: bucket.nonUnique,
columnNames: uniqueFieldNames,
};
});
}, [indexes]);
const selectedIndex = useMemo(() => {
if (selectedIndexKeys.length === 0) return null;
return groupedIndexes.find(idx => selectedIndexKeys.includes(idx.key)) || null;
}, [selectedIndexKeys, groupedIndexes]);
const groupedIndexFieldCount = useMemo(
() => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0),
[groupedIndexes]
);
const groupedForeignKeys = useMemo<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 || '(未命名外键)';
const refTableName = String(fk.refTableName || '').trim() || '-';
if (!buckets.has(key)) {
buckets.set(key, {
key,
constraintName,
refTableName,
order,
columns: [],
refColumns: [],
});
}
const bucket = buckets.get(key);
if (!bucket) return;
if (bucket.refTableName === '-' && refTableName !== '-') {
bucket.refTableName = refTableName;
}
const colName = String(fk.columnName || '').trim();
const refColName = String(fk.refColumnName || '').trim();
if (colName) bucket.columns.push({ name: colName, order });
if (refColName) bucket.refColumns.push({ name: refColName, order });
});
return Array.from(buckets.values())
.sort((a, b) => a.order - b.order)
.map((bucket) => {
const columnNames = bucket.columns
.slice()
.sort((a, b) => a.order - b.order)
.map(item => item.name);
const refColumnNames = bucket.refColumns
.slice()
.sort((a, b) => a.order - b.order)
.map(item => item.name);
return {
key: bucket.key,
name: bucket.constraintName,
constraintName: bucket.constraintName,
refTableName: bucket.refTableName,
columnNames: Array.from(new Set(columnNames)),
refColumnNames: Array.from(new Set(refColumnNames)),
};
});
}, [fks]);
const localColumnOptions = useMemo(
() => columns.map(col => ({ label: col.name, value: col.name })),
[columns]
);
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 stripIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).trim();
}
return text;
};
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
return {
schemaName: stripIdentifierQuotes(raw.substring(0, idx)),
objectName: stripIdentifierQuotes(raw.substring(idx + 1)),
};
};
const isPgLikeDialect = (dbType: string): boolean =>
dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase';
const isOracleLikeDialect = (dbType: string): boolean => dbType === 'oracle' || dbType === 'dm';
const isSqlServerDialect = (dbType: string): boolean => dbType === 'sqlserver';
const isMysqlLikeDialect = (dbType: string): boolean => dbType === 'mysql';
const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb';
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 => {
const ident = stripIdentifierQuotes(part);
if (!ident) return '';
if (isMysqlLikeDialect(dbType) || dbType === 'tdengine') {
return `\`${escapeBacktickIdentifier(ident)}\``;
}
if (isSqlServerDialect(dbType)) {
return `[${escapeBracketIdentifier(ident)}]`;
}
return `"${escapeDoubleQuoteIdentifier(ident)}"`;
};
const quoteIdentifierPathByDialect = (path: string, dbType: string): string => {
const raw = String(path || '').trim();
if (!raw) return '';
const parts = raw
.split('.')
.map(part => stripIdentifierQuotes(part))
.filter(Boolean);
if (parts.length === 0) return '';
return parts.map(part => quoteIdentifierPartByDialect(part, dbType)).join('.');
};
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 supportsIndexSchemaOps = (): boolean => {
const dbType = getDbType();
if (!dbType) return false;
if (isNonRelationalDialect(dbType)) return false;
return true;
};
const supportsForeignKeySchemaOps = (): boolean => {
const dbType = getDbType();
if (!dbType) return false;
if (isNonRelationalDialect(dbType)) return false;
if (lacksAlterForeignKeySupport(dbType)) return false;
return true;
};
const supportsTableCommentOps = (): boolean => {
const dbType = getDbType();
if (!dbType) return false;
if (isNonRelationalDialect(dbType)) return false;
if (lacksTableCommentSupport(dbType)) return false;
return true;
};
const getIndexKindOptions = () => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) {
return [
{ label: '普通索引(非聚合)', value: 'NORMAL' },
{ label: '唯一索引', value: 'UNIQUE' },
{ label: '主键索引(聚合)', value: 'PRIMARY' },
{ label: '全文索引', value: 'FULLTEXT' },
{ label: '空间索引', value: 'SPATIAL' },
];
}
return [
{ label: '普通索引', value: 'NORMAL' },
{ label: '唯一索引', value: 'UNIQUE' },
];
};
const getIndexTypeOptions = (kind?: IndexKind) => {
const dbType = getDbType();
const k = kind || 'NORMAL';
if (isMysqlLikeDialect(dbType)) {
// MySQL InnoDB: 所有索引均为固定方法类型
if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }];
if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }];
return [{ label: 'BTREE', value: 'BTREE' }];
}
if (isPgLikeDialect(dbType)) {
if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }];
return PGLIKE_INDEX_TYPE_OPTIONS;
}
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
return [{ label: '默认', value: 'DEFAULT' }];
};
/** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */
const getFixedIndexType = (kind: IndexKind): string | undefined => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
if (kind === 'FULLTEXT') return 'FULLTEXT';
if (kind === 'SPATIAL') return 'RTREE';
}
if (isPgLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
}
return undefined;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
let extra = curr.extra || "";
if (curr.isAutoIncrement && !extra.toLowerCase().includes('auto_increment')) {
extra += " AUTO_INCREMENT";
}
return `\`${escapeBacktickIdentifier(curr.name)}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${escapeSqlString(String(curr.default))}'` : ''} ${extra} COMMENT '${escapeSqlString(curr.comment || '')}'`;
});
const pks = targetColumns.filter(c => c.key === 'PRI').map(c => `\`${escapeBacktickIdentifier(c.name)}\``);
if (pks.length > 0) {
colDefs.push(`PRIMARY KEY (${pks.join(', ')})`);
}
return `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${targetCharset} COLLATE=${targetCollation};`;
};
const openCopySelectedColumnsModal = () => {
if (selectedColumns.length === 0) {
message.warning('请先勾选要复制的字段');
return;
}
const sourceName = (tab.tableName || 'new_table').trim();
setCopyTableName(`${sourceName}_copy`);
setCopyCharset(charset);
const charsetCollations = (COLLATIONS as any)[charset] || [];
setCopyCollation(
charsetCollations.some((item: any) => item.value === collation)
? collation
: (charsetCollations[0]?.value || 'utf8mb4_unicode_ci')
);
setIsCopyColumnsModalOpen(true);
};
const handleExecuteCopySelectedColumns = async () => {
if (!copyTableName.trim()) {
message.error('请输入目标表名');
return;
}
if (selectedColumns.length === 0) {
message.error('未选择可复制字段');
return;
}
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('Connection not found');
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const sql = buildCreateTableSql(copyTableName.trim(), selectedColumns, copyCharset, copyCollation);
setCopyExecuting(true);
try {
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success) {
message.success(`已将 ${selectedColumns.length} 个字段复制到新表 ${copyTableName.trim()}`);
setIsCopyColumnsModalOpen(false);
} else {
message.error("执行失败: " + res.message);
}
} finally {
setCopyExecuting(false);
}
};
const executeSchemaStatements = async (sqlText: string): Promise<SchemaExecutionResult> => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
return { ok: false, message: '未找到连接', statementCount: 0 };
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const statements = sqlText.split(/;\s*\n/).map(s => s.trim()).filter(Boolean);
for (let i = 0; i < statements.length; i++) {
let stmt = statements[i];
if (!stmt.endsWith(';')) stmt += ';';
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt);
if (!res.success) {
const prefix = statements.length > 1 ? `${i + 1}/${statements.length} 条语句执行失败: ` : '执行失败: ';
return {
ok: false,
message: prefix + res.message,
failedStatementIndex: i,
statementCount: statements.length,
};
}
}
return { ok: true, statementCount: statements.length };
};
const buildIndexFormFromRow = (row: IndexDisplayRow): IndexFormState => {
return normalizeIndexFormFromRow(
row as IndexDisplaySnapshot,
getIndexKindOptions().map(item => item.value as IndexKind),
);
};
const executeIndexEditSql = async (dropSql: string, addSql: string, previousIndex: IndexDisplayRow): Promise<boolean> => {
const result = await executeSchemaStatements(`${dropSql}\n${addSql}`);
if (result.ok) {
message.success('索引修改成功');
await fetchData();
return true;
}
const oldCreateSql = buildIndexCreateSql(buildIndexFormFromRow(previousIndex));
if (!oldCreateSql) {
message.error((result.message || '执行失败') + ';且无法自动恢复原索引,请尽快检查');
await fetchData();
return false;
}
if (!shouldRestoreOriginalIndex(result)) {
message.error(result.message || '执行失败');
return false;
}
const restoreResult = await executeSchemaStatements(oldCreateSql);
if (restoreResult.ok) {
message.error((result.message || '执行失败') + ';已自动恢复原索引');
} else {
message.error((result.message || '执行失败') + `;恢复原索引失败: ${restoreResult.message || '未知错误'}`);
}
await fetchData();
return false;
};
const executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
try {
const result = await executeSchemaStatements(sql);
if (!result.ok) {
message.error(result.message || '执行失败');
if ((result.failedStatementIndex ?? 0) > 0) await fetchData();
return false;
}
message.success(successMessage);
await fetchData();
return true;
} catch (e: any) {
message.error('执行失败: ' + (e?.message || String(e)));
return false;
}
};
const openTableCommentModal = () => {
setTableCommentDraft(tableComment || '');
setIsTableCommentModalOpen(true);
};
const buildTableCommentSql = (nextComment: string): string | null => {
const tableInfo = resolveTableInfo();
const dbType = tableInfo.dbType;
const escapedComment = escapeSqlString(nextComment);
if (isNonRelationalDialect(dbType)) return null;
if (isMysqlLikeDialect(dbType)) {
return `ALTER TABLE ${tableInfo.tableRef} COMMENT = '${escapedComment}';`;
}
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType)) {
return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`;
}
if (isSqlServerDialect(dbType)) {
const schemaName = escapeSqlString(tableInfo.schema || 'dbo');
const tableName = escapeSqlString(tableInfo.table);
return `IF EXISTS (
SELECT 1
FROM sys.extended_properties ep
JOIN sys.tables t ON ep.major_id = t.object_id AND ep.minor_id = 0
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE ep.name = N'MS_Description'
AND s.name = N'${schemaName}'
AND t.name = N'${tableName}'
)
BEGIN
EXEC sp_updateextendedproperty
@name = N'MS_Description',
@value = N'${escapedComment}',
@level0type = N'SCHEMA', @level0name = N'${schemaName}',
@level1type = N'TABLE', @level1name = N'${tableName}';
END
ELSE
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'${escapedComment}',
@level0type = N'SCHEMA', @level0name = N'${schemaName}',
@level1type = N'TABLE', @level1name = N'${tableName}';
END;`;
}
return `COMMENT ON TABLE ${tableInfo.tableRef} IS '${escapedComment}';`;
};
const handleSaveTableComment = async () => {
if (!supportsTableCommentOps()) {
message.warning('当前数据库暂不支持在此修改表备注');
return;
}
if (!tab.tableName) return;
const sql = buildTableCommentSql(tableCommentDraft);
if (!sql) {
message.warning('当前数据库暂不支持在此修改表备注');
return;
}
setTableCommentSaving(true);
const ok = await executeSchemaSql(sql, '表备注更新成功');
setTableCommentSaving(false);
if (ok) {
setTableComment(tableCommentDraft);
setIsTableCommentModalOpen(false);
}
};
const openCreateIndexModal = () => {
setIndexModalMode('create');
setIndexForm({
name: '',
columnNames: [],
kind: 'NORMAL',
indexType: 'DEFAULT',
});
setIsIndexModalOpen(true);
};
const openEditIndexModal = () => {
if (!selectedIndex) {
message.warning('请先选择一个索引');
return;
}
setIndexModalMode('edit');
setIndexForm(buildIndexFormFromRow(selectedIndex));
setIsIndexModalOpen(true);
};
const buildIndexCreateSql = (form: IndexFormState): string | null => {
const tableInfo = resolveTableInfo();
const dbType = tableInfo.dbType;
const kind: IndexKind = form.kind || 'NORMAL';
const indexName = String(form.name || '').trim();
const cleanedCols = form.columnNames.map(col => String(col || '').trim()).filter(Boolean);
if (cleanedCols.length === 0) {
message.error('请至少选择一个字段');
return null;
}
const colSql = cleanedCols
.map(col => quoteIdentifierPartByDialect(col, dbType))
.join(', ');
if (isMysqlLikeDialect(dbType)) {
if (kind === 'PRIMARY') {
return `ALTER TABLE ${tableInfo.tableRef}\nADD PRIMARY KEY (${colSql});`;
}
if (!indexName) {
message.error('请输入索引名');
return null;
}
const indexRef = quoteIdentifierPartByDialect(indexName, dbType);
if (kind === 'FULLTEXT') {
return `ALTER TABLE ${tableInfo.tableRef}\nADD FULLTEXT INDEX ${indexRef} (${colSql});`;
}
if (kind === 'SPATIAL') {
return `ALTER TABLE ${tableInfo.tableRef}\nADD SPATIAL INDEX ${indexRef} (${colSql});`;
}
const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT';
if (normalizedType === 'FULLTEXT' || normalizedType === 'SPATIAL') {
message.error(`请将“索引类别”切换为 ${normalizedType} 索引`);
return null;
}
const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : '';
const prefix = kind === 'UNIQUE' ? 'ADD UNIQUE INDEX' : 'ADD INDEX';
return `ALTER TABLE ${tableInfo.tableRef}\n${prefix} ${indexRef}${usingSql} (${colSql});`;
}
if (kind === 'PRIMARY' || kind === 'FULLTEXT' || kind === 'SPATIAL') {
message.warning('当前数据库仅支持普通索引与唯一索引维护');
return null;
}
if (!indexName) {
message.error('请输入索引名');
return null;
}
const indexRef = quoteIdentifierPartByDialect(indexName, dbType);
const normalizedType = String(form.indexType || '').trim().toUpperCase() || 'DEFAULT';
const uniquePrefix = kind === 'UNIQUE' ? 'UNIQUE ' : '';
if (isPgLikeDialect(dbType)) {
const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : '';
return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef}${usingSql} (${colSql});`;
}
if (isSqlServerDialect(dbType)) {
const methodSql = normalizedType === 'CLUSTERED' || normalizedType === 'NONCLUSTERED'
? `${normalizedType} `
: '';
return `CREATE ${uniquePrefix}${methodSql}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`;
}
if (isOracleLikeDialect(dbType) || dbType === 'sqlite') {
return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`;
}
if (isNonRelationalDialect(dbType)) {
message.warning('当前数据源不支持关系型索引维护');
return null;
}
return `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${tableInfo.tableRef} (${colSql});`;
};
const buildIndexDropSql = (indexName: string): string | null => {
const tableInfo = resolveTableInfo();
const dbType = tableInfo.dbType;
const name = String(indexName || '').trim();
if (!name) return null;
if (isMysqlLikeDialect(dbType)) {
if (name.toUpperCase() === 'PRIMARY') {
return `ALTER TABLE ${tableInfo.tableRef}\nDROP PRIMARY KEY;`;
}
const indexRef = quoteIdentifierPartByDialect(name, dbType);
return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`;
}
if (isSqlServerDialect(dbType)) {
const indexRef = quoteIdentifierPartByDialect(name, dbType);
return `DROP INDEX ${indexRef} ON ${tableInfo.tableRef};`;
}
if (isPgLikeDialect(dbType) || isOracleLikeDialect(dbType) || dbType === 'sqlite') {
const fullIndexName = name.includes('.') || !tableInfo.schema
? name
: `${tableInfo.schema}.${name}`;
const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType);
return `DROP INDEX ${indexRef};`;
}
if (isNonRelationalDialect(dbType)) {
return null;
}
const fullIndexName = name.includes('.') || !tableInfo.schema
? name
: `${tableInfo.schema}.${name}`;
const indexRef = quoteIdentifierPathByDialect(fullIndexName, dbType);
return `DROP INDEX ${indexRef};`;
};
const handleSubmitIndex = async () => {
if (!supportsIndexSchemaOps()) {
message.warning('当前数据库暂不支持在此维护索引');
return;
}
if (!tab.tableName) return;
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
if (!supportedKinds.has(indexForm.kind)) {
message.warning('当前数据库不支持该索引类型');
return;
}
const nextName = indexForm.kind === 'PRIMARY' ? 'PRIMARY' : String(indexForm.name || '').trim();
if (indexForm.kind !== 'PRIMARY' && !nextName) {
message.error('请输入索引名');
return;
}
if (indexForm.columnNames.length === 0) {
message.error('请至少选择一个字段');
return;
}
const upperName = nextName.toUpperCase();
const duplicate = groupedIndexes.some(idx => {
if (indexModalMode === 'edit' && selectedIndex && idx.key === selectedIndex.key) return false;
return idx.name.toUpperCase() === upperName;
});
if (duplicate) {
message.error(`索引名已存在:${nextName}`);
return;
}
setIndexSaving(true);
const addSql = buildIndexCreateSql({ ...indexForm, name: nextName });
if (!addSql) {
setIndexSaving(false);
return;
}
let sql = addSql;
if (indexModalMode === 'edit' && selectedIndex) {
const previousForm = buildIndexFormFromRow(selectedIndex);
const nextForm: IndexFormState = {
name: indexForm.kind === 'PRIMARY' ? 'PRIMARY' : nextName,
columnNames: [...indexForm.columnNames],
kind: indexForm.kind,
indexType: indexForm.kind === 'NORMAL' || indexForm.kind === 'UNIQUE'
? (String(indexForm.indexType || '').trim().toUpperCase() || 'DEFAULT')
: 'DEFAULT',
};
if (!hasIndexFormChanged(previousForm, nextForm)) {
setIndexSaving(false);
message.info('没有检测到索引变更');
return;
}
const dropSql = buildIndexDropSql(selectedIndex.name);
if (!dropSql) {
setIndexSaving(false);
message.warning('当前数据库暂不支持删除该索引');
return;
}
const ok = await executeIndexEditSql(dropSql, addSql, selectedIndex);
setIndexSaving(false);
if (ok) {
setIsIndexModalOpen(false);
}
return;
}
const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功');
setIndexSaving(false);
if (ok) {
setIsIndexModalOpen(false);
}
};
const handleDeleteIndex = () => {
if (selectedIndexKeys.length === 0) {
message.warning('请先选择要删除的索引');
return;
}
if (!supportsIndexSchemaOps()) {
message.warning('当前数据库暂不支持在此维护索引');
return;
}
// 根据选中的 key 找到对应的索引对象
const toDelete = groupedIndexes.filter(idx => selectedIndexKeys.includes(idx.key));
if (toDelete.length === 0) {
message.warning('请先选择要删除的索引');
return;
}
const names = toDelete.map(idx => `"${idx.name}"`).join('、');
Modal.confirm({
title: '确认删除索引',
icon: <ExclamationCircleOutlined />,
content: toDelete.length === 1
? `确定删除索引 ${names} 吗?`
: `确定删除以下 ${toDelete.length} 个索引吗?\n${names}`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const sqls: string[] = [];
for (const idx of toDelete) {
const sql = buildIndexDropSql(idx.name);
if (!sql) {
message.warning(`当前数据库暂不支持删除索引 "${idx.name}"`);
return;
}
sqls.push(sql);
}
const ok = await executeSchemaSql(sqls.join('\n'), toDelete.length === 1 ? '索引删除成功' : `${toDelete.length} 个索引删除成功`);
if (ok) {
setSelectedIndexKeys([]);
}
}
});
};
const openCreateForeignKeyModal = () => {
setForeignKeyModalMode('create');
setForeignKeyForm({
constraintName: '',
columnNames: [],
refTableName: '',
refColumnNames: [],
});
setIsForeignKeyModalOpen(true);
};
const openEditForeignKeyModal = () => {
if (!selectedForeignKey) {
message.warning('请先选择一个外键');
return;
}
setForeignKeyModalMode('edit');
setForeignKeyForm({
constraintName: selectedForeignKey.constraintName,
columnNames: [...selectedForeignKey.columnNames],
refTableName: selectedForeignKey.refTableName === '-' ? '' : selectedForeignKey.refTableName,
refColumnNames: [...selectedForeignKey.refColumnNames],
});
setIsForeignKeyModalOpen(true);
};
const buildForeignKeyAddSql = (form: ForeignKeyFormState): string | null => {
const tableInfo = resolveTableInfo();
const dbType = tableInfo.dbType;
if (!supportsForeignKeySchemaOps()) return null;
const localColsSql = form.columnNames
.map(col => quoteIdentifierPartByDialect(col, dbType))
.join(', ');
const refColsSql = form.refColumnNames
.map(col => quoteIdentifierPartByDialect(col, dbType))
.join(', ');
const refParts = splitQualifiedName(form.refTableName);
const refObjectName = refParts.objectName || String(form.refTableName || '').trim();
const refTableName = !refParts.schemaName && tableInfo.schema && (isPgLikeDialect(dbType) || isSqlServerDialect(dbType) || isOracleLikeDialect(dbType))
? `${tableInfo.schema}.${refObjectName}`
: String(form.refTableName || '').trim();
const refTableSql = quoteIdentifierPathByDialect(refTableName, dbType);
const constraintSql = quoteIdentifierPartByDialect(form.constraintName, dbType);
return `ALTER TABLE ${tableInfo.tableRef}\nADD CONSTRAINT ${constraintSql} FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql});`;
};
const buildForeignKeyDropSql = (constraintName: string): string | null => {
const tableInfo = resolveTableInfo();
const dbType = tableInfo.dbType;
if (!supportsForeignKeySchemaOps()) return null;
const constraintSql = quoteIdentifierPartByDialect(constraintName, dbType);
if (isMysqlLikeDialect(dbType)) {
return `ALTER TABLE ${tableInfo.tableRef}\nDROP FOREIGN KEY ${constraintSql};`;
}
return `ALTER TABLE ${tableInfo.tableRef}\nDROP CONSTRAINT ${constraintSql};`;
};
const handleSubmitForeignKey = async () => {
if (!supportsForeignKeySchemaOps()) {
message.warning('当前数据库暂不支持在此维护外键');
return;
}
if (!tab.tableName) return;
const nextConstraint = String(foreignKeyForm.constraintName || '').trim();
const refTable = String(foreignKeyForm.refTableName || '').trim();
const refCols = foreignKeyForm.refColumnNames.map(v => String(v || '').trim()).filter(Boolean);
const localCols = foreignKeyForm.columnNames.map(v => String(v || '').trim()).filter(Boolean);
if (!nextConstraint) {
message.error('请输入外键约束名');
return;
}
if (localCols.length === 0) {
message.error('请至少选择一个本表字段');
return;
}
if (!refTable) {
message.error('请输入参考表');
return;
}
if (refCols.length === 0) {
message.error('请至少填写一个参考字段');
return;
}
if (localCols.length !== refCols.length) {
message.error('本表字段数量与参考字段数量必须一致');
return;
}
const duplicate = groupedForeignKeys.some(item => {
if (foreignKeyModalMode === 'edit' && selectedForeignKey && item.key === selectedForeignKey.key) return false;
return item.constraintName.toUpperCase() === nextConstraint.toUpperCase();
});
if (duplicate) {
message.error(`外键约束名已存在:${nextConstraint}`);
return;
}
setForeignKeySaving(true);
const addSql = buildForeignKeyAddSql({
...foreignKeyForm,
constraintName: nextConstraint,
columnNames: localCols,
refTableName: refTable,
refColumnNames: refCols,
});
if (!addSql) {
setForeignKeySaving(false);
message.warning('当前数据库暂不支持在此维护外键');
return;
}
let sql = addSql;
if (foreignKeyModalMode === 'edit' && selectedForeignKey) {
const dropSql = buildForeignKeyDropSql(selectedForeignKey.constraintName);
if (!dropSql) {
setForeignKeySaving(false);
message.warning('当前数据库暂不支持删除该外键');
return;
}
sql = `${dropSql}\n${addSql}`;
}
const ok = await executeSchemaSql(sql, foreignKeyModalMode === 'create' ? '外键新增成功' : '外键修改成功');
setForeignKeySaving(false);
if (ok) {
setIsForeignKeyModalOpen(false);
}
};
const handleDeleteForeignKey = () => {
if (!selectedForeignKey) {
message.warning('请先选择一个外键');
return;
}
if (!supportsForeignKeySchemaOps()) {
message.warning('当前数据库暂不支持在此维护外键');
return;
}
Modal.confirm({
title: '确认删除外键',
icon: <ExclamationCircleOutlined />,
content: `确定删除外键约束 "${selectedForeignKey.constraintName}" 吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const sql = buildForeignKeyDropSql(selectedForeignKey.constraintName);
if (!sql) {
message.warning('当前数据库暂不支持删除该外键');
return;
}
await executeSchemaSql(sql, '外键删除成功');
}
});
};
const onDragEnd = ({ active, over }: any) => {
if (active.id !== over?.id) {
setColumns((previous) => {
const activeIndex = previous.findIndex((i) => i._key === active.id);
const overIndex = previous.findIndex((i) => i._key === over?.id);
return arrayMove(previous, activeIndex, overIndex);
});
}
};
const generateDDL = () => {
if (isNewTable && !newTableName.trim()) {
message.error("请输入表名");
return;
}
if (columns.length === 0) {
message.error("请至少添加一个字段");
return;
}
if (isNewTable) {
// CREATE TABLE
const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation);
setPreviewSql(sql);
setIsPreviewOpen(true);
} else {
const tableInfo = resolveTableInfo();
const sql = buildAlterTablePreviewSql({
dbType: tableInfo.dbType,
tableName: tableInfo.qualifiedName,
originalColumns,
columns,
});
if (!sql.trim()) {
message.info("没有检测到变更");
return;
}
setPreviewSql(sql);
setIsPreviewOpen(true);
}
};
const handleExecuteSave = async () => {
const result = await executeSchemaStatements(previewSql);
if (!result.ok) {
message.error(result.message || "执行失败");
return;
}
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
setIsPreviewOpen(false);
if (!isNewTable) {
fetchData();
} else {
// TODO: Close tab or reload sidebar?
// Ideally, refresh sidebar node.
}
};
// Merge columns with resize handler
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(index),
}),
})), [tableColumns]);
// 字段表 Checkbox 选择列(不参与 resize支持全选
const allColumnKeys = useMemo(() => columns.map(c => c._key), [columns]);
const isAllColumnsSelected = allColumnKeys.length > 0 && selectedColumnRowKeys.length === allColumnKeys.length;
const isColumnsIndeterminate = selectedColumnRowKeys.length > 0 && selectedColumnRowKeys.length < allColumnKeys.length;
const columnSelectCol = useMemo(() => ({
title: () => (
<Checkbox
checked={isAllColumnsSelected}
indeterminate={isColumnsIndeterminate}
onChange={(e: any) => setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])}
style={{ margin: 0 }}
/>
),
dataIndex: '_select',
key: '_select',
width: 48,
render: (_: any, record: any) => (
<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 }}
/>
),
}), [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: '索引名',
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: '字段',
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: '索引类型',
dataIndex: 'indexType',
key: 'indexType',
width: 140,
render: (text: string) => text || '-',
},
{
title: '唯一性',
dataIndex: 'nonUnique',
key: 'nonUnique',
width: 110,
render: (v: number) => (
<Tag color={v === 0 ? 'gold' : 'default'}>
{v === 0 ? '唯一' : '普通'}
</Tag>
),
},
]);
}, []);
// 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,
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 columnsTabContent = (
<div
ref={containerRef}
className="table-designer-wrapper"
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" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '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 .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 .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 .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',
}}
/>
<div
style={{
padding: '10px 12px 8px 12px',
borderBottom: `1px solid ${panelToolbarBorder}`,
borderTopLeftRadius: panelRadius,
borderTopRightRadius: panelRadius,
borderLeft: `1px solid ${panelFrameColor}`,
borderRight: `1px solid ${panelFrameColor}`,
borderTop: `1px solid ${panelFrameColor}`,
background: panelToolbarBg,
display: 'flex',
gap: '8px',
alignItems: 'center'
}}
>
{isNewTable && (
<>
<Input
placeholder="请输入表名"
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={CHARSETS}
style={{ width: 120 }}
/>
<Select
value={collation}
onChange={setCollation}
options={(COLLATIONS as any)[charset] || []}
style={{ width: 150 }}
/>
</>
)}
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}></Button>}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}></Button>
)}
{!readOnly && <Button size="small" icon={<PlusOutlined />} onClick={() => handleAddColumn()}></Button>}
{!readOnly && (
<Button
size="small"
icon={<PlusOutlined />}
onClick={handleAddColumnAfterSelected}
disabled={selectedColumnRowKeys.length === 0}
>
</Button>
)}
{!readOnly && (
<Button
size="small"
icon={<CopyOutlined />}
onClick={openCopySelectedColumnsModal}
disabled={selectedColumns.length === 0}
>
</Button>
)}
<div style={{ flex: 1 }} />
</div>
<Tabs
activeKey={activeKey}
onChange={(key) => React.startTransition(() => setActiveKey(key))}
style={{
flex: 1,
minHeight: 0,
padding: '8px 10px 10px 10px',
borderBottomLeftRadius: panelRadius,
borderBottomRightRadius: panelRadius,
borderLeft: `1px solid ${panelFrameColor}`,
borderRight: `1px solid ${panelFrameColor}`,
borderBottom: `1px solid ${panelFrameColor}`,
background: panelBodyBg
}}
items={[
{
key: 'columns',
label: '字段',
children: columnsTabContent
},
...(!isNewTable ? [
{
key: 'indexes',
label: '索引',
children: (
<div className="index-table-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length !== 1} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length === 0} onClick={handleDeleteIndex}></Button>
{!supportsIndexSchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedIndexKeys.length}
</span>
)}
</div>
)}
<div style={{ color: '#888', fontSize: 12 }}>
{groupedIndexes.length}{groupedIndexFieldCount}
</div>
<Table
dataSource={groupedIndexes}
columns={resizableIndexColumns}
rowKey="key"
size="small"
pagination={false}
loading={loading}
scroll={{ x: 960, y: tableHeight }}
components={{
header: { cell: ResizableTitle },
}}
onRow={(record) => ({
onClick: () => {
toggleIndexSelection(record.key);
},
style: { cursor: 'pointer' }
})}
/>
</div>
)
},
{
key: 'foreignKeys',
label: '外键',
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsForeignKeySchemaOps()} onClick={openCreateForeignKeyModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={openEditForeignKeyModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={handleDeleteForeignKey}></Button>
{!supportsForeignKeySchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsForeignKeySchemaOps() && selectedForeignKey && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedForeignKey.constraintName}
</span>
)}
</div>
)}
<Table
dataSource={groupedForeignKeys}
columns={[
{ title: '约束名', dataIndex: 'constraintName', key: 'constraintName', width: 220 },
{
title: '字段',
dataIndex: 'columnNames',
key: 'columnNames',
render: (vals: string[]) => vals?.length ? vals.join(', ') : '-',
},
{ title: '参考表', dataIndex: 'refTableName', key: 'refTableName', width: 220 },
{
title: '参考字段',
dataIndex: 'refColumnNames',
key: 'refColumnNames',
render: (vals: string[]) => vals?.length ? vals.join(', ') : '-',
},
]}
rowKey="key"
size="small"
pagination={false}
loading={loading}
scroll={{ x: 980, y: tableHeight }}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedForeignKey ? [selectedForeignKey.key] : [],
onChange: (_, selectedRows) => setSelectedForeignKey((selectedRows[0] as ForeignKeyDisplayRow) || null),
}}
onRow={(record) => ({
onClick: () => {
if (selectedForeignKey?.key === record.key) {
setSelectedForeignKey(null);
} else {
setSelectedForeignKey(record);
}
},
style: { cursor: 'pointer' }
})}
/>
</div>
)
},
{
key: 'triggers',
label: '触发器',
children: (
<div>
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<Button
size="small"
icon={<EyeOutlined />}
disabled={!selectedTrigger}
onClick={() => setIsTriggerModalOpen(true)}
>
</Button>
<Button size="small" icon={<PlusOutlined />} onClick={handleCreateTrigger}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!selectedTrigger} onClick={handleEditTrigger}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!selectedTrigger} onClick={handleDeleteTrigger}></Button>
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedTrigger ? `已选择: ${selectedTrigger.name}` : '请点击选择触发器'}
</span>
</div>
<Table
dataSource={triggers}
columns={[
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '时机', dataIndex: 'timing', key: 'timing', width: 100 },
{ title: '事件', dataIndex: 'event', key: 'event', width: 100 },
]}
rowKey="name"
size="small"
pagination={false}
loading={loading}
scroll={{ y: tableHeight }}
locale={{ emptyText: <Empty description="该表暂无触发器" 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 style={{ height: 'calc(100vh - 200px)', 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 ? ` - ${commentEditorColumnName}` : ''}`}
open={isCommentModalOpen}
onCancel={closeCommentEditor}
onOk={() => {
if (commentEditorColumnKey) {
handleColumnChange(commentEditorColumnKey, 'comment', commentEditorValue);
}
closeCommentEditor();
}}
okText="应用"
cancelText="取消"
width={640}
destroyOnHidden
>
<Input.TextArea
value={commentEditorValue}
onChange={(e) => setCommentEditorValue(e.target.value)}
autoSize={{ minRows: 8, maxRows: 18 }}
placeholder="请输入字段注释"
maxLength={2000}
/>
</Modal>
<Modal
title="复制选中字段到新表"
open={isCopyColumnsModalOpen}
onCancel={() => setIsCopyColumnsModalOpen(false)}
onOk={handleExecuteCopySelectedColumns}
okText="创建新表"
cancelText="取消"
confirmLoading={copyExecuting}
width={560}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div style={{ color: '#666' }}>
{selectedColumns.length}
</div>
<Input
placeholder="请输入目标表名"
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={CHARSETS}
style={{ width: 160 }}
/>
<Select
value={copyCollation}
onChange={setCopyCollation}
options={(COLLATIONS as any)[copyCharset] || []}
style={{ width: 220 }}
/>
</Space>
</Space>
</Modal>
<Modal
title="修改表备注"
open={isTableCommentModalOpen}
onCancel={() => setIsTableCommentModalOpen(false)}
onOk={handleSaveTableComment}
okText="保存"
cancelText="取消"
confirmLoading={tableCommentSaving}
width={640}
>
<Input.TextArea
value={tableCommentDraft}
onChange={(e) => setTableCommentDraft(e.target.value)}
autoSize={{ minRows: 5, maxRows: 12 }}
placeholder="请输入表备注"
maxLength={2048}
/>
<div style={{ marginTop: 8, color: '#888', fontSize: 12 }}>
{tableComment || '(空)'}
</div>
</Modal>
<Modal
title={indexModalMode === 'create' ? '新增索引' : '修改索引'}
open={isIndexModalOpen}
onCancel={() => setIsIndexModalOpen(false)}
onOk={handleSubmitIndex}
okText={indexModalMode === 'create' ? '创建' : '保存'}
cancelText="取消"
confirmLoading={indexSaving}
width={620}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称PRIMARY' : '索引名(例如 idx_user_name'}
value={indexForm.name}
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
maxLength={128}
disabled={indexForm.kind === 'PRIMARY'}
/>
<Select
mode="multiple"
allowClear
placeholder="请选择索引字段(按选择顺序生效)"
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 }}>
</div>
</Space>
</Modal>
<Modal
title={foreignKeyModalMode === 'create' ? '新增外键' : '修改外键'}
open={isForeignKeyModalOpen}
onCancel={() => setIsForeignKeyModalOpen(false)}
onOk={handleSubmitForeignKey}
okText={foreignKeyModalMode === 'create' ? '创建' : '保存'}
cancelText="取消"
confirmLoading={foreignKeySaving}
width={700}
>
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<Input
placeholder="外键约束名(例如 fk_order_user"
value={foreignKeyForm.constraintName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
maxLength={128}
/>
<Select
mode="multiple"
allowClear
placeholder="请选择本表字段(顺序需与参考字段一致)"
value={foreignKeyForm.columnNames}
onChange={(vals) => setForeignKeyForm(prev => ({ ...prev, columnNames: vals }))}
options={localColumnOptions}
style={{ width: '100%' }}
/>
<Input
placeholder="参考表(支持 db.table"
value={foreignKeyForm.refTableName}
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
maxLength={256}
/>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="请输入参考字段(支持多个)"
value={foreignKeyForm.refColumnNames}
onChange={(vals) => setForeignKeyForm(prev => ({ ...prev, refColumnNames: vals }))}
style={{ width: '100%' }}
/>
<div style={{ color: '#888', fontSize: 12 }}>
</div>
</Space>
</Modal>
<Modal
title="确认 SQL 变更"
open={isPreviewOpen}
onOk={handleExecuteSave}
onCancel={() => setIsPreviewOpen(false)}
width={700}
okText="执行"
cancelText="取消"
>
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
{previewSql}
</pre>
</div>
<p style={{ marginTop: 10, color: '#faad14' }}> SQL</p>
</Modal>
<Modal
title={selectedTrigger ? `触发器: ${selectedTrigger.name}` : '触发器详情'}
open={isTriggerModalOpen}
onCancel={() => setIsTriggerModalOpen(false)}
footer={null}
width={700}
>
{selectedTrigger && (
<div>
<div style={{ marginBottom: 12, display: 'flex', gap: 24 }}>
<span><strong>:</strong> {selectedTrigger.timing}</span>
<span><strong>:</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' ? '新增触发器' : '修改触发器'}
open={isTriggerEditModalOpen}
onCancel={() => setIsTriggerEditModalOpen(false)}
width={800}
okText={triggerEditMode === 'create' ? '创建' : '保存'}
cancelText="取消"
confirmLoading={triggerExecuting}
onOk={handleExecuteTriggerSql}
>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
{triggerEditMode === 'edit' && selectedTrigger && (
<span></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' }}> SQL </p>
</Modal>
</div>
);
};
export default TableDesigner;