Files
MyGoNavi/frontend/src/components/TableDesigner.tsx
Syngnat 91658848c9 🔧 fix(frontend): 修复表设计能力门禁并优化构建分包策略
- 修复触发器分组进入设计页时误设只读,恢复索引/外键页增删改按钮显示
  - 重构 TableDesigner 数据源方言识别,移除 MySQL 与固定方言白名单硬限制
  - 按能力控制索引/外键/表备注编辑入口,并补充多方言 DDL 生成与通用兜底
  - 收敛已知不支持场景:sqlite/duckdb/tdengine 禁用外键编辑,sqlite 禁用表备注编辑
  - Monaco 改为按需 worker(editor/json)并补齐 vite 类型声明,避免构建类型报错
  - 细化 Vite manualChunks(antd/monaco 子模块拆分),消除 >500k chunk 告警
  - refs #115
2026-02-26 12:08:07 +08:00

2579 lines
101 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 } 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';
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[];
}
const COMMON_TYPES = [
{ value: 'int' },
{ value: 'varchar(255)' },
{ value: 'text' },
{ value: 'datetime' },
{ value: 'tinyint(1)' },
{ value: 'decimal(10,2)' },
{ value: 'bigint' },
{ value: 'json' },
];
const COMMON_DEFAULTS = [
{ value: 'CURRENT_TIMESTAMP' },
{ value: 'NULL' },
{ value: '0' },
{ value: "''" },
];
const MYSQL_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
{ label: 'BTREE', value: 'BTREE' },
{ label: 'HASH', value: 'HASH' },
{ label: 'FULLTEXT', value: 'FULLTEXT' },
{ label: 'SPATIAL', value: 'SPATIAL' },
{ label: 'RTREE', value: 'RTREE' },
];
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 (!width) {
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 [selectedIndex, setSelectedIndex] = useState<IndexDisplayRow | null>(null);
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 readOnly = !!tab.readOnly;
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
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 主题
useEffect(() => {
loader.init().then(monaco => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
}
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
}
});
});
}, []);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
const h = Math.max(200, entry.contentRect.height - 40);
setTableHeight(h);
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, [activeKey]); // Re-attach when tab switches
// --- Resizable Columns State ---
const [tableColumns, setTableColumns] = useState<any[]>([]);
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number } | 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]);
// Initial Columns Definition
useEffect(() => {
const initialCols = [
...(readOnly ? [] : [{
key: 'sort',
width: 40,
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
}]),
{
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={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 handleResizeStart = useCallback((index: number) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const currentWidth = Number(tableColumns[index]?.width || 200);
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft };
latestResizeXRef.current = startX;
if (ghostRef.current && containerRef.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 } = resizeDragRef.current;
const deltaX = event.clientX - dragStartX;
const newWidth = Math.max(50, startWidth + deltaX);
setTableColumns((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, tableColumns]);
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(config as any, tab.dbName || '', tab.tableName || ''),
DBGetIndexes(config as any, tab.dbName || '', tab.tableName || ''),
DBGetForeignKeys(config as any, tab.dbName || '', tab.tableName || ''),
DBGetTriggers(config as any, tab.dbName || '', tab.tableName || '')
];
if (!isNewTable) {
promises.push(DBShowCreateTable(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 as any)?.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(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(config as any, tab.dbName || '', dropSql);
if (!dropRes.success) {
message.error('删除旧触发器失败: ' + dropRes.message);
setTriggerExecuting(false);
return;
}
}
// 执行创建语句
const res = await DBQuery(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 handleAddColumn = () => {
const newCol: EditableColumn = {
name: isNewTable ? 'new_column' : `new_col_${columns.length + 1}`,
type: 'varchar(255)',
nullable: 'YES',
key: '',
extra: '',
comment: '',
default: '',
_key: `new-${Date.now()}`,
isNew: true,
isAutoIncrement: false
};
setColumns([...columns, newCol]);
};
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 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 (!selectedIndex) return;
if (!groupedIndexes.some(idx => idx.key === selectedIndex.key)) {
setSelectedIndex(null);
}
}, [groupedIndexes, selectedIndex]);
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 = () => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS;
if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS;
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
return [{ label: '默认', value: 'DEFAULT' }];
};
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(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 executeSchemaSql = async (sql: string, successMessage: string): Promise<boolean> => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
message.error('未找到连接');
return false;
}
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: "" }
};
try {
const res = await DBQuery(config as any, tab.dbName || '', sql);
if (res.success) {
message.success(successMessage);
await fetchData();
return true;
}
message.error('执行失败: ' + res.message);
return false;
} 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');
const selectedName = String(selectedIndex.name || '').trim();
const selectedNameUpper = selectedName.toUpperCase();
const selectedTypeUpper = String(selectedIndex.indexType || '').trim().toUpperCase();
let kind: IndexKind = 'NORMAL';
if (selectedNameUpper === 'PRIMARY') {
kind = 'PRIMARY';
} else if (selectedTypeUpper === 'FULLTEXT') {
kind = 'FULLTEXT';
} else if (selectedTypeUpper === 'SPATIAL') {
kind = 'SPATIAL';
} else if (selectedIndex.nonUnique === 0) {
kind = 'UNIQUE';
}
const supportedKinds = new Set(getIndexKindOptions().map(item => item.value));
if (!supportedKinds.has(kind)) {
kind = selectedIndex.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
}
setIndexForm({
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
columnNames: [...selectedIndex.columnNames],
kind,
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
? (selectedTypeUpper || 'DEFAULT')
: 'DEFAULT',
});
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 dropSql = buildIndexDropSql(selectedIndex.name);
if (!dropSql) {
setIndexSaving(false);
message.warning('当前数据库暂不支持删除该索引');
return;
}
sql = `${dropSql}\n${addSql}`;
}
const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功');
setIndexSaving(false);
if (ok) {
setIsIndexModalOpen(false);
}
};
const handleDeleteIndex = () => {
if (!selectedIndex) {
message.warning('请先选择一个索引');
return;
}
if (!supportsIndexSchemaOps()) {
message.warning('当前数据库暂不支持在此维护索引');
return;
}
Modal.confirm({
title: '确认删除索引',
icon: <ExclamationCircleOutlined />,
content: `确定删除索引 "${selectedIndex.name}" 吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const sql = buildIndexDropSql(selectedIndex.name);
if (!sql) {
message.warning('当前数据库暂不支持删除该索引');
return;
}
await executeSchemaSql(sql, '索引删除成功');
}
});
};
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;
}
const tableName = `\`${isNewTable ? newTableName : tab.tableName}\``;
if (isNewTable) {
// CREATE TABLE
const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation);
setPreviewSql(sql);
setIsPreviewOpen(true);
} else {
// ALTER TABLE (Existing logic)
const alters: string[] = [];
originalColumns.forEach(orig => {
if (!columns.find(c => c._key === orig._key)) {
alters.push(`DROP COLUMN \`${orig.name}\``);
}
});
columns.forEach((curr, index) => {
const orig = originalColumns.find(c => c._key === curr._key);
const prevCol = index > 0 ? columns[index - 1] : null;
const positionSql = prevCol ? `AFTER \`${prevCol.name}\`` : 'FIRST';
let extra = curr.extra || "";
if (curr.isAutoIncrement) {
if (!extra.toLowerCase().includes('auto_increment')) extra += " AUTO_INCREMENT";
} else {
extra = extra.replace(/auto_increment/gi, "").trim();
}
const colDef = `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`;
if (!orig) {
alters.push(`ADD COLUMN ${colDef} ${positionSql}`);
} else {
const origIndex = originalColumns.findIndex(c => c._key === curr._key);
const origPrevCol = origIndex > 0 ? originalColumns[origIndex - 1] : null;
let positionChanged = false;
if (index === 0 && origIndex !== 0) positionChanged = true;
if (index > 0 && (!origPrevCol || origPrevCol._key !== prevCol?._key)) positionChanged = true;
const isNameChanged = orig.name !== curr.name;
const isTypeChanged = orig.type !== curr.type;
const isNullableChanged = orig.nullable !== curr.nullable;
const isDefaultChanged = orig.default !== curr.default;
const isCommentChanged = orig.comment !== curr.comment;
const isAIChanged = orig.isAutoIncrement !== curr.isAutoIncrement;
if (isNameChanged || isTypeChanged || isNullableChanged || isDefaultChanged || isCommentChanged || positionChanged || isAIChanged) {
if (isNameChanged) {
alters.push(`CHANGE COLUMN \`${orig.name}\` ${colDef} ${positionSql}`);
} else {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`);
}
}
}
});
const origPKKeys = originalColumns.filter(c => c.key === 'PRI').map(c => c._key);
const newPKKeys = columns.filter(c => c.key === 'PRI').map(c => c._key);
const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every(k => newPKKeys.includes(k));
if (keysChanged) {
if (origPKKeys.length > 0) alters.push(`DROP PRIMARY KEY`);
if (newPKKeys.length > 0) {
const pkNames = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``).join(', ');
alters.push(`ADD PRIMARY KEY (${pkNames})`);
}
}
if (alters.length === 0) {
message.info("没有检测到变更");
return;
}
const sql = `ALTER TABLE ${tableName}\n` + alters.join(",\n");
setPreviewSql(sql);
setIsPreviewOpen(true);
}
};
const handleExecuteSave = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) 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 res = await DBQuery(config as any, tab.dbName || '', previewSql);
if (res.success) {
message.success(isNewTable ? "表创建成功!" : "表结构修改成功!");
setIsPreviewOpen(false);
if (!isNewTable) {
fetchData();
} else {
// TODO: Close tab or reload sidebar?
// Ideally, refresh sidebar node.
}
} else {
message.error("执行失败: " + res.message);
}
};
// Merge columns with resize handler
const resizableColumns = tableColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(index),
}),
}));
const columnsTabContent = (
<div ref={containerRef} className="table-designer-wrapper" style={{ height: '100%', overflow: 'hidden', position: 'relative' }}>
<style>{`
.table-designer-wrapper .ant-table-body {
max-height: ${tableHeight}px !important;
}
`}</style>
{readOnly ? (
<Table
dataSource={columns}
columns={resizableColumns}
rowKey="_key"
size="small"
pagination={false}
loading={loading}
scroll={{ y: tableHeight }}
bordered
components={{
header: {
cell: ResizableTitle,
},
}}
/>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={columns.map(c => c._key)} strategy={verticalListSortingStrategy}>
<Table
dataSource={columns}
columns={resizableColumns}
rowSelection={{
selectedRowKeys: selectedColumnRowKeys,
onChange: (nextSelectedRowKeys) => setSelectedColumnRowKeys(nextSelectedRowKeys as string[]),
}}
rowKey="_key"
size="small"
pagination={false}
loading={loading}
scroll={{ y: tableHeight }}
bordered
components={{
body: { row: SortableRow },
header: { cell: ResizableTitle }
}}
/>
</SortableContext>
</DndContext>
)}
<div
ref={ghostRef}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '2px',
background: '#1890ff',
zIndex: 9999,
display: 'none',
pointerEvents: 'none',
willChange: 'transform',
}}
/>
</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', 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 icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}></Button>}
{!isNewTable && !readOnly && supportsTableCommentOps() && (
<Button icon={<EditOutlined />} onClick={openTableCommentModal}></Button>
)}
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}></Button>}
{!readOnly && (
<Button
icon={<CopyOutlined />}
onClick={openCopySelectedColumnsModal}
disabled={selectedColumns.length === 0}
>
</Button>
)}
<div style={{ flex: 1 }} />
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
style={{ flex: 1, padding: '0 10px' }}
items={[
{
key: 'columns',
label: '字段',
children: columnsTabContent
},
...(!isNewTable ? [
{
key: 'indexes',
label: '索引',
children: (
<div 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() || !selectedIndex} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={handleDeleteIndex}></Button>
{!supportsIndexSchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsIndexSchemaOps() && selectedIndex && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedIndex.name}
</span>
)}
</div>
)}
<div style={{ color: '#888', fontSize: 12 }}>
{groupedIndexes.length}{groupedIndexFieldCount}
</div>
<Table
dataSource={groupedIndexes}
columns={[
{
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',
render: (columnNames: string[]) => {
if (!columnNames || columnNames.length === 0) {
return '-';
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{columnNames.map((columnName, idx) => (
<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>
),
},
]}
rowKey="key"
size="small"
pagination={false}
loading={loading}
scroll={{ x: 960, y: tableHeight }}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedIndex ? [selectedIndex.key] : [],
onChange: (_, selectedRows) => setSelectedIndex((selectedRows[0] as IndexDisplayRow) || null),
}}
onRow={(record) => ({
onClick: () => {
if (selectedIndex?.key === record.key) {
setSelectedIndex(null);
} else {
setSelectedIndex(record);
}
},
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}
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: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
<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}
destroyOnClose
>
<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) =>
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: val === 'NORMAL' || val === 'UNIQUE' ? (prev.indexType || 'DEFAULT') : 'DEFAULT',
}))
}
style={{ width: 220 }}
/>
<Select
value={indexForm.indexType}
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
options={getIndexTypeOptions()}
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: '#f5f5f5', padding: '10px', borderRadius: '4px', border: '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: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
<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: darkMode ? '1px solid #303030' : '1px solid #d9d9d9', borderRadius: 4 }}>
<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;