diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee25303..1180a62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,8 @@ jobs: GOOS="${TARGET_PLATFORM%%/*}" GOARCH="${TARGET_PLATFORM##*/}" DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine) + OUTDIR="drivers/${{ matrix.os_name }}" + mkdir -p "$OUTDIR" for DRIVER in "${DRIVERS[@]}"; do TAG="gonavi_${DRIVER}_driver" @@ -111,20 +113,21 @@ jobs: if [ "$GOOS" = "windows" ]; then OUTPUT="${OUTPUT}.exe" fi - echo "🔧 构建 ${OUTPUT} (tag=${TAG})" + OUTPUT_PATH="${OUTDIR}/${OUTPUT}" + echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})" if [ "$DRIVER" = "duckdb" ]; then set +e CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ -tags "${TAG}" \ -trimpath \ -ldflags "-s -w" \ - -o "${OUTPUT}" \ + -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent DUCKDB_RC=$? set -e if [ "${DUCKDB_RC}" -ne 0 ]; then echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布" - rm -f "${OUTPUT}" + rm -f "${OUTPUT_PATH}" continue fi else @@ -132,7 +135,7 @@ jobs: -tags "${TAG}" \ -trimpath \ -ldflags "-s -w" \ - -o "${OUTPUT}" \ + -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent fi done @@ -295,7 +298,7 @@ jobs: GoNavi-*.zip GoNavi-*.tar.gz GoNavi-*.AppImage - *-driver-agent-* + drivers/** retention-days: 1 # Phase 2: Collect all artifacts and Publish Release (Single Job) @@ -314,6 +317,59 @@ jobs: - name: List Assets run: ls -R release-assets + - name: Package Driver Agents Bundle + shell: bash + run: | + set -euo pipefail + cd release-assets + if [ ! -d drivers ]; then + echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包" + exit 0 + fi + if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then + echo "⚠️ drivers 目录为空,跳过驱动总包打包" + rm -rf drivers + exit 0 + fi + + echo "📦 打包驱动总包:GoNavi-DriverAgents.zip" + python3 - <<'PY' + import json + import os + import zipfile + from pathlib import Path + + out_name = "GoNavi-DriverAgents.zip" + index_name = "GoNavi-DriverAgents-Index.json" + base = Path("drivers") + out_path = Path(out_name) + index_path = Path(index_name) + if out_path.exists(): + out_path.unlink() + if index_path.exists(): + index_path.unlink() + + size_index = {} + with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for p in base.rglob("*"): + if not p.is_file(): + continue + arcname = p.relative_to(base).as_posix() + zf.write(p, arcname) + size_index[p.name] = p.stat().st_size + + index_path.write_text( + json.dumps({"assets": size_index}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + print(f"created {out_name} size={out_path.stat().st_size} bytes") + print(f"created {index_name} entries={len(size_index)}") + PY + + # Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面 + rm -rf drivers + - name: Generate SHA256SUMS shell: bash run: | 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/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/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 4b6b804..8bb556b 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 { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag } from 'antd'; +import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -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' }, @@ -157,12 +199,46 @@ 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 [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); 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); @@ -172,6 +248,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 => { @@ -234,6 +325,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 = [ @@ -304,8 +399,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 ? [] : [{ @@ -439,7 +553,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 || '')); } @@ -448,7 +562,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) => ({ @@ -458,14 +572,36 @@ 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); } - 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 (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); }; @@ -721,6 +857,600 @@ ${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 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 => { + 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 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) => { @@ -745,21 +1475,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 +1609,10 @@ ${selectedTrigger.statement}`;
setSelectedColumnRowKeys(nextSelectedRowKeys as string[]), + }} rowKey="_key" size="small" pagination={false} @@ -957,7 +1677,19 @@ ${selectedTrigger.statement}`; )} {!readOnly && } {!isNewTable && } + {!isNewTable && !readOnly && supportsMysqlSchemaOps() && ( + + )} {!readOnly && } + {!readOnly && ( + + )}
v === 0 ? 'Unique' : 'Normal' }, - ]} - rowKey={(r) => r.name + r.columnName} - size="small" - pagination={false} - loading={loading} - /> +
+ {!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' } + })} + /> + ) }, { @@ -1071,7 +1932,7 @@ ${selectedTrigger.statement}`; ) } ] : []), - ...(readOnly ? [{ + ...(!isNewTable ? [{ key: 'ddl', label: 'DDL', icon: , @@ -1087,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 }, }} /> @@ -1098,6 +1960,200 @@ ${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} + /> + + + setIsCopyColumnsModalOpen(false)} + onOk={handleExecuteCopySelectedColumns} + okText="创建新表" + cancelText="取消" + confirmLoading={copyExecuting} + width={560} + > + +
+ 已选择字段:{selectedColumns.length} +
+ setCopyTableName(e.target.value)} + maxLength={128} + /> + + + +
+
+ + 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} + /> +