From 60a42e3c34df202b35dee5b5a0307e139c08d1a9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 14 Feb 2026 09:51:17 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=94=A7=20fix(connection-modal):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20SQLite=20=E8=BF=9E=E6=8E=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=9B=9E=E5=A1=AB=E5=AF=BC=E8=87=B4=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=8F=98=E5=BD=A2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectionModal 中 sqlite 使用独立路径规则,不再参与 host:port 解析 - 修复编辑连接时的回填逻辑,阻断 F:\... 被追加 :3306 - 统一 URI 解析与生成行为,确保保存后再次编辑不变形 - 保留并强化驱动安装态判断与现有交互 --- frontend/src/components/ConnectionModal.tsx | 123 +++++++++++------ internal/app/app.go | 13 +- internal/db/sqlite_impl.go | 141 +++++++++++++++++++- internal/db/sqlite_impl_test.go | 79 +++++++++++ 4 files changed, 311 insertions(+), 45 deletions(-) create mode 100644 internal/db/sqlite_impl_test.go diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index c25772f..f1b4112 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -27,6 +27,7 @@ const getDefaultPortByType = (type: string) => { case 'highgo': return 5866; case 'mariadb': return 3306; case 'vastbase': return 5432; + case 'sqlite': return 0; case 'duckdb': return 0; default: return 3306; } @@ -236,6 +237,23 @@ const ConnectionModal: React.FC<{ } }; + const normalizeFileDbPath = (rawPath: string): string => { + let pathText = String(rawPath || '').trim(); + if (!pathText) { + return ''; + } + // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 + if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { + pathText = pathText.slice(1); + } + // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 + const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); + if (legacyMatch?.[1]) { + return legacyMatch[1]; + } + return pathText; + }; + const parseMultiHostUri = (uriText: string, expectedScheme: string) => { const prefix = `${expectedScheme}://`; if (!uriText.toLowerCase().startsWith(prefix)) { @@ -335,30 +353,6 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const tryExtractPath = (uri: string, scheme: string): string | null => { - const parsed = parseMultiHostUri(uri, scheme); - if (!parsed) { - return null; - } - const host = String(parsed.hosts?.[0] || '').trim(); - const dbPath = String(parsed.database || '').trim(); - if (host && dbPath) { - return `/${host}/${dbPath}`.replace(/\/+/g, '/'); - } - if (host) { - return `/${host}`.replace(/\/+/g, '/'); - } - if (dbPath) { - return dbPath.startsWith('/') ? dbPath : `/${dbPath}`; - } - return null; - }; - - const pathFromScheme = tryExtractPath(trimmedUri, type); - if (pathFromScheme) { - return { host: decodeURIComponent(pathFromScheme) }; - } - const rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') @@ -366,7 +360,7 @@ const ConnectionModal: React.FC<{ if (!rawPath) { return null; } - return { host: decodeURIComponent(rawPath) }; + return { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'mongodb') { @@ -481,12 +475,11 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const pathText = String(values.host || '').trim(); + const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } - const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`; - return `${type}://${encodeURI(normalizedPath)}`; + return `${type}://${encodeURI(pathText)}`; } if (type === 'mongodb') { @@ -602,13 +595,20 @@ const ConnectionModal: React.FC<{ const config: any = initialValues.config || {}; const configType = String(config.type || 'mysql'); const defaultPort = getDefaultPortByType(configType); - const normalizedHosts = normalizeAddressList(config.hosts, defaultPort); - const primaryAddress = parseHostPort( - normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = primaryAddress?.host || String(config.host || 'localhost'); - const primaryPort = primaryAddress?.port || Number(config.port || defaultPort); + const isFileDbConfigType = isFileDatabaseType(configType); + const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort); + const primaryAddress = isFileDbConfigType + ? null + : parseHostPort( + normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), + defaultPort + ); + const primaryHost = isFileDbConfigType + ? normalizeFileDbPath(String(config.host || '')) + : (primaryAddress?.host || String(config.host || 'localhost')); + const primaryPort = isFileDbConfigType + ? 0 + : (primaryAddress?.port || Number(config.port || defaultPort)); const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; @@ -847,12 +847,22 @@ const ConnectionModal: React.FC<{ const type = String(mergedValues.type || '').toLowerCase(); const defaultPort = getDefaultPortByType(type); - const parsedPrimary = parseHostPort( - toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = parsedPrimary?.host || 'localhost'; - const primaryPort = parsedPrimary?.port || defaultPort; + const isFileDbType = isFileDatabaseType(type); + + let primaryHost = 'localhost'; + let primaryPort = defaultPort; + if (isFileDbType) { + // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 + primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim()); + primaryPort = 0; + } else { + const parsedPrimary = parseHostPort( + toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), + defaultPort + ); + primaryHost = parsedPrimary?.host || 'localhost'; + primaryPort = parsedPrimary?.port || defaultPort; + } let hosts: string[] = []; let topology: 'single' | 'replica' | undefined; @@ -960,7 +970,36 @@ const ConnectionModal: React.FC<{ form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); - if (!isFileDatabaseType(type) && type !== 'custom') { + if (isFileDatabaseType(type)) { + setUseSSH(false); + form.setFieldsValue({ + host: '', + port: 0, + user: '', + password: '', + database: '', + useSSH: false, + sshHost: '', + sshPort: 22, + sshUser: '', + sshPassword: '', + sshKeyPath: '', + mysqlTopology: 'single', + mongoTopology: 'single', + mongoSrv: false, + mongoReadPreference: 'primary', + mongoReplicaSet: '', + mongoAuthSource: '', + mongoAuthMechanism: '', + savePassword: true, + mysqlReplicaHosts: [], + mongoHosts: [], + mysqlReplicaUser: '', + mysqlReplicaPassword: '', + mongoReplicaUser: '', + mongoReplicaPassword: '', + }); + } else if (type !== 'custom') { form.setFieldsValue({ port: defaultPort, mysqlTopology: 'single', diff --git a/internal/app/app.go b/internal/app/app.go index 80c5541..248093c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -133,8 +133,17 @@ func formatConnSummary(config connection.ConnectionConfig) string { } var b strings.Builder - b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", - config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) + normalizedType := strings.ToLower(strings.TrimSpace(config.Type)) + if normalizedType == "sqlite" || normalizedType == "duckdb" { + path := strings.TrimSpace(config.Host) + if path == "" { + path = "(未配置)" + } + b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) + } else { + b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", + config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) + } if len(config.Hosts) > 0 { b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts))) diff --git a/internal/db/sqlite_impl.go b/internal/db/sqlite_impl.go index 582937a..e8e8c06 100644 --- a/internal/db/sqlite_impl.go +++ b/internal/db/sqlite_impl.go @@ -6,6 +6,9 @@ import ( "context" "database/sql" "fmt" + "os" + "path/filepath" + "strconv" "strings" "time" @@ -21,7 +24,14 @@ type SQLiteDB struct { } func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error { - dsn := config.Host + dsn, err := resolveSQLiteDSN(config) + if err != nil { + return err + } + if err := ensureSQLiteParentDir(dsn); err != nil { + return err + } + db, err := sql.Open("sqlite", dsn) if err != nil { return fmt.Errorf("打开数据库连接失败:%w", err) @@ -31,11 +41,140 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error { // Force verification if err := s.Ping(); err != nil { + _ = db.Close() + s.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } return nil } +func resolveSQLiteDSN(config connection.ConnectionConfig) (string, error) { + dsn := strings.TrimSpace(config.Host) + if dsn == "" { + dsn = strings.TrimSpace(config.Database) + } + dsn = normalizeSQLitePath(dsn) + if dsn == "" { + return "", fmt.Errorf("SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite)") + } + if strings.EqualFold(dsn, ":memory:") { + return dsn, nil + } + if looksLikeHostPort(dsn) { + return "", fmt.Errorf("SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:%s", dsn) + } + return dsn, nil +} + +func normalizeSQLitePath(raw string) string { + text := strings.TrimSpace(raw) + if strings.HasPrefix(text, "/") && len(text) > 3 && isWindowsDrivePath(text[1:]) { + text = text[1:] + } + if isWindowsDrivePath(text) { + text = trimLegacyPortSuffix(text) + } + return text +} + +func isWindowsDrivePath(path string) bool { + if len(path) < 3 { + return false + } + drive := path[0] + if !((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) { + return false + } + if path[1] != ':' { + return false + } + sep := path[2] + return sep == '\\' || sep == '/' +} + +func trimLegacyPortSuffix(path string) string { + normalized := path + for { + idx := strings.LastIndex(normalized, ":") + if idx <= 1 || idx+1 >= len(normalized) { + return normalized + } + suffix := normalized[idx+1:] + validDigits := true + for _, ch := range suffix { + if ch < '0' || ch > '9' { + validDigits = false + break + } + } + if !validDigits { + return normalized + } + normalized = normalized[:idx] + } +} + +func looksLikeHostPort(raw string) bool { + text := strings.TrimSpace(raw) + if text == "" { + return false + } + if strings.ContainsAny(text, `/\`) { + return false + } + if strings.HasPrefix(strings.ToLower(text), "file:") { + return false + } + if strings.HasPrefix(text, "[") { + closing := strings.LastIndex(text, "]") + if closing <= 0 || closing+1 >= len(text) { + return false + } + portText := strings.TrimSpace(strings.TrimPrefix(text[closing+1:], ":")) + return isValidPortText(portText) + } + if strings.Count(text, ":") != 1 { + return false + } + split := strings.LastIndex(text, ":") + if split <= 0 || split+1 >= len(text) { + return false + } + return isValidPortText(strings.TrimSpace(text[split+1:])) +} + +func isValidPortText(text string) bool { + port, err := strconv.Atoi(text) + return err == nil && port > 0 && port <= 65535 +} + +func ensureSQLiteParentDir(dsn string) error { + text := strings.TrimSpace(dsn) + if text == "" || strings.EqualFold(text, ":memory:") { + return nil + } + // file: URI 由驱动处理,避免在这里误判路径格式。 + if strings.HasPrefix(strings.ToLower(text), "file:") { + return nil + } + path := text + if idx := strings.Index(path, "?"); idx >= 0 { + path = path[:idx] + } + path = strings.TrimSpace(path) + if path == "" { + return nil + } + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建 SQLite 数据文件目录失败:%w", err) + } + return nil +} + func (s *SQLiteDB) Close() error { if s.conn != nil { return s.conn.Close() diff --git a/internal/db/sqlite_impl_test.go b/internal/db/sqlite_impl_test.go new file mode 100644 index 0000000..15745b8 --- /dev/null +++ b/internal/db/sqlite_impl_test.go @@ -0,0 +1,79 @@ +//go:build gonavi_full_drivers || gonavi_sqlite_driver + +package db + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestResolveSQLiteDSNRejectsHostPort(t *testing.T) { + _, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: "localhost:3306"}) + if err == nil { + t.Fatalf("期望拦截 host:port 输入") + } + if !strings.Contains(err.Error(), "本地数据库文件路径") { + t.Fatalf("错误提示不符合预期: %v", err) + } +} + +func TestResolveSQLiteDSNFallbackDatabase(t *testing.T) { + dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Database: "/tmp/demo.sqlite"}) + if err != nil { + t.Fatalf("解析 DSN 失败: %v", err) + } + if dsn != "/tmp/demo.sqlite" { + t.Fatalf("期望使用 database 作为 DSN,实际=%s", dsn) + } +} + +func TestResolveSQLiteDSNNormalizesWindowsLegacyPath(t *testing.T) { + dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `F:\py\py\history.db:3306:3306`}) + if err != nil { + t.Fatalf("解析 DSN 失败: %v", err) + } + if dsn != `F:\py\py\history.db` { + t.Fatalf("期望清理历史端口污染,实际=%s", dsn) + } +} + +func TestResolveSQLiteDSNNormalizesWindowsPathWithLeadingSlash(t *testing.T) { + dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `/F:\py\py\history.db:3306`}) + if err != nil { + t.Fatalf("解析 DSN 失败: %v", err) + } + if dsn != `F:\py\py\history.db` { + t.Fatalf("期望清理前导斜杠与端口污染,实际=%s", dsn) + } +} + +func TestEnsureSQLiteParentDirCreatesNestedDir(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "nested", "child", "demo.sqlite") + if err := ensureSQLiteParentDir(target); err != nil { + t.Fatalf("创建目录失败: %v", err) + } + info, err := os.Stat(filepath.Dir(target)) + if err != nil { + t.Fatalf("目录不存在: %v", err) + } + if !info.IsDir() { + t.Fatalf("目标不是目录: %s", filepath.Dir(target)) + } +} + +func TestLooksLikeHostPort(t *testing.T) { + if !looksLikeHostPort("localhost:3306") { + t.Fatalf("localhost:3306 应识别为 host:port") + } + if looksLikeHostPort("/tmp/demo.sqlite") { + t.Fatalf("/tmp/demo.sqlite 不应识别为 host:port") + } + if looksLikeHostPort(`C:\sqlite\demo.db`) { + t.Fatalf("Windows 路径不应识别为 host:port") + } +} From 9307ca5e16c84000ad4130b277187014d237a8af Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 14 Feb 2026 09:57:47 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20feat(table-designer):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8B=BE=E9=80=89=E5=AD=97=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=A4=8D=E5=88=B6=E5=88=B0=E6=96=B0=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设计表字段列表增加多选能力,支持按行勾选字段 - 工具栏新增“复制选中到新表”按钮与交互 - 新增目标表配置弹窗,支持表名、字符集、排序规则设置 - 复用建表 SQL 生成逻辑并直接执行创建新表 - refs #107 --- frontend/src/components/TableDesigner.tsx | 162 +++++++++++++++++++--- 1 file changed, 146 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 4b6b804..5da225a 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,6 +1,6 @@ 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 { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +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'; @@ -157,6 +157,12 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [previewSql, setPreviewSql] = useState(''); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [activeKey, setActiveKey] = useState(tab.initialTab || "columns"); + const [selectedColumnRowKeys, setSelectedColumnRowKeys] = useState([]); + const [isCopyColumnsModalOpen, setIsCopyColumnsModalOpen] = useState(false); + const [copyTableName, setCopyTableName] = useState(''); + const [copyCharset, setCopyCharset] = useState('utf8mb4'); + const [copyCollation, setCopyCollation] = useState('utf8mb4_unicode_ci'); + const [copyExecuting, setCopyExecuting] = useState(false); const [selectedTrigger, setSelectedTrigger] = useState(null); const [isTriggerModalOpen, setIsTriggerModalOpen] = useState(false); const [isTriggerEditModalOpen, setIsTriggerEditModalOpen] = useState(false); @@ -234,6 +240,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { } }, [tab.initialTab]); + useEffect(() => { + setSelectedColumnRowKeys(prev => prev.filter(key => columns.some(c => c._key === key))); + }, [columns]); + // Initial Columns Definition useEffect(() => { const initialCols = [ @@ -458,6 +468,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { })); setColumns(JSON.parse(JSON.stringify(colsWithKey))); setOriginalColumns(JSON.parse(JSON.stringify(colsWithKey))); + setSelectedColumnRowKeys([]); } else { message.error("Failed to load columns: " + colsRes.message); } @@ -721,6 +732,85 @@ ${selectedTrigger.statement}`; 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 escapeBacktickIdentifier = (name: string) => String(name || '').replace(/`/g, '``'); + const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); + + 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 onDragEnd = ({ active, over }: any) => { if (active.id !== over?.id) { setColumns((previous) => { @@ -745,21 +835,7 @@ ${selectedTrigger.statement}`; if (isNewTable) { // CREATE TABLE - const colDefs = columns.map(curr => { - let extra = curr.extra || ""; - if (curr.isAutoIncrement) { - extra += " AUTO_INCREMENT"; - } - return `\`${curr.name}\` ${curr.type} ${curr.nullable === 'NO' ? 'NOT NULL' : 'NULL'} ${curr.default ? `DEFAULT '${curr.default}'` : ''} ${extra} COMMENT '${curr.comment}'`; - }); - - const pks = columns.filter(c => c.key === 'PRI').map(c => `\`${c.name}\``); - if (pks.length > 0) { - colDefs.push(`PRIMARY KEY (${pks.join(', ')})`); - } - - // Append Charset and Collation - const sql = `CREATE TABLE ${tableName} (\n ${colDefs.join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARSET=${charset} COLLATE=${collation};`; + const sql = buildCreateTableSql(isNewTable ? newTableName : tab.tableName || '', columns, charset, collation); setPreviewSql(sql); setIsPreviewOpen(true); } else { @@ -893,6 +969,10 @@ ${selectedTrigger.statement}`; setSelectedColumnRowKeys(nextSelectedRowKeys as string[]), + }} rowKey="_key" size="small" pagination={false} @@ -958,6 +1038,15 @@ ${selectedTrigger.statement}`; {!readOnly && } {!isNewTable && } {!readOnly && } + {!readOnly && ( + + )}
+ setIsCopyColumnsModalOpen(false)} + onOk={handleExecuteCopySelectedColumns} + okText="创建新表" + cancelText="取消" + confirmLoading={copyExecuting} + width={560} + > + +
+ 已选择字段:{selectedColumns.length} +
+ setCopyTableName(e.target.value)} + maxLength={128} + /> + + + +
+
+ Date: Sat, 14 Feb 2026 10:30:01 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8feat(datagrid):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=88=97=E5=A4=B4=E5=AD=97=E6=AE=B5=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=B9=B6=E4=BC=98=E5=8C=96=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E4=B8=8E=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增列头类型/备注常驻显示与悬浮详情展示 - 新增字段信息开关并持久化 showColumnComment/showColumnType 配置 - 排序改为仅箭头区域可触发,排序提示仅显示在排序图标上 - 修复可编辑表中右键菜单重复弹出与透明重影问题 - refs #106 --- frontend/src/components/DataGrid.tsx | 207 +++++++++++++++++++++++++-- frontend/src/store.ts | 20 ++- 2 files changed, 213 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 47604c9..04fde1b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,12 +1,13 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd'; import type { SortOrder } from 'antd/es/table/interface'; import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; -import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App'; +import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; +import type { ColumnDefinition } from '../types'; import { v4 as uuidv4 } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; @@ -292,6 +293,7 @@ const DataContext = React.createContext<{ handleExportSelected: (format: string, r: any) => void; copyToClipboard: (t: string) => void; tableName?: string; + enableRowContextMenu: boolean; } | null>(null); interface Item { @@ -434,7 +436,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { if (!record || !context) return
{children}; - const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard } = context; + const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context; + + if (!enableRowContextMenu) { + return {children}; + } const getTargets = () => { const keys = selectedRowKeysRef.current; @@ -513,6 +519,11 @@ type GridFilterCondition = FilterCondition & { type GridViewMode = 'table' | 'json' | 'text'; +type ColumnMeta = { + type: string; + comment: string; +}; + const DataGrid: React.FC = ({ data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false, onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter @@ -521,10 +532,14 @@ const DataGrid: React.FC = ({ const addSqlLog = useStore(state => state.addSqlLog); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); + const queryOptions = useStore(state => state.queryOptions); + const setQueryOptions = useStore(state => state.setQueryOptions); const isMacLike = useMemo(() => isMacLikePlatform(), []); const darkMode = theme === 'dark'; const opacity = normalizeOpacityForPlatform(appearance.opacity); const canModifyData = !readOnly && !!tableName; + const showColumnComment = queryOptions?.showColumnComment !== false; + const showColumnType = queryOptions?.showColumnType !== false; const selectionColumnWidth = 46; // Background Helper @@ -538,7 +553,7 @@ const DataGrid: React.FC = ({ }; const bgContent = getBg('#1d1d1d'); const bgFilter = getBg('#262626'); - const bgContextMenu = getBg('#1f1f1f'); + const bgContextMenu = darkMode ? '#1f1f1f' : '#ffffff'; // Row Colors with Opacity const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`; @@ -661,6 +676,9 @@ const DataGrid: React.FC = ({ const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); const [columnWidths, setColumnWidths] = useState>({}); + const [columnMetaMap, setColumnMetaMap] = useState>({}); + const columnMetaCacheRef = useRef>>({}); + const columnMetaSeqRef = useRef(0); useEffect(() => { const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend' @@ -677,6 +695,129 @@ const DataGrid: React.FC = ({ } }, [sortInfoExternal, sortInfo]); + useEffect(() => { + const normalizedTableName = String(tableName || '').trim(); + const normalizedDbName = String(dbName || '').trim(); + if (!connectionId || !normalizedTableName) { + setColumnMetaMap({}); + return; + } + const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; + setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {}); + }, [connectionId, dbName, tableName]); + + useEffect(() => { + const normalizedTableName = String(tableName || '').trim(); + const normalizedDbName = String(dbName || '').trim(); + if (!connectionId || !normalizedTableName) return; + + const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; + if (columnMetaCacheRef.current[cacheKey]) return; + + const conn = connections.find(c => c.id === connectionId); + if (!conn) { + setColumnMetaMap({}); + 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 seq = ++columnMetaSeqRef.current; + DBGetColumns(config as any, normalizedDbName, normalizedTableName) + .then((res) => { + if (seq !== columnMetaSeqRef.current) return; + if (!res.success || !Array.isArray(res.data)) { + setColumnMetaMap({}); + return; + } + const nextMap: Record = {}; + (res.data as ColumnDefinition[]).forEach((column: any) => { + const name = String(column?.name ?? column?.Name ?? '').trim(); + if (!name) return; + const type = String(column?.type ?? column?.Type ?? '').trim(); + const comment = String(column?.comment ?? column?.Comment ?? '').trim(); + nextMap[name] = { type, comment }; + }); + columnMetaCacheRef.current[cacheKey] = nextMap; + setColumnMetaMap(nextMap); + }) + .catch(() => { + if (seq !== columnMetaSeqRef.current) return; + setColumnMetaMap({}); + }); + }, [connections, connectionId, dbName, tableName]); + + const columnMetaMapByLowerName = useMemo(() => { + const next: Record = {}; + Object.entries(columnMetaMap).forEach(([name, meta]) => { + const lowerName = String(name || '').toLowerCase(); + if (!lowerName || next[lowerName]) return; + next[lowerName] = meta; + }); + return next; + }, [columnMetaMap]); + + const renderColumnTitle = useCallback((name: string): React.ReactNode => { + const normalizedName = String(name || ''); + const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; + const hoverLines: string[] = []; + if (meta?.type) hoverLines.push(`类型:${meta.type}`); + if (meta?.comment) hoverLines.push(`备注:${meta.comment}`); + + const titleNode = ( +
+ {normalizedName} + {showColumnType && meta?.type && ( + + {meta.type} + + )} + {showColumnComment && meta?.comment && ( + + {meta.comment} + + )} +
+ ); + + if (hoverLines.length === 0) return titleNode; + return ( + {hoverLines.join('\n')}} + styles={{ root: { maxWidth: 640 } }} + > + {titleNode} + + ); + }, [columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]); + const closeCellEditor = useCallback(() => { setCellEditorOpen(false); setCellEditorMeta(null); @@ -1592,7 +1733,7 @@ const DataGrid: React.FC = ({ const columns = useMemo(() => { return columnNames.map(key => ({ - title: key, + title: renderColumnTitle(key), dataIndex: key, key: key, // 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为 @@ -1608,9 +1749,29 @@ const DataGrid: React.FC = ({ onHeaderCell: (column: any) => ({ width: column.width, onResizeStart: handleResizeStart(key), // Only need start + onClickCapture: (event: React.MouseEvent) => { + if (!onSort) return; + const headerCell = event.currentTarget as HTMLElement; + const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null; + const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null; + const isInArrow = [upArrow, downArrow].some((el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + }); + if (isInArrow) return; + // 仅允许点击上下箭头触发排序,点击字段名或表头其它区域不触发排序。 + event.preventDefault(); + event.stopPropagation(); + }, }), })); - }, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]); + }, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]); const mergedColumns = useMemo(() => columns.map(col => { if (!col.editable) return col; @@ -1620,7 +1781,7 @@ const DataGrid: React.FC = ({ record, editable: col.editable, dataIndex: col.dataIndex, - title: col.title, + title: String(col.dataIndex), handleSave: handleCellSave, focusCell: openCellEditor, }), @@ -2037,6 +2198,23 @@ const DataGrid: React.FC = ({ { key: 'md', label: 'Markdown', onClick: () => handleExport('md') }, ]; + const columnInfoSettingContent = ( +
+ setQueryOptions({ showColumnComment: e.target.checked })} + > + 下方显示备注 + + setQueryOptions({ showColumnType: e.target.checked })} + > + 下方显示类型 + +
+ ); + const tableComponents = useMemo(() => ({ body: { cell: EditableCell, row: ContextMenuRow }, header: { cell: ResizableTitle } @@ -2149,6 +2327,15 @@ const DataGrid: React.FC = ({ )}
+
+ + + +
= ({ {viewMode === 'table' ? (
- +
= ({ .${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; } .${gridId} .ant-table-thead > tr > th::before { display: none !important; } + .${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; } + .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter, + .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; } .${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; } .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; } diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 109f9d2..45b9f1f 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -252,6 +252,12 @@ export interface SqlLog { affectedRows?: number; } +export interface QueryOptions { + maxRows: number; + showColumnComment: boolean; + showColumnType: boolean; +} + interface AppState { connections: SavedConnection[]; tabs: TabData[]; @@ -261,7 +267,7 @@ interface AppState { theme: 'light' | 'dark'; appearance: { opacity: number; blur: number }; sqlFormatOptions: { keywordCase: 'upper' | 'lower' }; - queryOptions: { maxRows: number }; + queryOptions: QueryOptions; sqlLogs: SqlLog[]; tableAccessCount: Record; tableSortPreference: Record; @@ -287,7 +293,7 @@ interface AppState { setTheme: (theme: 'light' | 'dark') => void; setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void; - setQueryOptions: (options: Partial<{ maxRows: number }>) => void; + setQueryOptions: (options: Partial) => void; addSqlLog: (log: SqlLog) => void; clearSqlLogs: () => void; @@ -326,13 +332,15 @@ const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'low return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' }; }; -const sanitizeQueryOptions = (value: unknown): { maxRows: number } => { +const sanitizeQueryOptions = (value: unknown): QueryOptions => { const raw = (value && typeof value === 'object') ? value as Record : {}; const maxRows = Number(raw.maxRows); + const showColumnComment = typeof raw.showColumnComment === 'boolean' ? raw.showColumnComment : true; + const showColumnType = typeof raw.showColumnType === 'boolean' ? raw.showColumnType : true; if (!Number.isFinite(maxRows) || maxRows <= 0) { - return { maxRows: 5000 }; + return { maxRows: 5000, showColumnComment, showColumnType }; } - return { maxRows: Math.min(50000, Math.trunc(maxRows)) }; + return { maxRows: Math.min(50000, Math.trunc(maxRows)), showColumnComment, showColumnType }; }; const sanitizeTableAccessCount = (value: unknown): Record => { @@ -383,7 +391,7 @@ export const useStore = create()( theme: 'light', appearance: { ...DEFAULT_APPEARANCE }, sqlFormatOptions: { keywordCase: 'upper' }, - queryOptions: { maxRows: 5000 }, + queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true }, sqlLogs: [], tableAccessCount: {}, tableSortPreference: {}, From d6e967a0d00cbad305934f50b5566e49377c9680 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 14 Feb 2026 10:36:54 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8feat(table-designer):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AD=97=E6=AE=B5=E6=B3=A8=E9=87=8A=E5=BC=B9=E6=A1=86?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=B9=B6=E6=81=A2=E5=A4=8DDDL=E5=B8=B8?= =?UTF-8?q?=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注释列新增双击与按钮触发的弹框编辑能力 - 增加长文本注释编辑弹窗并支持直接回写字段定义 - 非新建表场景统一拉取并展示 DDL 标签页 - 优化注释只读态展示,补充悬浮完整内容 - refs #105 --- frontend/src/components/TableDesigner.tsx | 74 +++++++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 5da225a..641ddd8 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -169,6 +169,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [triggerEditMode, setTriggerEditMode] = useState<'create' | 'edit'>('create'); const [triggerEditSql, setTriggerEditSql] = useState(''); const [triggerExecuting, setTriggerExecuting] = useState(false); + const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); + const [commentEditorColumnKey, setCommentEditorColumnKey] = useState(''); + const [commentEditorColumnName, setCommentEditorColumnName] = useState(''); + const [commentEditorValue, setCommentEditorValue] = useState(''); const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); @@ -178,6 +182,21 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const [tableHeight, setTableHeight] = useState(500); const containerRef = useRef(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 => { @@ -314,8 +333,27 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { dataIndex: 'comment', key: 'comment', width: 200, - render: (text: string, record: EditableColumn) => readOnly ? text : ( - handleColumnChange(record._key, 'comment', e.target.value)} variant="borderless" /> + render: (text: string, record: EditableColumn) => readOnly ? ( + +
{text || ''}
+
+ ) : ( +
+ handleColumnChange(record._key, 'comment', e.target.value)} + onDoubleClick={() => openCommentEditor(record)} + variant="borderless" + /> + +
) }, ...(readOnly ? [] : [{ @@ -449,7 +487,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { DBGetTriggers(config as any, tab.dbName || '', tab.tableName || '') ]; - if (readOnly) { + if (!isNewTable) { promises.push(DBShowCreateTable(config as any, tab.dbName || '', tab.tableName || '')); } @@ -458,7 +496,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const idxRes = results[1]; const fkRes = results[2]; const trigRes = results[3]; - const ddlRes = readOnly ? results[4] : null; + const ddlRes = !isNewTable ? results[4] : null; if (colsRes.success) { const colsWithKey = (colsRes.data as ColumnDefinition[]).map((c, index) => ({ @@ -476,7 +514,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { if (idxRes.success) setIndexes(idxRes.data); if (fkRes.success) setFks(fkRes.data); if (trigRes.success) setTriggers(trigRes.data); - if (ddlRes && ddlRes.success) setDdl(ddlRes.data); + if (ddlRes && ddlRes.success) setDdl(String(ddlRes.data || '')); setLoading(false); }; @@ -1160,7 +1198,7 @@ ${selectedTrigger.statement}`; ) } ] : []), - ...(readOnly ? [{ + ...(!isNewTable ? [{ key: 'ddl', label: 'DDL', icon: , @@ -1187,6 +1225,30 @@ ${selectedTrigger.statement}`; ]} /> + { + if (commentEditorColumnKey) { + handleColumnChange(commentEditorColumnKey, 'comment', commentEditorValue); + } + closeCommentEditor(); + }} + okText="应用" + cancelText="取消" + width={640} + destroyOnClose + > + setCommentEditorValue(e.target.value)} + autoSize={{ minRows: 8, maxRows: 18 }} + placeholder="请输入字段注释" + maxLength={2000} + /> + + Date: Sat, 14 Feb 2026 11:25:13 +0800 Subject: [PATCH 5/6] =?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} + /> +