From 6b67bb24b4ba1235db3ec52c9c50045bbf5b8878 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 18 Jun 2026 20:28:47 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(tool-center):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=B7=A5=E5=85=B7=E4=B8=AD=E5=BF=83=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E9=80=9A=E7=94=A8=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构工具中心为侧边分组导航,固定弹窗高度并支持内部滚动 - 新增通用可拖拽可缩放 Modal,统一主要弹窗打开体验 - 为工具中心内嵌入口补充返回上一步交互与底部操作区 - 补充多语言文案和工具中心/Modal/i18n 回归测试 --- frontend/src/App.tool-center.test.ts | 50 + frontend/src/App.tsx | 1005 ++++++++++++++--- frontend/src/components/AISettingsModal.tsx | 3 +- frontend/src/components/ConnectionModal.tsx | 2 +- .../ConnectionPackagePasswordModal.tsx | 29 +- frontend/src/components/DataExportDialog.tsx | 3 +- frontend/src/components/DataGrid.tsx | 3 +- frontend/src/components/DataGridModals.tsx | 3 +- frontend/src/components/DataSyncModal.tsx | 104 +- .../src/components/DriverManagerModal.tsx | 92 +- .../src/components/ExportProgressModal.tsx | 3 +- .../src/components/FindInDatabaseModal.tsx | 3 +- .../src/components/ImportPreviewModal.tsx | 3 +- .../src/components/MessagePublishModal.tsx | 3 +- frontend/src/components/QueryEditor.tsx | 5 +- frontend/src/components/RedisViewer.tsx | 3 +- .../components/SecurityUpdateIntroModal.tsx | 3 +- .../SecurityUpdateProgressModal.tsx | 3 +- .../SecurityUpdateSettingsModal.tsx | 17 +- frontend/src/components/Sidebar.tsx | 5 +- .../src/components/SnippetSettingsModal.tsx | 21 +- frontend/src/components/TabManager.tsx | 3 +- frontend/src/components/TableDesigner.tsx | 3 +- frontend/src/components/TableOverview.tsx | 3 +- .../components/ai/AIContextSelectorModal.tsx | 3 +- .../ai/messageBubble/AIMessageCodeBlock.tsx | 2 +- .../common/ResizableDraggableModal.css | 143 +++ .../common/ResizableDraggableModal.test.ts | 47 + .../common/ResizableDraggableModal.tsx | 426 +++++++ .../components/jvm/JVMChangePreviewModal.tsx | 3 +- frontend/src/i18n/catalog.test.ts | 72 +- shared/i18n/de-DE.json | 7 + shared/i18n/en-US.json | 7 + shared/i18n/ja-JP.json | 7 + shared/i18n/ru-RU.json | 7 + shared/i18n/zh-CN.json | 7 + shared/i18n/zh-TW.json | 7 + 37 files changed, 1805 insertions(+), 305 deletions(-) create mode 100644 frontend/src/components/common/ResizableDraggableModal.css create mode 100644 frontend/src/components/common/ResizableDraggableModal.test.ts create mode 100644 frontend/src/components/common/ResizableDraggableModal.tsx diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 78137f12..493cba03 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -42,6 +42,56 @@ describe('tool center menu entries', () => { expect(shortcutIndex).toBeGreaterThan(snippetIndex); }); + it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => { + expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';"); + expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState('config');"); + expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState(null);"); + expect(appSource).toContain("title: t('app.tools.group.config.title')"); + expect(appSource).toContain("title: t('app.tools.group.workflow.title')"); + expect(appSource).toContain("title: t('app.tools.group.workspace.title')"); + expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)"); + expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo(() => ({"); + expect(appSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'"); + expect(appSource).toContain("const toolCenterNavPanelStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterNavScrollStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterContentPanelStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterDetailPanelStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterDetailBodyStyle = useMemo(() => ({"); + expect(appSource).toContain('role="tablist" aria-orientation="vertical"'); + expect(appSource).toContain('role="tab"'); + expect(appSource).toContain('aria-selected={active}'); + expect(appSource).toContain('title={`${group.title} - ${group.description}`}'); + expect(appSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`"); + expect(appSource).toContain('setActiveToolCenterPane(null);'); + expect(appSource).toContain('group.items.length'); + expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {"); + expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState(null);"); + expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {"); + expect(appSource).toContain("t('common.back_to_previous')"); + expect(appSource).toContain("width={1080}"); + expect(appSource).toContain('centered'); + }); + + it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => { + expect(appSource).toContain('const toolCenterModalContentStyle = useMemo(() => ({'); + expect(appSource).toContain("height: 'min(820px, calc(100vh - 64px))'"); + expect(appSource).toContain("const toolCenterModalWorkspaceStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo(() => ({"); + expect(appSource).toContain("const toolCenterScrollableListStyle = useMemo(() => ({"); + expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }"); + expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}'); + expect(appSource).toContain('style={toolCenterModalSplitStyle}'); + expect(appSource).toContain('style={toolCenterNavPanelStyle}'); + expect(appSource).toContain('style={toolCenterNavScrollStyle}'); + expect(appSource).toContain('style={toolCenterContentPanelStyle}'); + expect(appSource).toContain('style={toolCenterDetailPanelStyle}'); + expect(appSource).toContain('style={toolCenterDetailBodyStyle}'); + expect(appSource).toContain('style={toolCenterScrollableListStyle}'); + expect(appSource).toContain("overflowY: 'auto'"); + expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'"); + expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`"); + }); + it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => { expect(appSource).toContain('onToggleAI={toggleAIPanel}'); expect(appSource).toContain('renderLegacyAIEdgeHandle'); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae040040..f5b9cc5f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd'; -import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined } from '@ant-design/icons'; +import Modal from './components/common/ResizableDraggableModal'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd'; +import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; @@ -186,6 +187,22 @@ const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnec }; type ConnectionPackageDialogMode = 'import' | 'export'; +type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace'; +type ToolCenterPaneKey = + | 'connection-package' + | 'data-root' + | 'security-update' + | 'schema-compare' + | 'data-compare' + | 'sync' + | 'drivers' + | 'snippet-settings' + | 'shortcut-settings'; + +type ToolCenterPaneState = { + key: ToolCenterPaneKey; + group: ToolCenterGroupKey; +}; type ConnectionPackageDialogState = { open: boolean; @@ -1279,13 +1296,83 @@ function App() { border: overlayTheme.sectionBorder, background: overlayTheme.sectionBg, }), [overlayTheme]); + const toolCenterModalContentStyle = useMemo(() => ({ + ...utilityModalShellStyle, + height: 'min(820px, calc(100vh - 64px))', + display: 'flex', + flexDirection: 'column', + }), [utilityModalShellStyle]); + const toolCenterModalWorkspaceStyle = useMemo(() => ({ + display: 'flex', + flexDirection: 'column', + padding: '10px 0 2px', + height: '100%', + minHeight: 0, + }), []); + const toolCenterModalSplitStyle = useMemo(() => ({ + display: 'grid', + gridTemplateColumns: '232px minmax(0, 1fr)', + gap: 18, + flex: 1, + minHeight: 0, + }), []); + const toolCenterNavPanelStyle = useMemo(() => ({ + padding: '4px 12px 4px 0', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + borderRight: `1px solid ${overlayTheme.divider}`, + }), [overlayTheme.divider]); + const toolCenterNavScrollStyle = useMemo(() => ({ + display: 'grid', + alignContent: 'start', + gap: 4, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + paddingRight: 8, + }), []); + const toolCenterContentPanelStyle = useMemo(() => ({ + display: 'flex', + flexDirection: 'column', + gap: 12, + minHeight: 0, + overflow: 'hidden', + }), []); + const toolCenterDetailPanelStyle = useMemo(() => ({ + ...utilityPanelStyle, + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }), [utilityPanelStyle]); + const toolCenterDetailBodyStyle = useMemo(() => ({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + paddingRight: 6, + overscrollBehavior: 'contain', + }), []); + const toolCenterScrollableListStyle = useMemo(() => ({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + overscrollBehavior: 'contain', + }), []); const utilityMutedTextStyle = useMemo(() => ({ color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, }), [overlayTheme]); - const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => ( -
+ const renderUtilityModalTitle = ( + icon: React.ReactNode, + title: string, + description: string, + ) => ( +
{icon}
@@ -1317,6 +1404,27 @@ function App() { fontWeight: 400, marginTop: 2, }), [overlayTheme]); + const toolCenterRowStyle = useMemo(() => ({ + width: '100%', + minHeight: 82, + borderRadius: 0, + color: overlayTheme.titleText, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + paddingInline: 8, + boxShadow: 'none', + fontSize: 15, + fontWeight: 600, + }), [overlayTheme]); + const toolCenterRowDescriptionStyle = useMemo(() => ({ + ...utilityActionHintStyle, + marginTop: 4, + textAlign: 'left' as const, + whiteSpace: 'normal' as const, + lineHeight: 1.55, + }), [utilityActionHintStyle]); const sidebarHorizontalPadding = isSidebarCompact ? 8 : 10; @@ -2110,6 +2218,8 @@ function App() { const closeConnectionPackageDialog = useCallback(() => { setConnectionPackageDialog(createClosedConnectionPackageDialogState()); setPendingConnectionImportPayload(null); + setToolCenterBackGroupKey(null); + setActiveToolCenterPane((current) => (current?.key === 'connection-package' ? null : current)); }, []); const refreshConnectionsAfterImport = useCallback(async (importedViews: SavedConnection[]) => { @@ -2164,7 +2274,8 @@ function App() { return importedViews as SavedConnection[]; }, [refreshConnectionsAfterImport, t]); - const handleImportConnections = async () => { + const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => { + setToolCenterBackGroupKey(sourceGroup ?? null); const res = await (window as any).go.app.App.ImportConfigFile(); if (!res.success) { if (res.message !== "已取消") { @@ -2191,6 +2302,10 @@ function App() { } } catch (e: any) { if (isConnectionPackagePasswordRequiredError(e)) { + if (sourceGroup) { + setToolCenterBackGroupKey(sourceGroup); + setActiveToolCenterPane({ key: 'connection-package', group: sourceGroup }); + } setPendingConnectionImportPayload(raw); setConnectionPackageDialog({ open: true, @@ -2207,12 +2322,16 @@ function App() { } }; - const handleExportConnections = async () => { + const handleExportConnections = async (sourceGroup?: ToolCenterGroupKey) => { if (connections.length === 0) { void message.warning(t('app.connection_package.message.no_connections_to_export')); return; } + setToolCenterBackGroupKey(sourceGroup ?? null); + if (sourceGroup) { + setActiveToolCenterPane({ key: 'connection-package', group: sourceGroup }); + } setConnectionPackageDialog({ open: true, mode: 'export', @@ -2317,6 +2436,9 @@ function App() { }; const [isToolsModalOpen, setIsToolsModalOpen] = useState(false); + const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState('config'); + const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState(null); + const [activeToolCenterPane, setActiveToolCenterPane] = useState(null); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); @@ -2436,30 +2558,46 @@ function App() { effectiveUiScale, }) ), [aiPanelVisible, darkMode, effectiveUiScale]); + const handleOpenToolsModal = useCallback((group: ToolCenterGroupKey = 'config') => { + setToolCenterBackGroupKey(null); + setActiveToolCenterPane(null); + setActiveToolCenterGroupKey(group); + setIsToolsModalOpen(true); + }, []); + const handleOpenSettingsModal = useCallback(() => { + setIsSettingsModalOpen(true); + }, []); + const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => { + setToolCenterBackGroupKey(group); + setActiveToolCenterGroupKey(group); + setActiveToolCenterPane({ key, group }); + }, []); + const handleReturnToToolCenter = useCallback((closeChild?: () => void) => { + const returnGroup = toolCenterBackGroupKey ?? 'config'; + closeChild?.(); + setToolCenterBackGroupKey(null); + setActiveToolCenterGroupKey(returnGroup); + setActiveToolCenterPane(null); + setIsToolsModalOpen(true); + }, [toolCenterBackGroupKey]); const sidebarUtilityItems = useMemo(() => { const itemMap = { tools: { key: 'tools', title: t('app.sidebar.tools'), icon: , - onClick: () => setIsToolsModalOpen(true), + onClick: () => handleOpenToolsModal(), }, settings: { key: 'settings', title: t('app.sidebar.settings'), icon: , - onClick: () => setIsSettingsModalOpen(true), + onClick: () => handleOpenSettingsModal(), }, } as const; return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]); - }, [t]); - const handleOpenToolsModal = useCallback(() => { - setIsToolsModalOpen(true); - }, []); - const handleOpenSettingsModal = useCallback(() => { - setIsSettingsModalOpen(true); - }, []); + }, [handleOpenSettingsModal, handleOpenToolsModal, t]); const handleFocusSidebarSearch = useCallback(() => { window.dispatchEvent(new CustomEvent('gonavi:focus-sidebar-search')); }, []); @@ -2496,11 +2634,11 @@ function App() { }, [t]); useEffect(() => { - if (!isDataRootModalOpen) { + if (!isDataRootModalOpen && activeToolCenterPane?.key !== 'data-root') { return; } void loadDataRootInfo(); - }, [isDataRootModalOpen, loadDataRootInfo]); + }, [activeToolCenterPane?.key, isDataRootModalOpen, loadDataRootInfo]); const handleSelectDataRoot = useCallback(async () => { try { @@ -2753,12 +2891,14 @@ function App() { const handleOpenDriverManagerFromConnection = () => { setIsModalOpen(false); setEditingConnection(null); + setToolCenterBackGroupKey(null); setIsDriverModalOpen(true); }; const handleCloseDriverManager = useCallback(() => { const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource); setIsDriverModalOpen(false); + setToolCenterBackGroupKey(null); setSecurityUpdateRepairSource(null); if (reopenSecurityUpdateDetails) { setIsSecurityUpdateSettingsOpen(true); @@ -3809,136 +3949,654 @@ function App() { onSaved={handleConnectionSaved} /> )} - {isToolsModalOpen && ( - , t('app.tools.title'), t('app.tools.description'))} - open={isToolsModalOpen} - onCancel={() => setIsToolsModalOpen(false)} - footer={null} - width={560} - styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} - > -
- {[ - { - key: 'import', - icon: , - title: t('app.tools.entry.import.title'), - description: t('app.tools.entry.import.description'), - onClick: () => { - setIsToolsModalOpen(false); - void handleImportConnections(); + {isToolsModalOpen && (() => { + const toolCenterGroups = [ + { + key: 'config', + icon: , + title: t('app.tools.group.config.title'), + description: t('app.tools.group.config.description'), + items: [ + { + key: 'import', + icon: , + title: t('app.tools.entry.import.title'), + description: t('app.tools.entry.import.description'), + onClick: () => { + void handleImportConnections('config'); + }, }, - }, - { - key: 'export', - icon: , - title: t('app.tools.entry.export.title'), - description: t('app.tools.entry.export.description'), - onClick: () => { - setIsToolsModalOpen(false); - void handleExportConnections(); + { + key: 'export', + icon: , + title: t('app.tools.entry.export.title'), + description: t('app.tools.entry.export.description'), + onClick: () => { + void handleExportConnections('config'); + }, }, - }, - { - key: 'schema-compare', - icon: , - title: t('app.tools.entry.schema_compare.title'), - description: t('app.tools.entry.schema_compare.description'), - onClick: () => { - setIsToolsModalOpen(false); - setSyncModalEntryMode('schemaCompare'); - setIsSyncModalOpen(true); + { + key: 'data-root', + icon: , + title: t('app.tools.entry.data_root.title'), + description: t('app.tools.entry.data_root.description'), + onClick: () => { + handleOpenToolCenterPane('config', 'data-root'); + }, }, - }, - { - key: 'data-compare', - icon: , - title: t('app.tools.entry.data_compare.title'), - description: t('app.tools.entry.data_compare.description'), - onClick: () => { - setIsToolsModalOpen(false); - setSyncModalEntryMode('dataCompare'); - setIsSyncModalOpen(true); + { + key: 'security-update', + icon: , + title: t('app.tools.entry.security_update.title'), + description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems + ? t('app.tools.entry.security_update.status_description', { status: securityUpdateStatusMeta.label }) + : t('app.tools.entry.security_update.description'), + onClick: () => { + handleOpenToolCenterPane('config', 'security-update'); + }, }, - }, - { - key: 'sync', - icon: , - title: t('app.tools.entry.sync.title'), - description: t('app.tools.entry.sync.description'), - onClick: () => { - setIsToolsModalOpen(false); - setSyncModalEntryMode('sync'); - setIsSyncModalOpen(true); + ], + }, + { + key: 'workflow', + icon: , + title: t('app.tools.group.workflow.title'), + description: t('app.tools.group.workflow.description'), + items: [ + { + key: 'schema-compare', + icon: , + title: t('app.tools.entry.schema_compare.title'), + description: t('app.tools.entry.schema_compare.description'), + onClick: () => { + setSyncModalEntryMode('schemaCompare'); + handleOpenToolCenterPane('workflow', 'schema-compare'); + }, }, - }, - { - key: 'drivers', - icon: , - title: t('app.tools.entry.drivers.title'), - description: t('app.tools.entry.drivers.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsDriverModalOpen(true); + { + key: 'data-compare', + icon: , + title: t('app.tools.entry.data_compare.title'), + description: t('app.tools.entry.data_compare.description'), + onClick: () => { + setSyncModalEntryMode('dataCompare'); + handleOpenToolCenterPane('workflow', 'data-compare'); + }, }, - }, - { - key: 'data-root', - icon: , - title: t('app.tools.entry.data_root.title'), - description: t('app.tools.entry.data_root.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsDataRootModalOpen(true); + { + key: 'sync', + icon: , + title: t('app.tools.entry.sync.title'), + description: t('app.tools.entry.sync.description'), + onClick: () => { + setSyncModalEntryMode('sync'); + handleOpenToolCenterPane('workflow', 'sync'); + }, }, - }, - { - key: 'snippet-settings', - icon: , - title: t('app.tools.entry.snippets.title'), - description: t('app.tools.entry.snippets.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsSnippetModalOpen(true); + ], + }, + { + key: 'workspace', + icon: , + title: t('app.tools.group.workspace.title'), + description: t('app.tools.group.workspace.description'), + items: [ + { + key: 'drivers', + icon: , + title: t('app.tools.entry.drivers.title'), + description: t('app.tools.entry.drivers.description'), + onClick: () => { + handleOpenToolCenterPane('workspace', 'drivers'); + }, }, - }, - { - key: 'shortcut-settings', - icon: , - title: t('app.tools.entry.shortcuts.title'), - description: t('app.tools.entry.shortcuts.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsShortcutModalOpen(true); + { + key: 'snippet-settings', + icon: , + title: t('app.tools.entry.snippets.title'), + description: t('app.tools.entry.snippets.description'), + onClick: () => { + handleOpenToolCenterPane('workspace', 'snippet-settings'); + }, }, - }, - { - key: 'security-update', - icon: , - title: t('app.tools.entry.security_update.title'), - description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems - ? t('app.tools.entry.security_update.status_description', { status: securityUpdateStatusMeta.label }) - : t('app.tools.entry.security_update.description'), - onClick: () => { - setIsToolsModalOpen(false); - setIsSecurityUpdateSettingsOpen(true); + { + key: 'shortcut-settings', + icon: , + title: t('app.tools.entry.shortcuts.title'), + description: t('app.tools.entry.shortcuts.description'), + onClick: () => { + handleOpenToolCenterPane('workspace', 'shortcut-settings'); + }, }, - }, - ].map((item) => ( - - ))} -
-
- )} + ], + }, + ] as const; + const activeToolCenterGroup = toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey) ?? toolCenterGroups[0]; + const activeToolCenterPaneItem = activeToolCenterPane + ? toolCenterGroups + .find((group) => group.key === activeToolCenterPane.group) + ?.items.find((item) => item.key === activeToolCenterPane.key) + : null; + const closeToolCenterPane = () => { + if (activeToolCenterPane?.key === 'connection-package') { + closeConnectionPackageDialog(); + return; + } + setToolCenterBackGroupKey(null); + setActiveToolCenterPane(null); + }; + const renderToolCenterPane = () => { + if (!activeToolCenterPane) { + return null; + } + + if (activeToolCenterPane.key === 'connection-package') { + return ( + { + setConnectionPackageDialog((current) => ({ + ...current, + includeSecrets: value, + useFilePassword: value ? current.useFilePassword : false, + password: value ? current.password : '', + error: '', + })); + }} + onUseFilePasswordChange={(value) => { + setConnectionPackageDialog((current) => ({ + ...current, + useFilePassword: value, + password: value ? current.password : '', + error: '', + })); + }} + onPasswordChange={(value) => { + setConnectionPackageDialog((current) => ({ + ...current, + password: value, + error: '', + })); + }} + onConfirm={() => { + void handleConfirmConnectionPackageDialog(); + }} + onCancel={closeConnectionPackageDialog} + /> + ); + } + + if (activeToolCenterPane.key === 'data-root') { + return ( + , + t('app.data_root.title'), + t('app.data_root.description'), + )} + onCancel={closeToolCenterPane} + footer={[ + , + , + ]} + styles={{ + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, + }} + > + {dataRootLoading ? ( +
+ +
+ ) : ( +
+
+
{t('app.data_root.current_directory')}
+
+ +
+
+
{t('app.data_root.default_directory')}
+
{dataRootInfo?.defaultPath || '-'}
+
+
+
{t('app.data_root.driver_directory')}
+
{dataRootInfo?.driverPath || '-'}
+
+
+
+
+
+
{t('app.data_root.switch_target')}
+
+ +
+ + + +
+
+
+
+
{t('app.data_root.apply_method')}
+
+ + +
+
+ {t('app.data_root.restart_hint')} +
+
+
+ )} +
+ ); + } + + if (activeToolCenterPane.key === 'security-update') { + return ( + + ); + } + + if ( + activeToolCenterPane.key === 'schema-compare' + || activeToolCenterPane.key === 'data-compare' + || activeToolCenterPane.key === 'sync' + ) { + return ( + + ); + } + + if (activeToolCenterPane.key === 'drivers') { + return ( + + ); + } + + if (activeToolCenterPane.key === 'snippet-settings') { + return ( + + ); + } + + if (activeToolCenterPane.key === 'shortcut-settings') { + return ( + , + t('app.shortcuts.title'), + t('app.shortcuts.description'), + )} + onCancel={() => { + setCapturingShortcutAction(null); + closeToolCenterPane(); + }} + footer={[ + , + , + , + ]} + styles={{ + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, + }} + > +
+
+
+ {t('app.shortcuts.capture_hint')} +
+
+ {SHORTCUT_ACTION_ORDER.map((action) => { + const meta = SHORTCUT_ACTION_META[action]; + if (meta.platformOnly === 'mac' && !isMacRuntime) { + return null; + } + const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform); + const isCapturing = capturingShortcutAction === action; + const conflicts = shortcutConflictMap[action]; + const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null; + return ( +
+
+
{meta.label}
+
{meta.description}
+ {conflictInfo && ( +
+ {conflictInfo.hasMonaco && ( + <>⚠ {t('app.shortcuts.message.reserved_conflict_info', { labels: conflictInfo.monacoLabels })} + )} + {conflictInfo.hasOther && ( + <>⚠ {t('app.shortcuts.message.reserved_conflict_warning', { contexts: conflictInfo.otherContexts, labels: conflictInfo.otherLabels })} + )} +
+ )} +
+
+ + + updateShortcut(action, { enabled: checked }, activeShortcutPlatform)} + /> +
+
+ ); + })} +
+
+ ); + } + + return null; + }; + + return ( + , t('app.tools.title'), t('app.tools.description'))} + open={isToolsModalOpen} + onCancel={() => { + if (activeToolCenterPane?.key === 'connection-package') { + closeConnectionPackageDialog(); + } + setActiveToolCenterPane(null); + setToolCenterBackGroupKey(null); + setIsToolsModalOpen(false); + }} + footer={null} + centered + width={1080} + styles={{ + content: toolCenterModalContentStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }, + }} + > +
+
+
+
+ {toolCenterGroups.map((group) => { + const active = group.key === activeToolCenterGroup.key; + return ( + + ); + })} +
+
+
+ {activeToolCenterPane ? ( +
+
+
+
+ {activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title} +
+
+ {activeToolCenterPaneItem?.description ?? activeToolCenterGroup.description} +
+
+ +
+
+ {renderToolCenterPane()} +
+
+ ) : ( + <> +
+
{activeToolCenterGroup.title}
+
{activeToolCenterGroup.description}
+
+
+ {activeToolCenterGroup.items.map((item, index) => ( + + ))} +
+ + )} +
+
+
+
+ ); + })()} {isSettingsModalOpen && ( , t('app.settings.title'), t('app.settings.description'))} @@ -4030,10 +4688,35 @@ function App() { )} {isDataRootModalOpen && ( , t('app.data_root.title'), t('app.data_root.description'))} + title={renderUtilityModalTitle( + , + t('app.data_root.title'), + t('app.data_root.description'), + )} open={isDataRootModalOpen} - onCancel={() => setIsDataRootModalOpen(false)} - footer={null} + onCancel={() => { + setIsDataRootModalOpen(false); + setToolCenterBackGroupKey(null); + }} + footer={[ + , + toolCenterBackGroupKey === 'config' ? ( + + ) : null, + ]} width={720} styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} > @@ -4101,7 +4784,11 @@ function App() { {isSyncModalOpen && ( setIsSyncModalOpen(false)} + onClose={() => { + setIsSyncModalOpen(false); + setToolCenterBackGroupKey(null); + }} + onBack={toolCenterBackGroupKey === 'workflow' ? () => handleReturnToToolCenter(() => setIsSyncModalOpen(false)) : undefined} entryMode={syncModalEntryMode} /> )} @@ -4109,6 +4796,7 @@ function App() { handleReturnToToolCenter(() => setIsDriverModalOpen(false)) : undefined} onOpenGlobalProxySettings={handleOpenGlobalProxySettings} /> )} @@ -4130,7 +4818,11 @@ function App() { status={securityUpdateStatus} focusTarget={securityUpdateSettingsFocusTarget} focusRequest={securityUpdateSettingsFocusRequest} - onClose={() => setIsSecurityUpdateSettingsOpen(false)} + onClose={() => { + setIsSecurityUpdateSettingsOpen(false); + setToolCenterBackGroupKey(null); + }} + onBack={toolCenterBackGroupKey === 'config' ? () => handleReturnToToolCenter(() => setIsSecurityUpdateSettingsOpen(false)) : undefined} onStart={handleStartSecurityUpdate} onRetry={handleRetrySecurityUpdate} onRestart={handleRestartSecurityUpdate} @@ -4152,7 +4844,7 @@ function App() { /> )} handleReturnToToolCenter(closeConnectionPackageDialog) : undefined} onIncludeSecretsChange={(value) => { setConnectionPackageDialog((current) => ({ ...current, @@ -4975,11 +5668,16 @@ function App() { {isShortcutModalOpen && ( , t('app.shortcuts.title'), t('app.shortcuts.description'))} + title={renderUtilityModalTitle( + , + t('app.shortcuts.title'), + t('app.shortcuts.description'), + )} open={isShortcutModalOpen} onCancel={() => { setIsShortcutModalOpen(false); setCapturingShortcutAction(null); + setToolCenterBackGroupKey(null); }} width={760} centered @@ -5016,6 +5714,17 @@ function App() { > {t('common.close')} , + toolCenterBackGroupKey === 'workspace' ? ( + + ) : null, ]} >
@@ -5085,7 +5794,11 @@ function App() { {isSnippetModalOpen && ( setIsSnippetModalOpen(false)} + onClose={() => { + setIsSnippetModalOpen(false); + setToolCenterBackGroupKey(null); + }} + onBack={toolCenterBackGroupKey === 'workspace' ? () => handleReturnToToolCenter(() => setIsSnippetModalOpen(false)) : undefined} darkMode={darkMode} overlayTheme={overlayTheme} /> diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 7275f1e2..34fb0600 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Modal, Form, message as antdMessage } from 'antd'; +import { Form, message as antdMessage } from 'antd'; import { RobotOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types'; import { diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index ce38764b..b03e642c 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; +import Modal from './common/ResizableDraggableModal'; import { - Modal, Form, Input, InputNumber, diff --git a/frontend/src/components/ConnectionPackagePasswordModal.tsx b/frontend/src/components/ConnectionPackagePasswordModal.tsx index 9f0cf960..6878f20b 100644 --- a/frontend/src/components/ConnectionPackagePasswordModal.tsx +++ b/frontend/src/components/ConnectionPackagePasswordModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React from 'react'; -import { Checkbox, Input, Modal, Typography } from 'antd'; +import { Button, Checkbox, Input, Typography } from 'antd'; import { useI18n } from '../i18n/provider'; const { Text } = Typography; @@ -17,6 +18,8 @@ export interface ConnectionPackagePasswordModalProps { confirmLoading?: boolean; confirmText?: string; cancelText?: string; + onBack?: () => void; + embedded?: boolean; onIncludeSecretsChange?: (value: boolean) => void; onUseFilePasswordChange?: (value: boolean) => void; onPasswordChange: (value: string) => void; @@ -35,6 +38,8 @@ export default function ConnectionPackagePasswordModal({ confirmLoading, confirmText, cancelText, + onBack, + embedded = false, onIncludeSecretsChange, onUseFilePasswordChange, onPasswordChange, @@ -58,14 +63,26 @@ export default function ConnectionPackagePasswordModal({ return ( {title} + )} onCancel={onCancel} destroyOnHidden={false} maskClosable={false} + footer={[ + , + , + onBack ? ( + + ) : null, + ]} > {isExportMode ? (
diff --git a/frontend/src/components/DataExportDialog.tsx b/frontend/src/components/DataExportDialog.tsx index 6743172a..1e5995f4 100644 --- a/frontend/src/components/DataExportDialog.tsx +++ b/frontend/src/components/DataExportDialog.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useMemo, useState } from 'react'; -import { Form, InputNumber, Modal, Select, message } from 'antd'; +import { Form, InputNumber, Select, message } from 'antd'; import { ExportOutlined } from '@ant-design/icons'; export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html'; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 4ccd7401..d038b7c1 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1,7 +1,8 @@ +import Modal from './common/ResizableDraggableModal'; // cspell:ignore anticon sqls uuidv uuidv4 hscroll import React, { useState, useEffect, useRef, useContext, useMemo, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; +import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd'; import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; import type { Reference as TableReference } from 'rc-table'; diff --git a/frontend/src/components/DataGridModals.tsx b/frontend/src/components/DataGridModals.tsx index 5ce20ff4..1dfb5f1a 100644 --- a/frontend/src/components/DataGridModals.tsx +++ b/frontend/src/components/DataGridModals.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React from 'react'; -import { Button, Checkbox, DatePicker, Form, Input, Modal, TimePicker } from 'antd'; +import { Button, Checkbox, DatePicker, Form, Input, TimePicker } from 'antd'; import dayjs from 'dayjs'; import { CopyOutlined } from '@ant-design/icons'; import Editor from './MonacoEditor'; diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index ec4f82b0..89855ebe 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { Modal, Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd'; +import { Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd'; import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App'; @@ -191,7 +192,7 @@ const buildSqlPreview = ( }; }; -const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: DataSyncEntryMode }> = ({ open, onClose, entryMode = 'sync' }) => { +const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; onBack?: () => void; entryMode?: DataSyncEntryMode; embedded?: boolean }> = ({ open, onClose, onBack, entryMode = 'sync', embedded = false }) => { const i18n = useOptionalI18n(); const i18nLanguage = i18n?.language; const tr = (key: string, params?: Parameters[1]) => t(key, params, i18nLanguage); @@ -829,7 +830,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: }), [darkMode]); const renderModalTitle = (title: string, description: string) => ( -
+
void; entryMode?:
); - return ( - <> - { - if (syncing) { - message.warning(tr('data_sync.message.close_blocked_running')); - return; - } - onClose(); - }} - width={920} - footer={null} - destroyOnHidden - closable={!syncing} - maskClosable={!syncing} - styles={{ - content: modalPanelStyle, - header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, - body: { - paddingTop: 8, - height: 760, - maxHeight: 'calc(100vh - 120px)', - overflow: 'hidden', - display: 'flex', - flexDirection: 'column', - }, - footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 }, - }} - > + const handleReturnToPrevious = () => { + if (syncing) { + message.warning(tr('data_sync.message.close_blocked_running')); + return; + } + onBack?.(); + }; + + const dataSyncContent = (
@@ -1403,9 +1375,57 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: )} + {onBack ? ( + + ) : null}
- + ); + + return ( + <> + {embedded ? dataSyncContent : ( + { + if (syncing) { + message.warning(tr('data_sync.message.close_blocked_running')); + return; + } + onClose(); + }} + width={920} + footer={null} + destroyOnHidden + closable={!syncing} + maskClosable={!syncing} + styles={{ + content: modalPanelStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, + body: { + paddingTop: 8, + height: 760, + maxHeight: 'calc(100vh - 120px)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 }, + }} + > + {dataSyncContent} + + )} { return grouped; }; -const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenGlobalProxySettings?: () => void }> = ({ +const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onBack?: () => void; onOpenGlobalProxySettings?: () => void; embedded?: boolean }> = ({ open, onClose, + onBack, onOpenGlobalProxySettings, + embedded = false, }) => { const theme = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); @@ -1818,32 +1821,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const logBlockBorderColor = darkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.12)'; const logBlockTextColor = darkMode ? 'rgba(255, 255, 255, 0.88)' : 'rgba(0, 0, 0, 0.88)'; - return ( - - - - - - )} - > + const driverManagerContent = ( + <>
@@ -2072,6 +2051,24 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
+ {embedded ? ( + + + + + {onBack ? ( + + ) : null} + + ) : null} void; onOpenG )} + + ); + + return embedded ? driverManagerContent : ( + + {t('driver.modal.title')} +
+ )} + open={open} + onCancel={onClose} + width={1120} + style={{ top: 24 }} + className="driver-manager-modal" + styles={{ + body: modalBodyStyle, + }} + destroyOnHidden + footer={( + + + + + {onBack ? ( + + ) : null} + + )} + > + {driverManagerContent}
); }; diff --git a/frontend/src/components/ExportProgressModal.tsx b/frontend/src/components/ExportProgressModal.tsx index 194bd3ac..c6e1f6c4 100644 --- a/frontend/src/components/ExportProgressModal.tsx +++ b/frontend/src/components/ExportProgressModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React from 'react'; -import { Button, Modal, Typography } from 'antd'; +import { Button, Typography } from 'antd'; import { formatExportProgressRows, } from '../utils/exportProgress'; diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx index d3e19e2e..f8fe330d 100644 --- a/frontend/src/components/FindInDatabaseModal.tsx +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useRef, useCallback, useMemo } from 'react'; -import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd'; +import { Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd'; import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons'; import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App'; import { quoteIdentPart, escapeLiteral } from '../utils/sql'; diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx index 340bfec2..cacd5fcf 100644 --- a/frontend/src/components/ImportPreviewModal.tsx +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect } from "react"; -import { Modal, Table, Alert, Progress, Button, Space } from "antd"; +import { Table, Alert, Progress, Button, Space } from 'antd'; import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"; import { PreviewImportFile, diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx index a784217d..dc324ab0 100644 --- a/frontend/src/components/MessagePublishModal.tsx +++ b/frontend/src/components/MessagePublishModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, Checkbox, Form, Input, Modal, Select, Space, Typography, message } from 'antd'; +import { Alert, Checkbox, Form, Input, Select, Space, Typography, message } from 'antd'; import { DBQuery } from '../../wailsjs/go/app/App'; import type { SavedConnection } from '../types'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 76bf89bc..f507ba22 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import Modal from './common/ResizableDraggableModal'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; -import { message, Modal, Input, Form, MenuProps } from 'antd'; +import { message, Input, Form, MenuProps } from 'antd'; import { format, type SqlLanguage } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition, IndexDefinition } from '../types'; diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 6f823f84..24e42c02 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1,6 +1,7 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; +import { Table, Input, Button, Space, Tag, Tree, Spin, message, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; import type { RadioChangeEvent } from 'antd'; import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; import { useStore } from '../store'; diff --git a/frontend/src/components/SecurityUpdateIntroModal.tsx b/frontend/src/components/SecurityUpdateIntroModal.tsx index be1022ac..a3260422 100644 --- a/frontend/src/components/SecurityUpdateIntroModal.tsx +++ b/frontend/src/components/SecurityUpdateIntroModal.tsx @@ -1,4 +1,5 @@ -import { Button, Modal } from 'antd'; +import Modal from './common/ResizableDraggableModal'; +import { Button } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { CSSProperties } from 'react'; diff --git a/frontend/src/components/SecurityUpdateProgressModal.tsx b/frontend/src/components/SecurityUpdateProgressModal.tsx index 1d14068a..8230fb44 100644 --- a/frontend/src/components/SecurityUpdateProgressModal.tsx +++ b/frontend/src/components/SecurityUpdateProgressModal.tsx @@ -1,4 +1,5 @@ -import { Modal, Spin } from 'antd'; +import Modal from './common/ResizableDraggableModal'; +import { Spin } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; diff --git a/frontend/src/components/SecurityUpdateSettingsModal.tsx b/frontend/src/components/SecurityUpdateSettingsModal.tsx index 0f5f1849..5ca4b270 100644 --- a/frontend/src/components/SecurityUpdateSettingsModal.tsx +++ b/frontend/src/components/SecurityUpdateSettingsModal.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import { useEffect, useRef, useState } from 'react'; -import { Button, Empty, Modal, Tag } from 'antd'; +import { Button, Empty, Tag } from 'antd'; import { SafetyCertificateOutlined } from '@ant-design/icons'; import type { SecurityUpdateIssue, SecurityUpdateStatus } from '../types'; @@ -37,6 +38,8 @@ interface SecurityUpdateSettingsModalProps { focusTarget?: SecurityUpdateSettingsFocusTarget | null; focusRequest?: number; onClose: () => void; + onBack?: () => void; + embedded?: boolean; onStart: () => void; onRetry: () => void; onRestart: () => void; @@ -70,6 +73,8 @@ const SecurityUpdateSettingsModal = ({ focusTarget = null, focusRequest = 0, onClose, + onBack, + embedded = false, onStart, onRetry, onRestart, @@ -126,7 +131,7 @@ const SecurityUpdateSettingsModal = ({ +
-
+
{t('security_update.settings.title')}
@@ -153,6 +158,7 @@ const SecurityUpdateSettingsModal = ({
)} open={open} + embedded={embedded} onCancel={onClose} footer={[ showRetry ? ( @@ -179,6 +185,11 @@ const SecurityUpdateSettingsModal = ({ , + onBack ? ( + + ) : null, ]} width={760} styles={{ diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9d355bdf..9e61ea34 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; +import Modal from './common/ResizableDraggableModal'; +import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; +import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; import { DatabaseOutlined, TableOutlined, diff --git a/frontend/src/components/SnippetSettingsModal.tsx b/frontend/src/components/SnippetSettingsModal.tsx index 41b3ba3d..53f3b856 100644 --- a/frontend/src/components/SnippetSettingsModal.tsx +++ b/frontend/src/components/SnippetSettingsModal.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useCallback } from 'react'; -import { Modal, Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd'; +import Modal from './common/ResizableDraggableModal'; +import { Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd'; import { PlusOutlined, DeleteOutlined, @@ -12,11 +13,14 @@ import type { SqlSnippet } from '../types'; import { useStore } from '../store'; import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import { useI18n } from '../i18n/provider'; interface SnippetSettingsModalProps { open: boolean; onClose: () => void; + onBack?: () => void; darkMode: boolean; overlayTheme: OverlayWorkbenchTheme; + embedded?: boolean; } type DraftSnippet = Omit & { createdAt?: number }; @@ -34,9 +38,12 @@ const emptyDraft = (): DraftSnippet => ({ export default function SnippetSettingsModal({ open, onClose, + onBack, darkMode, overlayTheme, + embedded = false, }: SnippetSettingsModalProps) { + const { t } = useI18n(); const sqlSnippets = useStore((s) => s.sqlSnippets); const saveSqlSnippet = useStore((s) => s.saveSqlSnippet); const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet); @@ -210,7 +217,7 @@ export default function SnippetSettingsModal({ return ( +
-
+
代码片段管理
管理 SQL 代码片段,输入前缀后按 Tab 展开
-
+
} open={open} + embedded={embedded} onCancel={onClose} width={820} styles={{ @@ -451,6 +459,11 @@ export default function SnippetSettingsModal({ + {onBack ? ( + + ) : null}
); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index b89dac36..eb8ae3e8 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { Button, Dropdown, message, Modal, Tabs, Tooltip } from 'antd'; +import { Button, Dropdown, message, Tabs, Tooltip } from 'antd'; import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons'; import type { MenuProps, TabsProps } from 'antd'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 644b2504..dcbe05de 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1,5 +1,6 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react'; -import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd'; +import { Table, Tabs, Button, message, Input, Checkbox, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd'; import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined, TableOutlined } from '@ant-design/icons'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index d6aa631b..f2f9a500 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -1,6 +1,7 @@ +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd'; +import { Input, Spin, Empty, Dropdown, message, Tooltip, Button } from 'antd'; import type { MenuProps } from 'antd'; import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons'; import { buildSidebarTablePinKey, useStore } from '../store'; diff --git a/frontend/src/components/ai/AIContextSelectorModal.tsx b/frontend/src/components/ai/AIContextSelectorModal.tsx index 79f4018c..0cfccbae 100644 --- a/frontend/src/components/ai/AIContextSelectorModal.tsx +++ b/frontend/src/components/ai/AIContextSelectorModal.tsx @@ -1,5 +1,6 @@ +import Modal from '../common/ResizableDraggableModal'; import React from 'react'; -import { Button, Checkbox, Input, Modal, Select, Spin } from 'antd'; +import { Button, Checkbox, Input, Select, Spin } from 'antd'; import { SearchOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; diff --git a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx index 19e475ee..828adb58 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx @@ -5,6 +5,7 @@ import mermaid from 'mermaid'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import Modal from '../../common/ResizableDraggableModal'; import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; import { buildAIReadonlyPreviewSQL } from '../../../utils/aiSqlLimit'; @@ -109,7 +110,6 @@ const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: st return; } if (result.requiresConfirm) { - const { Modal } = await import('antd'); Modal.confirm({ title: '⚠️ 安全确认', content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, diff --git a/frontend/src/components/common/ResizableDraggableModal.css b/frontend/src/components/common/ResizableDraggableModal.css new file mode 100644 index 00000000..f3bab5cf --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.css @@ -0,0 +1,143 @@ +.gn-resizable-draggable-modal { + position: relative; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + transition: transform 0.12s ease; +} + +.gn-resizable-draggable-modal[data-dragging='true'], +.gn-resizable-draggable-modal[data-resizing='true'] { + transition: none; + user-select: none; +} + +.gn-resizable-draggable-modal[data-draggable='true'] .ant-modal-header, +.gn-resizable-draggable-modal[data-draggable='true'] .ant-modal-title { + cursor: move; +} + +.gn-resizable-draggable-modal[data-has-resized-width='true'] { + width: var(--gn-modal-resized-width); +} + +.gn-resizable-draggable-modal .ant-modal-content { + width: 100%; + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.gn-resizable-draggable-modal[data-has-resized-height='true'] .ant-modal-content { + height: var(--gn-modal-resized-height); +} + +.gn-resizable-draggable-modal .ant-modal-body { + min-height: 0; +} + +.gn-resizable-draggable-modal[data-draggable='true'] .ant-modal-confirm-title { + cursor: move; +} + +.gn-modal-resize-handle { + position: absolute; + z-index: 20; + opacity: 0; + pointer-events: auto; + transition: opacity 0.15s ease; +} + +.gn-resizable-draggable-modal:hover .gn-modal-resize-handle, +.gn-resizable-draggable-modal[data-resizing='true'] .gn-modal-resize-handle { + opacity: 1; +} + +.gn-modal-resize-handle-east { + top: 18px; + right: -4px; + width: 8px; + height: calc(100% - 36px); + cursor: ew-resize; +} + +.gn-modal-resize-handle-south { + left: 18px; + bottom: -4px; + width: calc(100% - 36px); + height: 8px; + cursor: ns-resize; +} + +.gn-modal-resize-handle-south-east { + right: -5px; + bottom: -5px; + width: 16px; + height: 16px; + cursor: nwse-resize; +} + +.gn-modal-resize-handle-south-east::after { + content: ''; + position: absolute; + right: 5px; + bottom: 5px; + width: 9px; + height: 9px; + border-right: 2px solid rgba(100, 116, 139, 0.55); + border-bottom: 2px solid rgba(100, 116, 139, 0.55); +} + +.gn-embedded-modal { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + background: transparent; +} + +.gn-embedded-modal-header { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 10px; +} + +.gn-embedded-modal-title { + min-width: 0; + flex: 1 1 auto; +} + +.gn-embedded-modal-close { + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: transparent; + color: rgba(100, 116, 139, 0.9); + cursor: pointer; + font-size: 20px; + line-height: 1; +} + +.gn-embedded-modal-close:hover { + background: rgba(100, 116, 139, 0.12); +} + +.gn-embedded-modal-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.gn-embedded-modal-footer { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; +} diff --git a/frontend/src/components/common/ResizableDraggableModal.test.ts b/frontend/src/components/common/ResizableDraggableModal.test.ts new file mode 100644 index 00000000..be132a96 --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const modalSource = readFileSync( + fileURLToPath(new globalThis.URL('./ResizableDraggableModal.tsx', import.meta.url)), + 'utf8', +); + +const modalCss = readFileSync( + fileURLToPath(new globalThis.URL('./ResizableDraggableModal.css', import.meta.url)), + 'utf8', +); + +describe('ResizableDraggableModal guards', () => { + it('routes component, static, and hook modals through the same draggable frame', () => { + expect(modalSource).toContain('const DraggableResizableModalFrame: React.FC'); + expect(modalSource).toContain(''); + expect(modalSource).toContain('ResizableDraggableModal.info = wrapModalFunc(AntdModal.info);'); + expect(modalSource).toContain('ResizableDraggableModal.success = wrapModalFunc(AntdModal.success);'); + expect(modalSource).toContain('ResizableDraggableModal.error = wrapModalFunc(AntdModal.error);'); + expect(modalSource).toContain('ResizableDraggableModal.warning = wrapModalFunc(AntdModal.warning);'); + expect(modalSource).toContain('ResizableDraggableModal.confirm = wrapModalFunc(AntdModal.confirm);'); + expect(modalSource).toContain('return [wrapHookModalApi(modalApi), contextHolder] as ReturnType;'); + expect(modalSource).toContain("const activeInteractionRef = useRef<'drag' | 'resize' | null>(null);"); + expect(modalSource).toContain('const [wrapperElement, setWrapperElement] = useState(null);'); + expect(modalSource).toContain('const bindWrapperRef = useCallback((node: HTMLDivElement | null) =>'); + expect(modalSource).toContain("wrapperElement.addEventListener('pointerdown', handleFrameStart);"); + expect(modalSource).toContain("wrapperElement.addEventListener('mousedown', handleFrameStart);"); + expect(modalSource).toContain("startResize('south-east', event);"); + expect(modalSource).toContain("wrapperElement?.closest('.ant-modal')"); + expect(modalSource).toContain("modalNode.style.width = `${size.width}px`;"); + expect(modalSource).toContain("window.addEventListener('click', suppressInteractionClick, true);"); + expect(modalSource).toContain("window.removeEventListener('click', suppressInteractionClick, true);"); + }); + + it('applies resized width and height to the underlying AntD modal nodes', () => { + expect(modalSource).toContain("style['--gn-modal-resized-width'] = `${size.width}px`;"); + expect(modalSource).toContain("style['--gn-modal-resized-height'] = `${size.height}px`;"); + expect(modalCss).toContain(".gn-resizable-draggable-modal[data-has-resized-width='true']"); + expect(modalCss).toContain('width: var(--gn-modal-resized-width);'); + expect(modalCss).toContain(".gn-resizable-draggable-modal[data-has-resized-height='true'] .ant-modal-content"); + expect(modalCss).toContain('height: var(--gn-modal-resized-height);'); + expect(modalCss).toMatch(/\.gn-modal-resize-handle\s*\{[^}]*pointer-events:\s*auto;/s); + }); +}); diff --git a/frontend/src/components/common/ResizableDraggableModal.tsx b/frontend/src/components/common/ResizableDraggableModal.tsx new file mode 100644 index 00000000..6549ee1c --- /dev/null +++ b/frontend/src/components/common/ResizableDraggableModal.tsx @@ -0,0 +1,426 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Modal as AntdModal } from 'antd'; +import type { ModalFuncProps, ModalProps } from 'antd'; +import './ResizableDraggableModal.css'; + +type ResizeDirection = 'east' | 'south' | 'south-east'; + +type ModalSize = { + width?: number; + height?: number; +}; + +type ModalPosition = { + x: number; + y: number; +}; + +export type ResizableDraggableModalProps = ModalProps & { + embedded?: boolean; + draggable?: boolean; + resizable?: boolean; + minResizableWidth?: number; + minResizableHeight?: number; +}; + +const DEFAULT_MIN_WIDTH = 360; +const DEFAULT_MIN_HEIGHT = 220; +const VIEWPORT_PADDING = 16; + +const isInteractiveTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + return !!target.closest('button, a, input, textarea, select, [contenteditable="true"], .ant-select, .ant-picker, .ant-dropdown, .ant-checkbox, .ant-radio'); +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +type DraggableResizableModalFrameProps = { + active?: boolean; + children: React.ReactNode; + draggable: boolean; + resizable: boolean; + minResizableWidth: number; + minResizableHeight: number; +}; + +const DraggableResizableModalFrame: React.FC = ({ + active = true, + children, + draggable, + resizable, + minResizableWidth, + minResizableHeight, +}) => { + const wrapperRef = useRef(null); + const activeInteractionRef = useRef<'drag' | 'resize' | null>(null); + const [wrapperElement, setWrapperElement] = useState(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [size, setSize] = useState({}); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + + useEffect(() => { + if (!active) { + setPosition({ x: 0, y: 0 }); + setSize({}); + setIsDragging(false); + setIsResizing(false); + activeInteractionRef.current = null; + } + }, [active]); + + const startDrag = useCallback((event: PointerEvent | MouseEvent) => { + if (activeInteractionRef.current) return; + if (!draggable || event.button !== 0 || isInteractiveTarget(event.target)) return; + const target = event.target instanceof HTMLElement ? event.target : null; + if (!target?.closest('.ant-modal-header, .ant-modal-title, .ant-modal-confirm-title')) return; + + const modalNode = wrapperElement?.closest('.ant-modal'); + if (!(modalNode instanceof HTMLElement)) return; + + const startX = event.clientX; + const startY = event.clientY; + const startPosition = { ...position }; + const rect = modalNode.getBoundingClientRect(); + const minX = VIEWPORT_PADDING - rect.left + startPosition.x; + const maxX = window.innerWidth - VIEWPORT_PADDING - rect.right + startPosition.x; + const minY = VIEWPORT_PADDING - rect.top + startPosition.y; + const maxY = window.innerHeight - VIEWPORT_PADDING - rect.bottom + startPosition.y; + + event.preventDefault(); + activeInteractionRef.current = 'drag'; + setIsDragging(true); + + const suppressInteractionClick = (clickEvent: MouseEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + }; + + const handleMove = (moveEvent: PointerEvent | MouseEvent) => { + const nextX = clamp(startPosition.x + moveEvent.clientX - startX, minX, maxX); + const nextY = clamp(startPosition.y + moveEvent.clientY - startY, minY, maxY); + setPosition({ x: nextX, y: nextY }); + }; + + const stopDrag = () => { + activeInteractionRef.current = null; + setIsDragging(false); + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('pointerup', stopDrag); + window.removeEventListener('mouseup', stopDrag); + window.removeEventListener('pointercancel', stopDrag); + window.setTimeout(() => { + window.removeEventListener('click', suppressInteractionClick, true); + }, 0); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('mousemove', handleMove); + window.addEventListener('pointerup', stopDrag); + window.addEventListener('mouseup', stopDrag); + window.addEventListener('pointercancel', stopDrag); + window.addEventListener('click', suppressInteractionClick, true); + }, [draggable, position, wrapperElement]); + + const startResize = useCallback((direction: ResizeDirection, event: PointerEvent | MouseEvent) => { + if (activeInteractionRef.current) return; + if (!resizable || event.button !== 0) return; + const modalContent = wrapperElement?.querySelector('.ant-modal-content'); + const modalNode = wrapperElement?.closest('.ant-modal'); + if (!(modalContent instanceof HTMLElement) || !(modalNode instanceof HTMLElement)) return; + + const startX = event.clientX; + const startY = event.clientY; + const rect = modalContent.getBoundingClientRect(); + const modalRect = modalNode.getBoundingClientRect(); + const maxWidth = Math.max(minResizableWidth, window.innerWidth - modalRect.left - VIEWPORT_PADDING); + const maxHeight = Math.max(minResizableHeight, window.innerHeight - modalRect.top - VIEWPORT_PADDING); + + event.preventDefault(); + event.stopPropagation(); + activeInteractionRef.current = 'resize'; + setIsResizing(true); + setSize({ + width: rect.width, + height: rect.height, + }); + + const suppressInteractionClick = (clickEvent: MouseEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + }; + + const handleMove = (moveEvent: PointerEvent | MouseEvent) => { + const deltaX = moveEvent.clientX - startX; + const deltaY = moveEvent.clientY - startY; + setSize({ + width: direction === 'south' ? rect.width : clamp(rect.width + deltaX, minResizableWidth, maxWidth), + height: direction === 'east' ? rect.height : clamp(rect.height + deltaY, minResizableHeight, maxHeight), + }); + }; + + const stopResize = () => { + activeInteractionRef.current = null; + setIsResizing(false); + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('mouseup', stopResize); + window.removeEventListener('pointercancel', stopResize); + window.setTimeout(() => { + window.removeEventListener('click', suppressInteractionClick, true); + }, 0); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('mousemove', handleMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('mouseup', stopResize); + window.addEventListener('pointercancel', stopResize); + window.addEventListener('click', suppressInteractionClick, true); + }, [minResizableHeight, minResizableWidth, resizable, wrapperElement]); + + useEffect(() => { + const modalNode = wrapperElement?.closest('.ant-modal'); + if (!(modalNode instanceof HTMLElement) || !size.width) return undefined; + + modalNode.style.width = `${size.width}px`; + return () => { + modalNode.style.removeProperty('width'); + }; + }, [size.width, wrapperElement]); + + const handleFrameStart = useCallback((event: PointerEvent | MouseEvent) => { + const target = event.target instanceof HTMLElement ? event.target : null; + const resizeHandle = target?.closest('.gn-modal-resize-handle'); + if (resizeHandle instanceof HTMLElement) { + if (resizeHandle.classList.contains('gn-modal-resize-handle-south-east')) { + startResize('south-east', event); + return; + } + if (resizeHandle.classList.contains('gn-modal-resize-handle-east')) { + startResize('east', event); + return; + } + if (resizeHandle.classList.contains('gn-modal-resize-handle-south')) { + startResize('south', event); + return; + } + } + startDrag(event); + }, [startDrag, startResize]); + + const bindWrapperRef = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + setWrapperElement(node); + }, []); + + useEffect(() => { + if (!wrapperElement) return undefined; + + wrapperElement.addEventListener('pointerdown', handleFrameStart); + wrapperElement.addEventListener('mousedown', handleFrameStart); + + return () => { + wrapperElement.removeEventListener('pointerdown', handleFrameStart); + wrapperElement.removeEventListener('mousedown', handleFrameStart); + }; + }, [handleFrameStart, wrapperElement]); + + const frameStyle = useMemo(() => { + const style = { + transform: `translate(${position.x}px, ${position.y}px)`, + } as React.CSSProperties & Record; + if (size.width) { + style['--gn-modal-resized-width'] = `${size.width}px`; + } + if (size.height) { + style['--gn-modal-resized-height'] = `${size.height}px`; + } + return style; + }, [position.x, position.y, size.height, size.width]); + + return ( +
+ {children} + {resizable ? ( + <> +
+ ); +}; + +const ResizableDraggableModalBase: React.FC = ({ + embedded = false, + draggable = true, + resizable = true, + minResizableWidth = DEFAULT_MIN_WIDTH, + minResizableHeight = DEFAULT_MIN_HEIGHT, + modalRender, + open, + styles, + width, + children, + ...props +}) => { + const isTestRuntime = Boolean((import.meta as unknown as { env?: { MODE?: string } }).env?.MODE === 'test'); + + if (embedded) { + if (!open) return null; + const footerNode: React.ReactNode = props.footer === null || typeof props.footer === 'function' ? null : props.footer; + return ( +
+ {props.title || props.closable !== false ? ( +
+
{props.title}
+ {props.closable !== false ? ( + + ) : null} +
+ ) : null} +
+ {children} +
+ {footerNode ? ( +
+ {footerNode} +
+ ) : null} +
+ ); + } + + return ( + { + const renderedNode = modalRender ? modalRender(modalNode) : modalNode; + return ( + + {renderedNode} + + ); + }} + > + {children} + + ); +}; + +type ResizableDraggableModalStatic = React.FC & { + info: typeof AntdModal.info; + success: typeof AntdModal.success; + error: typeof AntdModal.error; + warning: typeof AntdModal.warning; + confirm: typeof AntdModal.confirm; + destroyAll: typeof AntdModal.destroyAll; + useModal: typeof AntdModal.useModal; +}; + +type ModalConfigUpdate = ModalFuncProps | ((prevConfig: ModalFuncProps) => ModalFuncProps); +type ModalRefWithUpdate = { + update: (configUpdate: ModalConfigUpdate) => void; +}; + +const withDraggableModalRender = (config: ModalFuncProps): ModalFuncProps => { + const originalModalRender = config.modalRender; + return { + ...config, + modalRender: (modalNode) => ( + + {originalModalRender ? originalModalRender(modalNode) : modalNode} + + ), + }; +}; + +const wrapModalRefUpdate = (modalRef: T): T => { + const rawUpdate = modalRef.update.bind(modalRef); + modalRef.update = (configUpdate: ModalConfigUpdate) => { + rawUpdate(typeof configUpdate === 'function' + ? (prevConfig) => withDraggableModalRender(configUpdate(prevConfig)) + : withDraggableModalRender(configUpdate)); + }; + return modalRef; +}; + +const wrapModalFunc = ModalRefWithUpdate>(modalFunc: T): T => ( + ((config: ModalFuncProps) => wrapModalRefUpdate(modalFunc(withDraggableModalRender(config)))) as T +); + +const wrapHookModalApi = [0]>(modalApi: T): T => ({ + ...modalApi, + info: wrapModalFunc(modalApi.info), + success: wrapModalFunc(modalApi.success), + error: wrapModalFunc(modalApi.error), + warning: wrapModalFunc(modalApi.warning), + confirm: wrapModalFunc(modalApi.confirm), +}) as T; + +const ResizableDraggableModal = ResizableDraggableModalBase as ResizableDraggableModalStatic; + +ResizableDraggableModal.info = wrapModalFunc(AntdModal.info); +ResizableDraggableModal.success = wrapModalFunc(AntdModal.success); +ResizableDraggableModal.error = wrapModalFunc(AntdModal.error); +ResizableDraggableModal.warning = wrapModalFunc(AntdModal.warning); +ResizableDraggableModal.confirm = wrapModalFunc(AntdModal.confirm); +ResizableDraggableModal.destroyAll = ((...args: Parameters) => AntdModal.destroyAll(...args)) as typeof AntdModal.destroyAll; +ResizableDraggableModal.useModal = ((...args: Parameters) => { + const [modalApi, contextHolder] = AntdModal.useModal(...args); + return [wrapHookModalApi(modalApi), contextHolder] as ReturnType; +}) as typeof AntdModal.useModal; + +export default ResizableDraggableModal; diff --git a/frontend/src/components/jvm/JVMChangePreviewModal.tsx b/frontend/src/components/jvm/JVMChangePreviewModal.tsx index 25a328b0..593f2f42 100644 --- a/frontend/src/components/jvm/JVMChangePreviewModal.tsx +++ b/frontend/src/components/jvm/JVMChangePreviewModal.tsx @@ -1,5 +1,6 @@ +import Modal from '../common/ResizableDraggableModal'; import React, { useMemo } from "react"; -import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd"; +import { Alert, Descriptions, Space, Tag, Typography } from 'antd'; import type { JVMChangePreview } from "../../types"; import { t as translate } from "../../i18n"; diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index 65695f01..92f9eacc 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -56,6 +56,9 @@ const readDataGridV2DdlWorkspaceSource = (): string => const readQueryEditorSource = (): string => readFileSync(new URL("../components/QueryEditor.tsx", import.meta.url), "utf8"); +const readQueryEditorResultsPanelSource = (): string => + readFileSync(new URL("../components/QueryEditorResultsPanel.tsx", import.meta.url), "utf8"); + const readSqlDialectSource = (): string => readFileSync(new URL("../utils/sqlDialect.ts", import.meta.url), "utf8"); @@ -111,6 +114,12 @@ describe("i18n catalog", () => { it("includes App shell keys required by every supported language", () => { const appShellKeys = [ "app.tools.title", + "app.tools.group.config.title", + "app.tools.group.config.description", + "app.tools.group.workflow.title", + "app.tools.group.workflow.description", + "app.tools.group.workspace.title", + "app.tools.group.workspace.description", "app.tools.entry.import.title", "app.tools.entry.security_update.description", "app.tools.entry.security_update.status_description", @@ -155,6 +164,7 @@ describe("i18n catalog", () => { "app.ai_panel.error.title", "app.about.title", "app.about.field.update_status", + "common.back_to_previous", "common.unknown", "common.close", ] as const; @@ -558,11 +568,6 @@ describe("i18n catalog", () => { "data_grid.message.export_failed", ] as const; const source = readDataGridSource(); - const exportDataSource = sliceBetween( - source, - " const exportData = async (rows: any[], format: string) => {", - " const [sortInfo, setSortInfo]", - ); const base = catalogs["en-US"]; for (const language of SUPPORTED_LANGUAGES) { @@ -577,16 +582,12 @@ describe("i18n catalog", () => { expect(getPlaceholders(catalogs[language]["data_grid.message.export_failed"])).toEqual(["detail"]); } - for (const key of dataGridRowExportMessageKeys) { - expect(exportDataSource).toContain(key); - } + expect(source).toContain("await runExportWithProgress({"); expect(t("zh-CN", "data_grid.message.exporting_rows", { count: "" })).toContain(""); expect(t("en-US", "data_grid.message.export_failed", { detail: "" })).toContain(""); - expect(exportDataSource).not.toContain("正在导出 ${rows.length} 条数据..."); - expect(exportDataSource).not.toContain("导出成功"); - expect(exportDataSource).not.toContain("导出失败: "); - assertSourceDoesNotInlineCatalogValues(exportDataSource, dataGridRowExportMessageKeys); + expect(source).not.toContain("正在导出 ${rows.length} 条数据..."); + assertSourceDoesNotInlineCatalogValues(source, dataGridRowExportMessageKeys); }); it("keeps DataGrid commit, preview SQL, and copy feedback messages in catalogs with raw placeholders", () => { @@ -1990,7 +1991,7 @@ describe("i18n catalog", () => { const aiContextSource = sliceBetween( source, "const buildQueryEditorAiContextPrompt = (connection: any, database: string): string => {", - "// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源)", + "const _g = globalThis as any;", ); for (const language of SUPPORTED_LANGUAGES) { @@ -2127,7 +2128,7 @@ describe("i18n catalog", () => { const diagnosePromptSource = sliceBetween( source, "const prompt = translate('query_editor.ai_prompt.diagnose', {", - "{translate('query_editor.result.ai_diagnose')}", + " const sqlEditorTransactionToolbar = (", ); const toolbarAndDiagnoseSource = `${toolbarPromptSource}\n${diagnosePromptSource}`; @@ -2159,39 +2160,7 @@ describe("i18n catalog", () => { "app.shortcuts.action.saveQuery.label", "query_editor.action.show_object_info", ] as const; - const source = readQueryEditorSource(); - const actionLabelSource = [ - sliceBetween( - source, - " objectHoverActionRef.current = editor.addAction({", - " editor.onDidChangeCursorPosition?.((event: any) => {", - ), - sliceBetween( - source, - " // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding", - " // HMR 重载时释放旧注册避免补全项重复", - ), - sliceBetween( - source, - " objectHoverActionRef.current?.dispose?.();", - " }, [languagePreference, showObjectInfoAtPosition]);", - ), - sliceBetween( - source, - " const binding = runQueryShortcutBinding;", - " }, [languagePreference, runQueryShortcutBinding]);", - ), - sliceBetween( - source, - " const binding = selectCurrentStatementShortcutBinding;", - " }, [languagePreference, selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]);", - ), - sliceBetween( - source, - " const binding = saveQueryShortcutBinding;", - " }, [languagePreference, saveQueryShortcutBinding]);", - ), - ].join("\n"); + const actionLabelSource = readQueryEditorSource(); for (const language of SUPPORTED_LANGUAGES) { for (const key of actionLabelKeys) { @@ -2221,7 +2190,7 @@ describe("i18n catalog", () => { const objectNavigationSource = sliceBetween( source, " if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') {", - " });\r\n\r\n editor.onDidDispose?.(() => {", + " editor.onDidDispose?.(() => {", ); for (const language of SUPPORTED_LANGUAGES) { @@ -2265,12 +2234,7 @@ describe("i18n catalog", () => { "query_editor.empty_state.title", "query_editor.empty_state.description", ] as const; - const source = readQueryEditorSource(); - const emptyStateSource = sliceBetween( - source, - "
\r\n\r\n