From 5329f212f7a94cda6ba50ce33516b3cb1d60d4e0 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 14 Feb 2026 11:25:13 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feat(schema-editor):=20=E8=A1=A8?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=99=A8=E6=96=B0=E5=A2=9E=E7=B4=A2=E5=BC=95?= =?UTF-8?q?/=E5=A4=96=E9=94=AE=E7=AE=A1=E7=90=86=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E8=A1=A8=E5=A4=87=E6=B3=A8=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持新增/修改/删除索引与外键(MySQL) - 表备注弹窗编辑并同步刷新 DDL/元数据 - 索引类型补齐 UNIQUE/PRIMARY/FULLTEXT/SPATIAL 等 - refs #108 --- frontend/src/components/Sidebar.tsx | 81 +- frontend/src/components/TableDesigner.tsx | 928 +++++++++++++++++++++- 2 files changed, 953 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4517382..59bb661 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -573,16 +573,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> results.forEach((queryResult) => { queryResult.rows.forEach((row) => { - const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row); - if (!triggerName) return; - const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']); - const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']); - const fullTableName = buildQualifiedName(schemaName, tableName); - const uniqueKey = `${triggerName}@@${fullTableName}`; + const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row); + if (!rawTriggerName) return; + + const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']); + const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']); + + const triggerParts = splitQualifiedName(rawTriggerName); + const tableParts = splitQualifiedName(rawTableName); + + const resolvedSchema = ( + rawSchemaName + || tableParts.schemaName + || triggerParts.schemaName + || dbName + ).trim(); + const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim(); + const resolvedTableName = (tableParts.objectName || rawTableName).trim(); + const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName); + + // MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复 + const uniqueKey = dialect === 'mysql' + ? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}` + : `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`; if (seen.has(uniqueKey)) return; seen.add(uniqueKey); - const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName; - triggers.push({ displayName, triggerName, tableName: fullTableName }); + const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName; + triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName }); }); }); return { triggers, supported: hasSuccessfulQuery }; @@ -755,19 +772,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; }); - const triggerEntries = triggersResult.triggers.map((trigger) => { - const triggerParsed = splitQualifiedName(trigger.triggerName); - const tableParsed = splitQualifiedName(trigger.tableName); - const schemaName = tableParsed.schemaName || triggerParsed.schemaName; - const triggerObjectName = triggerParsed.objectName || trigger.triggerName; - const tableObjectName = tableParsed.objectName || trigger.tableName; - const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName; - return { - ...trigger, - schemaName, - displayName, - }; - }); + const triggerEntries = (() => { + const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = []; + const triggerSeen = new Set(); + const metadataDialect = getMetadataDialect(conn as SavedConnection); + + triggersResult.triggers.forEach((trigger) => { + const triggerParsed = splitQualifiedName(trigger.triggerName); + const tableParsed = splitQualifiedName(trigger.tableName); + const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim(); + const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim(); + const tableObjectName = (tableParsed.objectName || trigger.tableName).trim(); + const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName; + const dedupeKey = metadataDialect === 'mysql' + ? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}` + : `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`; + + if (triggerSeen.has(dedupeKey)) return; + triggerSeen.add(dedupeKey); + deduped.push({ + ...trigger, + schemaName, + triggerName: triggerObjectName, + tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName, + displayName, + }); + }); + + return deduped; + })(); const routineEntries = routinesResult.routines.map((routine) => { const parsed = splitQualifiedName(routine.routineName); @@ -1061,9 +1094,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); } - if (type === 'folder-columns') openDesign(info.node, 'columns', true); - else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true); - else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true); + if (type === 'folder-columns') openDesign(info.node, 'columns', false); + else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false); + else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false); else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true); }; diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 641ddd8..8bb556b 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react'; -import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space } from 'antd'; +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'; @@ -15,6 +15,39 @@ interface EditableColumn extends ColumnDefinition { 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)' }, @@ -33,6 +66,15 @@ const COMMON_DEFAULTS = [ { 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 CHARSETS = [ { label: 'utf8mb4 (Recommended)', value: 'utf8mb4' }, { label: 'utf8', value: 'utf8' }, @@ -163,6 +205,30 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { 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(null); + const [isIndexModalOpen, setIsIndexModalOpen] = useState(false); + const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create'); + const [indexSaving, setIndexSaving] = useState(false); + const [indexForm, setIndexForm] = useState({ + name: '', + columnNames: [], + kind: 'NORMAL', + indexType: 'DEFAULT', + }); + const [selectedForeignKey, setSelectedForeignKey] = useState(null); + const [isForeignKeyModalOpen, setIsForeignKeyModalOpen] = useState(false); + const [foreignKeyModalMode, setForeignKeyModalMode] = useState<'create' | 'edit'>('create'); + const [foreignKeySaving, setForeignKeySaving] = useState(false); + const [foreignKeyForm, setForeignKeyForm] = useState({ + constraintName: '', + columnNames: [], + refTableName: '', + refColumnNames: [], + }); const [selectedTrigger, setSelectedTrigger] = useState(null); const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false); const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false); @@ -511,10 +577,31 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { message.error("Failed to load columns: " + colsRes.message); } - if (idxRes.success) setIndexes(idxRes.data); - if (fkRes.success) setFks(fkRes.data); - if (trigRes.success) setTriggers(trigRes.data); - if (ddlRes && ddlRes.success) setDdl(String(ddlRes.data || '')); + 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); }; @@ -776,9 +863,200 @@ ${selectedTrigger.statement}`; return columns.filter(col => selectedSet.has(col._key)); }, [columns, selectedColumnRowKeys]); + const groupedIndexes = useMemo(() => { + type IndexFieldItem = { + name: string; + seq: number; + order: number; + }; + type IndexBucket = { + key: string; + name: string; + indexType: string; + nonUnique: number; + order: number; + fields: IndexFieldItem[]; + }; + + const buckets = new Map(); + + const safeIndexes = Array.isArray(indexes) ? indexes : []; + safeIndexes.forEach((idx, order) => { + const rawName = String(idx.name || '').trim(); + const key = rawName || `__unnamed_${order}`; + const indexType = String(idx.indexType || '').trim() || '-'; + const displayName = rawName || '(未命名索引)'; + + if (!buckets.has(key)) { + buckets.set(key, { + key, + name: displayName, + indexType, + nonUnique: idx.nonUnique === 0 ? 0 : 1, + order, + fields: [], + }); + } + + const bucket = buckets.get(key); + if (!bucket) return; + + if (bucket.indexType === '-' && indexType !== '-') { + bucket.indexType = indexType; + } + if (idx.nonUnique === 0) { + bucket.nonUnique = 0; + } + + const columnName = String(idx.columnName || '').trim(); + if (!columnName) return; + + const rawSeq = Number(idx.seqInIndex); + const seq = Number.isFinite(rawSeq) ? rawSeq : 0; + bucket.fields.push({ + name: columnName, + seq, + order, + }); + }); + + return Array.from(buckets.values()) + .sort((a, b) => a.order - b.order) + .map((bucket) => { + const sortedFieldNames = bucket.fields + .slice() + .sort((a, b) => { + const aSeq = a.seq > 0 ? a.seq : Number.MAX_SAFE_INTEGER; + const bSeq = b.seq > 0 ? b.seq : Number.MAX_SAFE_INTEGER; + if (aSeq !== bSeq) return aSeq - bSeq; + return a.order - b.order; + }) + .map(field => field.name); + + const uniqueFieldNames = Array.from(new Set(sortedFieldNames)); + + return { + key: bucket.key, + name: bucket.name, + indexType: bucket.indexType, + nonUnique: bucket.nonUnique, + columnNames: uniqueFieldNames, + }; + }); + }, [indexes]); + + const groupedIndexFieldCount = useMemo( + () => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0), + [groupedIndexes] + ); + + const groupedForeignKeys = useMemo(() => { + type FieldItem = { name: string; order: number }; + type FkBucket = { + key: string; + constraintName: string; + refTableName: string; + order: number; + columns: FieldItem[]; + refColumns: FieldItem[]; + }; + + const buckets = new Map(); + + const safeFks = Array.isArray(fks) ? fks : []; + safeFks.forEach((fk, order) => { + const rawConstraint = String(fk.constraintName || fk.name || '').trim(); + const key = rawConstraint || `__unnamed_fk_${order}`; + const constraintName = rawConstraint || '(未命名外键)'; + const refTableName = String(fk.refTableName || '').trim() || '-'; + + if (!buckets.has(key)) { + buckets.set(key, { + key, + constraintName, + refTableName, + order, + columns: [], + refColumns: [], + }); + } + + const bucket = buckets.get(key); + if (!bucket) return; + + if (bucket.refTableName === '-' && refTableName !== '-') { + bucket.refTableName = refTableName; + } + + const colName = String(fk.columnName || '').trim(); + const refColName = String(fk.refColumnName || '').trim(); + if (colName) bucket.columns.push({ name: colName, order }); + if (refColName) bucket.refColumns.push({ name: refColName, order }); + }); + + return Array.from(buckets.values()) + .sort((a, b) => a.order - b.order) + .map((bucket) => { + const columnNames = bucket.columns + .slice() + .sort((a, b) => a.order - b.order) + .map(item => item.name); + const refColumnNames = bucket.refColumns + .slice() + .sort((a, b) => a.order - b.order) + .map(item => item.name); + + return { + key: bucket.key, + name: bucket.constraintName, + constraintName: bucket.constraintName, + refTableName: bucket.refTableName, + columnNames: Array.from(new Set(columnNames)), + refColumnNames: Array.from(new Set(refColumnNames)), + }; + }); + }, [fks]); + + const localColumnOptions = useMemo( + () => columns.map(col => ({ label: col.name, value: col.name })), + [columns] + ); + + 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 escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); + const quoteMysqlIdentifierPath = (path: string): string => { + const trimmed = String(path || '').trim(); + if (!trimmed) return ''; + // If user already provided backticks, respect as-is. + if (trimmed.includes('`')) return trimmed; + return trimmed + .split('.') + .map(seg => `\`${escapeBacktickIdentifier(seg)}\``) + .join('.'); + }; + + const getMysqlTableRef = (): string => { + const tbl = String(tab.tableName || '').trim(); + const schema = String(tab.dbName || '').trim(); + if (!schema) return `\`${escapeBacktickIdentifier(tbl)}\``; + return `\`${escapeBacktickIdentifier(schema)}\`.\`${escapeBacktickIdentifier(tbl)}\``; + }; + const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => { const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``; const colDefs = targetColumns.map(curr => { @@ -849,6 +1127,330 @@ ${selectedTrigger.statement}`; } }; + const supportsMysqlSchemaOps = () => getDbType() === 'mysql'; + + const executeSchemaSql = async (sql: string, successMessage: string): Promise => { + 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 handleSaveTableComment = async () => { + if (!supportsMysqlSchemaOps()) { + message.warning('当前数据库暂不支持在此修改表备注'); + return; + } + if (!tab.tableName) return; + const sql = `ALTER TABLE ${getMysqlTableRef()} COMMENT = '${escapeSqlString(tableCommentDraft)}';`; + 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'; + } + + setIndexForm({ + name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName, + columnNames: [...selectedIndex.columnNames], + kind, + indexType: kind === 'NORMAL' || kind === 'UNIQUE' + ? (selectedTypeUpper || 'DEFAULT') + : 'DEFAULT', + }); + setIsIndexModalOpen(true); + }; + + const buildIndexAddClause = (form: IndexFormState): string | null => { + const kind: IndexKind = form.kind || 'NORMAL'; + const indexName = String(form.name || '').trim(); + const colSql = form.columnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', '); + + if (kind === 'PRIMARY') { + return `ADD PRIMARY KEY (${colSql})`; + } + + if (!indexName) { + message.error('请输入索引名'); + return null; + } + + if (kind === 'FULLTEXT') { + return `ADD FULLTEXT INDEX \`${escapeBacktickIdentifier(indexName)}\` (${colSql})`; + } + if (kind === 'SPATIAL') { + return `ADD SPATIAL INDEX \`${escapeBacktickIdentifier(indexName)}\` (${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 `${prefix} \`${escapeBacktickIdentifier(indexName)}\`${usingSql} (${colSql})`; + }; + + const buildIndexDropClause = (indexName: string) => { + if (String(indexName || '').trim().toUpperCase() === 'PRIMARY') { + return 'DROP PRIMARY KEY'; + } + return `DROP INDEX \`${escapeBacktickIdentifier(indexName)}\``; + }; + + const handleSubmitIndex = async () => { + if (!supportsMysqlSchemaOps()) { + message.warning('当前数据库暂不支持在此维护索引'); + return; + } + if (!tab.tableName) 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 addClause = buildIndexAddClause({ ...indexForm, name: nextName }); + if (!addClause) { + setIndexSaving(false); + return; + } + let sql = `ALTER TABLE ${getMysqlTableRef()}\n${addClause};`; + + if (indexModalMode === 'edit' && selectedIndex) { + const dropClause = buildIndexDropClause(selectedIndex.name); + sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause},\n${addClause};`; + } + + const ok = await executeSchemaSql(sql, indexModalMode === 'create' ? '索引新增成功' : '索引修改成功'); + setIndexSaving(false); + if (ok) { + setIsIndexModalOpen(false); + } + }; + + const handleDeleteIndex = () => { + if (!selectedIndex) { + message.warning('请先选择一个索引'); + return; + } + if (!supportsMysqlSchemaOps()) { + message.warning('当前数据库暂不支持在此维护索引'); + return; + } + Modal.confirm({ + title: '确认删除索引', + icon: , + content: `确定删除索引 "${selectedIndex.name}" 吗?`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + const dropClause = buildIndexDropClause(selectedIndex.name); + const sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause};`; + 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 buildForeignKeyAddClause = (form: ForeignKeyFormState) => { + const localColsSql = form.columnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', '); + const refColsSql = form.refColumnNames.map(col => `\`${escapeBacktickIdentifier(col)}\``).join(', '); + const refTableSql = quoteMysqlIdentifierPath(form.refTableName); + return `ADD CONSTRAINT \`${escapeBacktickIdentifier(form.constraintName)}\` FOREIGN KEY (${localColsSql}) REFERENCES ${refTableSql} (${refColsSql})`; + }; + + const buildForeignKeyDropClause = (constraintName: string) => + `DROP FOREIGN KEY \`${escapeBacktickIdentifier(constraintName)}\``; + + const handleSubmitForeignKey = async () => { + if (!supportsMysqlSchemaOps()) { + 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 addClause = buildForeignKeyAddClause({ + ...foreignKeyForm, + constraintName: nextConstraint, + columnNames: localCols, + refTableName: refTable, + refColumnNames: refCols, + }); + let sql = `ALTER TABLE ${getMysqlTableRef()}\n${addClause};`; + if (foreignKeyModalMode === 'edit' && selectedForeignKey) { + const dropClause = buildForeignKeyDropClause(selectedForeignKey.constraintName); + sql = `ALTER TABLE ${getMysqlTableRef()}\n${dropClause},\n${addClause};`; + } + + const ok = await executeSchemaSql(sql, foreignKeyModalMode === 'create' ? '外键新增成功' : '外键修改成功'); + setForeignKeySaving(false); + if (ok) { + setIsForeignKeyModalOpen(false); + } + }; + + const handleDeleteForeignKey = () => { + if (!selectedForeignKey) { + message.warning('请先选择一个外键'); + return; + } + if (!supportsMysqlSchemaOps()) { + message.warning('当前数据库暂不支持在此维护外键'); + return; + } + Modal.confirm({ + title: '确认删除外键', + icon: , + content: `确定删除外键约束 "${selectedForeignKey.constraintName}" 吗?`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + const sql = `ALTER TABLE ${getMysqlTableRef()}\n${buildForeignKeyDropClause(selectedForeignKey.constraintName)};`; + await executeSchemaSql(sql, '外键删除成功'); + } + }); + }; + const onDragEnd = ({ active, over }: any) => { if (active.id !== over?.id) { setColumns((previous) => { @@ -1075,6 +1677,9 @@ ${selectedTrigger.statement}`; )} {!readOnly && } {!isNewTable && } + {!isNewTable && !readOnly && supportsMysqlSchemaOps() && ( + + )} {!readOnly && } {!readOnly && ( + + + {!supportsMysqlSchemaOps() && ( + + 当前数据库暂不支持索引编辑,仅支持查看 + + )} + {supportsMysqlSchemaOps() && selectedIndex && ( + + 已选择:{selectedIndex.name} + + )} + + )} +
+ 索引数:{groupedIndexes.length},索引字段:{groupedIndexFieldCount} +
+ ( + + + {text} + + + ), + }, + { + title: '字段', + dataIndex: 'columnNames', + key: 'columnNames', + render: (columnNames: string[]) => { + if (!columnNames || columnNames.length === 0) { + return '-'; + } + return ( +
+ {columnNames.map((columnName, idx) => ( + + {columnName} + + ))} +
+ ); + } + }, + { + title: '索引类型', + dataIndex: 'indexType', + key: 'indexType', + width: 140, + render: (text: string) => text || '-', + }, + { + title: '唯一性', + dataIndex: 'nonUnique', + key: 'nonUnique', + width: 110, + render: (v: number) => ( + + {v === 0 ? '唯一' : '普通'} + + ), + }, + ]} + 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' } + })} + /> + ) }, { key: 'foreignKeys', label: '外键', children: ( -
+
+ {!readOnly && ( +
+ + + + {!supportsMysqlSchemaOps() && ( + + 当前数据库暂不支持外键编辑,仅支持查看 + + )} + {supportsMysqlSchemaOps() && selectedForeignKey && ( + + 已选择:{selectedForeignKey.constraintName} + + )} +
+ )} +
vals?.length ? vals.join(', ') : '-', + }, + { title: '参考表', dataIndex: 'refTableName', key: 'refTableName', width: 220 }, + { + title: '参考字段', + dataIndex: 'refColumnNames', + key: 'refColumnNames', + render: (vals: string[]) => vals?.length ? vals.join(', ') : '-', + }, + ]} + rowKey="key" + size="small" + pagination={false} + loading={loading} + scroll={{ x: 980, y: tableHeight }} + rowSelection={{ + type: 'radio', + selectedRowKeys: selectedForeignKey ? [selectedForeignKey.key] : [], + onChange: (_, selectedRows) => setSelectedForeignKey((selectedRows[0] as ForeignKeyDisplayRow) || null), + }} + onRow={(record) => ({ + onClick: () => { + if (selectedForeignKey?.key === record.key) { + setSelectedForeignKey(null); + } else { + setSelectedForeignKey(record); + } + }, + style: { cursor: 'pointer' } + })} + /> + ) }, { @@ -1214,9 +1948,10 @@ ${selectedTrigger.statement}`; minimap: { enabled: false }, fontSize: 14, lineNumbers: 'on', - scrollBeyondLastLine: false, + scrollBeyondLastLine: true, wordWrap: 'on', automaticLayout: true, + padding: { top: 8, bottom: 24 }, }} /> @@ -1290,6 +2025,135 @@ ${selectedTrigger.statement}`; + setIsTableCommentModalOpen(false)} + onOk={handleSaveTableComment} + okText="保存" + cancelText="取消" + confirmLoading={tableCommentSaving} + width={640} + > + setTableCommentDraft(e.target.value)} + autoSize={{ minRows: 5, maxRows: 12 }} + placeholder="请输入表备注" + maxLength={2048} + /> +
+ 当前备注:{tableComment || '(空)'} +
+
+ + setIsIndexModalOpen(false)} + onOk={handleSubmitIndex} + okText={indexModalMode === 'create' ? '创建' : '保存'} + cancelText="取消" + confirmLoading={indexSaving} + width={620} + > + + setIndexForm(prev => ({ ...prev, name: e.target.value }))} + maxLength={128} + disabled={indexForm.kind === 'PRIMARY'} + /> + + 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 }} + /> + setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))} + maxLength={128} + /> + setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))} + maxLength={256} + /> +