From 820b064e7f2234eb634fa1f70a6c5e788c1c4ca9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 6 Feb 2026 16:57:05 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sidebar-redis-db):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=BA=93=E8=A1=A8=E9=87=8D=E5=91=BD=E5=90=8D=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E3=80=81=E6=89=B9=E9=87=8F=E4=BB=85=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E4=B8=8ERedis=E5=A4=9A=E9=80=89=E5=88=A0?= =?UTF-8?q?=E9=94=AE=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增数据库/表重命名与删除能力,覆盖多数据源差异处理 - 批量操作表新增“仅导出数据(INSERT)”模式并完善导出链路 - Redis Key 列表支持分组展示、勾选批量删除与当前Key删除入口 - 同步 Wails 前后端绑定接口并优化批量操作弹窗按钮布局 --- frontend/src/components/RedisViewer.tsx | 410 ++++++++++++++---------- frontend/src/components/Sidebar.tsx | 292 +++++++++++++++-- frontend/wailsjs/go/app/App.d.ts | 10 + frontend/wailsjs/go/app/App.js | 20 ++ internal/app/methods_db.go | 225 ++++++++++++- internal/app/methods_file.go | 60 ++-- 6 files changed, 796 insertions(+), 221 deletions(-) diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 5f6c429c..fb8ccfa8 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1,13 +1,16 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Table, Input, Button, Space, Tag, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; -import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined } from '@ant-design/icons'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; +import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { RedisKeyInfo, RedisValue } from '../types'; import Editor from '@monaco-editor/react'; -import type { ColumnType } from 'antd/es/table'; +import type { DataNode } from 'antd/es/tree'; const { Search } = Input; +const KEY_GROUP_DELIMITER = ':'; +const EMPTY_SEGMENT_LABEL = '(empty)'; + interface RedisViewerProps { connectionId: string; redisDB: number; @@ -222,86 +225,167 @@ const ResizableDivider: React.FC<{ }; // 可拖拽列头组件 - 纯 DOM 操作实现 -const ResizableTitle: React.FC = (props) => { - const { onResize, width, children, ...restProps } = props; - const thRef = useRef(null); +type RedisKeyTreeLeaf = { + keyInfo: RedisKeyInfo; + label: string; +}; - // 如果没有 onResize 或 width,说明这列不需要拖拽(如复选框列) - if (!onResize || !width) { - return {children}; - } +type RedisKeyTreeGroup = { + name: string; + path: string; + children: Map; + leaves: RedisKeyTreeLeaf[]; +}; - const handleMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); +type RedisKeyTreeResult = { + treeData: DataNode[]; + rawKeyByNodeKey: Map; + leafNodeKeyByRawKey: Map; + groupKeys: string[]; +}; - const startX = e.clientX; - const startWidth = width; - const th = thRef.current; - if (!th) return; +const normalizeKeySegment = (segment: string): string => { + return segment === '' ? EMPTY_SEGMENT_LABEL : segment; +}; - // 找到对应的 colgroup col 元素来同步更新列宽 - const table = th.closest('table'); - const thIndex = Array.from(th.parentElement?.children || []).indexOf(th); - const col = table?.querySelector(`colgroup col:nth-child(${thIndex + 1})`) as HTMLElement | null; +const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => { + return { name, path, children: new Map(), leaves: [] }; +}; - // 创建遮罩层防止文本选择 - const overlay = document.createElement('div'); - overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;'; - document.body.appendChild(overlay); +const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => { + let count = group.leaves.length; + group.children.forEach((child) => { + count += countGroupLeafNodes(child); + }); + return count; +}; - let currentWidth = startWidth; +const buildRedisKeyTree = ( + keys: RedisKeyInfo[], + formatTTL: (ttl: number) => string, + getTypeColor: (type: string) => string +): RedisKeyTreeResult => { + const root = createTreeGroup('__root__', '__root__'); - const handleMouseMove = (moveEvent: MouseEvent) => { - moveEvent.preventDefault(); - const delta = moveEvent.clientX - startX; - currentWidth = Math.max(50, startWidth + delta); - // 直接操作 DOM - th.style.width = `${currentWidth}px`; - if (col) { - col.style.width = `${currentWidth}px`; + keys.forEach((keyInfo) => { + const segments = keyInfo.key.split(KEY_GROUP_DELIMITER); + if (segments.length <= 1) { + root.leaves.push({ keyInfo, label: keyInfo.key }); + return; + } + + const groupSegments = segments.slice(0, -1); + const leafLabel = normalizeKeySegment(segments[segments.length - 1]); + let current = root; + const pathParts: string[] = []; + + groupSegments.forEach((segment) => { + const normalized = normalizeKeySegment(segment); + pathParts.push(normalized); + const groupPath = pathParts.join(KEY_GROUP_DELIMITER); + let child = current.children.get(normalized); + if (!child) { + child = createTreeGroup(normalized, groupPath); + current.children.set(normalized, child); } - }; + current = child; + }); - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.removeChild(overlay); - // 拖拽结束时更新 React state - onResize(null, { size: { width: currentWidth } }); - }; + current.leaves.push({ keyInfo, label: leafLabel }); + }); - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + const rawKeyByNodeKey = new Map(); + const leafNodeKeyByRawKey = new Map(); + const groupKeys: string[] = []; + + const toTreeNodes = (group: RedisKeyTreeGroup): DataNode[] => { + const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name)); + const childLeaves = [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key)); + + const groupNodes: DataNode[] = childGroups.map((child) => { + const groupNodeKey = `group:${child.path}`; + groupKeys.push(groupNodeKey); + return { + key: groupNodeKey, + title: ( + + + {child.name} + ({countGroupLeafNodes(child)}) + + ), + selectable: false, + disableCheckbox: true, + children: toTreeNodes(child), + }; + }); + + const leafNodes: DataNode[] = childLeaves.map((leaf) => { + const nodeKey = `key:${leaf.keyInfo.key}`; + rawKeyByNodeKey.set(nodeKey, leaf.keyInfo.key); + leafNodeKeyByRawKey.set(leaf.keyInfo.key, nodeKey); + return { + key: nodeKey, + isLeaf: true, + title: ( +
+ + + + + {leaf.label} + + + + + {leaf.keyInfo.type} + + + {formatTTL(leaf.keyInfo.ttl)} + +
+ ), + }; + }); + + return [...groupNodes, ...leafNodes]; }; - return ( - - {children} -
{ e.currentTarget.style.background = 'rgba(0,0,0,0.06)'; }} - onMouseOut={(e) => { e.currentTarget.style.background = 'transparent'; }} - /> - - ); + return { + treeData: toTreeNodes(root), + rawKeyByNodeKey, + leafNodeKeyByRawKey, + groupKeys, + }; }; const RedisViewer: React.FC = ({ connectionId, redisDB }) => { @@ -317,7 +401,6 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [keyValue, setKeyValue] = useState(null); const [valueLoading, setValueLoading] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); - const [editForm] = Form.useForm(); const [newKeyModalOpen, setNewKeyModalOpen] = useState(false); const [newKeyForm] = Form.useForm(); const [ttlModalOpen, setTtlModalOpen] = useState(false); @@ -341,15 +424,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { // 面板宽度状态和 ref - 默认占据 50% 宽度 const [leftPanelWidth, setLeftPanelWidth] = useState('50%'); const leftPanelRef = useRef(null); - - // 列宽状态 - 复选框列约 32px,总宽度需要接近面板宽度 - // Key 列自适应剩余空间,其他列固定宽度 - const [columnWidths, setColumnWidths] = useState({ - key: 220, // Key 名称,需要较宽 - type: 65, // 类型标签 - ttl: 80, // TTL 显示 - action: 50 // 操作按钮 - }); + const [expandedGroupKeys, setExpandedGroupKeys] = useState([]); const getConfig = useCallback(() => { if (!connection) return null; @@ -373,7 +448,12 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { if (res.success) { const result = res.data; if (append) { - setKeys(prev => [...prev, ...result.keys]); + setKeys(prev => { + const keyMap = new Map(); + prev.forEach(item => keyMap.set(item.key, item)); + result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); + return Array.from(keyMap.values()); + }); } else { setKeys(result.keys); } @@ -451,6 +531,11 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } }; + const handleDeleteCurrentKey = async () => { + if (!selectedKey) return; + await handleDeleteKeys([selectedKey]); + }; + const handleSetTTL = async () => { const config = getConfig(); if (!config || !selectedKey) return; @@ -529,65 +614,54 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return `${Math.floor(ttl / 86400)}天${Math.floor((ttl % 86400) / 3600)}时`; }; - // 处理列宽调整 - react-resizable 的 onResize 回调格式 - const handleColumnResize = (key: string) => (_e: any, { size }: { size: { width: number } }) => { - setColumnWidths(prev => ({ ...prev, [key]: size.width })); + const keyTree = useMemo(() => { + return buildRedisKeyTree(keys, formatTTL, getTypeColor); + }, [keys]); + + const selectedTreeNodeKeys = useMemo(() => { + if (!selectedKey) { + return [] as string[]; + } + const nodeKey = keyTree.leafNodeKeyByRawKey.get(selectedKey); + return nodeKey ? [nodeKey] : []; + }, [selectedKey, keyTree]); + + const checkedTreeNodeKeys = useMemo(() => { + return selectedKeys + .map(rawKey => keyTree.leafNodeKeyByRawKey.get(rawKey)) + .filter((nodeKey): nodeKey is string => Boolean(nodeKey)); + }, [selectedKeys, keyTree]); + + useEffect(() => { + const existingKeySet = new Set(keys.map(item => item.key)); + setSelectedKeys(prev => prev.filter(rawKey => existingKeySet.has(rawKey))); + }, [keys]); + + useEffect(() => { + setExpandedGroupKeys((prev) => { + const validKeys = prev.filter(nodeKey => keyTree.groupKeys.includes(nodeKey)); + return validKeys; + }); + }, [keyTree]); + + const handleTreeSelect = (nodeKeys: React.Key[]) => { + if (nodeKeys.length === 0) { + return; + } + const rawKey = keyTree.rawKeyByNodeKey.get(String(nodeKeys[0])); + if (!rawKey) { + return; + } + loadKeyValue(rawKey); }; - const columns: ColumnType[] = [ - { - title: 'Key', - dataIndex: 'key', - key: 'key', - width: columnWidths.key, - ellipsis: true, - onHeaderCell: (column: any) => ({ - width: column.width, - onResize: handleColumnResize('key') - }), - render: (text: string) => ( - - loadKeyValue(text)}>{text} - - ) - }, - { - title: '类型', - dataIndex: 'type', - key: 'type', - width: columnWidths.type, - onHeaderCell: (column: any) => ({ - width: column.width, - onResize: handleColumnResize('type') - }), - render: (type: string) => {type} - }, - { - title: 'TTL', - dataIndex: 'ttl', - key: 'ttl', - width: columnWidths.ttl, - onHeaderCell: (column: any) => ({ - width: column.width, - onResize: handleColumnResize('ttl') - }), - render: (ttl: number) => formatTTL(ttl) - }, - { - title: '操作', - key: 'action', - width: columnWidths.action, - onHeaderCell: (column: any) => ({ - width: column.width, - onResize: handleColumnResize('action') - }), - render: (_: any, record: RedisKeyInfo) => ( - handleDeleteKeys([record.key])}> - + + +
@@ -1410,36 +1487,35 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { - {selectedKeys.length > 0 && ( - handleDeleteKeys(selectedKeys)}> - - - )} + handleDeleteKeys(selectedKeys)} + disabled={selectedKeys.length === 0} + > + +
- setSelectedKeys(keys as string[]) - }} - onRow={(record) => ({ - onClick: () => loadKeyValue(record.key), - style: { cursor: 'pointer', background: selectedKey === record.key ? '#e6f7ff' : undefined } - })} - style={{ width: '100%' }} - /> + + setExpandedGroupKeys(nextExpandedKeys as string[])} + onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)} + onCheck={(checked) => handleTreeCheck(checked)} + style={{ padding: '8px 6px' }} + /> + {hasMore && (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e8c21c05..289cc7ce 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -28,7 +28,7 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, } from '@ant-design/icons'; import { useStore } from '../store'; import { SavedConnection } from '../types'; - import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform } from '../utils/appearance'; const { Search } = Input; @@ -43,6 +43,8 @@ interface TreeNode { type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db'; } +type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; + const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => { const connections = useStore(state => state.connections); const savedQueries = useStore(state => state.savedQueries); @@ -96,6 +98,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false); const [createDbForm] = Form.useForm(); const [targetConnection, setTargetConnection] = useState(null); + const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false); + const [renameDbForm] = Form.useForm(); + const [renameDbTarget, setRenameDbTarget] = useState(null); + const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false); + const [renameTableForm] = Form.useForm(); + const [renameTableTarget, setRenameTableTarget] = useState(null); // Batch Operations Modal const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); @@ -661,7 +669,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } }; - const handleBatchExport = async (includeData: boolean) => { + const handleBatchExport = async (mode: BatchTableExportMode) => { const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key)); if (selectedTables.length === 0) { message.warning('请至少选择一张表'); @@ -673,9 +681,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const { conn, dbName } = batchDbContext; const tableNames = selectedTables.map(t => t.tableName); - const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0); + const loadingText = mode === 'backup' + ? `正在备份选中表 (${tableNames.length})...` + : mode === 'dataOnly' + ? `正在导出选中表数据 (INSERT) (${tableNames.length})...` + : `正在导出选中表结构 (${tableNames.length})...`; + const hide = message.loading(loadingText, 0); try { - const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, includeData); + const app = (window as any).go.app.App; + const res = mode === 'dataOnly' + ? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames) + : await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup'); hide(); if (res.success) { message.success('导出成功'); @@ -865,6 +881,148 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } }; + const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { + return { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || "", + database: clearDatabase ? "" : ((overrideDatabase ?? conn.config.database) || ""), + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }; + }; + + const getConnectionNodeRef = (connRef: any) => { + const latestConn = connections.find(c => c.id === connRef.id); + return { key: connRef.id, dataRef: latestConn || connRef }; + }; + + const getDatabaseNodeRef = (connRef: any, dbName: string) => { + const latestConn = connections.find(c => c.id === connRef.id); + return { + key: `${connRef.id}-${dbName}`, + dataRef: { ...(latestConn || connRef), dbName } + }; + }; + + const extractObjectName = (fullName: string) => { + const raw = String(fullName || '').trim(); + const idx = raw.lastIndexOf('.'); + if (idx >= 0 && idx < raw.length - 1) { + return raw.substring(idx + 1); + } + return raw; + }; + + const handleRenameDatabase = async () => { + if (!renameDbTarget) return; + try { + const values = await renameDbForm.validateFields(); + const conn = renameDbTarget.dataRef; + const oldDbName = String(conn.dbName || '').trim(); + const newDbName = String(values.newName || '').trim(); + if (!oldDbName || !newDbName) { + message.error("数据库名称不能为空"); + return; + } + if (oldDbName === newDbName) { + message.warning("新旧数据库名称相同,无需修改"); + return; + } + + const config = buildRuntimeConfig(conn, conn.dbName); + const res = await RenameDatabase(config as any, oldDbName, newDbName); + if (res.success) { + message.success("数据库重命名成功"); + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`))); + await loadDatabases(getConnectionNodeRef(conn)); + setIsRenameDbModalOpen(false); + setRenameDbTarget(null); + renameDbForm.resetFields(); + } else { + message.error("重命名失败: " + res.message); + } + } catch (e) { + // Validate failed + } + }; + + const handleDeleteDatabase = (node: any) => { + const conn = node.dataRef; + const dbName = String(conn.dbName || '').trim(); + if (!dbName) return; + Modal.confirm({ + title: '确认删除数据库', + content: `确定删除数据库 "${dbName}" 吗?该操作不可恢复。`, + okButtonProps: { danger: true }, + onOk: async () => { + const config = buildRuntimeConfig(conn, conn.dbName); + const res = await DropDatabase(config as any, dbName); + if (res.success) { + message.success("数据库删除成功"); + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`))); + await loadDatabases(getConnectionNodeRef(conn)); + } else { + message.error("删除失败: " + res.message); + } + } + }); + }; + + const handleRenameTable = async () => { + if (!renameTableTarget) return; + try { + const values = await renameTableForm.validateFields(); + const conn = renameTableTarget.dataRef; + const oldTableName = String(conn.tableName || '').trim(); + const newTableName = String(values.newName || '').trim(); + if (!oldTableName || !newTableName) { + message.error("表名不能为空"); + return; + } + if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) { + message.warning("新旧表名相同,无需修改"); + return; + } + const config = buildRuntimeConfig(conn, conn.dbName); + const res = await RenameTable(config as any, conn.dbName, oldTableName, newTableName); + if (res.success) { + message.success("表重命名成功"); + await loadTables(getDatabaseNodeRef(conn, conn.dbName)); + setIsRenameTableModalOpen(false); + setRenameTableTarget(null); + renameTableForm.resetFields(); + } else { + message.error("重命名失败: " + res.message); + } + } catch (e) { + // Validate failed + } + }; + + const handleDeleteTable = (node: any) => { + const conn = node.dataRef; + const tableName = String(conn.tableName || '').trim(); + if (!tableName) return; + Modal.confirm({ + title: '确认删除表', + content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, + okButtonProps: { danger: true }, + onOk: async () => { + const config = buildRuntimeConfig(conn, conn.dbName); + const res = await DropTable(config as any, conn.dbName, tableName); + if (res.success) { + message.success("表删除成功"); + await loadTables(getDatabaseNodeRef(conn, conn.dbName)); + } else { + message.error("删除失败: " + res.message); + } + } + }); + }; + const onSearch = (e: React.ChangeEvent) => { const { value } = e.target; setSearchValue(value); @@ -1088,6 +1246,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> icon: , onClick: () => openNewTableDesign(node) }, + { + key: 'rename-db', + label: '重命名数据库', + icon: , + onClick: () => { + setRenameDbTarget(node); + renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' }); + setIsRenameDbModalOpen(true); + } + }, + { + key: 'drop-db', + label: '删除数据库', + icon: , + danger: true, + onClick: () => handleDeleteDatabase(node) + }, { key: 'refresh', label: '刷新', @@ -1180,6 +1355,23 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> icon: , onClick: () => handleExport(node, 'sql') }, + { + key: 'rename-table', + label: '重命名表', + icon: , + onClick: () => { + setRenameTableTarget(node); + renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); + setIsRenameTableModalOpen(true); + } + }, + { + key: 'drop-table', + label: '删除表', + icon: , + danger: true, + onClick: () => handleDeleteTable(node) + }, { type: 'divider' }, @@ -1295,33 +1487,79 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> + { + setIsRenameDbModalOpen(false); + setRenameDbTarget(null); + renameDbForm.resetFields(); + }} + > +
+ + + + +
+ + { + setIsRenameTableModalOpen(false); + setRenameTableTarget(null); + renameTableForm.resetFields(); + }} + > +
+ + + + +
+ setIsBatchModalOpen(false)} - width={600} - footer={[ - , - , - - ]} + width={680} + footer={ +
+ + + + + + +
+ } >
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index d4f1a766..4250e4e3 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -38,6 +38,10 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr export function DownloadUpdate():Promise; +export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; + +export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise; @@ -46,6 +50,8 @@ export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:st export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; + export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array,arg4:boolean):Promise; export function GetAppInfo():Promise; @@ -110,4 +116,8 @@ export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:A export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; +export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + export function TestConnection(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 3621a89b..9bd8482a 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -70,6 +70,14 @@ export function DownloadUpdate() { return window['go']['app']['App']['DownloadUpdate'](); } +export function DropDatabase(arg1, arg2) { + return window['go']['app']['App']['DropDatabase'](arg1, arg2); +} + +export function DropTable(arg1, arg2, arg3) { + return window['go']['app']['App']['DropTable'](arg1, arg2, arg3); +} + export function ExportData(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4); } @@ -86,6 +94,10 @@ export function ExportTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4); } +export function ExportTablesDataSQL(arg1, arg2, arg3) { + return window['go']['app']['App']['ExportTablesDataSQL'](arg1, arg2, arg3); +} + export function ExportTablesSQL(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportTablesSQL'](arg1, arg2, arg3, arg4); } @@ -214,6 +226,14 @@ export function RedisZSetRemove(arg1, arg2, arg3) { return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3); } +export function RenameDatabase(arg1, arg2, arg3) { + return window['go']['app']['App']['RenameDatabase'](arg1, arg2, arg3); +} + +export function RenameTable(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4); +} + export function TestConnection(arg1) { return window['go']['app']['App']['TestConnection'](arg1); } diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index f7a10bd5..334abb88 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -20,7 +20,7 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu logger.Error(err, "DBConnect 连接失败:%s", formatConnSummary(config)) return connection.QueryResult{Success: false, Message: err.Error()} } - + logger.Infof("DBConnect 连接成功:%s", formatConnSummary(config)) return connection.QueryResult{Success: true, Message: "连接成功"} } @@ -31,14 +31,14 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer logger.Error(err, "TestConnection 连接测试失败:%s", formatConnSummary(config)) return connection.QueryResult{Success: false, Message: err.Error()} } - + logger.Infof("TestConnection 连接测试成功:%s", formatConnSummary(config)) return connection.QueryResult{Success: true, Message: "连接成功"} } func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult { runConfig := config - runConfig.Database = "" + runConfig.Database = "" dbInst, err := a.getDatabase(runConfig) if err != nil { @@ -60,6 +60,221 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) return connection.QueryResult{Success: true, Message: "Database created successfully"} } +func resolveDDLDBType(config connection.ConnectionConfig) string { + dbType := strings.ToLower(strings.TrimSpace(config.Type)) + if dbType != "custom" { + return dbType + } + + driver := strings.ToLower(strings.TrimSpace(config.Driver)) + switch driver { + case "postgresql": + return "postgres" + case "dm": + return "dameng" + case "sqlite3": + return "sqlite" + default: + return driver + } +} + +func normalizeSchemaAndTableByType(dbType string, dbName string, tableName string) (string, string) { + rawTable := strings.TrimSpace(tableName) + rawDB := strings.TrimSpace(dbName) + if rawTable == "" { + return rawDB, rawTable + } + + if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 { + schema := strings.TrimSpace(parts[0]) + table := strings.TrimSpace(parts[1]) + if schema != "" && table != "" { + return schema, table + } + } + + switch dbType { + case "postgres", "kingbase": + return "public", rawTable + default: + return rawDB, rawTable + } +} + +func quoteTableIdentByType(dbType string, schema string, table string) string { + s := strings.TrimSpace(schema) + t := strings.TrimSpace(table) + if s == "" { + return quoteIdentByType(dbType, t) + } + return fmt.Sprintf("%s.%s", quoteIdentByType(dbType, s), quoteIdentByType(dbType, t)) +} + +func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbName string) connection.ConnectionConfig { + runConfig := normalizeRunConfig(config, dbName) + if strings.EqualFold(strings.TrimSpace(config.Type), "custom") { + // custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。 + switch dbType { + case "mysql", "postgres", "kingbase", "dameng": + if strings.TrimSpace(dbName) != "" { + runConfig.Database = strings.TrimSpace(dbName) + } + } + } + return runConfig +} + +func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string, newName string) connection.QueryResult { + oldName = strings.TrimSpace(oldName) + newName = strings.TrimSpace(newName) + if oldName == "" || newName == "" { + return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} + } + if strings.EqualFold(oldName, newName) { + return connection.QueryResult{Success: false, Message: "新旧数据库名称不能相同"} + } + + dbType := resolveDDLDBType(config) + switch dbType { + case "mysql": + return connection.QueryResult{Success: false, Message: "MySQL 不支持直接重命名数据库,请新建库后迁移数据"} + case "postgres", "kingbase": + if strings.EqualFold(strings.TrimSpace(config.Database), oldName) { + return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"} + } + runConfig := config + if strings.TrimSpace(runConfig.Database) == "" { + runConfig.Database = "postgres" + } + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + sql := fmt.Sprintf("ALTER DATABASE %s RENAME TO %s", quoteIdentByType(dbType, oldName), quoteIdentByType(dbType, newName)) + if _, err := dbInst.Exec(sql); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "数据库重命名成功"} + default: + return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名数据库", dbType)} + } +} + +func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult { + dbName = strings.TrimSpace(dbName) + if dbName == "" { + return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} + } + + dbType := resolveDDLDBType(config) + var ( + runConfig connection.ConnectionConfig + sql string + ) + switch dbType { + case "mysql": + runConfig = config + runConfig.Database = "" + sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName)) + case "postgres", "kingbase": + if strings.EqualFold(strings.TrimSpace(config.Database), dbName) { + return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"} + } + runConfig = config + if strings.TrimSpace(runConfig.Database) == "" { + runConfig.Database = "postgres" + } + sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName)) + default: + return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)} + } + + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if _, err := dbInst.Exec(sql); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "数据库删除成功"} +} + +func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, oldTableName string, newTableName string) connection.QueryResult { + oldTableName = strings.TrimSpace(oldTableName) + newTableName = strings.TrimSpace(newTableName) + if oldTableName == "" || newTableName == "" { + return connection.QueryResult{Success: false, Message: "表名不能为空"} + } + if strings.EqualFold(oldTableName, newTableName) { + return connection.QueryResult{Success: false, Message: "新旧表名不能相同"} + } + if strings.Contains(newTableName, ".") { + return connection.QueryResult{Success: false, Message: "新表名不能包含 schema 或数据库前缀"} + } + + dbType := resolveDDLDBType(config) + switch dbType { + case "mysql", "postgres", "kingbase", "sqlite", "oracle", "dameng": + default: + return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)} + } + + schemaName, pureOldTableName := normalizeSchemaAndTableByType(dbType, dbName, oldTableName) + if pureOldTableName == "" { + return connection.QueryResult{Success: false, Message: "旧表名不能为空"} + } + oldQualifiedTable := quoteTableIdentByType(dbType, schemaName, pureOldTableName) + newTableQuoted := quoteIdentByType(dbType, newTableName) + + sql := fmt.Sprintf("ALTER TABLE %s RENAME TO %s", oldQualifiedTable, newTableQuoted) + if dbType == "mysql" { + newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName) + sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable) + } + + runConfig := buildRunConfigForDDL(config, dbType, dbName) + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if _, err := dbInst.Exec(sql); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "表重命名成功"} +} + +func (a *App) DropTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult { + tableName = strings.TrimSpace(tableName) + if tableName == "" { + return connection.QueryResult{Success: false, Message: "表名不能为空"} + } + + dbType := resolveDDLDBType(config) + switch dbType { + case "mysql", "postgres", "kingbase", "sqlite", "oracle", "dameng": + default: + return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)} + } + + schemaName, pureTableName := normalizeSchemaAndTableByType(dbType, dbName, tableName) + if pureTableName == "" { + return connection.QueryResult{Success: false, Message: "表名不能为空"} + } + qualifiedTable := quoteTableIdentByType(dbType, schemaName, pureTableName) + sql := fmt.Sprintf("DROP TABLE %s", qualifiedTable) + + runConfig := buildRunConfigForDDL(config, dbType, dbName) + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if _, err := dbInst.Exec(sql); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Message: "表删除成功"} +} + func (a *App) MySQLConnect(config connection.ConnectionConfig) connection.QueryResult { config.Type = "mysql" return a.DBConnect(config) @@ -156,12 +371,12 @@ func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.Quer logger.Error(err, "DBGetDatabases 获取数据库列表失败:%s", formatConnSummary(config)) return connection.QueryResult{Success: false, Message: err.Error()} } - + var resData []map[string]string for _, name := range dbs { resData = append(resData, map[string]string{"Database": name}) } - + return connection.QueryResult{Success: true, Data: resData} } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index b4c40007..0cdac784 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -102,8 +102,8 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s } defer f.Close() - var rows []map[string]interface{ } - + var rows []map[string]interface{} + if strings.HasSuffix(strings.ToLower(selection), ".json") { decoder := json.NewDecoder(f) if err := decoder.Decode(&rows); err != nil { @@ -120,7 +120,7 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s } headers := records[0] for _, record := range records[1:] { - row := make(map[string]interface{ }) + row := make(map[string]interface{}) for i, val := range record { if i < len(headers) { if val == "NULL" { @@ -153,7 +153,7 @@ func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName s for k := range firstRow { cols = append(cols, k) } - + for _, row := range rows { var values []string for _, col := range cols { @@ -195,7 +195,7 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - + if applier, ok := dbInst.(db.BatchApplier); ok { err := applier.ApplyChanges(tableName, changes) if err != nil { @@ -219,7 +219,7 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab runConfig := normalizeRunConfig(config, dbName) -dbInst, err := a.getDatabase(runConfig) + dbInst, err := a.getDatabase(runConfig) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -238,7 +238,7 @@ dbInst, err := a.getDatabase(runConfig) if err := writeSQLHeader(w, runConfig, dbName); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true); err != nil { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } if err := writeSQLFooter(w, runConfig); err != nil { @@ -249,8 +249,8 @@ dbInst, err := a.getDatabase(runConfig) } query := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(runConfig.Type, tableName)) - -data, columns, err := dbInst.Query(query) + + data, columns, err := dbInst.Query(query) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } @@ -268,13 +268,27 @@ data, columns, err := dbInst.Query(query) } func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeData bool) connection.QueryResult { + return a.exportTablesSQL(config, dbName, tableNames, true, includeData) +} + +func (a *App) ExportTablesDataSQL(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult { + return a.exportTablesSQL(config, dbName, tableNames, false, true) +} + +func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string, tableNames []string, includeSchema bool, includeData bool) connection.QueryResult { + if !includeSchema && !includeData { + return connection.QueryResult{Success: false, Message: "invalid export mode"} + } + safeDbName := strings.TrimSpace(dbName) if safeDbName == "" { safeDbName = "export" } suffix := "schema" - if includeData { + if includeSchema && includeData { suffix = "backup" + } else if !includeSchema && includeData { + suffix = "data" } defaultFilename := fmt.Sprintf("%s_%s_%dtables.sql", safeDbName, suffix, len(tableNames)) if len(tableNames) == 1 && strings.TrimSpace(tableNames[0]) != "" { @@ -323,7 +337,7 @@ func (a *App) ExportTablesSQL(config connection.ConnectionConfig, dbName string, return connection.QueryResult{Success: false, Message: err.Error()} } for _, t := range tables { - if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeSchema, includeData); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } } @@ -377,7 +391,7 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin return connection.QueryResult{Success: false, Message: err.Error()} } for _, t := range tables { - if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeData); err != nil { + if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } } @@ -534,7 +548,7 @@ func formatSQLValue(dbType string, v interface{}) string { } } -func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeData bool) error { +func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error { schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil { @@ -547,15 +561,17 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect return err } - createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName) - if err != nil { - return err - } - if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil { - return err - } - if _, err := w.WriteString("\n\n"); err != nil { - return err + if includeSchema { + createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName) + if err != nil { + return err + } + if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil { + return err + } + if _, err := w.WriteString("\n\n"); err != nil { + return err + } } if !includeData {