diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77e545d..dac62c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Popover } from 'antd'; +import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons'; +import { EventsOn } from '../wailsjs/runtime/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -52,6 +53,7 @@ function App() { const updateCheckInFlightRef = React.useRef(false); const updateDownloadInFlightRef = React.useRef(false); const updateDownloadedVersionRef = React.useRef(null); + const updateDownloadMetaRef = React.useRef(null); const updateDeferredVersionRef = React.useRef(null); const updateNotifiedVersionRef = React.useRef(null); const updateMutedVersionRef = React.useRef(null); @@ -60,6 +62,23 @@ function App() { const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string } | null>(null); const [aboutUpdateStatus, setAboutUpdateStatus] = useState(''); const [lastUpdateInfo, setLastUpdateInfo] = useState(null); + const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{ + open: boolean; + version: string; + status: 'idle' | 'start' | 'downloading' | 'done' | 'error'; + percent: number; + downloaded: number; + total: number; + message: string; + }>({ + open: false, + version: '', + status: 'idle', + percent: 0, + downloaded: 0, + total: 0, + message: '' + }); type UpdateInfo = { hasUpdate: boolean; @@ -73,10 +92,51 @@ function App() { sha256?: string; }; - const promptRestartForUpdate = (info: UpdateInfo) => { + type UpdateDownloadProgressEvent = { + status?: 'start' | 'downloading' | 'done' | 'error'; + percent?: number; + downloaded?: number; + total?: number; + message?: string; + }; + + type UpdateDownloadResultData = { + info?: UpdateInfo; + downloadPath?: string; + installLogPath?: string; + installTarget?: string; + platform?: string; + autoRelaunch?: boolean; + }; + + const formatBytes = (bytes?: number) => { + if (!bytes || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx++; + } + return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; + }; + + const promptRestartForUpdate = (info: UpdateInfo, resultData?: UpdateDownloadResultData) => { + const downloadPathHint = resultData?.downloadPath + ? `更新包路径:${resultData.downloadPath}` + : ''; + const installLogHint = resultData?.installLogPath + ? `安装日志:${resultData.installLogPath}` + : ''; Modal.confirm({ title: '更新已下载', - content: `版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`, + content: ( +
+
{`版本 ${info.latestVersion} 已下载完成,是否现在重启完成更新?`}
+ {downloadPathHint ?
{downloadPathHint}
: null} + {installLogHint ?
{installLogHint}
: null} +
+ ), okText: '立即重启', cancelText: '稍后', onOk: async () => { @@ -96,25 +156,49 @@ function App() { if (updateDownloadInFlightRef.current) return; if (updateDownloadedVersionRef.current === info.latestVersion) { if (!silent) { - message.info(`更新包已就绪(${info.latestVersion})`); + const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath; + message.info(cachedDownloadPath ? `更新包已就绪(${info.latestVersion}),路径:${cachedDownloadPath}` : `更新包已就绪(${info.latestVersion})`); } if (!silent || updateDeferredVersionRef.current !== info.latestVersion) { - promptRestartForUpdate(info); + promptRestartForUpdate(info, updateDownloadMetaRef.current || undefined); } return; } updateDownloadInFlightRef.current = true; + updateDownloadMetaRef.current = null; const key = 'update-download'; + setUpdateDownloadProgress({ + open: true, + version: info.latestVersion, + status: 'start', + percent: 0, + downloaded: 0, + total: info.assetSize || 0, + message: '' + }); message.loading({ content: `正在下载更新 ${info.latestVersion}...`, key, duration: 0 }); const res = await (window as any).go.app.App.DownloadUpdate(); updateDownloadInFlightRef.current = false; if (res?.success) { + const resultData = (res?.data || {}) as UpdateDownloadResultData; + updateDownloadMetaRef.current = resultData; updateDownloadedVersionRef.current = info.latestVersion; - message.success({ content: '更新下载完成', key, duration: 2 }); + setUpdateDownloadProgress(prev => ({ ...prev, status: 'done', percent: 100, open: false })); + if (resultData?.downloadPath) { + message.success({ content: `更新下载完成,更新包路径:${resultData.downloadPath}`, key, duration: 5 }); + } else { + message.success({ content: '更新下载完成', key, duration: 2 }); + } + setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(已下载,待重启安装)`); if (!silent || updateDeferredVersionRef.current !== info.latestVersion) { - promptRestartForUpdate(info); + promptRestartForUpdate(info, resultData); } } else { + setUpdateDownloadProgress(prev => ({ + ...prev, + status: 'error', + message: res?.message || '未知错误' + })); message.error({ content: '更新下载失败: ' + (res?.message || '未知错误'), key, duration: 4 }); } }, []); @@ -329,6 +413,14 @@ function App() { setIsModalOpen(false); setEditingConnection(null); }; + + const handleTitleBarDoubleClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (target?.closest('[data-no-titlebar-toggle="true"]')) { + return; + } + (window as any).runtime.WindowToggleMaximise(); + }; // Sidebar Resizing const [sidebarWidth, setSidebarWidth] = useState(300); @@ -422,6 +514,35 @@ function App() { }; }, [checkForUpdates]); + useEffect(() => { + const offDownloadProgress = EventsOn('update:download-progress', (event: UpdateDownloadProgressEvent) => { + if (!event) return; + const status = event.status || 'downloading'; + const nextStatus: 'idle' | 'start' | 'downloading' | 'done' | 'error' = + status === 'start' || status === 'downloading' || status === 'done' || status === 'error' + ? status + : 'downloading'; + const downloaded = typeof event.downloaded === 'number' ? event.downloaded : 0; + const total = typeof event.total === 'number' ? event.total : 0; + const percentRaw = typeof event.percent === 'number' + ? event.percent + : (total > 0 ? (downloaded / total) * 100 : 0); + const percent = Math.max(0, Math.min(100, percentRaw)); + setUpdateDownloadProgress(prev => ({ + open: nextStatus === 'start' || nextStatus === 'downloading' || nextStatus === 'error', + version: prev.version, + status: nextStatus, + percent, + downloaded, + total, + message: String(event.message || '') + })); + }); + return () => { + offDownloadProgress(); + }; + }, []); + return ( {/* Custom Title Bar */}
-
+
e.stopPropagation()} + style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any} + >
@@ -704,6 +830,56 @@ function App() {
+ + { + if (updateDownloadProgress.status === 'error') { + setUpdateDownloadProgress({ + open: false, + version: '', + status: 'idle', + percent: 0, + downloaded: 0, + total: 0, + message: '' + }); + } + }} + footer={updateDownloadProgress.status === 'error' ? [ + + ] : null} + > +
+ +
+ {`${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`} +
+ {updateDownloadProgress.message ? ( +
{updateDownloadProgress.message}
+ ) : null} +
+
{/* Ghost Resize Line for Sidebar */}
= (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 e8c21c0..289cc7c 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/src/store.ts b/frontend/src/store.ts index f3c69c3..e431b79 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -2,6 +2,19 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { SavedConnection, TabData, SavedQuery } from './types'; +const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 }; +const LEGACY_DEFAULT_OPACITY = 0.95; +const OPACITY_EPSILON = 1e-6; + +const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => { + if (!appearance) { + return true; + } + const opacity = typeof appearance.opacity === 'number' ? appearance.opacity : LEGACY_DEFAULT_OPACITY; + const blur = typeof appearance.blur === 'number' ? appearance.blur : 0; + return Math.abs(opacity - LEGACY_DEFAULT_OPACITY) < OPACITY_EPSILON && blur === 0; +}; + export interface SqlLog { id: string; timestamp: number; @@ -59,7 +72,7 @@ export const useStore = create()( activeContext: null, savedQueries: [], theme: 'light', - appearance: { opacity: 0.95, blur: 0 }, + appearance: { ...DEFAULT_APPEARANCE }, sqlFormatOptions: { keywordCase: 'upper' }, queryOptions: { maxRows: 5000 }, sqlLogs: [], @@ -138,6 +151,33 @@ export const useStore = create()( }), { name: 'lite-db-storage', // name of the item in the storage (must be unique) + version: 2, + migrate: (persistedState: unknown, version: number) => { + if (!persistedState || typeof persistedState !== 'object') { + return persistedState as AppState; + } + const state = persistedState as Partial; + const nextState: Partial = { ...state }; + const appearance = state.appearance; + + if (!appearance || typeof appearance !== 'object') { + nextState.appearance = { ...DEFAULT_APPEARANCE }; + return nextState as AppState; + } + + const nextAppearance = { + opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity, + blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur, + }; + + if (version < 2 && isLegacyDefaultAppearance(appearance)) { + nextState.appearance = { ...DEFAULT_APPEARANCE }; + } else { + nextState.appearance = nextAppearance; + } + + return nextState as AppState; + }, partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs } ) diff --git a/frontend/src/utils/appearance.ts b/frontend/src/utils/appearance.ts index 02c739d..f140bf5 100644 --- a/frontend/src/utils/appearance.ts +++ b/frontend/src/utils/appearance.ts @@ -1,4 +1,4 @@ -const DEFAULT_OPACITY = 0.95; +const DEFAULT_OPACITY = 1.0; const MIN_OPACITY = 0.1; const MAX_OPACITY = 1.0; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index d4f1a76..4250e4e 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 3621a89..9bd8482 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 f7a10bd..334abb8 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 b4c4000..0cdac78 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 { diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index e766e0d..893e3aa 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "os" "os/exec" @@ -22,9 +23,10 @@ import ( ) const ( - updateRepo = "Syngnat/GoNavi" - updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest" - updateChecksumAsset = "SHA256SUMS" + updateRepo = "Syngnat/GoNavi" + updateAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/latest" + updateChecksumAsset = "SHA256SUMS" + updateDownloadProgressEvent = "update:download-progress" ) type updateState struct { @@ -54,11 +56,29 @@ type AppInfo struct { BuildTime string `json:"buildTime,omitempty"` } +type updateDownloadResult struct { + Info UpdateInfo `json:"info"` + DownloadPath string `json:"downloadPath,omitempty"` + InstallLogPath string `json:"installLogPath,omitempty"` + InstallTarget string `json:"installTarget,omitempty"` + Platform string `json:"platform"` + AutoRelaunch bool `json:"autoRelaunch"` +} + +type updateDownloadProgressPayload struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + Downloaded int64 `json:"downloaded"` + Total int64 `json:"total"` + Message string `json:"message,omitempty"` +} + type stagedUpdate struct { - Version string - AssetName string - FilePath string - StagedDir string + Version string + AssetName string + FilePath string + StagedDir string + InstallLogPath string } type githubRelease struct { @@ -124,13 +144,15 @@ func (a *App) DownloadUpdate() connection.QueryResult { a.updateMu.Unlock() return connection.QueryResult{Success: false, Message: "未找到可用的更新包"} } - if a.updateState.staged != nil && a.updateState.staged.Version == info.LatestVersion { + staged := a.updateState.staged + if staged != nil && staged.Version == info.LatestVersion { a.updateMu.Unlock() - return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: info} + return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)} } a.updateState.downloading = true a.updateMu.Unlock() + a.emitUpdateDownloadProgress("start", 0, info.AssetSize, "") result := a.downloadAndStageUpdate(*info) a.updateMu.Lock() @@ -143,6 +165,9 @@ func (a *App) DownloadUpdate() connection.QueryResult { func (a *App) InstallUpdateAndRestart() connection.QueryResult { a.updateMu.Lock() staged := a.updateState.staged + if staged != nil && strings.TrimSpace(staged.InstallLogPath) == "" { + staged.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + } a.updateMu.Unlock() if staged == nil { return connection.QueryResult{Success: false, Message: "未找到已下载的更新包"} @@ -150,7 +175,17 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { if err := launchUpdateScript(staged); err != nil { logger.Error(err, "启动更新脚本失败") - return connection.QueryResult{Success: false, Message: err.Error()} + msg := err.Error() + if staged.InstallLogPath != "" { + msg = fmt.Sprintf("%s(更新日志:%s)", msg, staged.InstallLogPath) + } + return connection.QueryResult{ + Success: false, + Message: msg, + Data: map[string]any{ + "logPath": staged.InstallLogPath, + }, + } } go func() { @@ -161,41 +196,79 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult { os.Exit(0) }() - return connection.QueryResult{Success: true, Message: "更新已开始安装"} + msg := "更新已开始安装" + if staged.InstallLogPath != "" { + msg = fmt.Sprintf("更新已开始安装,日志路径:%s", staged.InstallLogPath) + } + return connection.QueryResult{ + Success: true, + Message: msg, + Data: map[string]any{ + "logPath": staged.InstallLogPath, + }, + } } func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult { - stagedDir, err := os.MkdirTemp("", "gonavi-update-") - if err != nil { - return connection.QueryResult{Success: false, Message: "创建临时目录失败"} + workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir()) + if workspaceDir == "" { + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "无法确定当前应用目录") + return connection.QueryResult{Success: false, Message: "无法确定当前应用目录,无法下载更新"} + } + if err := os.MkdirAll(workspaceDir, 0o755); err != nil { + errMsg := fmt.Sprintf("无法访问应用目录:%s", workspaceDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg) + return connection.QueryResult{Success: false, Message: errMsg} } - assetPath := filepath.Join(stagedDir, info.AssetName) - actualHash, err := downloadFileWithHash(info.AssetURL, assetPath) + stagedDir, err := os.MkdirTemp(workspaceDir, ".gonavi-update-work-") if err != nil { + errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", workspaceDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg) + return connection.QueryResult{Success: false, Message: errMsg} + } + + assetPath := filepath.Join(workspaceDir, info.AssetName) + actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) { + reportTotal := total + if reportTotal <= 0 { + reportTotal = info.AssetSize + } + a.emitUpdateDownloadProgress("downloading", downloaded, reportTotal, "") + }) + if err != nil { + _ = os.Remove(assetPath) _ = os.RemoveAll(stagedDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, err.Error()) return connection.QueryResult{Success: false, Message: err.Error()} } if info.SHA256 == "" { + _ = os.Remove(assetPath) _ = os.RemoveAll(stagedDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "缺少更新包校验值(SHA256SUMS)") return connection.QueryResult{Success: false, Message: "缺少更新包校验值(SHA256SUMS)"} } if !strings.EqualFold(info.SHA256, actualHash) { + _ = os.Remove(assetPath) _ = os.RemoveAll(stagedDir) + a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "更新包校验失败,请重试") return connection.QueryResult{Success: false, Message: "更新包校验失败,请重试"} } - a.updateMu.Lock() - a.updateState.staged = &stagedUpdate{ - Version: info.LatestVersion, - AssetName: info.AssetName, - FilePath: assetPath, - StagedDir: stagedDir, + staged := &stagedUpdate{ + Version: info.LatestVersion, + AssetName: info.AssetName, + FilePath: assetPath, + StagedDir: stagedDir, + InstallLogPath: buildUpdateInstallLogPath(workspaceDir), } + a.updateMu.Lock() + a.updateState.staged = staged a.updateMu.Unlock() - return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: info} + a.emitUpdateDownloadProgress("done", info.AssetSize, info.AssetSize, "") + return connection.QueryResult{Success: true, Message: "更新包下载完成", Data: buildUpdateDownloadResult(info, staged)} } func fetchLatestUpdateInfo() (UpdateInfo, error) { @@ -370,7 +443,32 @@ func parseSHA256Sums(content string) map[string]string { return result } -func downloadFileWithHash(url, filePath string) (string, error) { +type downloadProgressWriter struct { + total int64 + written int64 + lastEmit time.Time + emitEvery time.Duration + onProgress func(downloaded, total int64) +} + +func (w *downloadProgressWriter) Write(p []byte) (int, error) { + n := len(p) + if n == 0 { + return 0, nil + } + w.written += int64(n) + if w.onProgress == nil { + return n, nil + } + now := time.Now() + if w.lastEmit.IsZero() || now.Sub(w.lastEmit) >= w.emitEvery || (w.total > 0 && w.written >= w.total) { + w.lastEmit = now + w.onProgress(w.written, w.total) + } + return n, nil +} + +func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) { client := &http.Client{Timeout: 10 * time.Minute} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -395,14 +493,99 @@ func downloadFileWithHash(url, filePath string) (string, error) { defer out.Close() hasher := sha256.New() - writer := io.MultiWriter(out, hasher) - if _, err := io.Copy(writer, resp.Body); err != nil { + total := resp.ContentLength + progressWriter := &downloadProgressWriter{ + total: total, + emitEvery: 120 * time.Millisecond, + onProgress: onProgress, + } + writers := []io.Writer{out, hasher, progressWriter} + if onProgress != nil { + onProgress(0, total) + } + if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil { return "", err } + if onProgress != nil { + onProgress(progressWriter.written, total) + } return hex.EncodeToString(hasher.Sum(nil)), nil } +func buildUpdateDownloadResult(info UpdateInfo, staged *stagedUpdate) updateDownloadResult { + result := updateDownloadResult{ + Info: info, + Platform: stdRuntime.GOOS, + InstallTarget: resolveUpdateInstallTarget(), + AutoRelaunch: true, + } + if staged != nil { + result.DownloadPath = staged.FilePath + result.InstallLogPath = staged.InstallLogPath + } + return result +} + +func buildUpdateInstallLogPath(baseDir string) string { + platform := stdRuntime.GOOS + if platform == "darwin" { + platform = "macos" + } + logDir := strings.TrimSpace(baseDir) + if logDir == "" { + logDir = os.TempDir() + } + return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano())) +} + +func resolveUpdateWorkspaceDir() string { + exePath, err := os.Executable() + if err != nil { + return "" + } + exePath, _ = filepath.EvalSymlinks(exePath) + if stdRuntime.GOOS == "darwin" { + appPath := detectMacAppPath(exePath) + if appPath != "" { + return filepath.Dir(appPath) + } + } + return filepath.Dir(exePath) +} + +func resolveUpdateInstallTarget() string { + exePath, err := os.Executable() + if err != nil { + return "" + } + exePath, _ = filepath.EvalSymlinks(exePath) + if stdRuntime.GOOS == "darwin" { + return resolveMacUpdateTarget(exePath) + } + return exePath +} + +func (a *App) emitUpdateDownloadProgress(status string, downloaded, total int64, message string) { + if a.ctx == nil { + return + } + payload := updateDownloadProgressPayload{ + Status: status, + Percent: 0, + Downloaded: downloaded, + Total: total, + Message: strings.TrimSpace(message), + } + if total > 0 { + payload.Percent = math.Min(100, (float64(downloaded)/float64(total))*100) + } + if status == "done" && payload.Percent < 100 { + payload.Percent = 100 + } + wailsRuntime.EventsEmit(a.ctx, updateDownloadProgressEvent, payload) +} + func launchUpdateScript(staged *stagedUpdate) error { exePath, err := os.Executable() if err != nil { @@ -425,7 +608,11 @@ func launchUpdateScript(staged *stagedUpdate) error { func launchWindowsUpdate(staged *stagedUpdate, targetExe string, pid int) error { scriptPath := filepath.Join(staged.StagedDir, "update.cmd") - logPath := filepath.Join(staged.StagedDir, "update.log") + logPath := strings.TrimSpace(staged.InstallLogPath) + if logPath == "" { + logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + staged.InstallLogPath = logPath + } content := buildWindowsScript(staged.FilePath, targetExe, staged.StagedDir, logPath, pid) if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil { return err @@ -442,7 +629,11 @@ func launchMacUpdate(staged *stagedUpdate, targetExe string, pid int) error { if err := os.MkdirAll(mountDir, 0o755); err != nil { return err } - logPath := filepath.Join(staged.StagedDir, "update.log") + logPath := strings.TrimSpace(staged.InstallLogPath) + if logPath == "" { + logPath = buildUpdateInstallLogPath(filepath.Dir(staged.FilePath)) + staged.InstallLogPath = logPath + } scriptPath := filepath.Join(staged.StagedDir, "update.sh") content := buildMacScript(staged.FilePath, targetApp, staged.StagedDir, mountDir, logPath, pid) @@ -509,8 +700,12 @@ exit /b 1 :move_done start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1 if %%ERRORLEVEL%% NEQ 0 ( - call :log relaunch failed - exit /b 1 + call :log cmd start failed, trying powershell Start-Process + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1 + if %%ERRORLEVEL%% NEQ 0 ( + call :log relaunch failed + exit /b 1 + ) ) rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1 call :log update finished @@ -531,30 +726,69 @@ TARGET_APP="%s" STAGED="%s" MOUNT_DIR="%s" LOG_FILE="%s" +TMP_APP="${TARGET_APP}.new" +BACKUP_APP="${TARGET_APP}.backup" +APP_BIN_NAME=$(basename "$TARGET_APP" .app) +APP_BIN_REL="Contents/MacOS/$APP_BIN_NAME" log() { echo "[$(date '+%%Y-%%m-%%d %%H:%%M:%%S')] $*" >> "$LOG_FILE" } -run_admin_install() { - /usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$LOG_FILE" +run_admin_replace() { + /usr/bin/osascript <<'APPLESCRIPT' "$APP_SRC" "$TARGET_APP" "$TMP_APP" "$BACKUP_APP" "$APP_BIN_REL" "$LOG_FILE" on run argv set srcPath to item 1 of argv set dstPath to item 2 of argv - set logPath to item 3 of argv - do shell script "rm -rf " & quoted form of dstPath & " && cp -R " & quoted form of srcPath & " " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1" with administrator privileges + set tmpPath to item 3 of argv + set bakPath to item 4 of argv + set binRel to item 5 of argv + set logPath to item 6 of argv + set cmd to "set -eu; " & ¬ + "rm -rf " & quoted form of tmpPath & " " & quoted form of bakPath & "; " & ¬ + "/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of tmpPath & "; " & ¬ + "if [ ! -x " & quoted form of (tmpPath & "/" & binRel) & " ]; then echo 'tmp app binary missing' >> " & quoted form of logPath & "; exit 1; fi; " & ¬ + "xattr -rd com.apple.quarantine " & quoted form of tmpPath & " >> " & quoted form of logPath & " 2>&1 || true; " & ¬ + "if [ -d " & quoted form of dstPath & " ]; then mv " & quoted form of dstPath & " " & quoted form of bakPath & "; fi; " & ¬ + "mv " & quoted form of tmpPath & " " & quoted form of dstPath & "; " & ¬ + "rm -rf " & quoted form of bakPath & "; " & ¬ + "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1 || true" + do shell script cmd with administrator privileges end run APPLESCRIPT } -run_admin_xattr() { - /usr/bin/osascript <<'APPLESCRIPT' "$TARGET_APP" "$LOG_FILE" -on run argv - set dstPath to item 1 of argv - set logPath to item 2 of argv - do shell script "xattr -rd com.apple.quarantine " & quoted form of dstPath & " >> " & quoted form of logPath & " 2>&1" with administrator privileges -end run -APPLESCRIPT +replace_app_direct() { + rm -rf "$TMP_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true + /usr/bin/ditto "$APP_SRC" "$TMP_APP" >>"$LOG_FILE" 2>&1 + if [ ! -x "$TMP_APP/$APP_BIN_REL" ]; then + log "tmp app binary missing: $TMP_APP/$APP_BIN_REL" + return 1 + fi + xattr -rd com.apple.quarantine "$TMP_APP" >>"$LOG_FILE" 2>&1 || true + if [ -d "$TARGET_APP" ]; then + mv "$TARGET_APP" "$BACKUP_APP" >>"$LOG_FILE" 2>&1 + fi + if ! mv "$TMP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then + log "move new app failed, trying rollback" + rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true + if [ -d "$BACKUP_APP" ]; then + mv "$BACKUP_APP" "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true + fi + return 1 + fi + rm -rf "$BACKUP_APP" >>"$LOG_FILE" 2>&1 || true + xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1 || true + return 0 +} + +relaunch_app() { + if /usr/bin/open -n "$TARGET_APP" >>"$LOG_FILE" 2>&1; then + return 0 + fi + log "open -n failed, trying binary launch" + "$TARGET_APP/$APP_BIN_REL" >>"$LOG_FILE" 2>&1 & + return 0 } log "updater started" @@ -571,21 +805,22 @@ if [ -z "$APP_SRC" ]; then fi log "install target: $TARGET_APP" -if ! rm -rf "$TARGET_APP" >>"$LOG_FILE" 2>&1 || ! cp -R "$APP_SRC" "$TARGET_APP" >>"$LOG_FILE" 2>&1; then - log "direct install failed, trying admin install" - run_admin_install >>"$LOG_FILE" 2>&1 +if ! replace_app_direct; then + log "direct replace failed, trying admin replace" + run_admin_replace >>"$LOG_FILE" 2>&1 fi -if ! xattr -rd com.apple.quarantine "$TARGET_APP" >>"$LOG_FILE" 2>&1; then - log "direct xattr failed, trying admin xattr" - run_admin_xattr >>"$LOG_FILE" 2>&1 || true +if [ ! -x "$TARGET_APP/$APP_BIN_REL" ]; then + log "target app binary missing after replace: $TARGET_APP/$APP_BIN_REL" + hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true + exit 1 fi hdiutil detach "$MOUNT_DIR" -quiet >>"$LOG_FILE" 2>&1 || true rm -rf "$MOUNT_DIR" "$DMG" "$STAGED" >>"$LOG_FILE" 2>&1 || true -open "$TARGET_APP" >>"$LOG_FILE" 2>&1 +relaunch_app log "relaunch requested" -`, pid, dmgPath, targetApp, stagedDir, mountDir, logPath) + `, pid, dmgPath, targetApp, stagedDir, mountDir, logPath) } func buildLinuxScript(tarPath, targetExe, stagedDir string, pid int) string {