diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e212312..11961e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ 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 { BrowserOpenURL, Environment, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import SlowQueryRailButton from './components/sidebar/SlowQueryRailButton'; import TabManager from './components/TabManager'; @@ -24,7 +24,7 @@ import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModa import LanguageSettingsPanel from './components/LanguageSettingsPanel'; import { DEFAULT_APPEARANCE, useStore } from './store'; import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types'; -import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; +import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; import { buildFontFamilyOptions, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, getLinuxCJKFontInstallHint, matchFontFamilyOption, resolveMonoFontFamily, resolveUIFontFamily, sanitizeFontFamilyInput, type FontFamilyOption, type InstalledFontFamily } from './utils/fontFamilies'; import { DENSITY_OPTIONS, @@ -45,8 +45,6 @@ import { } from './utils/tabDisplay'; import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics'; -import { resolveAboutDisplayVersion } from './utils/appVersionDisplay'; -import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; import { getConnectionWorkbenchState } from './utils/startupReadiness'; import { toSaveGlobalProxyInput } from './utils/globalProxyDraft'; import { @@ -110,15 +108,18 @@ import { } from './utils/aiEntryLayout'; import { DEFAULT_AI_PANEL_WIDTH, resolveOverlayAIPanelWidth, shouldOverlayAIPanel } from './utils/aiPanelLayout'; import { safeWindowRuntimeCall } from './utils/wailsRuntime'; +import { useAppUpdateManager } from './hooks/useAppUpdateManager'; +import { useAppLogPanelResize } from './hooks/useAppLogPanelResize'; +import { useAppSidebarResize } from './hooks/useAppSidebarResize'; +import { useAppUtilityStyles } from './hooks/useAppUtilityStyles'; import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, GetSavedConnections, ListInstalledFontFamilies, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App'; import { getAntdLocale } from './i18n/frameworkLocale'; import { useI18n } from './i18n/provider'; import './App.css'; import './v2-theme.css'; +import './styles/v2-theme-workbench.css'; const { Sider, Content } = Layout; -const SIDEBAR_RESIZE_MIN_WIDTH = 200; -const SIDEBAR_RESIZE_MAX_WIDTH = 600; const MIN_UI_SCALE = 0.8; const MAX_UI_SCALE = 1.25; const MIN_FONT_SIZE = 12; @@ -126,33 +127,6 @@ const MAX_FONT_SIZE = 20; const DEFAULT_UI_SCALE = 1.0; const DEFAULT_FONT_SIZE = 14; const EMPTY_INSTALLED_FONT_FAMILIES: InstalledFontFamily[] = []; -type SidebarResizeBounds = { minWidth: number; maxWidth: number }; -type SidebarResizeDragState = SidebarResizeBounds & { - startX: number; - startWidth: number; - startGuideLeft: number; -}; - -const parseCssPixelValue = (value: string | null | undefined): number | null => { - const parsed = Number.parseFloat(String(value || '')); - return Number.isFinite(parsed) ? parsed : null; -}; - -const resolveSidebarResizeBounds = (siderElement: Element | null): SidebarResizeBounds => { - if (typeof window === 'undefined' || !(siderElement instanceof HTMLElement)) { - return { minWidth: SIDEBAR_RESIZE_MIN_WIDTH, maxWidth: SIDEBAR_RESIZE_MAX_WIDTH }; - } - const computed = window.getComputedStyle(siderElement); - const cssMinWidth = parseCssPixelValue(computed.minWidth); - const cssMaxWidth = parseCssPixelValue(computed.maxWidth); - const minWidth = Math.max(SIDEBAR_RESIZE_MIN_WIDTH, cssMinWidth && cssMinWidth > 0 ? cssMinWidth : SIDEBAR_RESIZE_MIN_WIDTH); - const maxWidth = Math.max(minWidth, Math.min(SIDEBAR_RESIZE_MAX_WIDTH, cssMaxWidth && cssMaxWidth > 0 ? cssMaxWidth : SIDEBAR_RESIZE_MAX_WIDTH)); - return { minWidth, maxWidth }; -}; - -const clampSidebarResizeWidth = (width: number, bounds: SidebarResizeBounds): number => ( - Math.max(bounds.minWidth, Math.min(bounds.maxWidth, width)) -); const createEmptySecurityUpdateStatus = (): SecurityUpdateStatus => ({ overallStatus: 'not_detected', @@ -1182,252 +1156,25 @@ function App() { }; }, []); - // Background Helper - const getBg = (darkHex: string) => { - if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white - - // Parse hex to rgb - const hex = darkHex.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - return `rgba(${r}, ${g}, ${b}, ${effectiveOpacity})`; - }; - // Specific colors - const bgMain = getBg('#141414'); - const bgContent = getBg('#1d1d1d'); - const floatingLogButtonBorderColor = darkMode ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.16)'; - const floatingLogButtonTextColor = darkMode ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.82)'; - const floatingLogButtonBgColor = darkMode - ? `rgba(34, 34, 34, ${Math.max(effectiveOpacity, 0.82)})` - : `rgba(255, 255, 255, ${Math.max(effectiveOpacity, 0.9)})`; - const floatingLogButtonShadow = darkMode - ? '0 8px 22px rgba(0,0,0,0.38)' - : '0 8px 20px rgba(0,0,0,0.16)'; - const isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0; - const utilityButtonBgAlpha = darkMode - ? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72)) - : Math.max(0.52, Math.min(0.92, effectiveOpacity * 0.9)); - const utilityButtonBgColor = isOpaqueUtilityMode - ? 'transparent' - : (darkMode - ? `rgba(20, 26, 38, ${utilityButtonBgAlpha})` - : `rgba(255, 255, 255, ${utilityButtonBgAlpha})`); - const utilityButtonBorderColor = isOpaqueUtilityMode - ? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)') - : (darkMode - ? `rgba(255,255,255,${Math.max(0.08, Math.min(0.18, effectiveOpacity * 0.16))})` - : `rgba(16,24,40,${Math.max(0.06, Math.min(0.14, effectiveOpacity * 0.12))})`); - const utilityButtonShadow = isOpaqueUtilityMode - ? 'none' - : (darkMode - ? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})` - : `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`); - const isSidebarNarrow = sidebarWidth < 360; - const isSidebarCompact = sidebarWidth < 320; - const isSidebarUltraCompact = sidebarWidth < 260; - const utilityButtonStyle = useMemo(() => ({ - height: Math.max(30, Math.round(32 * effectiveUiScale)), - width: '100%', - paddingInline: isSidebarCompact ? Math.max(8, Math.round(9 * effectiveUiScale)) : Math.max(10, Math.round(12 * effectiveUiScale)), - borderRadius: 10, - border: `1px solid ${utilityButtonBorderColor}`, - background: utilityButtonBgColor, - color: darkMode ? 'rgba(255,255,255,0.94)' : '#162033', - boxShadow: utilityButtonShadow, - backdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter, - WebkitBackdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: isSidebarCompact ? 4 : 6, - minWidth: 0, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - fontSize: isSidebarCompact ? 13 : 14, - }), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]); - const disableLocalBackdropFilter = isMacLikePlatform(); - const overlayTheme = useMemo( - () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }), - [darkMode, disableLocalBackdropFilter], - ); - - const sidebarQuickActionBaseStyle = useMemo(() => ({ - height: Math.max(34, Math.round(36 * effectiveUiScale)), - borderRadius: 12, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - paddingInline: Math.max(12, Math.round(14 * effectiveUiScale)), - fontWeight: 700, - boxShadow: darkMode ? '0 8px 18px rgba(0,0,0,0.16)' : '0 8px 16px rgba(15,23,42,0.08)', - backdropFilter: blurFilter, - WebkitBackdropFilter: blurFilter, - minWidth: 0, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }), [blurFilter, darkMode, effectiveUiScale]); - const sidebarQueryActionStyle = useMemo(() => ({ - ...sidebarQuickActionBaseStyle, - flex: '1 1 0', - border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)'}`, - background: darkMode ? `rgba(255,255,255,0.05)` : 'rgba(255,255,255,0.88)', - color: darkMode ? 'rgba(255,255,255,0.92)' : '#162033', - }), [darkMode, sidebarQuickActionBaseStyle]); - const sidebarCreateConnectionActionStyle = useMemo(() => ({ - ...sidebarQuickActionBaseStyle, - flex: '1 1 0', - border: 'none', - background: 'linear-gradient(135deg, rgba(34,197,94,0.96) 0%, rgba(22,163,74,0.92) 100%)', - color: '#f3fff7', - }), [sidebarQuickActionBaseStyle]); - - const utilityModalShellStyle = useMemo(() => ({ - background: overlayTheme.shellBg, - border: overlayTheme.shellBorder, - boxShadow: overlayTheme.shellShadow, - backdropFilter: overlayTheme.shellBackdropFilter, - }), [overlayTheme]); - const utilityPanelStyle = useMemo(() => ({ - padding: 16, - borderRadius: 14, - 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, - ) => ( -
-
- {icon} -
-
-
{title}
-
{description}
-
-
- ); - const utilityActionCardStyle = useMemo(() => ({ - width: '100%', - minHeight: 68, - borderRadius: 14, - border: overlayTheme.sectionBorder, - background: overlayTheme.sectionBg, - color: overlayTheme.titleText, - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 14, - paddingInline: 16, - boxShadow: 'none', - fontSize: 15, - fontWeight: 600, - }), [overlayTheme]); - const utilityActionHintStyle = useMemo(() => ({ - fontSize: 12, - color: overlayTheme.mutedText, - 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; + const { + bgContent, bgMain, + floatingLogButtonBgColor, floatingLogButtonBorderColor, floatingLogButtonShadow, floatingLogButtonTextColor, + isSidebarCompact, isSidebarNarrow, isSidebarUltraCompact, + overlayTheme, renderUtilityModalTitle, + sidebarCreateConnectionActionStyle, sidebarHorizontalPadding, sidebarQueryActionStyle, + toolCenterContentPanelStyle, toolCenterDetailBodyStyle, toolCenterDetailPanelStyle, + toolCenterModalContentStyle, toolCenterModalSplitStyle, toolCenterModalWorkspaceStyle, + toolCenterNavPanelStyle, toolCenterNavScrollStyle, toolCenterRowDescriptionStyle, toolCenterRowStyle, + toolCenterScrollableListStyle, utilityActionCardStyle, utilityActionHintStyle, utilityButtonStyle, + utilityModalShellStyle, utilityMutedTextStyle, utilityPanelStyle, + } = useAppUtilityStyles({ + blurFilter, + darkMode, + effectiveOpacity, + effectiveUiScale, + resolvedAppearance, + sidebarWidth, + }); const addTab = useStore(state => state.addTab); const activeContext = useStore(state => state.activeContext); @@ -1621,84 +1368,6 @@ function App() { setSecurityUpdateRepairSource(null); openSecurityUpdateSettings(repairEntry.focusTarget); }, [connections, openSecurityUpdateSettings, runSecurityUpdateRound, securityUpdateStatus, t]); - const updateCheckInFlightRef = React.useRef(false); - const updateDownloadInFlightRef = React.useRef(false); - const updateUserDismissedRef = React.useRef(false); - const updateDownloadedVersionRef = React.useRef(null); - const updateInstallTriggeredVersionRef = React.useRef(null); - const updateDownloadMetaRef = React.useRef(null); - const updateNotifiedVersionRef = React.useRef(null); - const updateMutedVersionRef = React.useRef(null); - const [isAboutOpen, setIsAboutOpen] = useState(false); - const isAboutOpenRef = React.useRef(false); - const [aboutLoading, setAboutLoading] = useState(false); - const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string; communityUrl?: string } | null>(null); - const aboutDisplayVersion = resolveAboutDisplayVersion(runtimeBuildType, aboutInfo?.version); - 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; - currentVersion: string; - latestVersion: string; - releaseName?: string; - releaseNotesUrl?: string; - assetName?: string; - assetUrl?: string; - assetSize?: number; - sha256?: string; - downloaded?: boolean; - downloadPath?: string; - }; - - type UpdateDownloadProgressEvent = { - status?: 'start' | 'downloading' | 'done' | 'error'; - percent?: number; - downloaded?: number; - total?: number; - message?: string; - }; - - const formatAboutUpdateStatus = useCallback((info: UpdateInfo | null): string => { - if (!info) { - return t('app.about.update_status.not_checked'); - } - if (info.hasUpdate) { - const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion; - const hasDownloaded = Boolean(info.downloaded) || localDownloaded; - return hasDownloaded - ? t('app.about.update_status.new_version_downloaded', { version: info.latestVersion }) - : t('app.about.update_status.new_version_not_downloaded', { version: info.latestVersion }); - } - return t('app.about.update_status.latest', { version: info.currentVersion || t('common.unknown') }); - }, [t]); - - type UpdateDownloadResultData = { - info?: UpdateInfo; - downloadPath?: string; - installLogPath?: string; - installTarget?: string; - platform?: string; - autoRelaunch?: boolean; - }; - const isMacRuntime = runtimePlatform === 'darwin' || (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform())); const isWindowsRuntime = runtimePlatform === 'windows' @@ -1710,6 +1379,31 @@ function App() { import.meta.env.DEV, import.meta.env.VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS, ); + const { + aboutDisplayVersion, + aboutInfo, + aboutLoading, + aboutUpdateStatus, + canShowProgressEntry, + checkForUpdates, + downloadUpdate, + formatBytes, + handleInstallFromProgress, + hideUpdateDownloadProgress, + isAboutOpen, + isBackgroundProgressForLatestUpdate, + isLatestUpdateDownloaded, + lastUpdateInfo, + markUpdateProgressDismissed, + muteLatestUpdate, + setIsAboutOpen, + showUpdateDownloadProgress, + updateDownloadProgress, + } = useAppUpdateManager({ + isMacRuntime, + runtimeBuildType, + t, + }); const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record = {}) => { if (!macWindowDiagnosticsEnabled) { @@ -1877,308 +1571,6 @@ function App() { }; }, [emitWindowDiagnostic, macWindowDiagnosticsEnabled]); - 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 downloadUpdate = React.useCallback(async (info: UpdateInfo, silent: boolean) => { - if (updateDownloadInFlightRef.current) return; - if (updateDownloadedVersionRef.current === info.latestVersion) { - if (!silent) { - const cachedDownloadPath = updateDownloadMetaRef.current?.downloadPath; - void message.info(cachedDownloadPath - ? t('app.about.message.update_package_ready_with_path', { version: info.latestVersion, path: cachedDownloadPath }) - : t('app.about.message.update_package_ready', { version: info.latestVersion })); - showUpdateDownloadProgress(); - } - return; - } - updateDownloadInFlightRef.current = true; - updateUserDismissedRef.current = false; - updateDownloadMetaRef.current = null; - setUpdateDownloadProgress({ - open: true, - version: info.latestVersion, - status: 'start', - percent: 0, - downloaded: 0, - total: info.assetSize || 0, - message: '' - }); - let res: any = null; - try { - res = await (window as any).go.app.App.DownloadUpdate(); - } catch (e) { - console.warn("Wails API: DownloadUpdate unavailable", e); - } - updateDownloadInFlightRef.current = false; - if (res?.success) { - const resultData = (res?.data || {}) as UpdateDownloadResultData; - updateDownloadMetaRef.current = resultData; - updateDownloadedVersionRef.current = info.latestVersion; - setUpdateDownloadProgress(prev => { - const total = prev.total > 0 ? prev.total : (info.assetSize || 0); - return { ...prev, status: 'done', percent: 100, downloaded: total, total, message: '', open: false }; - }); - setLastUpdateInfo((prev) => { - if (!prev || prev.latestVersion !== info.latestVersion) { - return { - ...info, - downloaded: true, - downloadPath: resultData?.downloadPath || info.downloadPath, - }; - } - return { - ...prev, - downloaded: true, - downloadPath: resultData?.downloadPath || prev.downloadPath || info.downloadPath, - }; - }); - if (resultData?.downloadPath) { - void message.success({ content: t('app.about.message.download_completed_with_path', { path: resultData.downloadPath }), duration: 5 }); - } else { - void message.success({ content: t('app.about.message.download_completed'), duration: 2 }); - } - setAboutUpdateStatus(formatAboutUpdateStatus({ ...info, downloaded: true })); - // macOS:如果用户没有主动隐藏进度弹窗,则下载完成后自动打开下载目录 - if (isMacRuntime && !updateUserDismissedRef.current) { - try { - const openRes = await (window as any).go.app.App.OpenDownloadedUpdateDirectory(); - if (openRes?.success) { - void message.success(openRes?.message || t('app.about.message.install_directory_opened_manual_replace')); - } - } catch (e) { - console.warn('自动打开下载目录失败', e); - } - } - } else { - setUpdateDownloadProgress(prev => ({ - ...prev, - status: 'error', - message: res?.message || t('common.unknown') - })); - void message.error({ content: t('app.about.message.download_failed_with_error', { error: res?.message || t('common.unknown') }), duration: 4 }); - } - }, [formatAboutUpdateStatus, isMacRuntime, t]); - - const showUpdateDownloadProgress = React.useCallback(() => { - setUpdateDownloadProgress((prev) => { - if (prev.status === 'idle') return prev; - return { ...prev, open: true }; - }); - }, []); - - const hideUpdateDownloadProgress = React.useCallback(() => { - setUpdateDownloadProgress((prev) => ({ ...prev, open: false })); - }, []); - - const isLatestUpdateDownloaded = Boolean(lastUpdateInfo?.hasUpdate) && ( - Boolean(lastUpdateInfo?.downloaded) - || (Boolean(lastUpdateInfo?.latestVersion) && updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion) - ); - const isBackgroundProgressForLatestUpdate = Boolean(lastUpdateInfo?.hasUpdate) - && Boolean(lastUpdateInfo?.latestVersion) - && updateDownloadProgress.version === lastUpdateInfo?.latestVersion - && (updateDownloadProgress.status === 'start' - || updateDownloadProgress.status === 'downloading' - || updateDownloadProgress.status === 'done' - || updateDownloadProgress.status === 'error'); - const canShowProgressEntry = (isLatestUpdateDownloaded || isBackgroundProgressForLatestUpdate) - && updateInstallTriggeredVersionRef.current !== (lastUpdateInfo?.latestVersion || null); - - const handleInstallFromProgress = React.useCallback(async () => { - // 允许从下载进度弹窗(status=done)或关于弹窗(isLatestUpdateDownloaded=true)触发 - const canInstall = updateDownloadProgress.status === 'done' - || (Boolean(lastUpdateInfo?.hasUpdate) && (Boolean(lastUpdateInfo?.downloaded) || updateDownloadedVersionRef.current === lastUpdateInfo?.latestVersion)); - if (!canInstall) { - return; - } - if (isMacRuntime) { - const res = await (window as any).go.app.App.OpenDownloadedUpdateDirectory(); - if (!res?.success) { - void message.error(t('app.about.message.open_install_directory_failed_with_error', { error: res?.message || t('common.unknown') })); - // 文件可能已被用户删除,清除已下载状态以允许重新下载 - updateDownloadedVersionRef.current = null; - updateDownloadMetaRef.current = null; - setUpdateDownloadProgress(prev => ({ - ...prev, - status: 'idle', - percent: 0, - downloaded: 0, - open: false, - })); - setLastUpdateInfo(prev => prev ? { ...prev, downloaded: false, downloadPath: undefined } : prev); - setAboutUpdateStatus((prev) => lastUpdateInfo ? formatAboutUpdateStatus({ ...lastUpdateInfo, downloaded: false, downloadPath: undefined }) : prev); - return; - } - updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null; - hideUpdateDownloadProgress(); - void message.success(res?.message || t('app.about.message.install_directory_opened_manual_replace')); - return; - } - const res = await (window as any).go.app.App.InstallUpdateAndRestart(); - if (!res?.success) { - void message.error(t('app.about.message.install_failed_with_error', { error: res?.message || t('common.unknown') })); - return; - } - updateInstallTriggeredVersionRef.current = updateDownloadProgress.version || lastUpdateInfo?.latestVersion || null; - hideUpdateDownloadProgress(); - }, [formatAboutUpdateStatus, hideUpdateDownloadProgress, isMacRuntime, lastUpdateInfo, updateDownloadProgress.status, updateDownloadProgress.version, t]); - - const checkForUpdates = React.useCallback(async (silent: boolean) => { - if (updateCheckInFlightRef.current) return; - updateCheckInFlightRef.current = true; - if (!silent) { - setAboutUpdateStatus(t('app.about.update_status.checking')); - } - const updateAPI = (window as any).go.app.App; - const checkFn = silent && typeof updateAPI.CheckForUpdatesSilently === 'function' - ? updateAPI.CheckForUpdatesSilently - : updateAPI.CheckForUpdates; - const res = await checkFn(); - updateCheckInFlightRef.current = false; - if (!res?.success) { - if (!silent) { - const error = res?.message || t('common.unknown'); - void message.error(t('app.about.message.check_failed_with_error', { error })); - setAboutUpdateStatus(t('app.about.update_status.check_failed', { error })); - } - return; - } - const info: UpdateInfo = res.data; - if (!info) return; - const aboutOpen = isAboutOpenRef.current; - if (info.hasUpdate) { - // 以后端校验为准:如果后端确认文件不存在(downloaded=false),清除本地 ref - if (!info.downloaded && updateDownloadedVersionRef.current === info.latestVersion) { - updateDownloadedVersionRef.current = null; - updateDownloadMetaRef.current = null; - } - const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion; - const hasDownloaded = Boolean(info.downloaded) || localDownloaded; - if (hasDownloaded) { - const downloadPath = info.downloadPath || updateDownloadMetaRef.current?.downloadPath || ''; - updateDownloadedVersionRef.current = info.latestVersion; - updateDownloadMetaRef.current = { - ...(updateDownloadMetaRef.current || {}), - info, - downloadPath: downloadPath || undefined, - }; - setUpdateDownloadProgress((prev) => { - if (prev.status === 'start' || prev.status === 'downloading') { - return prev; - } - const total = info.assetSize || prev.total || 0; - return { - ...prev, - open: prev.open && prev.version === info.latestVersion, - version: info.latestVersion, - status: 'done', - percent: 100, - downloaded: total, - total, - message: '', - }; - }); - setLastUpdateInfo({ - ...info, - downloaded: true, - downloadPath: downloadPath || undefined, - }); - } else { - if (updateDownloadedVersionRef.current !== info.latestVersion) { - updateDownloadMetaRef.current = null; - } - setUpdateDownloadProgress((prev) => { - if (prev.status === 'start' || prev.status === 'downloading') { - return prev; - } - return { - ...prev, - open: false, - version: info.latestVersion, - status: 'idle', - percent: 0, - downloaded: 0, - total: info.assetSize || 0, - message: '', - }; - }); - setLastUpdateInfo(info); - } - const statusText = formatAboutUpdateStatus({ ...info, downloaded: hasDownloaded }); - if (!silent) { - void message.info(t('app.about.message.new_version_found', { version: info.latestVersion })); - setAboutUpdateStatus(statusText); - } - if (silent && aboutOpen) { - setAboutUpdateStatus(statusText); - } - if (silent && !aboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) { - updateNotifiedVersionRef.current = info.latestVersion; - setIsAboutOpen(true); - } - } else if (!silent) { - setUpdateDownloadProgress((prev) => { - if (prev.status === 'start' || prev.status === 'downloading') { - return prev; - } - return { - open: false, - version: '', - status: 'idle', - percent: 0, - downloaded: 0, - total: 0, - message: '', - }; - }); - setLastUpdateInfo(info); - const text = formatAboutUpdateStatus(info); - void message.success(text); - setAboutUpdateStatus(text); - } else if (silent && aboutOpen) { - setUpdateDownloadProgress((prev) => { - if (prev.status === 'start' || prev.status === 'downloading') { - return prev; - } - return { - open: false, - version: '', - status: 'idle', - percent: 0, - downloaded: 0, - total: 0, - message: '', - }; - }); - setLastUpdateInfo(info); - const text = formatAboutUpdateStatus(info); - setAboutUpdateStatus(text); - } else { - setLastUpdateInfo(info); - } - }, [formatAboutUpdateStatus, t]); - - const loadAboutInfo = React.useCallback(async () => { - setAboutLoading(true); - const res = await (window as any).go.app.App.GetAppInfo(); - if (res?.success) { - setAboutInfo(res.data); - } else { - void message.error(t('app.about.message.load_failed', { error: res?.message || t('common.unknown') })); - } - setAboutLoading(false); - }, [t]); - const handleNewQuery = useCallback(() => { let connId = ''; let db = ''; @@ -2695,59 +2087,14 @@ function App() { }, [t]); - // Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制 - const LOG_PANEL_TOOLBAR_HEIGHT = 32; - const LOG_PANEL_SINGLE_ROW_HEIGHT = 39; - const LOG_PANEL_MIN_VISIBLE_ROWS = 1; - const LOG_PANEL_MIN_HEIGHT = LOG_PANEL_TOOLBAR_HEIGHT + (LOG_PANEL_SINGLE_ROW_HEIGHT * LOG_PANEL_MIN_VISIBLE_ROWS); - const LOG_PANEL_MAX_HEIGHT = 800; - const [logPanelHeight, setLogPanelHeight] = useState(Math.max(200, LOG_PANEL_MIN_HEIGHT)); - const [isLogPanelOpen, setIsLogPanelOpen] = useState(false); - const logResizeRef = React.useRef<{ startY: number, startHeight: number } | null>(null); - const logGhostRef = React.useRef(null); - const handleToggleLogPanel = useCallback(() => { - setIsLogPanelOpen((prev) => !prev); - }, []); - - const handleLogResizeStart = (e: React.MouseEvent) => { - e.preventDefault(); - logResizeRef.current = { startY: e.clientY, startHeight: logPanelHeight }; - - if (logGhostRef.current) { - logGhostRef.current.style.top = `${e.clientY}px`; - logGhostRef.current.style.display = 'block'; - } - - document.addEventListener('mousemove', handleLogResizeMove); - document.addEventListener('mouseup', handleLogResizeUp); - }; - - const handleLogResizeMove = (e: MouseEvent) => { - if (!logResizeRef.current) return; - // Just update ghost line, no state update - if (logGhostRef.current) { - logGhostRef.current.style.top = `${e.clientY}px`; - } - }; - - const handleLogResizeUp = (e: MouseEvent) => { - if (logResizeRef.current) { - const delta = logResizeRef.current.startY - e.clientY; - const newHeight = Math.max( - LOG_PANEL_MIN_HEIGHT, - Math.min(LOG_PANEL_MAX_HEIGHT, logResizeRef.current.startHeight + delta) - ); - setLogPanelHeight(newHeight); - } - - if (logGhostRef.current) { - logGhostRef.current.style.display = 'none'; - } - - logResizeRef.current = null; - document.removeEventListener('mousemove', handleLogResizeMove); - document.removeEventListener('mouseup', handleLogResizeUp); - }; + const { + handleCloseLogPanel, + handleLogResizeStart, + handleToggleLogPanel, + isLogPanelOpen, + logGhostRef, + logPanelHeight, + } = useAppLogPanelResize(); const handleCreateConnection = useCallback(() => { setSecurityUpdateRepairSource(null); @@ -3054,111 +2401,16 @@ function App() { } }, [t]); - // Sidebar Resizing - const sidebarDragRef = React.useRef(null); - const rafRef = React.useRef(null); - const ghostRef = React.useRef(null); - const siderRef = React.useRef(null); - const sidebarDragBodyStyleRef = React.useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null); - const latestMouseX = React.useRef(0); // Store latest mouse position - const sidebarResizeHandleWidth = Math.max(16, Math.round(16 * effectiveUiScale)); - - const restoreSidebarDragBodyStyles = () => { - if (!sidebarDragBodyStyleRef.current || typeof document === 'undefined') { - sidebarDragBodyStyleRef.current = null; - return; - } - - const previous = sidebarDragBodyStyleRef.current; - document.body.style.cursor = previous.cursor; - document.body.style.userSelect = previous.userSelect; - (document.body.style as any).WebkitUserSelect = previous.webkitUserSelect; - sidebarDragBodyStyleRef.current = null; - }; - - const handleSidebarMouseDown = (e: React.MouseEvent) => { - if (e.button !== 0) { - e.preventDefault(); - e.stopPropagation(); - return; - } - - e.preventDefault(); - e.stopPropagation(); - - if (typeof document !== 'undefined') { - sidebarDragBodyStyleRef.current = { - cursor: document.body.style.cursor, - userSelect: document.body.style.userSelect, - webkitUserSelect: (document.body.style as any).WebkitUserSelect || '', - }; - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - (document.body.style as any).WebkitUserSelect = 'none'; - } - - const siderRect = siderRef.current?.getBoundingClientRect(); - const startGuideLeft = siderRect?.right ?? sidebarWidth; - const startWidth = siderRect?.width ?? sidebarWidth; - const resizeBounds = resolveSidebarResizeBounds(siderRef.current); - - if (ghostRef.current) { - ghostRef.current.style.left = `${startGuideLeft}px`; - ghostRef.current.style.display = 'block'; - } - - sidebarDragRef.current = { - startX: e.clientX, - startWidth, - startGuideLeft, - ...resizeBounds, - }; - latestMouseX.current = e.clientX; // Init - document.addEventListener('mousemove', handleSidebarMouseMove); - document.addEventListener('mouseup', handleSidebarMouseUp); - }; - - const handleSidebarMouseMove = (e: MouseEvent) => { - if (!sidebarDragRef.current) return; - - latestMouseX.current = e.clientX; // Always update latest pos - - if (rafRef.current) return; // Schedule once per frame - - rafRef.current = requestAnimationFrame(() => { - if (!sidebarDragRef.current || !ghostRef.current) return; - // Use latestMouseX.current instead of stale closure 'e.clientX' - const { startX, startWidth, startGuideLeft, minWidth, maxWidth } = sidebarDragRef.current; - const delta = latestMouseX.current - startX; - const newWidth = clampSidebarResizeWidth(startWidth + delta, { minWidth, maxWidth }); - ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`; - rafRef.current = null; - }); - }; - - const handleSidebarMouseUp = (e: MouseEvent) => { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - - if (sidebarDragRef.current) { - // Use latest position for final commit too - const { startX, startWidth, minWidth, maxWidth } = sidebarDragRef.current; - const delta = e.clientX - startX; - const newWidth = clampSidebarResizeWidth(startWidth + delta, { minWidth, maxWidth }); - setSidebarWidth(newWidth); - } - - if (ghostRef.current) { - ghostRef.current.style.display = 'none'; - } - restoreSidebarDragBodyStyles(); - - sidebarDragRef.current = null; - document.removeEventListener('mousemove', handleSidebarMouseMove); - document.removeEventListener('mouseup', handleSidebarMouseUp); - }; + const { + ghostRef, + handleSidebarMouseDown, + sidebarResizeHandleWidth, + siderRef, + } = useAppSidebarResize({ + effectiveUiScale, + setSidebarWidth, + sidebarWidth, + }); useEffect(() => { document.body.style.backgroundColor = 'transparent'; @@ -3195,64 +2447,6 @@ function App() { tokenControlHeightSM, ]); - useEffect(() => { - isAboutOpenRef.current = isAboutOpen; - }, [isAboutOpen]); - - useEffect(() => { - if (isAboutOpen) { - setAboutUpdateStatus(formatAboutUpdateStatus(lastUpdateInfo)); - void loadAboutInfo(); - } - }, [formatAboutUpdateStatus, isAboutOpen, lastUpdateInfo, loadAboutInfo]); - - useEffect(() => { - const startupTimer = window.setTimeout(() => { - void checkForUpdates(true); - }, 2000); - const interval = window.setInterval(() => { - void checkForUpdates(true); - }, 30 * 60 * 1000); - return () => { - window.clearTimeout(startupTimer); - window.clearInterval(interval); - }; - }, [checkForUpdates]); - - useEffect(() => { - let offDownloadProgress: any = null; - try { - 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: prev.open, - version: prev.version, - status: nextStatus, - percent, - downloaded, - total, - message: String(event.message || '') - })); - }); - } catch (e) { - console.warn("Wails API: EventsOn unavailable", e); - } - return () => { - if (offDownloadProgress) offDownloadProgress(); - }; - }, []); - useEffect(() => { const handleOpenShortcutSettingsEvent = () => { setIsShortcutModalOpen(true); @@ -3945,9 +3139,9 @@ function App() { )} {isLogPanelOpen && ( - setIsLogPanelOpen(false)} + )} @@ -4910,7 +4104,7 @@ function App() { ) : null, lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( - + ) : null, , , @@ -5907,7 +5101,7 @@ function App() { - - - - {uriFeedback && ( - setUriFeedback(null)} - style={{ marginBottom: 16 }} - /> - )} - {renderStoredSecretControls({ - fieldName: "uri", - clearKey: "opaqueURI", - hasStoredSecret: initialValues?.hasOpaqueURI, - clearLabel: t("connection.modal.uri.stored.clear"), - description: t("connection.modal.uri.stored.description"), - })} - - ), - })} - - {isCustom ? ( - <> - {renderConfigSectionCard({ - sectionKey: "customDriver", - icon: , - children: ( - - - - ), - })} - {renderConfigSectionCard({ - sectionKey: "customDsn", - icon: , - children: ( - <> - - - - {renderStoredSecretControls({ - fieldName: "dsn", - clearKey: "opaqueDSN", - hasStoredSecret: initialValues?.hasOpaqueDSN, - clearLabel: t("connection.modal.field.dsn.clearSaved"), - description: t( - "connection.modal.field.dsn.savedDescription", - ), - })} - - ), - })} - - ) : isJVM ? ( - <> - {unsupportedJvmModeMessage && ( - - )} -
-
- {renderJvmSectionHeader( - , - t("connection.modal.jvm.target.title"), - t("connection.modal.jvm.target.description"), - )} -
- - - - - - -
-
-
- - {t("connection.modal.jvm.environment.title")} - - {renderChoiceCards({ - fieldName: "jvmEnvironment", - value: String(jvmEnvironment), - minWidth: 120, - options: [ - { - value: "dev", - label: t( - "connection.modal.jvm.environment.dev.label", - ), - description: t( - "connection.modal.jvm.environment.dev.description", - ), - }, - { - value: "uat", - label: t( - "connection.modal.jvm.environment.staging.label", - ), - description: t( - "connection.modal.jvm.environment.staging.description", - ), - }, - { - value: "prod", - label: t( - "connection.modal.jvm.environment.prod.label", - ), - description: t( - "connection.modal.jvm.environment.prod.description", - ), - }, - ], - })} -
- - - - - {t("connection.modal.jvm.readonlyPreferred")} - -
-
- -
- {renderJvmSectionHeader( - , - t("connection.modal.jvm.accessMode.title"), - t("connection.modal.jvm.accessMode.description"), - )} - -
- {JVM_EDITABLE_MODES.map((mode) => { - const meta = resolveJVMModeMeta(mode); - const enabled = normalizedJvmAllowedModes.includes(mode); - const preferred = jvmPreferredMode === mode; - return ( -
handleJvmModeCardSelect(mode)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleJvmModeCardSelect(mode); - } - }} - aria-pressed={enabled} - style={{ - textAlign: "left", - padding: 14, - borderRadius: 16, - border: enabled - ? darkMode - ? "1px solid rgba(255,214,102,0.36)" - : "1px solid rgba(22,119,255,0.34)" - : darkMode - ? "1px solid rgba(255,255,255,0.08)" - : "1px solid rgba(16,24,40,0.08)", - background: enabled - ? darkMode - ? "rgba(255,214,102,0.08)" - : "rgba(22,119,255,0.06)" - : darkMode - ? "rgba(255,255,255,0.03)" - : "rgba(16,24,40,0.03)", - boxShadow: preferred - ? darkMode - ? "0 0 0 2px rgba(255,214,102,0.12)" - : "0 0 0 2px rgba(22,119,255,0.10)" - : "none", - color: darkMode ? "#f5f7ff" : "#162033", - cursor: "pointer", - transition: "all 120ms ease", - }} - > - - - {meta.label} - - {preferred ? ( - - {t("connection.modal.jvm.tag.preferred")} - - ) : null} - {!enabled ? ( - {t("connection.modal.jvm.tag.notEnabled")} - ) : null} - -
- {mode === "jmx" - ? t("connection.modal.jvm.mode.jmx.description") - : mode === "endpoint" - ? t( - "connection.modal.jvm.mode.endpoint.description", - ) - : t("connection.modal.jvm.mode.agent.description")} -
- -
- ); - })} -
-
- {t("connection.modal.jvm.preferredSummary", { - mode: resolveJVMModeMeta(String(jvmPreferredMode || "jmx")) - .label, - })} -
-
- -
- {renderJvmSectionHeader( - , - "JMX", - t("connection.modal.jvm.jmx.description"), - - {normalizedJvmAllowedModes.includes("jmx") - ? t("connection.modal.jvm.tag.enabled") - : t("connection.modal.jvm.tag.notEnabled")} - , - )} -
- - - - - - -
-
- - - - - - -
-
- -
- {renderJvmSectionHeader( - , - "Endpoint", - t("connection.modal.jvm.endpoint.description"), - - {normalizedJvmAllowedModes.includes("endpoint") - ? t("connection.modal.jvm.tag.enabled") - : t("connection.modal.jvm.tag.notEnabled")} - , - )} - - - - - - -
- -
- {renderJvmSectionHeader( - , - "Agent", - t("connection.modal.jvm.agent.description"), - - {normalizedJvmAllowedModes.includes("agent") - ? t("connection.modal.jvm.tag.enabled") - : t("connection.modal.jvm.tag.notEnabled")} - , - )} - - - - - - -
- -
- {renderJvmSectionHeader( - , - t("connection.modal.jvm.diagnostic.title"), - t("connection.modal.jvm.diagnostic.description"), - - - , - )} - {jvmDiagnosticEnabled ? ( - <> -
-
- - {t("connection.modal.jvm.diagnostic.transport.label")} - - {renderChoiceCards({ - fieldName: "jvmDiagnosticTransport", - value: String(jvmDiagnosticTransport), - options: [ - { - value: "agent-bridge", - label: t( - "connection.modal.jvm.diagnostic.transport.agent_bridge", - ), - description: t( - "connection.modal.jvm.diagnostic.transport.agentBridge.description", - ), - }, - { - value: "arthas-tunnel", - label: t( - "connection.modal.jvm.diagnostic.transport.arthas_tunnel", - ), - description: t( - "connection.modal.jvm.diagnostic.transport.arthasTunnel.description", - ), - }, - ], - })} -
- - - -
-
- - - - - - -
- - - -
- {[ - { - name: "jvmDiagnosticAllowObserveCommands", - label: t( - "connection.modal.jvm.diagnostic.command.observe.label", - ), - description: t( - "connection.modal.jvm.diagnostic.command.observe.description", - ), - }, - { - name: "jvmDiagnosticAllowTraceCommands", - label: t( - "connection.modal.jvm.diagnostic.command.trace.label", - ), - description: t( - "connection.modal.jvm.diagnostic.command.trace.description", - ), - }, - { - name: "jvmDiagnosticAllowMutatingCommands", - label: t( - "connection.modal.jvm.diagnostic.command.mutating.label", - ), - description: t( - "connection.modal.jvm.diagnostic.command.mutating.description", - ), - }, - ].map((item) => ( -
- - {item.label} - -
- {item.description} -
-
- ))} -
- - ) : ( -
- {t("connection.modal.jvm.diagnostic.disabledHint")} -
- )} -
-
- - ) : ( - <> - {renderConfigSectionCard({ - sectionKey: isFileDb ? "fileTarget" : "target", - icon: isFileDb ? : , - children: ( -
- - - - {isFileDb ? ( - - - - ) : ( - Number(value) > 0, - ), - ]} - style={{ marginBottom: 0 }} - > - - - )} -
- ), - })} - - {dbType === "clickhouse" && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: ( - - { - form.setFieldsValue({ mysqlTopology: "single" }); - clearConnectionTestResultForChoice(); - }} - /> - - ), - })} - - {(dbType === "postgres" || - dbType === "kingbase" || - dbType === "highgo" || - dbType === "vastbase" || - dbType === "opengauss" || - dbType === "gaussdb") && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "kafka" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "rocketmq" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "mqtt" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {dbType === "rabbitmq" && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {(dbType === "oracle" || isOceanBaseOracle) && - renderConfigSectionCard({ - sectionKey: "service", - icon: , - children: ( - - - - ), - })} - - {isMySQLLike && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "mysqlTopology", - value: String(mysqlTopology), - options: [ - { - value: "single", - label: t("connection.modal.topology.single.label"), - description: t( - "connection.modal.topology.mysql.single.description", - ), - }, - { - value: "replica", - label: t( - "connection.modal.topology.mysql.replica.label", - ), - description: t( - "connection.modal.topology.mysql.replica.description", - ), - }, - ], - }), - })} - - {isKafka && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "kafkaTopology", - value: String(kafkaTopology), - options: [ - { - value: "single", - label: t("connection.modal.messageQueue.kafka.topology.single.label"), - description: t( - "connection.modal.messageQueue.kafka.topology.single.description", - ), - }, - { - value: "cluster", - label: t("connection.modal.messageQueue.topology.cluster.label"), - description: t( - "connection.modal.messageQueue.kafka.topology.cluster.description", - ), - }, - ], - }), - })} - - {isRocketMQ && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "rocketmqTopology", - value: String(rocketmqTopology), - options: [ - { - value: "single", - label: t("connection.modal.messageQueue.rocketmq.topology.single.label"), - description: t( - "connection.modal.messageQueue.rocketmq.topology.single.description", - ), - }, - { - value: "cluster", - label: t("connection.modal.messageQueue.topology.cluster.label"), - description: t( - "connection.modal.messageQueue.rocketmq.topology.cluster.description", - ), - }, - ], - }), - })} - - {isMQTT && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "mqttTopology", - value: String(mqttTopology), - options: [ - { - value: "single", - label: t("connection.modal.messageQueue.mqtt.topology.single.label"), - description: t( - "connection.modal.messageQueue.mqtt.topology.single.description", - ), - }, - { - value: "cluster", - label: t("connection.modal.messageQueue.topology.cluster.label"), - description: t( - "connection.modal.messageQueue.mqtt.topology.cluster.description", - ), - }, - ], - }), - })} - - {isKafka && - kafkaTopology === "cluster" && - renderConfigSectionCard({ - sectionKey: "replica", - icon: , - children: ( - - - - ), - })} - - {isMQTT && - mqttTopology === "cluster" && - renderConfigSectionCard({ - sectionKey: "replica", - icon: , - children: ( - - - -
- - - - - - -
- {renderStoredSecretControls({ - fieldName: "mysqlReplicaPassword", - clearKey: "mysqlReplicaPassword", - hasStoredSecret: initialValues?.hasMySQLReplicaPassword, - clearLabel: t( - "connection.modal.field.mysqlReplicaPassword.clear", - ), - description: t( - "connection.modal.field.mysqlReplicaPassword.savedDescription", - ), - })} - - ), - })} - - {dbType === "mongodb" && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: renderChoiceCards({ - fieldName: "mongoTopology", - value: String(mongoTopology), - options: [ - { - value: "single", - label: t("connection.modal.topology.single.label"), - description: t( - "connection.modal.topology.mongodb.single.description", - ), - }, - { - value: "replica", - label: t( - "connection.modal.topology.mongodb.replica.label", - ), - description: t( - "connection.modal.topology.mongodb.replica.description", - ), - }, - ], - }), - })} - - {dbType === "mongodb" && - renderConfigSectionCard({ - sectionKey: "mongoDiscovery", - icon: , - children: ( - <> - -
- {[ - { - value: false, - label: t( - "connection.modal.mongo.discovery.standard.label", - ), - description: t( - "connection.modal.mongo.discovery.standard.description", - ), - }, - { - value: true, - label: t( - "connection.modal.mongo.discovery.srv.label", - ), - description: t( - "connection.modal.mongo.discovery.srv.description", - ), - }, - ].map((option) => { - const active = mongoSrv === option.value; - return ( - - ); - })} -
- {mongoSrv && useSSH && ( - - )} - - ), - })} - - {dbType === "mongodb" && - mongoTopology === "replica" && - renderConfigSectionCard({ - sectionKey: "replica", - icon: , - children: ( - <> - - - - - - - - - - - {renderStoredSecretControls({ - fieldName: "mongoReplicaPassword", - clearKey: "mongoReplicaPassword", - hasStoredSecret: initialValues?.hasMongoReplicaPassword, - clearLabel: t( - "connection.modal.field.mongoReplicaPassword.clear", - ), - description: t( - "connection.modal.field.mongoReplicaPassword.savedDescription", - ), - })} - - - - {mongoMembers.length > 0 && ( - record.host} - pagination={false} - dataSource={mongoMembers} - style={{ marginBottom: 12 }} - columns={[ - { - title: t("connection.modal.field.host.label"), - dataIndex: "host", - width: "48%", - }, - { - title: t("connection.modal.mongo.member.role"), - dataIndex: "role", - width: "32%", - render: ( - value: string, - record: MongoMemberInfo, - ) => ( - - {value || - record.state || - t("common.unknown")} - - ), - }, - { - title: t("connection.modal.mongo.member.health"), - dataIndex: "healthy", - width: "20%", - render: (value: boolean) => ( - - {value - ? t("connection.modal.mongo.member.healthy") - : t( - "connection.modal.mongo.member.unhealthy", - )} - - ), - }, - ]} - /> - )} - - ), - })} - - {dbType === "mongodb" && - renderConfigSectionCard({ - sectionKey: "mongoPolicy", - icon: , - children: ( -
- - - -
- - {t("connection.modal.mongo.readPreference.label")} - - {renderChoiceCards({ - fieldName: "mongoReadPreference", - value: String(mongoReadPreference), - minWidth: 130, - options: [ - { - value: "primary", - label: "primary", - description: t( - "connection.modal.mongo.readPreference.primary.description", - ), - }, - { - value: "primaryPreferred", - label: "primaryPreferred", - description: t( - "connection.modal.mongo.readPreference.primaryPreferred.description", - ), - }, - { - value: "secondary", - label: "secondary", - description: t( - "connection.modal.mongo.readPreference.secondary.description", - ), - }, - { - value: "secondaryPreferred", - label: "secondaryPreferred", - description: t( - "connection.modal.mongo.readPreference.secondaryPreferred.description", - ), - }, - { - value: "nearest", - label: "nearest", - description: t( - "connection.modal.mongo.readPreference.nearest.description", - ), - }, - ], - })} -
-
- ), - })} - - {isRedis && - renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: ( - <> - {renderChoiceCards({ - fieldName: "redisTopology", - value: String(redisTopology), - options: [ - { - value: "single", - label: t("connection.modal.topology.single.label"), - description: t( - "connection.modal.topology.redis.single.description", - ), - }, - { - value: "cluster", - label: t( - "connection.modal.topology.redis.cluster.label", - ), - description: t( - "connection.modal.topology.redis.cluster.description", - ), - }, - ], - })} - {redisTopology === "cluster" && ( - - - {redisDbList.map((db) => ( - - db{db} - - ))} - - - ), - })} - - {!isFileDb && - !isRedis && - renderConfigSectionCard({ - sectionKey: "credentials", - icon: , - children: ( - <> -
- - - - - - - {dbType === "mongodb" && ( -
- - {t( - "connection.modal.mongo.authMechanism.label", - )} - - {renderChoiceCards({ - fieldName: "mongoAuthMechanism", - value: String(mongoAuthMechanism), - minWidth: 150, - options: [ - { - value: "", - label: t( - "connection.modal.mongo.authMechanism.auto.label", - ), - description: t( - "connection.modal.mongo.authMechanism.auto.description", - ), - }, - { - value: "NONE", - label: t( - "connection.modal.mongo.authMechanism.none.label", - ), - description: t( - "connection.modal.mongo.authMechanism.none.description", - ), - }, - { - value: "SCRAM-SHA-1", - label: "SCRAM-SHA-1", - description: t( - "connection.modal.mongo.authMechanism.scramSha1.description", - ), - }, - { - value: "SCRAM-SHA-256", - label: "SCRAM-SHA-256", - description: t( - "connection.modal.mongo.authMechanism.scramSha256.description", - ), - }, - { - value: "MONGODB-AWS", - label: "MONGODB-AWS", - description: t( - "connection.modal.mongo.authMechanism.aws.description", - ), - }, - ], - })} -
- )} -
- {dbType === "mongodb" && ( - - - {t("connection.modal.field.savePassword")} - - - )} - - ), - })} - - {!isFileDb && - !isRedis && - !isKafka && - renderConfigSectionCard({ - sectionKey: "databaseScope", - icon: , - children: ( - - - - ), - })} - - )} - - - ); - - const networkSecuritySection = - !isFileDb && !isJVM - ? (() => { - const effectiveUseSSL = useSSL || !!form.getFieldValue("useSSL"); - const effectiveUseSSH = useSSH || !!form.getFieldValue("useSSH"); - const effectiveUseHttpTunnel = - useHttpTunnel || - !!form.getFieldValue("useHttpTunnel"); - const effectiveUseProxy = - !effectiveUseHttpTunnel && - (useProxy || !!form.getFieldValue("useProxy")); - const networkItems: Array<{ - key: "ssl" | "ssh" | "proxy" | "httpTunnel"; - title: string; - description: string; - enabled: boolean; - }> = [ - ...(isSSLType - ? [ - { - key: "ssl" as const, - title: t("connection.modal.network.ssl_tls"), - description: t( - "connection.modal.network.ssl.description", - ), - enabled: effectiveUseSSL, - }, - ] - : []), - { - key: "ssh", - title: t("connection.modal.network.ssh.title"), - description: t("connection.modal.network.ssh.description"), - enabled: effectiveUseSSH, - }, - { - key: "proxy", - title: t("connection.modal.network.proxy.title"), - description: t("connection.modal.network.proxy.description"), - enabled: effectiveUseProxy, - }, - { - key: "httpTunnel", - title: t("connection.modal.network.httpTunnel.title"), - description: t( - "connection.modal.network.httpTunnel.description", - ), - enabled: effectiveUseHttpTunnel, - }, - ]; - const resolvedNetworkConfig = - activeNetworkConfig === "ssl" && !effectiveUseSSL - ? networkItems.find((item) => item.enabled)?.key || - (networkItems.some((item) => item.key === activeNetworkConfig) - ? activeNetworkConfig - : networkItems[0]?.key || "ssh") - : networkItems.some((item) => item.key === activeNetworkConfig) - ? activeNetworkConfig - : networkItems[0]?.key || "ssh"; - const renderNetworkPanel = () => { - if (resolvedNetworkConfig === "ssl") { - return ( -
-
- {t("connection.modal.network.ssl_tls")} -
-
- {t("connection.modal.network.ssl.panelDescription")} -
- {!effectiveUseSSL ? ( -
-
{t("connection.modal.network.ssl.disabledHint")}
-
{sslHintText}
-
- ) : ( -
-
- - {t("connection.modal.network.ssl.mode")} - - {renderChoiceCards({ - fieldName: "sslMode", - value: String(sslMode), - options: [ - { - value: "preferred", - label: t( - "connection.modal.network.ssl_mode.preferred", - ), - description: t( - "connection.modal.network.ssl.preferred.description", - ), - }, - { - value: "required", - label: t( - "connection.modal.network.ssl_mode.required", - ), - description: t( - "connection.modal.network.ssl.required.description", - ), - }, - { - value: "skip-verify", - label: t( - "connection.modal.network.ssl_mode.skip_verify", - ), - description: t( - "connection.modal.network.ssl.skipVerify.description", - ), - }, - ], - })} -
- {(supportsSSLCAPath || supportsSSLClientCertificate) && ( -
- {supportsSSLCAPath && ( - - - - - - - - - )} - {supportsSSLClientCertificate && ( - <> - - - - - - - - - - - - - - - - - - )} -
- )} - - {sslHintText} - -
- )} -
- ); - } - if (resolvedNetworkConfig === "ssh") { - return ( -
-
- {t("connection.modal.network.ssh.title")} -
-
- {t("connection.modal.network.ssh.panelDescription")} -
- {!effectiveUseSSH ? ( -
- {t("connection.modal.network.ssh.disabledHint")} -
- ) : ( -
-
- - - - - - -
-
- - - - - - -
- - - - - - - - - {renderStoredSecretControls({ - fieldName: "sshPassword", - clearKey: "sshPassword", - hasStoredSecret: initialValues?.hasSSHPassword, - clearLabel: t( - "connection.modal.network.ssh.clearPassword", - ), - description: t( - "connection.modal.network.ssh.savedDescription", - ), - })} -
- )} -
- ); - } - if (resolvedNetworkConfig === "proxy") { - return ( -
-
- {t("connection.modal.network.proxy.title")} -
-
- {t("connection.modal.network.proxy.panelDescription")} -
- {!effectiveUseProxy ? ( -
- {t("connection.modal.network.proxy.disabledHint")} -
- ) : ( -
- - - -
-
- - {t("connection.modal.network.proxy.type")} - - {renderChoiceCards({ - fieldName: "proxyType", - value: String(proxyType), - minWidth: 150, - options: [ - { - value: "socks5", - label: "SOCKS5", - description: t( - "connection.modal.network.proxy.socks5.description", - ), - }, - { - value: "http", - label: "HTTP CONNECT", - description: t( - "connection.modal.network.proxy.http.description", - ), - }, - ], - })} -
- - - -
-
- - - - - - -
- {renderStoredSecretControls({ - fieldName: "proxyPassword", - clearKey: "proxyPassword", - hasStoredSecret: initialValues?.hasProxyPassword, - clearLabel: t( - "connection.modal.network.proxy.clearPassword", - ), - description: t( - "connection.modal.network.proxy.savedDescription", - ), - })} -
- )} -
- ); - } - return ( -
-
- {t("connection.modal.network.httpTunnel.title")} -
-
- {t( - "connection.modal.network.httpTunnel.panelDescription", - )} -
- {!effectiveUseHttpTunnel ? ( -
- {t("connection.modal.network.httpTunnel.disabledHint")} -
- ) : ( -
-
- - - - - - -
-
- - - - - - -
- {renderStoredSecretControls({ - fieldName: "httpTunnelPassword", - clearKey: "httpTunnelPassword", - hasStoredSecret: initialValues?.hasHttpTunnelPassword, - clearLabel: t( - "connection.modal.network.httpTunnel.clearPassword", - ), - description: t( - "connection.modal.network.httpTunnel.savedDescription", - ), - })} - - {t( - "connection.modal.network.httpTunnel.exclusiveHint", - )} - -
- )} -
- ); - }; - - return ( -
-
- {t("connection.modal.network.title")} -
-
- {t("connection.modal.network.description")} -
-
- {networkItems.map((item) => { - const active = item.key === resolvedNetworkConfig; - const activeColor = darkMode ? "#ffd666" : "#1677ff"; - return ( -
setActiveNetworkConfig(item.key)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setActiveNetworkConfig(item.key); - } - }} - style={{ - ...getConnectionOptionCardStyle(item.enabled), - borderColor: active - ? darkMode - ? "rgba(255,214,102,0.46)" - : "rgba(24,144,255,0.36)" - : "transparent", - background: active - ? darkMode - ? "linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)" - : "linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)" - : getConnectionOptionCardStyle(item.enabled) - .background, - boxShadow: active - ? darkMode - ? "0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)" - : "0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)" - : "none", - cursor: "pointer", - outline: "none", - }} - > -
-
-
- - - -
-
- - {item.title} - -
- {active && ( - - {t( - "connection.modal.network.currentEditing", - )} - - )} - - {item.enabled - ? t("connection.modal.network.enabled") - : t( - "connection.modal.network.notEnabled", - )} - -
-
-
- {item.description} -
-
-
-
-
- ); - })} -
-
{renderNetworkPanel()}
-
-
- {t("connection.modal.network.advanced.title")} -
- - - -
-
- ); - })() - : null; - - return ( -
{ - if (testResult) { - setTestResult(null); - setTestErrorLogOpen(false); - } - if ( - changed.uri !== undefined || - changed.connectionParams !== undefined || - changed.type !== undefined || - changed.oceanBaseProtocol !== undefined - ) { - setUriFeedback(null); - } - if (changed.useSSL !== undefined) { - setUseSSL(changed.useSSL); - if (changed.useSSL) setActiveNetworkConfig("ssl"); - } - if (changed.useSSH !== undefined) { - setUseSSH(changed.useSSH); - if (changed.useSSH) setActiveNetworkConfig("ssh"); - } - if (changed.useProxy !== undefined) { - const enabledProxy = !!changed.useProxy; - setUseProxy(enabledProxy); - if (enabledProxy) setActiveNetworkConfig("proxy"); - if (enabledProxy && form.getFieldValue("useHttpTunnel")) { - form.setFieldValue("useHttpTunnel", false); - setUseHttpTunnel(false); - } - } - if (changed.proxyType !== undefined) { - const nextType = String( - changed.proxyType || "socks5", - ).toLowerCase(); - if (nextType === "http") { - const currentPort = Number(form.getFieldValue("proxyPort") || 0); - if (!currentPort || currentPort === 1080) { - form.setFieldValue("proxyPort", 8080); - } - } else { - const currentPort = Number(form.getFieldValue("proxyPort") || 0); - if (!currentPort || currentPort === 8080) { - form.setFieldValue("proxyPort", 1080); - } - } - } - if (changed.useHttpTunnel !== undefined) { - const enabledHttpTunnel = !!changed.useHttpTunnel; - setUseHttpTunnel(enabledHttpTunnel); - if (enabledHttpTunnel) setActiveNetworkConfig("httpTunnel"); - if (enabledHttpTunnel && form.getFieldValue("useProxy")) { - form.setFieldValue("useProxy", false); - setUseProxy(false); - } - if (enabledHttpTunnel) { - const currentPort = Number( - form.getFieldValue("httpTunnelPort") || 0, - ); - if (!currentPort || currentPort <= 0) { - form.setFieldValue("httpTunnelPort", 8080); - } - } - } - if (changed.type !== undefined) setDbType(changed.type); - if (changed.jvmAllowedModes !== undefined) { - const resolvedModes = normalizeEditableJVMModes( - changed.jvmAllowedModes, - ); - const currentPreferredMode = String( - form.getFieldValue("jvmPreferredMode") || "", - ) - .trim() - .toLowerCase(); - const resolvedPreferredMode = - resolvedModes.find((mode) => mode === currentPreferredMode) || - resolvedModes[0]; - form.setFieldValue("jvmAllowedModes", resolvedModes); - form.setFieldValue("jvmPreferredMode", resolvedPreferredMode); - form.setFieldValue( - "jvmEndpointEnabled", - resolvedModes.includes("endpoint"), - ); - form.setFieldValue( - "jvmAgentEnabled", - resolvedModes.includes("agent"), - ); - } - if (changed.redisTopology !== undefined) { - const nextRedisTopology = String( - changed.redisTopology || "single", - ).toLowerCase(); - const currentRedisPort = Number(form.getFieldValue("port") || 0); - if ( - nextRedisTopology === "sentinel" && - (!currentRedisPort || currentRedisPort === 6379) - ) { - form.setFieldValue("port", 26379); - } else if ( - nextRedisTopology !== "sentinel" && - currentRedisPort === 26379 - ) { - form.setFieldValue("port", 6379); - } - const supportedDbs = buildRedisDatabaseList( - form.getFieldValue("redisDB"), - form.getFieldValue("includeRedisDatabases"), - ); - setRedisDbList(supportedDbs); - form.setFieldValue( - "includeRedisDatabases", - normalizeRedisDatabaseSelection( - form.getFieldValue("includeRedisDatabases"), - supportedDbs, - ), - ); - } - if ( - changed.type !== undefined || - changed.host !== undefined || - changed.port !== undefined || - changed.mongoHosts !== undefined || - changed.mongoTopology !== undefined || - changed.mongoSrv !== undefined - ) { - setMongoMembers([]); - } - }} - > - - {currentDriverUnavailableReason && ( - - {currentDriverUnavailableReason} - - - } - /> - )} - {currentDriverUpdateReason && ( - - {currentDriverUpdateReason} - - - } - /> - )} - {(() => { - const sectionItems: Array<{ - key: "basic" | "network" | "appearance"; - title: string; - description: string; - icon: React.ReactNode; - }> = [ - { - key: "basic", - title: t("connection.modal.config.basic.title"), - description: isJVM - ? t("connection.modal.config.basic.jvmNavDescription") - : t("connection.modal.config.basic.navDescription"), - icon: , - }, - ...(!isCustom && !isFileDb && !isJVM - ? [ - { - key: "network" as const, - title: t("connection.modal.network.title"), - description: t( - "connection.modal.network.navDescription", - ), - icon: , - }, - ] - : []), - { - key: "appearance", - title: t("connection.modal.appearance.title"), - description: t("connection.modal.appearance.description"), - icon: , - }, - ]; - const resolvedSection = sectionItems.some( - (item) => item.key === activeConfigSection, - ) - ? activeConfigSection - : sectionItems[0]?.key || "basic"; - - const effectiveIconType = customIconType || dbType; - const effectiveIconColor = - customIconColor || getDbDefaultColor(effectiveIconType); - - const appearanceSection = ( -
-
-
- {t("connection.modal.appearance.icon")} -
-
- {DB_ICON_TYPES.map((iconKey) => { - const isActive = effectiveIconType === iconKey; - return ( - - ); - })} -
-
- {t("connection.modal.appearance.current", { - name: getDbIconLabel(effectiveIconType), - })} -
-
-
-
- {t("connection.modal.appearance.color")} -
-
- {PRESET_ICON_COLORS.map((presetColor) => { - const isActive = effectiveIconColor === presetColor; - return ( -
-
-
-
- {t("connection.modal.appearance.preview")} -
-
- {getDbIcon(effectiveIconType, effectiveIconColor, 24)} - - {form.getFieldValue("name") || - t("connection.modal.appearance.previewName")} - -
- {(customIconType || customIconColor) && ( - - )} -
-
- ); - - const currentSectionContent = - resolvedSection === "basic" - ? baseInfoSection - : resolvedSection === "appearance" - ? appearanceSection - : networkSecuritySection; - - if (sectionItems.length <= 1) { - return currentSectionContent; - } - - return ( -
-
-
- {t("connection.modal.config.sections")} -
-
- {sectionItems.map((item) => { - const active = item.key === resolvedSection; - return ( - - ); - })} -
-
-
{currentSectionContent}
-
- ); - })()} - - ); - }; + const renderStep2 = () => ( + + ); const getFooter = () => { if (step === 1) { diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 3902d89..2e79517 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; +import { readV2ThemeCss } from '../test/readV2ThemeCss'; import DataGrid, { buildGridFieldSelectOptions, @@ -23,6 +24,17 @@ import { getCurrentLanguage, setCurrentLanguage, type LanguagePreference } from import { V2CellContextMenuView } from './V2TableContextMenu'; import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS } from '../utils/shortcuts'; +const readDataGridSource = () => [ + './useDataGridBatchActions.ts', + './DataGrid.tsx', + './useDataGridV2Actions.ts', + './useDataGridMetadata.ts', + './useDataGridColumnResize.ts', + './dataGridStyles.ts', + './DataGridCore.tsx', + './DataGridShell.tsx', +].map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n'); + const mockStoreState = vi.hoisted(() => ({ languagePreference: 'system' as LanguagePreference, uiVersion: 'v2', @@ -88,6 +100,21 @@ vi.mock('@monaco-editor/react', () => ({ ), })); +const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; +}); +const cancelAnimationFrameMock = vi.fn(); + +vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock); +vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock); +vi.stubGlobal('window', { + requestAnimationFrame: requestAnimationFrameMock, + cancelAnimationFrame: cancelAnimationFrameMock, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}); + const renderDataGridWithI18n = ( element: React.ReactElement, options: { preference?: LanguagePreference; systemLanguages?: readonly string[] } = {}, @@ -164,7 +191,7 @@ describe('DataGrid layout', () => { }); it('localizes DataGrid error boundary, column drag affordances, and legacy row context menu labels through i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const expectedKeys = [ 'data_grid.error_boundary.title', 'data_grid.error_boundary.description', @@ -230,7 +257,7 @@ describe('DataGrid layout', () => { }); it('localizes legacy cell context menu labels through translateDataGrid', () => { - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const legacyMenuSource = readFileSync(new URL('./DataGridLegacyCellContextMenu.tsx', import.meta.url), 'utf8'); const legacyMountStart = dataGridSource.indexOf(' { }); it('localizes row copy and paste feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const rowCopyPasteFeedbackKeys = [ 'data_grid.message.select_rows_to_copy', 'data_grid.message.copied_rows', @@ -345,7 +372,7 @@ describe('DataGrid layout', () => { }); it('localizes selected-column copy and paste feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const columnCopyPasteFeedbackKeys = [ 'data_grid.message.select_same_row_cells_to_copy', 'data_grid.message.no_copyable_cells', @@ -423,7 +450,7 @@ describe('DataGrid layout', () => { }); it('localizes commit, preview SQL, and basic copy feedback in the scoped DataGrid windows', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const previewCommitCopyStart = source.indexOf('const handlePreviewChanges = useCallback'); const clipboardRowsStart = source.indexOf('const getClipboardRows = useCallback', previewCommitCopyStart); expect(previewCommitCopyStart).toBeGreaterThan(-1); @@ -478,7 +505,7 @@ describe('DataGrid layout', () => { }); it('localizes Preview SQL Modal chrome while preserving raw SQL text and operation labels', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const previewModalStart = source.indexOf('{/* Preview SQL Modal */}'); const importPreviewStart = source.indexOf('{/* Import Preview Modal */}', previewModalStart); expect(previewModalStart).toBeGreaterThan(-1); @@ -514,7 +541,7 @@ describe('DataGrid layout', () => { }); it('localizes query-result, selected-cell, copy-SQL, and current-row copy feedback', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const copyFeedbackStart = source.indexOf('const getClipboardRows = useCallback'); const copyFeedbackEnd = source.indexOf('const buildConnConfig = useCallback', copyFeedbackStart); expect(copyFeedbackStart).toBeGreaterThan(-1); @@ -552,7 +579,7 @@ describe('DataGrid layout', () => { }); it('localizes batch fill feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const batchFillCellsKeys = [ 'data_grid.message.select_cells_to_fill', 'data_grid.message.selected_cells_no_update', @@ -631,7 +658,7 @@ describe('DataGrid layout', () => { }); it('localizes editor and JSON feedback through DataGrid i18n keys', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const sliceCallback = (startMarker: string, endMarker: string) => { const start = source.indexOf(startMarker); expect(start).toBeGreaterThan(-1); @@ -780,7 +807,7 @@ describe('DataGrid layout', () => { mockStoreState.uiVersion = previousUiVersion; } - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toMatch(/import\s+\{\s*getCurrentLanguage,\s*t\s*\}\s+from\s+['"]\.\.\/i18n['"]/); expect(source).toMatch(/import\s+\{\s*useOptionalI18n\s*\}\s+from\s+['"]\.\.\/i18n\/provider['"]/); @@ -942,7 +969,7 @@ describe('DataGrid layout', () => { }); it('hides current-page find in JSON and text record views', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toContain("const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null;"); expect(source).toContain('pageFindContent={visiblePageFindContent}'); @@ -952,7 +979,7 @@ describe('DataGrid layout', () => { const source = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8'); const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8'); const pageFindSource = readFileSync(new URL('./DataGridPageFind.tsx', import.meta.url), 'utf8'); - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const paginationSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8'); expect(source).toContain('data-grid-legacy-secondary-actions="true"'); @@ -1028,7 +1055,7 @@ describe('DataGrid layout', () => { }); it('keeps detached DataGrid chrome text behind translateDataGrid', () => { - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const toolbarFrameSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); const pageFindSource = readFileSync(new URL('./DataGridPageFind.tsx', import.meta.url), 'utf8'); const resultViewSource = readFileSync(new URL('./DataGridResultViewSwitcher.tsx', import.meta.url), 'utf8'); @@ -1524,7 +1551,7 @@ describe('DataGrid layout', () => { }); it('localizes DataGrid filter option labels through the filter hook translator', () => { - const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridSource = readDataGridSource(); const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8'); const filterOpOptionsStart = filterHookSource.indexOf('const filterOpOptions = React.useMemo'); const filterLogicOptionsStart = filterHookSource.indexOf('const filterLogicOptions = React.useMemo'); @@ -1965,13 +1992,13 @@ describe('DataGrid layout', () => { }); it('clears modified cell markers when refreshing the grid', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); - expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[clearAutoCommitTimer, onReload\]\);/); + expect(source).toMatch(/const handleRefreshGrid = useCallback\(\(\) => \{[\s\S]*setModifiedColumns\(\{\}\);[\s\S]*if \(onReload\) onReload\(\);[\s\S]*\}, \[[\s\S]*clearAutoCommitTimer[\s\S]*onReload[\s\S]*\]\);/); }); it('routes temporal inline editors through the current connection config', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toContain('const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig);'); expect(source).toContain('const pickerType = getTemporalPickerType(columnType, dbType, currentConnConfig);'); @@ -2174,7 +2201,7 @@ describe('DataGrid layout', () => { }); it('keeps export and import chrome behind translateDataGrid while preserving raw details', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const exportDialogSource = readFileSync(new URL('./DataExportDialog.tsx', import.meta.url), 'utf8'); expect(source).toContain("type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll';"); @@ -2200,7 +2227,7 @@ describe('DataGrid layout', () => { }); it('keeps inline cell editors stretched to the full cell width', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); expect(source).toContain('const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: \'100%\', minWidth: 0 };'); expect(source).toContain('className="data-grid-inline-editor-form-item"'); @@ -2211,7 +2238,7 @@ describe('DataGrid layout', () => { }); it('disables browser autocapitalization for inline cell editors', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const editorInputCount = source.match(/\{\.\.\.noAutoCapInputProps\}[\s\S]{0,180}className="data-grid-inline-editor-input"/g)?.length || 0; @@ -2265,10 +2292,10 @@ describe('DataGrid layout', () => { }); it('keeps quick WHERE input clipboard editing isolated from grid shortcuts', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); const filterHookSource = readFileSync(new URL('./useDataGridFilters.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + const css = readV2ThemeCss(); expect(filterHookSource).toContain('const handleQuickWherePaste = React.useCallback'); expect(filterHookSource).toContain("event.clipboardData.getData('text/plain')"); @@ -2286,12 +2313,12 @@ describe('DataGrid layout', () => { }); it('keeps DataGrid scroll synchronization throttled to animation frames', () => { - const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const source = readDataGridSource(); const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8'); const columnTitleSource = readFileSync(new URL('./DataGridColumnTitle.tsx', import.meta.url), 'utf8'); const columnQuickFindSource = readFileSync(new URL('./DataGridColumnQuickFind.tsx', import.meta.url), 'utf8'); const paginationBarSource = readFileSync(new URL('./DataGridPaginationBar.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + const css = readV2ThemeCss(); expect(source).toContain('virtualHorizontalElementsRef'); expect(source).toContain('const handleSubmitColumnQuickFind = useCallback((submittedValue?: string) => {'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index d038b7c..3b05716 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -131,6 +131,7 @@ import DataGridPaginationBar from './DataGridPaginationBar'; import DataGridResultViewSwitcher from './DataGridResultViewSwitcher'; import DataGridSecondaryActions from './DataGridSecondaryActions'; import DataGridToolbarFrame from './DataGridToolbarFrame'; +import DataGridShell from './DataGridShell'; import DataGridModals from './DataGridModals'; import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; import DataGridPreviewPanel from './DataGridPreviewPanel'; @@ -150,1458 +151,133 @@ import { useExportProgressDialog } from './ExportProgressModal'; import { useDataGridFilters } from './useDataGridFilters'; import { useDataGridDdlView } from './useDataGridDdlView'; import { useDataGridModalEditors } from './useDataGridModalEditors'; +import { useDataGridBatchActions } from './useDataGridBatchActions'; +import { useDataGridV2Actions } from './useDataGridV2Actions'; +import { useDataGridMetadata } from './useDataGridMetadata'; +import { useDataGridColumnResize } from './useDataGridColumnResize'; import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; import { buildTableExportTab } from '../utils/tableExportTab'; +import { buildDataGridCssText } from './dataGridStyles'; // --- Error Boundary --- -interface DataGridErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -interface DataGridErrorBoundaryProps { - children: React.ReactNode; - i18nLanguage?: string; -} - -class DataGridErrorBoundary extends React.Component< +import { + DataGridErrorBoundary, + GONAVI_ROW_KEY, + GONAVI_ROW_NUMBER_COLUMN_KEY, + CELL_KEY_SEP, + CELL_SELECTION_DRAG_THRESHOLD_PX, + DATE_TIME_CACHE_LIMIT, + TABLE_CELL_PREVIEW_MAX_CHARS, + ROW_NUMBER_COLUMN_WIDTH, + DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS, + DATA_GRID_DISPLAY_RENDER_VERSION, + DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, + DEFAULT_GRID_MONO_FONT_FAMILY, + normalizedDateTimeCache, + objectCellPreviewCache, + useDataGridI18nLanguage, + makeCellKey, + splitCellKey, + resolveContextMenuFieldName, + trimSimpleCache, + looksLikeDateTimeText, + normalizeDateTimeString, + normalizeBitHexDisplayText, + isDateOnlyColumnType, + isOceanBaseOracleDisplayConnection, + normalizeOceanBaseOracleDateDisplayText, + formatCellDisplayText, + formatClipboardCellText, + normalizeClipboardTsvCell, + buildClipboardTsv, + renderHighlightedCellText, + renderCellDisplayValue, + formatCellValue, + attachDataGridVirtualEditRenderVersion, + attachDataGridDisplayRenderVersion, + hasDataGridDisplayRenderVersionChanged, + hasDataGridVirtualEditRenderVersionChanged, + toEditableText, + toFormText, + isCellValueEqualForDiff, + isCellValueEqualForRender, + INLINE_EDIT_MAX_CHARS, + shouldOpenModalEditor, + getCellFieldName, + setCellFieldValue, + looksLikeJsonText, + isPlainObject, + normalizeValueForJsonView, + isJsonViewValueEqual, + coerceJsonEditorValueForStorage, + ResizableTitle, + sortableHeaderStaticStyles, + SortableHeaderCell, + EditableContext, + CellContextMenuContext, + DataContext, + setGlobalDeletedRowKeys, + resolveEditableCellRowKey, + isEditableCellDeleted, + isEditableCellModified, + areEditableCellPropsEqual, + EditableCell, + ContextMenuRow, + buildColumnMetaMap, + hasUsableColumnMeta, + EXACT_GRID_FILTER_OPERATOR, + CONTAINS_GRID_FILTER_OPERATOR, + FILTER_FIELD_SELECT_STYLE, + FILTER_FIELD_POPUP_WIDTH, + FILTER_FIELD_OPTION_STYLE, + STRING_LIKE_GRID_FILTER_TYPES, + normalizeGridFilterColumnType, + isStringLikeGridFilterColumnType, + resolveDefaultGridFilterOperator, + resolveNextGridFilterOperatorForColumnChange, + buildGridFieldSelectOptions, + renderGridFieldSelectOption, + buildDataGridCommitChangeSet, + CELL_ELLIPSIS_STYLE, + VIRTUAL_CELL_TEXT_STYLE, + READONLY_CELL_WRAP_STYLE, + INLINE_EDIT_FORM_ITEM_STYLE, + VIRTUAL_EDITING_CELL_STYLE, +} from './DataGridCore'; +import type { + DataGridErrorBoundaryState, DataGridErrorBoundaryProps, - DataGridErrorBoundaryState -> { - constructor(props: DataGridErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('DataGrid render error:', error, errorInfo); - } - - render() { - if (this.state.hasError) { - return ( -
-

{t('data_grid.error_boundary.title', undefined, this.props.i18nLanguage)}

-

{t('data_grid.error_boundary.description', undefined, this.props.i18nLanguage)}

-
-                        {this.state.error?.message}
-                    
- -
- ); - } - return this.props.children; - } -} - -// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。 -export const GONAVI_ROW_KEY = '__gonavi_row_key__'; -export const GONAVI_ROW_NUMBER_COLUMN_KEY = '__gonavi_row_number__'; - -// Cell key helpers for batch selection/fill. -// Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). -const CELL_KEY_SEP = '\u0001'; -const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; -const DATE_TIME_CACHE_LIMIT = 2000; -const TABLE_CELL_PREVIEW_MAX_CHARS = 240; -const ROW_NUMBER_COLUMN_WIDTH = 58; -const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ - { value: 3000, seconds: 3 }, - { value: 5000, seconds: 5 }, - { value: 10000, seconds: 10 }, - { value: 30000, seconds: 30 }, -]; -const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); -const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); -const DEFAULT_GRID_MONO_FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace'; -const normalizedDateTimeCache = new Map(); -const objectCellPreviewCache = new WeakMap(); -const useDataGridI18nLanguage = () => { - const i18n = useOptionalI18n(); - return i18n?.language ?? getCurrentLanguage(); -}; -const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`; -const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | null => { - const sepIndex = cellKey.indexOf(CELL_KEY_SEP); - if (sepIndex === -1) return null; - return { - rowKey: cellKey.slice(0, sepIndex), - colName: cellKey.slice(sepIndex + CELL_KEY_SEP.length), - }; -}; -export const resolveContextMenuFieldName = (dataIndex: string, title?: string): string => { - const name = String(dataIndex || title || '').trim(); - return name; -}; - -const trimSimpleCache = (cache: Map, limit: number) => { - if (cache.size < limit) return; - const firstKey = cache.keys().next().value; - if (typeof firstKey === 'string') { - cache.delete(firstKey); - } -}; - -const looksLikeDateTimeText = (val: string): boolean => { - if (!val) return false; - const len = val.length; - if (len < 19 || len > 48) return false; - const charCode0 = val.charCodeAt(0); - if (charCode0 < 48 || charCode0 > 57) return false; - return ( - val[4] === '-' && - val[7] === '-' && - (val[10] === ' ' || val[10] === 'T') && - val[13] === ':' && - val[16] === ':' - ); -}; - -// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing. -// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. -// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. -const normalizeDateTimeString = (val: string) => { - if (!looksLikeDateTimeText(val)) { - return val; - } - - const cached = normalizedDateTimeCache.get(val); - if (cached !== undefined) { - return cached; - } - - // 检查是否为无效日期时间(0000-00-00 或类似格式) - if (/^0{4}-0{2}-0{2}/.test(val)) { - return val; // 保持原样显示,不尝试转换 - } - - const match = val.match( - /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ - ); - const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val; - trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT); - normalizedDateTimeCache.set(val, normalized); - return normalized; -}; - -// --- Helper: Format Value --- -const normalizeBitHexDisplayText = (val: any, columnType?: string): string | null => { - const typeText = String(columnType || '').trim().toLowerCase(); - if (!/^varbit(?:\s*\(\s*\d+\s*\))?$/.test(typeText) - && !/^bit(?:\s+varying)?(?:\s*\(\s*\d+\s*\))?$/.test(typeText)) { - return null; - } - if (typeof val !== 'string') return null; - const raw = val.trim(); - if (!/^0x[0-9a-f]+$/i.test(raw)) return null; - try { - return BigInt(raw).toString(10); - } catch { - return null; - } -}; - -type CellDisplayConnectionLike = TemporalConnectionLike; - -const isDateOnlyColumnType = (columnType?: string): boolean => { - const normalized = String(columnType || '').trim().toLowerCase(); - if (!normalized) return false; - const base = normalized.split(/[ (]/)[0]; - return base === 'date' || base === 'newdate'; -}; - -const isOceanBaseOracleDisplayConnection = (connectionConfig?: CellDisplayConnectionLike): boolean => { - if (!connectionConfig) return false; - const type = String(connectionConfig.type || '').trim().toLowerCase(); - const driver = String(connectionConfig.driver || '').trim().toLowerCase(); - return (type === 'oceanbase' || driver === 'oceanbase') - && normalizeOceanBaseProtocol(connectionConfig.oceanBaseProtocol) === 'oracle'; -}; - -const normalizeOceanBaseOracleDateDisplayText = ( - val: string, - columnType?: string, - connectionConfig?: CellDisplayConnectionLike, -): string | null => { - if (!isDateOnlyColumnType(columnType) || !isOceanBaseOracleDisplayConnection(connectionConfig)) { - return null; - } - const trimmed = String(val || '').trim(); - if (!trimmed) return trimmed; - const match = trimmed.match( - /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?)?$/ - ); - if (!match) return null; - const [, datePart, timePart, fractionPart] = match; - if (!timePart) return datePart; - if (timePart === '00:00:00' && (!fractionPart || /^\.0+$/.test(fractionPart))) { - return datePart; - } - return null; -}; - -export const formatCellDisplayText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { - try { - if (val === null) return 'NULL'; - const bitText = normalizeBitHexDisplayText(val, columnType); - if (bitText !== null) return bitText; - if (typeof val === 'object') { - if (!Array.isArray(val) && !isPlainObject(val)) { - return String(val); - } - const cached = objectCellPreviewCache.get(val); - if (cached !== undefined) { - return cached; - } - const topLevelSize = Array.isArray(val) ? val.length : Object.keys(val || {}).length; - if (topLevelSize > 80) { - const summary = Array.isArray(val) ? `[Array(${topLevelSize})]` : `{Object(${topLevelSize})}`; - objectCellPreviewCache.set(val, summary); - return summary; - } - try { - const nextText = JSON.stringify(val); - const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText; - objectCellPreviewCache.set(val, previewText); - return previewText; - } catch { - return '[Object]'; - } - } - if (typeof val === 'string') { - const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); - if (oceanBaseDateOnly !== null) { - return oceanBaseDateOnly.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${oceanBaseDateOnly.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : oceanBaseDateOnly; - } - const normalized = normalizeDateTimeString(val); - return normalized.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${normalized.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : normalized; - } - return String(val); - } catch (e) { - console.error('formatCellValue error:', e); - return '[Error]'; - } -}; - -const formatClipboardCellText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { - try { - if (val === null || val === undefined) return 'NULL'; - const bitText = normalizeBitHexDisplayText(val, columnType); - if (bitText !== null) return bitText; - if (typeof val === 'string') { - const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); - if (oceanBaseDateOnly !== null) return oceanBaseDateOnly; - return normalizeDateTimeString(val); - } - if (typeof val === 'object') { - try { - return JSON.stringify(val); - } catch { - return String(val); - } - } - return String(val); - } catch (e) { - console.error('formatClipboardCellText error:', e); - return '[Error]'; - } -}; - -const normalizeClipboardTsvCell = (text: string): string => text.replace(/\t/g, ' ').replace(/\r?\n/g, ' '); - -const buildClipboardTsv = ( - rows: Array>, - columnNames: string[], - getColumnType?: (columnName: string) => string | undefined, - connectionConfig?: CellDisplayConnectionLike, -): string => { - if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) { - return ''; - } - const header = columnNames.map(normalizeClipboardTsvCell).join('\t'); - const lines = rows.map((row) => ( - columnNames - .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName), connectionConfig))) - .join('\t') - )); - return [header, ...lines].join('\n'); -}; - -const renderHighlightedCellText = (text: string, query: string): React.ReactNode => { - const ranges = findDataGridTextRanges(text, query); - if (ranges.length === 0) return text; - - const nodes: React.ReactNode[] = []; - let cursor = 0; - ranges.forEach((range, index) => { - if (range.start > cursor) { - nodes.push(text.slice(cursor, range.start)); - } - nodes.push( - - {text.slice(range.start, range.end)} - , - ); - cursor = range.end; - }); - if (cursor < text.length) { - nodes.push(text.slice(cursor)); - } - return <>{nodes}; -}; - -const renderCellDisplayValue = (val: any, query: string, columnType?: string, connectionConfig?: CellDisplayConnectionLike): React.ReactNode => { - const text = formatCellDisplayText(val, columnType, connectionConfig); - const content = renderHighlightedCellText(text, query); - if (val === null) return {content}; - return content; -}; - -const formatCellValue = (val: any) => renderCellDisplayValue(val, ''); - -export const attachDataGridVirtualEditRenderVersion = ( - rows: T[], - editingCell: VirtualEditingCellState | null, -): T[] => { - if (!editingCell) return rows; - - return rows.map((row) => { - const rowKey = row?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null || String(rowKey) !== editingCell.rowKey) { - return row; - } - const nextRow = { ...(row as object) } as T; - Object.defineProperty(nextRow, DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, { - value: `${editingCell.rowKey}${CELL_KEY_SEP}${editingCell.dataIndex}`, - enumerable: true, - }); - return nextRow; - }); -}; - -export const attachDataGridDisplayRenderVersion = ( - rows: T[], - renderVersion: string, -): T[] => { - if (!renderVersion) return rows; - - return rows.map((row) => { - if (!row || typeof row !== 'object') return row; - const nextRow = { ...(row as object) } as T; - Object.defineProperty(nextRow, DATA_GRID_DISPLAY_RENDER_VERSION, { - value: renderVersion, - enumerable: true, - }); - return nextRow; - }); -}; - -export const hasDataGridDisplayRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { - const nextVersion = nextRecord && typeof nextRecord === 'object' - ? (nextRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] - : undefined; - const previousVersion = previousRecord && typeof previousRecord === 'object' - ? (previousRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] - : undefined; - return nextVersion !== previousVersion; -}; - -export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { - const nextVersion = nextRecord && typeof nextRecord === 'object' - ? (nextRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] - : undefined; - const previousVersion = previousRecord && typeof previousRecord === 'object' - ? (previousRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] - : undefined; - return nextVersion !== previousVersion; -}; - -const toEditableText = (val: any): string => { - if (val === null || val === undefined) return ''; - if (typeof val === 'string') return val; - try { - return JSON.stringify(val, null, 2); - } catch { - return String(val); - } -}; - -const toFormText = (val: any): string => { - if (val === null || val === undefined) return ''; - if (typeof val === 'string') return normalizeDateTimeString(val); - return toEditableText(val); -}; - -// 用于变更比较:NULL 与 undefined 视为同类空值;与空字符串严格区分。 -const isCellValueEqualForDiff = (left: any, right: any): boolean => { - if (left === right) return true; - const leftNullish = left === null || left === undefined; - const rightNullish = right === null || right === undefined; - if (leftNullish || rightNullish) return leftNullish && rightNullish; - return toFormText(left) === toFormText(right); -}; - -// 渲染阶段轻量比较:避免对象值在 shouldCellUpdate 中反复深度序列化导致卡顿。 -const isCellValueEqualForRender = (left: any, right: any): boolean => { - if (left === right) return true; - const leftNullish = left === null || left === undefined; - const rightNullish = right === null || right === undefined; - if (leftNullish || rightNullish) return leftNullish && rightNullish; - - const leftType = typeof left; - const rightType = typeof right; - if (leftType === 'object' || rightType === 'object') { - // 对象仅按引用比较;真正的值差异在提交保存时再做严格比对。 - return false; - } - - if (leftType === 'string' || rightType === 'string') { - return normalizeDateTimeString(String(left)) === normalizeDateTimeString(String(right)); - } - return left === right; -}; - -const INLINE_EDIT_MAX_CHARS = 2000; - -const shouldOpenModalEditor = (val: any): boolean => { - if (val === null || val === undefined) return false; - if (typeof val === 'string') { - if (val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n')) return true; - const trimmed = val.trimStart(); - return trimmed.startsWith('{') || trimmed.startsWith('['); - } - return typeof val === 'object'; -}; - -const getCellFieldName = (record: Item, dataIndex: string) => { - const rowKey = record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null) return dataIndex; - return [String(rowKey), dataIndex]; -}; - -const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => { - if (!form) return; - if (Array.isArray(fieldName)) { - const [rowKey, colKey] = fieldName; - form.setFieldsValue({ [rowKey]: { [colKey]: value } }); - return; - } - form.setFieldsValue({ [fieldName]: value }); -}; - -const looksLikeJsonText = (text: string): boolean => { - const raw = (text || '').trim(); - if (!raw) return false; - const first = raw[0]; - const last = raw[raw.length - 1]; - return (first === '{' && last === '}') || (first === '[' && last === ']'); -}; - -const isPlainObject = (value: any): value is Record => { - return Object.prototype.toString.call(value) === '[object Object]'; -}; - -const normalizeValueForJsonView = (value: any): any => { - if (value === null || value === undefined) return value; - - if (typeof value === 'string') { - const normalizedText = normalizeDateTimeString(value); - if (!looksLikeJsonText(normalizedText)) return normalizedText; - try { - return normalizeValueForJsonView(JSON.parse(normalizedText)); - } catch { - return normalizedText; - } - } - - if (Array.isArray(value)) { - return value.map((item) => normalizeValueForJsonView(item)); - } - - if (isPlainObject(value)) { - const next: Record = {}; - Object.entries(value).forEach(([key, val]) => { - next[key] = normalizeValueForJsonView(val); - }); - return next; - } - - return value; -}; - -const isJsonViewValueEqual = (left: any, right: any): boolean => { - const leftNormalized = normalizeValueForJsonView(left); - const rightNormalized = normalizeValueForJsonView(right); - - if (leftNormalized === rightNormalized) return true; - if (leftNormalized === null || rightNormalized === null) return leftNormalized === rightNormalized; - if (leftNormalized === undefined || rightNormalized === undefined) return leftNormalized === rightNormalized; - - if (typeof leftNormalized !== 'object' && typeof rightNormalized !== 'object') { - return String(leftNormalized) === String(rightNormalized); - } - - try { - return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); - } catch { - return false; - } -}; - -const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): any => { - if (typeof currentValue === 'string') { - const raw = currentValue.trim(); - const parsedCurrent = looksLikeJsonText(raw); - if (parsedCurrent && (isPlainObject(editedValue) || Array.isArray(editedValue))) { - return JSON.stringify(editedValue); - } - } - return editedValue; -}; - -// --- Resizable Header (Native Implementation) --- -const ResizableTitle = React.forwardRef((props, ref) => { - const { onResizeStart, onResizeAutoFit, width, ...restProps } = props; - - const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; - if (width) { - nextStyle.width = width; - } - - // 注意:virtual table 模式下,rc-table 会依赖 header cell 的 width 样式来渲染选择列。 - // 若这里丢失 width,可能导致左上角“全选”checkbox 不显示。 - if (!width || typeof onResizeStart !== 'function') { - return
- ); -}); - -// --- Sortable Header Cell --- -interface SortableHeaderCellProps extends React.HTMLAttributes { - id?: string; -} - -// --- Sortable Header Cell --- -interface SortableHeaderCellProps extends React.HTMLAttributes { - id?: string; -} - -// 静态 CSS 移到组件外,强制去除 th 内边距并确保指针穿透 -const sortableHeaderStaticStyles = ` - .gonavi-sortable-header-cell { - padding: 0 !important; - overflow: hidden; - } - .gonavi-sortable-header-cell[data-cursor-grabbing="true"], - .gonavi-sortable-header-cell[data-cursor-grabbing="true"] *, - .gonavi-sortable-header-cell.is-dragging, - .gonavi-sortable-header-cell.is-dragging * { - cursor: grabbing !important; - } - .sortable-header-cell-drag-handle { - display: flex; - align-items: center; - width: 100%; - height: 100%; - min-height: var(--gonavi-header-min-height, 40px); - padding: 0 10px; - user-select: none; - cursor: inherit; - overflow: hidden; - } -`; - -const SortableHeaderCell: React.FC = React.memo((props) => { - const { id, children, style: propStyle, className: propClassName, ...restProps } = props; - const [isPressed, setIsPressed] = useState(false); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: id || '' }); - - const style: React.CSSProperties = { - ...propStyle, - transform: CSS.Transform.toString(transform), - transition, - ...(isDragging ? { - position: 'relative', - zIndex: 9999, - opacity: 0.6, - backgroundColor: 'rgba(24, 144, 255, 0.15)', - boxShadow: '0 4px 12px rgba(0,0,0,0.15)' - } : {}), - touchAction: 'none', - willChange: 'transform', - // 核心修复:将指针直接绑定到 th 级别,并由 isPressed 控制 - cursor: (isDragging || isPressed) ? 'grabbing' : 'pointer', - }; - - useEffect(() => { - const handleGlobalMouseUp = () => setIsPressed(false); - window.addEventListener('mouseup', handleGlobalMouseUp); - return () => window.removeEventListener('mouseup', handleGlobalMouseUp); - }, []); - - if (!id || id === 'GONAVI_SELECTION_COLUMN') { - return {children}; - } - - return ( - { - setIsPressed(true); - if (listeners?.onPointerDown) listeners.onPointerDown(e); - }} - > - -
-
- {children} -
-
-
- ); -}); - -// --- Contexts --- -const EditableContext = React.createContext(null); -const CellContextMenuContext = React.createContext<{ - showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void; - handleBatchFillToSelected: (record: Item, dataIndex: string) => void; -} | null>(null); -const DataContext = React.createContext<{ - selectedRowKeysRef: React.MutableRefObject; - displayDataRef: React.MutableRefObject; - handleCopyInsert: (r: any) => void; - handleCopyUpdate: (r: any) => void; - handleCopyDelete: (r: any) => void; - handleCopyJson: (r: any) => void; - handleCopyCsv: (r: any) => void; - handleExportSelected: (options: DataExportFileOptions, r: any) => Promise; - copyToClipboard: (t: string) => void; - tableName?: string; - enableRowContextMenu: boolean; - supportsCopyInsert: boolean; -} | null>(null); - -interface Item { - [key: string]: any; -} - -interface EditableCellProps { - title: React.ReactNode; - editable: boolean; - children: React.ReactNode; - dataIndex: string; - record: Item; - handleSave: (record: Item) => void; - focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; - columnType?: string; - dbType?: string; - connectionConfig?: CellDisplayConnectionLike; - inputCellPadding?: React.CSSProperties; - as?: any; - modifiedColumns?: Record>; - rowKeyStr?: (k: React.Key) => string; - deletedRowKeys?: Set; - darkMode?: boolean; - [key: string]: any; -} - -// 模块级变量:绕过 React 渲染链条,在事件处理器中直接读取最新删除状态。 -// EditableCell 内部通过 React.memo 包裹,且 Ant Design rc-table 有多层 memo 缓存, -// 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。 -let globalDeletedRowKeys: Set = new Set(); - -const resolveEditableCellRowKey = ( - record: Item | undefined, - rowKeyStr?: (k: React.Key) => string, -): string | null => { - const rowKey = record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null || typeof rowKeyStr !== 'function') { - return null; - } - return rowKeyStr(rowKey); -}; - -const isEditableCellDeleted = ( - record: Item | undefined, - deletedRowKeys?: Set, - rowKeyStr?: (k: React.Key) => string, -): boolean => { - const rowKey = resolveEditableCellRowKey(record, rowKeyStr); - return rowKey ? !!deletedRowKeys?.has(rowKey) : false; -}; - -const isEditableCellModified = ( - record: Item | undefined, - dataIndex: string, - modifiedColumns?: Record>, - rowKeyStr?: (k: React.Key) => string, -): boolean => { - const rowKey = resolveEditableCellRowKey(record, rowKeyStr); - return rowKey ? !!modifiedColumns?.[rowKey]?.has(dataIndex) : false; -}; - -const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: EditableCellProps): boolean => { - if (prevProps.editable !== nextProps.editable) return false; - if (prevProps.dataIndex !== nextProps.dataIndex) return false; - if (prevProps.title !== nextProps.title) return false; - if (prevProps.columnType !== nextProps.columnType) return false; - if (prevProps.dbType !== nextProps.dbType) return false; - if ((prevProps.connectionConfig?.type ?? null) !== (nextProps.connectionConfig?.type ?? null)) return false; - if ((prevProps.connectionConfig?.driver ?? null) !== (nextProps.connectionConfig?.driver ?? null)) return false; - if ((prevProps.connectionConfig?.oceanBaseProtocol ?? null) !== (nextProps.connectionConfig?.oceanBaseProtocol ?? null)) return false; - if (prevProps.darkMode !== nextProps.darkMode) return false; - if (prevProps.as !== nextProps.as) return false; - if (prevProps.handleSave !== nextProps.handleSave) return false; - if (prevProps.focusCell !== nextProps.focusCell) return false; - if ((prevProps.inputCellPadding?.padding ?? null) !== (nextProps.inputCellPadding?.padding ?? null)) return false; - if (prevProps.style !== nextProps.style) return false; - - const prevRecord = prevProps.record; - const nextRecord = nextProps.record; - if (resolveEditableCellRowKey(prevRecord, prevProps.rowKeyStr) !== resolveEditableCellRowKey(nextRecord, nextProps.rowKeyStr)) { - return false; - } - if (hasDataGridFindRenderVersionChanged(nextRecord, prevRecord)) { - return false; - } - if (!isCellValueEqualForRender(prevRecord?.[prevProps.dataIndex], nextRecord?.[nextProps.dataIndex])) { - return false; - } - if (isEditableCellDeleted(prevRecord, prevProps.deletedRowKeys, prevProps.rowKeyStr) !== isEditableCellDeleted(nextRecord, nextProps.deletedRowKeys, nextProps.rowKeyStr)) { - return false; - } - if (isEditableCellModified(prevRecord, prevProps.dataIndex, prevProps.modifiedColumns, prevProps.rowKeyStr) !== isEditableCellModified(nextRecord, nextProps.dataIndex, nextProps.modifiedColumns, nextProps.rowKeyStr)) { - return false; - } - - return true; -}; - -const EditableCell: React.FC = React.memo(({ - title, - editable, - children, - dataIndex, - record, - handleSave, - focusCell, - columnType, - dbType, - connectionConfig, - inputCellPadding, - as: Component = 'td', - modifiedColumns, - rowKeyStr, - deletedRowKeys, - darkMode, - ...restProps -}) => { - const [editing, setEditing] = useState(false); - const inputRef = useRef(null); - const cellRef = useRef(null); - const pickerOpenRef = useRef(false); - const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); - const form = useContext(EditableContext); - const cellContextMenuContext = useContext(CellContextMenuContext); - const i18nLanguage = useDataGridI18nLanguage(); - const dateTimePickerNowLabel = t('data_grid.datetime_picker.now', undefined, i18nLanguage); - - /** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */ - const lockTableScroll = useCallback((lock: boolean) => { - if (lock) { - // 查找虚拟滚动容器或常规滚动容器 - const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null; - if (tableWrapper) { - const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); }; - tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false }); - scrollLockRef.current = { el: tableWrapper, handler }; - } - } else if (scrollLockRef.current) { - const { el, handler } = scrollLockRef.current; - el.removeEventListener('wheel', handler, { capture: true } as any); - scrollLockRef.current = null; - } - }, []); - - useEffect(() => { - if (editing) { - // 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值) - const raw = record[dataIndex]; - const fieldName = getCellFieldName(record, dataIndex); - if (isDateTimeField) { - const dayjsVal = parseToDayjs(raw, pickerType); - setCellFieldValue(form, fieldName, dayjsVal); - } else { - const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; - setCellFieldValue(form, fieldName, initialValue); - } - inputRef.current?.focus(); - } - }, [editing]); - - const toggleEdit = () => { - setEditing(!editing); - }; - - const save = async (pickerValue?: dayjs.Dayjs | null) => { - try { - if (!form || !editing) return; - const fieldName = getCellFieldName(record, dataIndex); - await form.validateFields([fieldName]); - let nextValue = form.getFieldValue(fieldName); - if (isDateTimeField) { - nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType); - } - toggleEdit(); - // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 - if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) { - handleSave({ ...record, [dataIndex]: nextValue }); - } - // 保存后移除焦点 - if (inputRef.current) { - inputRef.current.blur(); - } - } catch (errInfo) { - console.log('Save failed:', errInfo); - // 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态 - if (isDateTimeField && editing) setEditing(false); - } - }; - - const handleContextMenu = (e: React.MouseEvent) => { - if (!cellContextMenuContext) return; - e.preventDefault(); - e.stopPropagation(); // 阻止冒泡到行级菜单 - cellContextMenuContext.showMenu(e, record, dataIndex, title); - }; - - let childNode = children; - - const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig); - const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); - - const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined - ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY])) - : false; - - const isModified = !editing && modifiedColumns && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined - ? modifiedColumns[rowKeyStr(record[GONAVI_ROW_KEY])]?.has(dataIndex) - : false; - - const modifiedStyle: React.CSSProperties | undefined = isModified - ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } - : undefined; - - if (editable) { - childNode = editing ? ( - - {isDateTimeField ? ( - pickerType === 'time' ? ( - setTimeout(() => { void save(value); }, 0)} - onOpenChange={lockTableScroll} - onBlur={() => setTimeout(() => { void save(); }, 0)} - needConfirm={false} - /> - ) : pickerType === 'datetime' ? ( - ( - { - // 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。 - // 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。 - const fieldName = getCellFieldName(record, dataIndex); - setCellFieldValue(form, fieldName, dayjs()); - }} - >{dateTimePickerNowLabel} - )} - onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} - onOpenChange={(open) => { - pickerOpenRef.current = open; - lockTableScroll(open); - // 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存 - if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); - }} - onBlur={() => { - // 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。 - // 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。 - setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150); - }} - needConfirm - /> - ) : ( - setTimeout(() => { void save(value); }, 0)} - onOpenChange={lockTableScroll} - onBlur={() => setTimeout(() => { void save(); }, 0)} - needConfirm={false} - /> - ) - ) : ( - { void save(); }} - onBlur={() => { void save(); }} - onFocus={(e) => { - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - onDoubleClick={(e) => { - e.stopPropagation(); - try { - (e.target as HTMLInputElement)?.select?.(); - } catch { - // ignore - } - }} - /> - )} - - ) : ( -
- {children} -
- ); - } else if (cellContextMenuContext) { - // 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作 - childNode = ( -
- {children} -
- ); - } else if (isModified) { - childNode = ( -
- {children} -
- ); - } - - const handleDoubleClick = () => { - if (!editable) return; - if (isRowDeleted) return; - // 模块级检查:绕过 React 渲染链条,确保即使组件因 memo 缓存未重渲染也能拿到最新状态 - if (record?.[GONAVI_ROW_KEY] !== undefined - && rowKeyStr - && globalDeletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))) return; - // 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。 - if (editing) return; - const raw = record?.[dataIndex]; - if (focusCell && shouldOpenModalEditor(raw)) { - focusCell(record, dataIndex, title); - return; - } - toggleEdit(); - }; - - return ( - - {childNode} - - ); -}, areEditableCellPropsEqual); - -const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { - const context = useContext(DataContext); - - if (!record || !context) return
{children}; - - const { - selectedRowKeysRef, - displayDataRef, - handleCopyInsert, - handleCopyUpdate, - handleCopyDelete, - handleCopyJson, - handleCopyCsv, - handleExportSelected, - copyToClipboard, - enableRowContextMenu, - supportsCopyInsert, - } = context; - - if (!enableRowContextMenu) { - return {children}; - } - - const getTargets = () => { - const keys = selectedRowKeysRef.current; - const recordKey = record?.[GONAVI_ROW_KEY]; - if (recordKey !== undefined && keys.includes(recordKey)) { - return displayDataRef.current.filter(d => keys.includes(d?.[GONAVI_ROW_KEY])); - } - return [record]; - }; - - const menuItems: MenuProps['items'] = [ - ...(supportsCopyInsert ? [{ - key: 'insert', - label: t('data_grid.context_menu.copy_as_insert'), - icon: , - onClick: () => handleCopyInsert(record), - }, { - key: 'update', - label: t('data_grid.context_menu.copy_as_update'), - icon: , - onClick: () => handleCopyUpdate(record), - }, { - key: 'delete', - label: t('data_grid.context_menu.copy_as_delete'), - icon: , - onClick: () => handleCopyDelete(record), - }] : []), - { key: 'json', label: t('data_grid.context_menu.copy_as_json'), icon: , onClick: () => handleCopyJson(record) }, - { key: 'csv', label: t('data_grid.context_menu.copy_as_csv'), icon: , onClick: () => handleCopyCsv(record) }, - { key: 'copy', label: t('data_grid.context_menu.copy_as_markdown'), icon: , onClick: () => { - const records = getTargets(); - const orderedCols = displayDataRef.current.length > 0 - ? Object.keys(displayDataRef.current[0]).filter(c => c !== GONAVI_ROW_KEY) - : []; - const header = `| ${orderedCols.join(' | ')} |`; - const separator = `| ${orderedCols.map(() => '---').join(' | ')} |`; - const rows = records.map((r: any) => { - const values = orderedCols.map(c => { - const v = r[c]; - if (v === null || v === undefined) return 'NULL'; - return String(v).replace(/\|/g, '\\|').replace(/\n/g, ' '); - }); - return `| ${values.join(' | ')} |`; - }); - copyToClipboard([header, separator, ...rows].join('\n')); - } }, - { type: 'divider' }, - { - key: 'export-selected', - label: t('data_grid.context_menu.export_selected'), - icon: , - children: [ - { key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected({ format: 'csv' }, record).catch(console.error) }, - { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected({ format: 'xlsx' }, record).catch(console.error) }, - { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected({ format: 'json' }, record).catch(console.error) }, - { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected({ format: 'md' }, record).catch(console.error) }, - { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected({ format: 'html' }, record).catch(console.error) }, - ] - } - ]; - - return ( - document.body} autoAdjustOverflow> - {children} - - ); -}); - -interface DataGridProps { - data: any[]; - columnNames: string[]; - loading: boolean; - tableName?: string; - objectType?: 'table' | 'view' | 'materialized-view'; - exportScope?: 'table' | 'queryResult'; - resultSql?: string; - resultExportAllSql?: string; - dbName?: string; - connectionId?: string; - pkColumns?: string[]; - editLocator?: EditRowLocator; - readOnly?: boolean; - showRowNumberColumn?: boolean; - onReload?: () => void; - onSort?: (field: string, order: string) => void; - onPageChange?: (page: number, size: number) => void; - pagination?: { - current: number, - pageSize: number, - total: number, - totalKnown?: boolean, - totalApprox?: boolean, - approximateTotal?: number, - totalCountLoading?: boolean, - totalCountCancelled?: boolean, - }; - onRequestTotalCount?: () => void; - onCancelTotalCount?: () => void; - sortInfoExternal?: Array<{ columnKey: string, order: string, enabled?: boolean }>; - // Filtering - showFilter?: boolean; - onToggleFilter?: () => void; - exportSqlWithFilter?: string; - onApplyFilter?: (conditions: GridFilterCondition[]) => void; - appliedFilterConditions?: FilterCondition[]; - quickWhereCondition?: string; - onApplyQuickWhereCondition?: (condition: string) => void; - scrollSnapshot?: { top: number; left: number }; - onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; - toolbarExtraActions?: React.ReactNode; -} - -type GridFilterCondition = FilterCondition & { - id: number; - column: string; - op: string; - value: string; - value2?: string; -}; - -type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; -type DdlViewLayoutMode = 'bottom' | 'side'; -type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; -type VirtualEditingCellState = { - rowKey: string; - dataIndex: string; - title: React.ReactNode; - columnType?: string; -}; - -type ColumnMeta = { - type: string; - comment: string; -}; - -const buildColumnMetaMap = (columns: ColumnDefinition[]): Record => { - const nextMap: Record = {}; - (columns || []).forEach((column: any) => { - const name = getColumnDefinitionName(column); - if (!name) return; - nextMap[name] = { - type: getColumnDefinitionType(column), - comment: getColumnDefinitionComment(column), - }; - }); - return nextMap; -}; - -const hasUsableColumnMeta = (metaMap: Record): boolean => ( - Object.values(metaMap || {}).some((meta) => { - const type = String(meta?.type || '').trim(); - const comment = String(meta?.comment || '').trim(); - return type.length > 0 || comment.length > 0; - }) -); - -type ForeignKeyTarget = { - columnName: string; - refTableName: string; - refColumnName: string; - constraintName: string; -}; - -type VirtualTableScrollReference = TableReference & { - scrollTo: (config: { left?: number; top?: number; index?: number; key?: React.Key }) => void; -}; - -const EXACT_GRID_FILTER_OPERATOR = '='; -const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS'; -const FILTER_FIELD_SELECT_STYLE: React.CSSProperties = { - width: 320, - flex: '0 1 320px', - minWidth: 260, - maxWidth: 'min(460px, 100%)', -}; -const FILTER_FIELD_POPUP_WIDTH = 520; -const FILTER_FIELD_OPTION_STYLE: React.CSSProperties = { - display: 'block', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; -const STRING_LIKE_GRID_FILTER_TYPES = new Set([ - 'bpchar', - 'char', - 'character', - 'character varying', - 'citext', - 'clob', - 'fixedstring', - 'long nvarchar', - 'long varchar', - 'longtext', - 'mediumtext', - 'nchar', - 'nclob', - 'ntext', - 'nvarchar', - 'nvarchar2', - 'string', - 'text', - 'tinytext', - 'varchar', - 'varchar2', -]); - -const normalizeGridFilterColumnType = (columnType: unknown): string => { - let normalized = String(columnType ?? '').trim().toLowerCase().replace(/\s+/g, ' '); - for (let i = 0; i < 4; i += 1) { - const wrapped = normalized.match(/^(?:nullable|lowcardinality)\((.+)\)$/); - if (!wrapped) break; - normalized = wrapped[1].trim().replace(/\s+/g, ' '); - } - return normalized; -}; - -export const isStringLikeGridFilterColumnType = (columnType: unknown): boolean => { - const normalized = normalizeGridFilterColumnType(columnType); - if (!normalized) return false; - const baseType = normalized.replace(/\(.*/, '').trim(); - return STRING_LIKE_GRID_FILTER_TYPES.has(baseType); -}; - -export const resolveDefaultGridFilterOperator = (columnType: unknown): string => ( - isStringLikeGridFilterColumnType(columnType) ? CONTAINS_GRID_FILTER_OPERATOR : EXACT_GRID_FILTER_OPERATOR -); - -export const resolveNextGridFilterOperatorForColumnChange = ({ - currentOperator, - previousColumnType, - nextColumnType, -}: { - currentOperator: unknown; - previousColumnType: unknown; - nextColumnType: unknown; -}): string => { - const current = String(currentOperator || '').trim(); - if (!current) return resolveDefaultGridFilterOperator(nextColumnType); - const previousDefault = resolveDefaultGridFilterOperator(previousColumnType); - return current === previousDefault ? resolveDefaultGridFilterOperator(nextColumnType) : current; -}; - -export const buildGridFieldSelectOptions = (columnNames: string[]) => ( - (columnNames || []).map((columnName) => { - const text = String(columnName || ''); - return { - value: text, - label: text, - title: text, - }; - }) -); - -const renderGridFieldSelectOption = (option: { label?: React.ReactNode; value?: unknown; title?: unknown }) => { - const text = String(option?.title ?? option?.label ?? option?.value ?? ''); - return ( - - {text} - - ); -}; - -type NormalizeCommitCellValue = (columnName: string, value: any, mode: 'insert' | 'update') => any; - -type DataGridCommitChangeSet = { - inserts: any[]; - updates: any[]; - deletes: any[]; -}; - -export const buildDataGridCommitChangeSet = ({ - addedRows, - modifiedRows, - deletedRowKeys, - data, - editLocator, - visibleColumnNames, - rowKeyToString, - normalizeCommitCellValue, - shouldCommitColumn, - rowLocatorMessages, -}: { - addedRows: any[]; - modifiedRows: Record; - deletedRowKeys: Set; - data: any[]; - editLocator?: EditRowLocator; - visibleColumnNames: string[]; - rowKeyToString: (key: any) => string; - normalizeCommitCellValue: NormalizeCommitCellValue; - shouldCommitColumn: (columnName: string) => boolean; - rowLocatorMessages?: RowLocatorMessages; -}): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => { - if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') { - return { ok: false, error: editLocator?.reason || rowLocatorMessages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' }; - } - - const normalizeValues = (values: Record, mode: 'insert' | 'update') => { - const normalizedValues: Record = {}; - Object.entries(values).forEach(([col, val]) => { - if (!shouldCommitColumn(col)) return; - const commitColumnName = resolveWritableColumnName(col, editLocator); - if (!commitColumnName) return; - const normalizedVal = normalizeCommitCellValue(col, val, mode); - if (normalizedVal !== undefined) { - normalizedValues[commitColumnName] = normalizedVal; - } - }); - return normalizedValues; - }; - - const originalRowsByKey = new Map(); - data.forEach((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return; - originalRowsByKey.set(rowKeyToString(key), row); - }); - - const inserts: any[] = []; - const updates: any[] = []; - const deletes: any[] = []; - - addedRows.forEach(row => { - const key = row?.[GONAVI_ROW_KEY]; - if (key !== undefined && key !== null && deletedRowKeys.has(rowKeyToString(key))) return; - inserts.push(normalizeValues(row, 'insert')); - }); - - for (const keyStr of deletedRowKeys) { - const originalRow = originalRowsByKey.get(keyStr); - if (!originalRow) continue; - const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); - if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; - deletes.push(locatorValues.values); - } - - for (const [keyStr, newRow] of Object.entries(modifiedRows)) { - if (deletedRowKeys.has(keyStr)) continue; - const originalRow = originalRowsByKey.get(keyStr); - if (!originalRow) continue; - - const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); - if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; - - const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY); - let values: Record = {}; - if (!hasRowKey) { - values = { ...(newRow as any) }; - } else { - visibleColumnNames.forEach((col) => { - const nextVal = (newRow as any)?.[col]; - const prevVal = (originalRow as any)?.[col]; - if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; - }); - } - - const normalizedValues = normalizeValues(values, 'update'); - if (Object.keys(normalizedValues).length === 0) continue; - updates.push({ keys: locatorValues.values, values: normalizedValues }); - } - - return { ok: true, changes: { inserts, updates, deletes } }; -}; - -// P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象 -const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, width: '100%' }; -const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - minWidth: 0, - width: '100%', -}; -const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 }; -const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: '100%', minWidth: 0 }; -const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { - margin: 0, - padding: 0, - display: 'flex', - flex: '1 1 auto', - alignItems: 'center', - width: '100%', - minWidth: 0, - minHeight: 'calc(28px * var(--gn-ui-scale, 1))', - height: 'calc(28px * var(--gn-ui-scale, 1))', - overflow: 'visible', - whiteSpace: 'nowrap', - boxSizing: 'border-box', -}; - + CellDisplayConnectionLike, + SortableHeaderCellProps, + Item, + EditableCellProps, + DataGridProps, + GridFilterCondition, + GridViewMode, + DdlViewLayoutMode, + DataGridExportScope, + VirtualEditingCellState, + ColumnMeta, + ForeignKeyTarget, + VirtualTableScrollReference, + NormalizeCommitCellValue, + DataGridCommitChangeSet, +} from './DataGridCore'; +export { + GONAVI_ROW_KEY, + GONAVI_ROW_NUMBER_COLUMN_KEY, + resolveContextMenuFieldName, + formatCellDisplayText, + attachDataGridVirtualEditRenderVersion, + attachDataGridDisplayRenderVersion, + hasDataGridDisplayRenderVersionChanged, + hasDataGridVirtualEditRenderVersionChanged, + isStringLikeGridFilterColumnType, + resolveDefaultGridFilterOperator, + resolveNextGridFilterOperatorForColumnChange, + buildGridFieldSelectOptions, + buildDataGridCommitChangeSet, +} from './DataGridCore'; const DataGrid: React.FC = ({ data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, resultSql, @@ -2229,20 +905,10 @@ const DataGrid: React.FC = ({ const [sortInfo, setSortInfo] = useState>([]); const [columnWidths, setColumnWidths] = useState>({}); - const [columnMetaMap, setColumnMetaMap] = useState>({}); - const [foreignKeyMap, setForeignKeyMap] = useState>({}); - const [uniqueKeyGroups, setUniqueKeyGroups] = useState([]); - const [metadataReloadVersion, setMetadataReloadVersion] = useState(0); const mergedDisplayDataRef = useRef([]); const closeCellEditModeRef = useRef<() => void>(() => {}); const formRef = useRef(form); formRef.current = form; - const columnMetaCacheRef = useRef>>({}); - const columnMetaSeqRef = useRef(0); - const foreignKeyCacheRef = useRef>>({}); - const foreignKeySeqRef = useRef(0); - const uniqueKeyGroupsCacheRef = useRef>({}); - const uniqueKeyGroupsSeqRef = useRef(0); useEffect(() => { const ext = sortInfoExternal || []; @@ -2252,191 +918,28 @@ const DataGrid: React.FC = ({ setSortInfo(ext); }, [sortInfoExternal, sortInfo]); - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName) { - setColumnMetaMap({}); - setForeignKeyMap({}); - setUniqueKeyGroups([]); - return; - } - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {}); - foreignKeySeqRef.current += 1; - setForeignKeyMap(exportScope === 'table' ? (foreignKeyCacheRef.current[cacheKey] || {}) : {}); - setUniqueKeyGroups(uniqueKeyGroupsCacheRef.current[cacheKey] || []); - }, [connectionId, dbName, tableName, exportScope]); - - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName) return; - - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - if (columnMetaCacheRef.current[cacheKey]) return; - - const conn = connections.find(c => c.id === connectionId); - if (!conn) { - setColumnMetaMap({}); - return; - } - - const config = { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || "", - database: conn.config.database || "", - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } - }; - - const seq = ++columnMetaSeqRef.current; - const loadColumnMeta = async () => { - let nextMap: Record | null = null; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName); - if (seq !== columnMetaSeqRef.current) return; - if (!res.success || !Array.isArray(res.data)) { - continue; - } - const candidateMap = buildColumnMetaMap(res.data as ColumnDefinition[]); - if (!hasUsableColumnMeta(candidateMap)) { - continue; - } - nextMap = candidateMap; - break; - } catch { - if (seq !== columnMetaSeqRef.current) return; - } - } - - if (seq !== columnMetaSeqRef.current) return; - if (nextMap) { - columnMetaCacheRef.current[cacheKey] = nextMap; - setColumnMetaMap(nextMap); - return; - } - setColumnMetaMap({}); - }; - - void loadColumnMeta(); - }, [connections, connectionId, dbName, tableName, metadataReloadVersion]); - - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName || exportScope !== 'table') return; - - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - if (foreignKeyCacheRef.current[cacheKey]) return; - - const conn = connections.find(c => c.id === connectionId); - if (!conn) { - setForeignKeyMap({}); - return; - } - - const config = { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || "", - database: conn.config.database || "", - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } - }; - - const seq = ++foreignKeySeqRef.current; - DBGetForeignKeys(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName) - .then((res) => { - if (seq !== foreignKeySeqRef.current) return; - if (!res.success || !Array.isArray(res.data)) { - setForeignKeyMap({}); - return; - } - const nextMap: Record = {}; - (res.data as ForeignKeyDefinition[]).forEach((fk: any) => { - const columnName = String(fk?.columnName ?? fk?.ColumnName ?? '').trim(); - const refTableName = String(fk?.refTableName ?? fk?.RefTableName ?? '').trim(); - if (!columnName || !refTableName || refTableName === '-') return; - const target: ForeignKeyTarget = { - columnName, - refTableName, - refColumnName: String(fk?.refColumnName ?? fk?.RefColumnName ?? '').trim(), - constraintName: String(fk?.constraintName ?? fk?.ConstraintName ?? fk?.name ?? fk?.Name ?? '').trim(), - }; - nextMap[columnName] = target; - }); - foreignKeyCacheRef.current[cacheKey] = nextMap; - setForeignKeyMap(nextMap); - }) - .catch(() => { - if (seq !== foreignKeySeqRef.current) return; - setForeignKeyMap({}); - }); - }, [connections, connectionId, dbName, tableName, exportScope, metadataReloadVersion]); - - useEffect(() => { - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (!connectionId || !normalizedTableName) return; - - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - if (uniqueKeyGroupsCacheRef.current[cacheKey]) return; - - const conn = connections.find(c => c.id === connectionId); - if (!conn) { - setUniqueKeyGroups([]); - return; - } - - const config = { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || "", - database: conn.config.database || "", - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } - }; - - const seq = ++uniqueKeyGroupsSeqRef.current; - DBGetIndexes(config as any, normalizedDbName, normalizedTableName) - .then((res) => { - if (seq !== uniqueKeyGroupsSeqRef.current) return; - if (!res.success || !Array.isArray(res.data)) { - setUniqueKeyGroups([]); - return; - } - const nextGroups = resolveUniqueKeyGroupsFromIndexes(res.data as IndexDefinition[]); - uniqueKeyGroupsCacheRef.current[cacheKey] = nextGroups; - setUniqueKeyGroups(nextGroups); - }) - .catch(() => { - if (seq !== uniqueKeyGroupsSeqRef.current) return; - setUniqueKeyGroups([]); - }); - }, [connections, connectionId, dbName, tableName, metadataReloadVersion]); - - const columnMetaMapByLowerName = useMemo(() => { - const next: Record = {}; - Object.entries(columnMetaMap).forEach(([name, meta]) => { - const lowerName = String(name || '').toLowerCase(); - if (!lowerName || next[lowerName]) return; - next[lowerName] = meta; - }); - return next; - }, [columnMetaMap]); - - const columnTypeMapByLowerName = useMemo(() => { - const next: Record = {}; - Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => { - const type = String(meta?.type || '').trim(); - if (!name || !type) return; - next[name] = type; - }); - return next; - }, [columnMetaMapByLowerName]); + const { + allTableColumnNames, + columnMetaCacheRef, + columnMetaMap, + columnMetaMapByLowerName, + columnTypeMapByLowerName, + foreignKeyCacheRef, + foreignKeyMap, + foreignKeyMapByLowerName, + getColumnFilterType, + metadataReloadVersion, + setMetadataReloadVersion, + uniqueKeyGroups, + uniqueKeyGroupsCacheRef, + } = useDataGridMetadata({ + connections, + connectionId, + dbName, + tableName, + exportScope, + visibleColumnNames, + }); const displayColumnTypeMap = useMemo(() => { const next: Record = {}; @@ -2448,33 +951,6 @@ const DataGrid: React.FC = ({ return next; }, [displayColumnNames, columnMetaMap, columnTypeMapByLowerName]); - const foreignKeyMapByLowerName = useMemo(() => { - const next: Record = {}; - Object.entries(foreignKeyMap).forEach(([name, target]) => { - const lowerName = String(name || '').toLowerCase(); - if (!lowerName || next[lowerName]) return; - next[lowerName] = target; - }); - return next; - }, [foreignKeyMap]); - - const getColumnFilterType = useCallback((columnName: string): string => { - const normalizedName = String(columnName || '').trim(); - if (!normalizedName) return ''; - return (columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()])?.type || ''; - }, [columnMetaMap, columnMetaMapByLowerName]); - - const allTableColumnNames = useMemo(() => { - const metaColumns = Object.keys(columnMetaMap); - if (metaColumns.length > 0) { - return metaColumns; - } - if (exportScope === 'table') { - return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY); - } - return []; - }, [columnMetaMap, exportScope, visibleColumnNames]); - const normalizeCommitCellValue = useCallback( (columnName: string, value: any, mode: 'insert' | 'update') => { if (value === undefined) return undefined; @@ -2581,649 +1057,56 @@ const DataGrid: React.FC = ({ const [tableBodyBottomPadding, setTableBodyBottomPadding] = useState(0); // P0 性能优化:CSS 模板字符串 memoize,仅在主题/布局变量变化时重算 - const gridCssText = useMemo(() => ` - .${gridId} .data-grid-toolbar-scroll > * { - flex-shrink: 0; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar { - height: 7px; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb { - background: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.22)'}; - border: 0; - background-clip: border-box; - border-radius: 999px; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb:hover { - background: ${darkMode ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.32)'}; - border: 0; - background-clip: border-box; - } - .${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track { - background: transparent; - } - .${gridId} .ant-table, - .${gridId} .ant-table-wrapper, - .${gridId} .ant-table-container { - background: transparent !important; - border-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-wrapper, - .${gridId} .ant-table-container { - border: none !important; - overflow: hidden !important; - } - .${gridId} .ant-table-tbody > tr > td, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell, - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; vertical-align: middle !important; } - .${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: ${dataTableVerticalBorderRule} !important; font-size: ${densityParams.dataFontSize}px !important; } - .${gridId} .ant-table-tbody > tr > td:last-child, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell:last-child, - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell:last-child, - .${gridId} .ant-table-thead > tr > th:last-child { - border-inline-end-color: transparent !important; - } - /* 选择列对齐:header TH 无 class(Ant Design 虚拟模式),需用 :first-child 匹配 */ - .${gridId} .ant-table-header th:first-child, - .${gridId} .ant-table-thead > tr > th:first-child { - text-align: center !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - .${gridId} .ant-table-selection-column { - vertical-align: middle !important; - text-align: center !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - } - .${gridId} .ant-table-selection-column .ant-checkbox-wrapper { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - margin-right: 0 !important; - } - /* 窄表场景下 rc-table 会按视口等比放大选择列宽度,不能再额外锁死 header 宽度; - 这里只统一 header/body 的内边距与对齐方式,避免第一列把后续数据列整体顶偏。 */ - .${gridId} .ant-table-tbody > tr > td.ant-table-selection-column, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column { - text-align: center !important; - vertical-align: middle !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - .${gridId} .ant-table-tbody > tr > td.ant-table-selection-column .ant-checkbox-wrapper, - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell.ant-table-selection-column .ant-checkbox-wrapper { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - margin-right: 0 !important; - } - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-selection-column { - display: flex !important; - align-items: center !important; - justify-content: center !important; - padding-inline-start: 0 !important; - padding-inline-end: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - .${gridId} .ant-table-thead > tr:first-child > th:first-child, - .${gridId} .ant-table-header table > thead > tr:first-child > th:first-child { - border-top-left-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-thead > tr:first-child > th:last-child, - .${gridId} .ant-table-header table > thead > tr:first-child > th:last-child { - border-top-right-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-body { - border-bottom-left-radius: ${panelRadius}px !important; - border-bottom-right-radius: ${panelRadius}px !important; - } - .${gridId} .ant-table-thead > tr > th::before { display: none !important; } - .${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; } - .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter, - .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; } - .${gridId} .ant-table-tbody > tr:hover > td, - .${gridId} .ant-table-tbody .ant-table-row:hover > .ant-table-cell { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td, - .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.18)` : `rgba(${selectionAccentRgb}, 0.08)`} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.ant-table-row-selected:hover > .ant-table-cell { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.28)` : `rgba(${selectionAccentRgb}, 0.12)`} !important; } - .${gridId} .row-added td, - .${gridId} .row-added > .ant-table-cell { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; } - .${gridId} .row-modified td, - .${gridId} .row-modified > .ant-table-cell { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } - .${gridId} .row-deleted td, - .${gridId} .row-deleted > .ant-table-cell { background-color: ${darkMode ? '#1f1f1f' : '#f0f0f0'} !important; color: ${darkMode ? '#595959' : '#bfbfbf'} !important; text-decoration: line-through; } - .${gridId} .ant-table-tbody > tr.row-added:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.row-added:hover > .ant-table-cell { background-color: ${rowAddedHover} !important; } - .${gridId} .ant-table-tbody > tr.row-modified:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; } - .${gridId} .ant-table-tbody > tr.row-deleted:hover > td, - .${gridId} .ant-table-tbody .ant-table-row.row-deleted:hover > .ant-table-cell { background-color: ${darkMode ? '#2a2a2a' : '#e8e8e8'} !important; } - .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name], - .${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } - .${gridId} .ant-table-tbody > tr > td[data-cell-selected="true"], - .${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"], - .${gridId} [data-cell-selected="true"] { - box-shadow: inset 0 0 0 2px ${selectionAccentHex} !important; - background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}) !important; - } - .${gridId} .ant-table-content, - .${gridId} .ant-table-body { - scrollbar-gutter: stable; - } - .${gridId} .ant-table-body { - padding-bottom: ${tableBodyBottomPadding}px; - box-sizing: border-box; - scroll-padding-bottom: ${tableBodyBottomPadding}px; - contain: layout paint style; - } - .${gridId} .ant-table-tbody-virtual-holder, - .${gridId} .rc-virtual-list-holder { - padding-bottom: ${tableBodyBottomPadding}px; - box-sizing: border-box; - scroll-padding-bottom: ${tableBodyBottomPadding}px; - contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'}; - content-visibility: ${useVirtualHolderPaintHints ? 'auto' : 'visible'}; - } - .${gridId} .ant-table-tbody-virtual-holder-inner { - padding-bottom: ${tableBodyBottomPadding}px; - box-sizing: border-box; - contain: ${useVirtualHolderPaintHints ? 'layout paint style' : 'layout style'}; - } - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row, - .${gridId} .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { - contain: ${useVirtualRowCellContain ? 'layout paint style' : 'none'}; - } - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .${gridId}.gn-v2-data-grid .ant-table-tbody > tr > td, - .${gridId}.gn-v2-data-grid .ant-table-tbody .ant-table-row > .ant-table-cell, - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell { - vertical-align: middle !important; - } - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.ant-table-cell-row-hover, - .${gridId}.gn-v2-data-grid .ant-table-tbody-virtual-holder .ant-table-row > .ant-table-cell.data-grid-virtual-inline-editing { - overflow: visible; - text-overflow: clip; - white-space: normal; - } - .${gridId} .data-grid-table-wrap { - width: 100%; - max-width: 100%; - overflow: hidden; - } - .${gridId} .ant-table-sticky-scroll { - display: none !important; - } - .${gridId} .data-grid-find-highlight { - padding: 0 1px; - border-radius: 3px; - background: ${darkMode ? 'rgba(246, 196, 83, 0.42)' : 'rgba(255, 193, 7, 0.42)'}; - color: inherit; - } - .${gridId} .editable-cell-value-wrap { - display: block; - width: 100%; - min-width: 0; - min-height: 20px; - padding-right: 0; - position: relative; - contain: ${useVirtualEditablePaintContain ? 'layout paint style' : 'layout style'}; - } - .${gridId} .editable-cell-value-wrap > * { - min-width: 0; - } - .${gridId} .data-grid-inline-editor-form-item, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-row, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-control, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input, - .${gridId} .data-grid-inline-editor-form-item .ant-form-item-control-input-content { - width: 100%; - min-width: 0; - } - .${gridId} .data-grid-inline-editor-input, - .${gridId} .data-grid-inline-editor-form-item .ant-picker { - width: 100% !important; - min-width: 0; - } - .${gridId} .ant-table-tbody-virtual-holder .editable-cell-value-wrap { - content-visibility: ${useVirtualEditableVisibilityHints ? 'auto' : 'visible'}; - contain-intrinsic-size: ${useVirtualEditableVisibilityHints ? '24px 160px' : 'auto'}; - } - /* 虚拟表列对齐:阻止 header
; - } - - return ( - - {restProps.children} - { - e.stopPropagation(); - // Pass the header element reference implicitly via event target - onResizeStart(e); - }} - onDoubleClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - if (typeof onResizeAutoFit === 'function') { - onResizeAutoFit(e); - } - }} - onPointerDown={(e) => { - // 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor, - // 避免调整列宽时意外触发列拖拽排序 - e.stopPropagation(); - }} - onClick={(e) => e.stopPropagation()} - title={t('data_grid.column.resize_tooltip')} - style={{ - position: 'absolute', - right: 0, // Align to right edge - bottom: 0, - top: 0, - width: 10, - cursor: 'col-resize', - zIndex: 10, - touchAction: 'none' - }} - /> -
通过 min-width:100% 拉伸到视口, - 使 header 列宽与虚拟 body 单元格宽度精确一致 */ - .${gridId} .ant-table-header > table { - min-width: 0 !important; - } - .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal { - display: none !important; - } - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content { - overflow-x: hidden !important; - } - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-body { - overflow-x: hidden !important; - overflow-y: auto !important; - } - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-tbody-virtual-holder, - .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .rc-virtual-list-holder { - overflow-x: hidden !important; - } - .${gridId} .ant-table-body { - scrollbar-width: thin; - scrollbar-color: ${floatingScrollbarThumbBg} transparent; - } - .${gridId} .ant-table-body::-webkit-scrollbar { - width: ${floatingScrollbarHeight}px; - height: 0; - } - .${gridId} .ant-table-body::-webkit-scrollbar-track { - background: ${verticalScrollbarTrackBg}; - margin: 8px 0; - border-radius: 999px; - } - .${gridId} .ant-table-body::-webkit-scrollbar-thumb { - background: ${floatingScrollbarThumbBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - border-radius: 999px; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .ant-table-body::-webkit-scrollbar-thumb:hover { - background: ${floatingScrollbarThumbHoverBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .rc-virtual-list-holder { - scrollbar-width: thin; - scrollbar-color: ${floatingScrollbarThumbBg} transparent; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar { - width: ${floatingScrollbarHeight}px; - height: 0; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track { - background: ${verticalScrollbarTrackBg}; - margin: 8px 0; - border-radius: 999px; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb { - background: ${floatingScrollbarThumbBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - border-radius: 999px; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb:hover { - background: ${floatingScrollbarThumbHoverBg}; - border: 1px solid ${floatingScrollbarThumbBorderColor}; - background-clip: border-box; - box-shadow: ${floatingScrollbarThumbShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll { - position: absolute; - left: ${floatingScrollbarInset}px; - right: ${floatingScrollbarInset}px; - bottom: ${floatingScrollbarBottomOffset}px; - height: ${floatingScrollbarHeight + 4}px; - overflow-x: auto; - overflow-y: hidden; - background: transparent; - z-index: 24; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar { - height: ${floatingScrollbarHeight}px; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-track { - background: ${horizontalScrollbarTrackBg}; - border: 1px solid ${horizontalScrollbarTrackBorderColor}; - border-radius: 999px; - box-shadow: ${horizontalScrollbarTrackShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb { - background: ${horizontalScrollbarThumbBg}; - border: 1px solid ${horizontalScrollbarThumbBorderColor}; - background-clip: border-box; - border-radius: 999px; - box-shadow: ${horizontalScrollbarThumbShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll::-webkit-scrollbar-thumb:hover { - background: ${horizontalScrollbarThumbHoverBg}; - border: 1px solid ${horizontalScrollbarThumbBorderColor}; - background-clip: border-box; - box-shadow: ${horizontalScrollbarThumbShadow}; - } - .${gridId} .data-grid-external-horizontal-scroll-inner { - height: 1px; - } - .${gridId} .data-grid-pagination-shell { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - flex-wrap: wrap; - max-width: 100%; - padding: 8px 10px; - border-radius: 16px; - border: 1px solid ${paginationShellBorderColor}; - background: ${paginationShellBg}; - box-shadow: ${paginationShellShadow}; - backdrop-filter: ${dataGridBackdropFilter}; - -webkit-backdrop-filter: ${dataGridBackdropFilter}; - } - .${gridId} .data-grid-pagination-summary, - .${gridId} .data-grid-pagination-page-chip { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 34px; - padding: 0 12px; - border-radius: 999px; - border: 1px solid ${paginationChipBorderColor}; - background: ${paginationChipBg}; - color: ${paginationPrimaryTextColor}; - font-size: 12px; - line-height: 1; - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - .${gridId} .data-grid-pagination-kicker { - display: inline-flex; - align-items: center; - height: 20px; - padding: 0 8px; - border-radius: 999px; - background: ${paginationAccentBg}; - border: 1px solid ${paginationAccentBorderColor}; - color: ${paginationActiveItemTextColor}; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - } - .${gridId} .data-grid-pagination-summary-value { - color: ${paginationPrimaryTextColor}; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - .${gridId} .data-grid-pagination-page-chip { - color: ${paginationSecondaryTextColor}; - font-weight: 600; - } - .${gridId} .ant-pagination { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 0; - color: ${paginationPrimaryTextColor}; - } - .${gridId} .ant-pagination .ant-pagination-item, - .${gridId} .ant-pagination .ant-pagination-prev, - .${gridId} .ant-pagination .ant-pagination-next, - .${gridId} .ant-pagination .ant-pagination-jump-prev, - .${gridId} .ant-pagination .ant-pagination-jump-next { - min-width: 34px; - height: 34px; - margin-inline-end: 0; - border-radius: 12px; - border: 1px solid ${paginationChipBorderColor}; - background: ${paginationChipBg}; - box-shadow: none; - display: inline-flex; - align-items: center; - justify-content: center; - overflow: hidden; - transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; - } - .${gridId} .ant-pagination .ant-pagination-item a, - .${gridId} .ant-pagination .ant-pagination-prev .ant-pagination-item-link, - .${gridId} .ant-pagination .ant-pagination-next .ant-pagination-item-link, - .${gridId} .ant-pagination .ant-pagination-prev > *, - .${gridId} .ant-pagination .ant-pagination-next > * { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: ${paginationPrimaryTextColor}; - font-weight: 600; - border: none; - background: transparent; - border-radius: inherit; - line-height: 1; - } - .${gridId} .ant-pagination .ant-pagination-item:hover, - .${gridId} .ant-pagination .ant-pagination-prev:hover, - .${gridId} .ant-pagination .ant-pagination-next:hover { - background: ${paginationHoverBg}; - border-color: ${paginationActiveItemBorderColor}; - transform: translateY(-1px); - } - .${gridId} .ant-pagination .ant-pagination-item-active { - border-color: ${paginationActiveItemBorderColor}; - background: ${paginationActiveItemBg}; - box-shadow: inset 0 0 0 1px ${paginationAccentBorderColor}; - } - .${gridId} .ant-pagination .ant-pagination-item-active a { - color: ${paginationActiveItemTextColor}; - } - .${gridId} .ant-pagination .ant-pagination-disabled, - .${gridId} .ant-pagination .ant-pagination-disabled:hover { - background: transparent; - border-color: ${paginationChipBorderColor}; - transform: none; - opacity: 0.42; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev, - .${gridId} .ant-pagination .ant-pagination-jump-next { - padding: 0; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - padding: 0; - margin: 0; - line-height: 1; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-container, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-container { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - position: relative; - line-height: 1; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis, - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon { - position: absolute !important; - top: 0 !important; - right: 0 !important; - bottom: 0 !important; - left: 0 !important; - inset: 0 !important; - width: fit-content !important; - height: fit-content !important; - min-width: 0 !important; - min-height: 0 !important; - margin: auto !important; - padding: 0 !important; - transform: none !important; - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - line-height: 1 !important; - color: ${paginationSecondaryTextColor}; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis { - letter-spacing: 0.18em; - text-indent: 0.18em; - text-align: center; - } - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon .anticon, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon .anticon, - .${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon svg, - .${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon svg { - display: inline-flex !important; - align-items: center !important; - justify-content: center !important; - width: 1em; - height: 1em; - line-height: 1; - } - .${gridId} .data-grid-pagination-nav-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - font-size: 12px; - line-height: 1; - } - .${gridId} .data-grid-pagination-nav-icon .anticon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - } - .${gridId} .data-grid-pagination-jump { - display: inline-flex; - align-items: center; - gap: 6px; - height: 34px; - color: ${paginationSecondaryTextColor}; - font-size: 12px; - font-weight: 600; - white-space: nowrap; - } - .${gridId} .data-grid-pagination-jump-label { - color: ${paginationSecondaryTextColor}; - font-variant-numeric: tabular-nums; - } - .${gridId} .data-grid-pagination-jump-input, - .${gridId} .data-grid-pagination-jump-input.ant-input-number { - width: 64px; - min-width: 64px; - height: 34px; - display: inline-flex; - align-items: stretch; - } - .${gridId} .data-grid-pagination-jump-input .ant-input-number-input-wrap, - .${gridId} .data-grid-pagination-jump-input .ant-input-number-input { - height: 100%; - } - .${gridId} .data-grid-pagination-jump-input .ant-input-number-input { - padding: 0 10px; - text-align: center; - color: ${paginationPrimaryTextColor}; - font-weight: 600; - font-variant-numeric: tabular-nums; - line-height: 34px; - } - .${gridId} .data-grid-pagination-jump-input.ant-input-number { - border-radius: 12px; - border: 1px solid ${paginationChipBorderColor}; - background: ${paginationChipBg}; - box-shadow: none; - } - .${gridId} .data-grid-pagination-jump-button.ant-btn { - height: 34px; - min-width: 34px; - padding: 0 10px; - border-radius: 12px; - border-color: ${paginationChipBorderColor}; - background: ${paginationChipBg}; - color: ${paginationPrimaryTextColor}; - font-weight: 700; - box-shadow: none; - } - .${gridId} .data-grid-pagination-size-select { - width: 72px; - min-width: 72px; - max-width: 72px; - height: 34px; - display: inline-flex; - align-items: stretch; - } - .${gridId} .data-grid-pagination-size-select.ant-select-single, - .${gridId} .data-grid-pagination-size-select.ant-select-single.ant-select-sm { - width: 72px; - min-width: 72px; - max-width: 72px; - height: 34px; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selector { - height: 34px !important; - border-radius: 12px !important; - border: 1px solid ${paginationChipBorderColor} !important; - background: ${paginationChipBg} !important; - box-shadow: none !important; - padding: 0 24px 0 10px !important; - display: flex !important; - align-items: center !important; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-wrap { - display: flex !important; - align-items: center !important; - height: 100%; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-search, - .${gridId} .data-grid-pagination-size-select .ant-select-selection-search-input { - height: 100% !important; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-item, - .${gridId} .data-grid-pagination-size-select .ant-select-selection-placeholder { - display: flex; - align-items: center; - height: 100%; - line-height: 34px !important; - color: ${paginationPrimaryTextColor}; - font-weight: 600; - justify-content: flex-start; - font-variant-numeric: tabular-nums; - } - .${gridId} .data-grid-pagination-size-select .ant-select-selection-search { - inset-inline-start: 10px !important; - inset-inline-end: 24px !important; - } - .${gridId} .data-grid-pagination-size-select .ant-select-arrow { - color: ${paginationSecondaryTextColor}; - inset-inline-end: 10px; - top: 50%; - transform: translateY(-50%); - margin-top: 0; - display: inline-flex; - align-items: center; - justify-content: center; - height: 16px; - line-height: 1; - } - .${gridId} .data-grid-pagination-size-select .ant-select-arrow .anticon { - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 1; - } - `, [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor, densityParams]); + const gridCssText = useMemo( + () => buildDataGridCssText({ + darkMode, + dataGridBackdropFilter, + dataTableVerticalBorderRule, + densityParams, + floatingScrollbarBottomOffset, + floatingScrollbarHeight, + floatingScrollbarInset, + floatingScrollbarThumbBg, + floatingScrollbarThumbBorderColor, + floatingScrollbarThumbHoverBg, + floatingScrollbarThumbShadow, + gridId, + horizontalScrollbarThumbBg, + horizontalScrollbarThumbBorderColor, + horizontalScrollbarThumbHoverBg, + horizontalScrollbarThumbShadow, + horizontalScrollbarTrackBg, + horizontalScrollbarTrackBorderColor, + horizontalScrollbarTrackShadow, + paginationAccentBg, + paginationAccentBorderColor, + paginationActiveItemBg, + paginationActiveItemBorderColor, + paginationActiveItemTextColor, + paginationChipBg, + paginationChipBorderColor, + paginationHoverBg, + paginationPrimaryTextColor, + paginationSecondaryTextColor, + paginationShellBg, + paginationShellBorderColor, + paginationShellShadow, + panelRadius, + rowAddedBg, + rowAddedHover, + rowModBg, + rowModHover, + selectionAccentHex, + selectionAccentRgb, + tableBodyBottomPadding, + useVirtualEditablePaintContain, + useVirtualEditableVisibilityHints, + useVirtualHolderPaintHints, + useVirtualRowCellContain, + verticalScrollbarTrackBg, + }), + [themeStyles, gridId, tableBodyBottomPadding, darkMode, opacity, dataTableVerticalBorderColor, densityParams], + ); const recalculateTableMetrics = useCallback((targetElement?: HTMLElement | null) => { const target = targetElement || containerRef.current; @@ -3301,7 +1184,7 @@ const DataGrid: React.FC = ({ const [modifiedRows, setModifiedRows] = useState>({}); const [deletedRowKeys, setDeletedRowKeys] = useState>(new Set()); // 同步到模块级变量,确保 EditableCell 事件处理器始终读取最新删除状态 - globalDeletedRowKeys = deletedRowKeys; + setGlobalDeletedRowKeys(deletedRowKeys); const [modifiedColumns, setModifiedColumns] = useState>>({}); const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewSqlData, setPreviewSqlData] = useState<{ @@ -3520,625 +1403,56 @@ const DataGrid: React.FC = ({ }, [closeCellEditMode]); // 批量填充选中的单元格 - const handleBatchFillCells = useCallback(() => { - const cellsToFill = currentSelectionRef.current; - if (cellsToFill.size === 0) { - void message.info(translateDataGrid('data_grid.message.select_cells_to_fill')); - return; - } - - const fillValue = batchEditSetNull ? null : batchEditValue; - - const addedRowMap = new Map(); - addedRows.forEach((r) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - addedRowMap.set(rowKeyStr(k), r); - }); - - const baseRowMap = new Map(); - displayDataRef.current.forEach((r) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - baseRowMap.set(rowKeyStr(k), r); - }); - - const patchesByRow = new Map>(); - let updatedCount = 0; - - cellsToFill.forEach((cellKey) => { - const parts = splitCellKey(cellKey); - if (!parts) return; - const { rowKey, colName } = parts; - - const existing = modifiedRows[rowKey]; - const baseRow = baseRowMap.get(rowKey); - let currentVal: any; - - const addedRow = addedRowMap.get(rowKey); - if (addedRow) { - currentVal = addedRow?.[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, GONAVI_ROW_KEY)) { - currentVal = (existing as any)?.[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, colName)) { - currentVal = (existing as any)?.[colName]; - } else { - currentVal = baseRow?.[colName]; - } - - const isSame = isCellValueEqualForDiff(currentVal, fillValue); - if (isSame) return; - - const patch = patchesByRow.get(rowKey) || {}; - patch[colName] = fillValue; - patchesByRow.set(rowKey, patch); - updatedCount++; - }); - - if (updatedCount === 0) { - void message.info(translateDataGrid('data_grid.message.selected_cells_no_update')); - return; - } - - // 仅做一次状态提交,避免大量 setState 循环 - setAddedRows(prev => prev.map(r => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return r; - const patch = patchesByRow.get(rowKeyStr(k)); - if (!patch) return r; - return { ...r, ...patch }; - })); - - setModifiedRows(prev => { - let next: Record | null = null; - - patchesByRow.forEach((patch, keyStr) => { - if (addedRowMap.has(keyStr)) return; - - const existing = prev[keyStr]; - const merged = existing ? { ...(existing as any), ...patch } : patch; - if (!next) next = { ...prev }; - next[keyStr] = merged; - }); - - return next || prev; - }); - - void message.success(translateDataGrid('data_grid.message.filled_cells', { count: updatedCount })); - closeBatchEditModal(); - - // 清除选中状态 - setSelectedCells(new Set()); - currentSelectionRef.current = new Set(); - selectionStartRef.current = null; - isDraggingRef.current = false; - cellSelectionPointerRef.current = null; - if (cellSelectionAutoScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); - cellSelectionAutoScrollRafRef.current = null; - } - updateCellSelection(new Set()); - }, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal, translateDataGrid]); - - // 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。 - useEffect(() => { - const container = containerRef.current; - if (!canModifyData || !isTableSurfaceActive) return; - if (!container) return; - const EDGE_THRESHOLD_PX = 28; - const MIN_SCROLL_STEP = 8; - const MAX_SCROLL_STEP = 24; - - const isInteractiveTarget = (target: HTMLElement | null): boolean => { - if (!target) return false; - return !!target.closest('input, textarea, button, select, [contenteditable="true"], .ant-checkbox, .ant-picker, .ant-select, .ant-dropdown, .ant-modal'); - }; - - const getCellElement = (target: HTMLElement | null): HTMLElement | null => { - if (!target) return null; - const cell = target.closest('[data-row-key][data-col-name]') as HTMLElement; - if (!cell || !container.contains(cell)) return null; - const colName = cell.getAttribute('data-col-name'); - if (!colName || !isWritableResultColumn(colName, effectiveEditLocator)) return null; - return cell; - }; - - const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => { - const cell = getCellElement(target); - if (!cell) return null; - const rowKey = cell.getAttribute('data-row-key'); - const colName = cell.getAttribute('data-col-name'); - if (!rowKey || !colName) return null; - return { rowKey, colName }; - }; - - const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => { - const target = document.elementFromPoint(x, y) as HTMLElement | null; - return getCellInfo(target); - }; - - const applySelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { - const start = selectionStartRef.current; - if (!start) return; - - const currentData = displayDataRef.current; - const rowIndexMap = rowIndexMapRef.current; - const startRowIndex = start.rowIndex; - const endRowIndex = rowIndexMap.get(cellInfo.rowKey) ?? -1; - if (startRowIndex === -1 || endRowIndex === -1) return; - - const startColIndex = start.colIndex; - const endColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - if (startColIndex === -1 || endColIndex === -1) return; - - const minRowIndex = Math.min(startRowIndex, endRowIndex); - const maxRowIndex = Math.max(startRowIndex, endRowIndex); - const minColIndex = Math.min(startColIndex, endColIndex); - const maxColIndex = Math.max(startColIndex, endColIndex); - - const newSelectedCells = new Set(); - for (let i = minRowIndex; i <= maxRowIndex; i++) { - const row = currentData[i]; - const rKey = String(row?.[GONAVI_ROW_KEY]); - for (let j = minColIndex; j <= maxColIndex; j++) { - newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j])); - } - } - - currentSelectionRef.current = newSelectedCells; - updateCellSelection(newSelectedCells); - }; - - const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => { - if (cellSelectionRafRef.current !== null) { - cancelAnimationFrame(cellSelectionRafRef.current); - } - - cellSelectionRafRef.current = requestAnimationFrame(() => { - cellSelectionRafRef.current = null; - applySelectionUpdate(cellInfo); - }); - }; - - const stopAutoScroll = () => { - if (cellSelectionAutoScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionAutoScrollRafRef.current); - cellSelectionAutoScrollRafRef.current = null; - } - }; - - const getScrollStep = (distanceToEdge: number): number => { - const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX)); - return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio); - }; - - const autoScrollTick = () => { - if (!isDraggingRef.current || !selectionStartRef.current) { - stopAutoScroll(); - return; - } - - const pointer = cellSelectionPointerRef.current; - const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null; - if (!pointer || !tableBody) { - cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); - return; - } - - const rect = tableBody.getBoundingClientRect(); - const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight); - const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth); - let deltaY = 0; - let deltaX = 0; - - if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) { - const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y; - deltaY = -getScrollStep(distance); - } else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) { - const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX); - deltaY = getScrollStep(distance); - } - - if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) { - const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x; - deltaX = -getScrollStep(distance); - } else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) { - const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX); - deltaX = getScrollStep(distance); - } - - let didScroll = false; - if (deltaY !== 0) { - const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY)); - if (nextTop !== tableBody.scrollTop) { - tableBody.scrollTop = nextTop; - didScroll = true; - } - } - - if (deltaX !== 0) { - const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX)); - if (nextLeft !== tableBody.scrollLeft) { - tableBody.scrollLeft = nextLeft; - didScroll = true; - } - } - - if (didScroll) { - const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y); - if (cellInfo) scheduleSelectionUpdate(cellInfo); - } - - cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); - }; - - const ensureAutoScroll = () => { - if (cellSelectionAutoScrollRafRef.current !== null) return; - cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick); - }; - - const beginCellSelection = (cellInfo: { rowKey: string; colName: string }, x: number, y: number) => { - if (!cellEditModeRef.current) { - cellEditModeRef.current = true; - setCellEditMode(true); - } - suppressCellSelectionClickRef.current = true; - pendingCellSelectionStartRef.current = null; - isDraggingRef.current = true; - cellSelectionPointerRef.current = { x, y }; - - const currentData = displayDataRef.current; - const nextRowIndexMap = new Map(); - currentData.forEach((r, idx) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - nextRowIndexMap.set(String(k), idx); - }); - rowIndexMapRef.current = nextRowIndexMap; - - const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1; - const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1; - selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex }; - currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]); - updateCellSelection(currentSelectionRef.current); - ensureAutoScroll(); - }; - - const onMouseDown = (e: MouseEvent) => { - if (e.button !== 0) return; - const target = e.target instanceof HTMLElement ? e.target : null; - if (isInteractiveTarget(target)) return; - const cellInfo = getCellInfo(target); - if (!cellInfo) return; - - if (cellEditModeRef.current) { - e.preventDefault(); - beginCellSelection(cellInfo, e.clientX, e.clientY); - return; - } - - pendingCellSelectionStartRef.current = { ...cellInfo, x: e.clientX, y: e.clientY }; - }; - - const onMouseMove = (e: MouseEvent) => { - const pendingStart = pendingCellSelectionStartRef.current; - if (!isDraggingRef.current && pendingStart) { - const dx = e.clientX - pendingStart.x; - const dy = e.clientY - pendingStart.y; - if (Math.hypot(dx, dy) < CELL_SELECTION_DRAG_THRESHOLD_PX) return; - - e.preventDefault(); - beginCellSelection( - { rowKey: pendingStart.rowKey, colName: pendingStart.colName }, - e.clientX, - e.clientY, - ); - } - - if (!isDraggingRef.current || !selectionStartRef.current) return; - e.preventDefault(); - cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY }; - ensureAutoScroll(); - - const target = e.target instanceof HTMLElement ? e.target : null; - const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); - if (!cellInfo) return; - scheduleSelectionUpdate(cellInfo); - }; - - const onMouseUp = (e: MouseEvent) => { - pendingCellSelectionStartRef.current = null; - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - cellSelectionPointerRef.current = null; - stopAutoScroll(); - - if (cellSelectionRafRef.current !== null) { - cancelAnimationFrame(cellSelectionRafRef.current); - cellSelectionRafRef.current = null; - } - - const target = e.target instanceof HTMLElement ? e.target : null; - const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY); - if (cellInfo) applySelectionUpdate(cellInfo); - - if (currentSelectionRef.current.size > 0) { - setSelectedCells(new Set(currentSelectionRef.current)); - } - }; - - const onClickCapture = (e: MouseEvent) => { - if (!suppressCellSelectionClickRef.current) return; - suppressCellSelectionClickRef.current = false; - e.preventDefault(); - e.stopPropagation(); - }; - - const onScroll = () => { - if (currentSelectionRef.current.size === 0) return; - if (cellSelectionScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionScrollRafRef.current); - } - cellSelectionScrollRafRef.current = requestAnimationFrame(() => { - cellSelectionScrollRafRef.current = null; - updateCellSelection(currentSelectionRef.current); - }); - }; - - container.addEventListener('mousedown', onMouseDown); - container.addEventListener('mousemove', onMouseMove); - container.addEventListener('click', onClickCapture, true); - container.addEventListener('scroll', onScroll, true); - document.addEventListener('mouseup', onMouseUp); - - return () => { - container.removeEventListener('mousedown', onMouseDown); - container.removeEventListener('mousemove', onMouseMove); - container.removeEventListener('click', onClickCapture, true); - container.removeEventListener('scroll', onScroll, true); - document.removeEventListener('mouseup', onMouseUp); - if (cellSelectionRafRef.current !== null) { - cancelAnimationFrame(cellSelectionRafRef.current); - cellSelectionRafRef.current = null; - } - if (cellSelectionScrollRafRef.current !== null) { - cancelAnimationFrame(cellSelectionScrollRafRef.current); - cellSelectionScrollRafRef.current = null; - } - stopAutoScroll(); - pendingCellSelectionStartRef.current = null; - cellSelectionPointerRef.current = null; - isDraggingRef.current = false; - }; - }, [canModifyData, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, updateCellSelection]); - - const handleCopySelectedColumnsFromRow = useCallback(() => { - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - if (activeSelection.size === 0) { - void message.info(translateDataGrid('data_grid.message.select_same_row_cells_to_copy')); - return; - } - - const parsed = Array.from(activeSelection) - .map((cellKey) => splitCellKey(cellKey)) - .filter((item): item is { rowKey: string; colName: string } => !!item); - if (parsed.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_copyable_cells')); - return; - } - - const sourceRowKeySet = new Set(parsed.map((item) => item.rowKey)); - if (sourceRowKeySet.size !== 1) { - void message.info(translateDataGrid('data_grid.message.copy_columns_same_row_only')); - return; - } - - const sourceRowKey = parsed[0].rowKey; - const selectedColumnNames = Array.from(new Set(parsed.map((item) => item.colName))); - if (selectedColumnNames.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_copyable_columns')); - return; - } - - const sourceBaseRow = displayDataRef.current.find((row) => { - const key = row?.[GONAVI_ROW_KEY]; - return key !== undefined && key !== null && rowKeyStr(key) === sourceRowKey; - }); - const sourceAddedRow = addedRows.find((row) => { - const key = row?.[GONAVI_ROW_KEY]; - return key !== undefined && key !== null && rowKeyStr(key) === sourceRowKey; - }); - const sourceModified = modifiedRows[sourceRowKey]; - - const values: Record = {}; - selectedColumnNames.forEach((colName) => { - if (sourceAddedRow) { - values[colName] = sourceAddedRow[colName]; - return; - } - - if (sourceModified && Object.prototype.hasOwnProperty.call(sourceModified as any, colName)) { - values[colName] = (sourceModified as any)[colName]; - return; - } - - values[colName] = sourceBaseRow?.[colName]; - }); - - setCopiedCellPatch({ sourceRowKey, values }); - void message.success(translateDataGrid('data_grid.message.copied_columns', { count: selectedColumnNames.length })); - }, [selectedCells, rowKeyStr, addedRows, modifiedRows, translateDataGrid]); - - const handlePasteCopiedColumnsToSelectedRows = useCallback((fallbackRowKey?: React.Key) => { - if (!copiedCellPatch || Object.keys(copiedCellPatch.values).length === 0) { - void message.info(translateDataGrid('data_grid.message.copy_columns_first')); - return; - } - - const writablePatchValues = Object.fromEntries( - Object.entries(copiedCellPatch.values) - .filter(([colName]) => isWritableResultColumn(colName, effectiveEditLocator)) - ); - if (Object.keys(writablePatchValues).length === 0) { - void message.info(translateDataGrid('data_grid.message.no_pasteable_editable_fields')); - return; - } - - const targetKeySet = new Set(); - const selectedKeys = selectedRowKeysRef.current; - if (selectedKeys.length > 0) { - selectedKeys.forEach((key) => targetKeySet.add(rowKeyStr(key))); - } else if (fallbackRowKey !== undefined && fallbackRowKey !== null) { - targetKeySet.add(rowKeyStr(fallbackRowKey)); - } else { - void message.info(translateDataGrid('data_grid.message.select_target_rows')); - return; - } - - targetKeySet.delete(copiedCellPatch.sourceRowKey); - if (targetKeySet.size === 0) { - void message.info(translateDataGrid('data_grid.message.target_rows_cannot_only_source')); - return; - } - - const addedRowMap = new Map(); - addedRows.forEach((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return; - addedRowMap.set(rowKeyStr(key), row); - }); - - const baseRowMap = new Map(); - displayDataRef.current.forEach((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return; - baseRowMap.set(rowKeyStr(key), row); - }); - - const patchesByRow = new Map>(); - let updatedCellCount = 0; - - targetKeySet.forEach((targetRowKey) => { - const patch: Record = {}; - const existing = modifiedRows[targetRowKey]; - const addedRow = addedRowMap.get(targetRowKey); - const baseRow = baseRowMap.get(targetRowKey); - - Object.entries(writablePatchValues).forEach(([colName, nextValue]) => { - let currentValue: any; - - if (addedRow) { - currentValue = addedRow[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, GONAVI_ROW_KEY)) { - currentValue = (existing as any)[colName]; - } else if (existing && Object.prototype.hasOwnProperty.call(existing as any, colName)) { - currentValue = (existing as any)[colName]; - } else { - currentValue = baseRow?.[colName]; - } - - if (isCellValueEqualForDiff(currentValue, nextValue)) return; - patch[colName] = nextValue; - updatedCellCount++; - }); - - if (Object.keys(patch).length > 0) { - patchesByRow.set(targetRowKey, patch); - } - }); - - if (patchesByRow.size === 0 || updatedCellCount === 0) { - void message.info(translateDataGrid('data_grid.message.target_rows_no_update')); - return; - } - - setAddedRows(prev => prev.map((row) => { - const key = row?.[GONAVI_ROW_KEY]; - if (key === undefined || key === null) return row; - const patch = patchesByRow.get(rowKeyStr(key)); - if (!patch) return row; - return { ...row, ...patch }; - })); - - setModifiedRows(prev => { - let next: Record | null = null; - - patchesByRow.forEach((patch, keyStr) => { - if (addedRowMap.has(keyStr)) return; - const existing = prev[keyStr]; - const merged = existing ? { ...(existing as any), ...patch } : patch; - if (!next) next = { ...prev }; - next[keyStr] = merged; - }); - - return next || prev; - }); - - void message.success(translateDataGrid('data_grid.message.pasted_columns_to_rows', { rows: patchesByRow.size, cells: updatedCellCount })); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [copiedCellPatch, addedRows, modifiedRows, rowKeyStr, effectiveEditLocator, translateDataGrid]); - - // 批量填充到选中行 - const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { - if (!isWritableResultColumn(dataIndex, effectiveEditLocator)) { - void message.info(translateDataGrid('data_grid.message.current_field_not_editable')); - return; - } - const sourceValue = sourceRecord[dataIndex]; - const selKeys = selectedRowKeysRef.current; - - if (selKeys.length === 0) { - void message.info(translateDataGrid('data_grid.message.select_rows_to_fill')); - return; - } - - const sourceKey = sourceRecord?.[GONAVI_ROW_KEY]; - // 过滤掉源行本身 - const targetKeys = selKeys.filter(k => k !== sourceKey); - - if (targetKeys.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_other_rows_to_fill')); - return; - } - - // 批量更新 - const addedKeySet = new Set(); - addedRows.forEach((r) => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return; - addedKeySet.add(rowKeyStr(k)); - }); - - const targetKeyStrList = targetKeys.map(rowKeyStr); - const targetKeyStrSet = new Set(targetKeyStrList); - const updatedCount = targetKeyStrSet.size; - - setAddedRows(prev => prev.map(r => { - const k = r?.[GONAVI_ROW_KEY]; - if (k === undefined) return r; - const keyStr = rowKeyStr(k); - if (!targetKeyStrSet.has(keyStr)) return r; - return { ...r, [dataIndex]: sourceValue }; - })); - - setModifiedRows(prev => { - let next: Record | null = null; - - targetKeyStrSet.forEach((keyStr) => { - if (addedKeySet.has(keyStr)) return; - const existing = prev[keyStr]; - const patch = { [dataIndex]: sourceValue }; - const merged = existing ? { ...(existing as any), ...patch } : patch; - if (!next) next = { ...prev }; - next[keyStr] = merged; - }); - - return next || prev; - }); - - void message.success(translateDataGrid('data_grid.message.filled_rows', { count: updatedCount })); - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [addedRows, rowKeyStr, effectiveEditLocator, translateDataGrid]); + const { + handleBatchFillCells, + handleCopySelectedColumnsFromRow, + handlePasteCopiedColumnsToSelectedRows, + handleBatchFillToSelected, + } = useDataGridBatchActions({ + CELL_SELECTION_DRAG_THRESHOLD_PX, + GONAVI_ROW_KEY, + addedRows, + batchEditSetNull, + batchEditValue, + canModifyData, + cancelAnimationFrame, + cellEditModeRef, + cellSelectionAutoScrollRafRef, + cellSelectionPointerRef, + cellSelectionRafRef, + cellSelectionScrollRafRef, + closeBatchEditModal, + columnIndexMap, + containerRef, + copiedCellPatch, + currentSelectionRef, + displayColumnNames, + displayDataRef, + effectiveEditLocator, + isCellValueEqualForDiff, + isDraggingRef, + isTableSurfaceActive, + isWritableResultColumn, + makeCellKey, + modifiedRows, + pendingCellSelectionStartRef, + requestAnimationFrame, + rowIndexMapRef, + rowKeyStr, + selectedCells, + selectedRowKeysRef, + selectionStartRef, + setAddedRows, + setCellContextMenu, + setCellEditMode, + setCopiedCellPatch, + setModifiedRows, + setSelectedCells, + splitCellKey, + suppressCellSelectionClickRef, + translateDataGrid, + updateCellSelection, + }); const displayData = useMemo(() => { return [...data, ...addedRows]; @@ -4222,213 +1536,26 @@ const DataGrid: React.FC = ({ applySortInfo(next); }, [applySortInfo, sortInfo]); - // Native Drag State - const draggingRef = useRef<{ - startX: number, - startWidth: number, - key: string, - containerLeft: number - } | null>(null); - const ghostRef = useRef(null); - const resizeRafRef = useRef(null); - const latestClientXRef = useRef(null); - const isResizingRef = useRef(false); // Lock for sorting - const autoFitCanvasRef = useRef(null); - - const flushGhostPosition = useCallback(() => { - resizeRafRef.current = null; - if (!draggingRef.current || !ghostRef.current) return; - if (latestClientXRef.current === null) return; - const relativeLeft = latestClientXRef.current - draggingRef.current.containerLeft; - ghostRef.current.style.transform = `translateX(${relativeLeft}px)`; - }, []); - - // 1. Drag Start - - const handleResizeStart = useCallback((key: string) => (e: React.MouseEvent) => { - - e.preventDefault(); - - e.stopPropagation(); - - - - isResizingRef.current = true; // Engage lock - - - - const startX = e.clientX; - - const currentWidth = resolveDataTableColumnWidth({ - manualWidth: columnWidths[key], - density: dataTableDensity, - }); - - const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0; - - draggingRef.current = { startX, startWidth: currentWidth, key, containerLeft }; - latestClientXRef.current = startX; - - - - // Show Ghost Line at initial position - - if (ghostRef.current && containerRef.current) { - const relativeLeft = startX - containerLeft; - ghostRef.current.style.transform = `translateX(${relativeLeft}px)`; - - ghostRef.current.style.display = 'block'; - - } - - - - // Add global listeners - - document.addEventListener('mousemove', handleResizeMove); - - document.addEventListener('mouseup', handleResizeStop); - - document.body.style.cursor = 'col-resize'; - - document.body.style.userSelect = 'none'; - - }, [columnWidths, dataTableDensity]); - - const measureTextWidth = useCallback((text: string, font: string) => { - if (typeof document === 'undefined') { - return text.length * 8; - } - if (!autoFitCanvasRef.current) { - autoFitCanvasRef.current = document.createElement('canvas'); - } - const context = autoFitCanvasRef.current.getContext('2d'); - if (!context) { - return text.length * 8; - } - context.font = font; - return context.measureText(text).width; - }, []); - - const buildAutoFitMeasurer = useCallback((element: HTMLElement | null, fallbackFont: string) => { - let font = fallbackFont; - if (typeof window !== 'undefined' && element) { - const computed = window.getComputedStyle(element); - const weight = computed.fontWeight || '400'; - const size = computed.fontSize || '13px'; - const family = computed.fontFamily || DEFAULT_GRID_MONO_FONT_FAMILY; - font = `${weight} ${size} ${family}`; - } - return (text: string) => measureTextWidth(text, font); - }, [measureTextWidth]); - - const autoFitDoneRef = useRef(''); - useEffect(() => { - if (displayColumnNames.length === 0 || displayData.length === 0) return; - const sig = displayColumnNames.join(','); - if (autoFitDoneRef.current === sig) return; - const font = `${densityParams.dataFontSize}px ${DEFAULT_GRID_MONO_FONT_FAMILY}`; - const newWidths: Record = {}; - displayColumnNames.forEach((key) => { - const autoWidth = calculateAutoFitColumnWidth({ - headerTexts: [key], - valueTexts: displayData.slice(0, 200).map((row) => row?.[key]), - measureHeaderText: (t) => measureTextWidth(t, `600 ${font}`), - measureCellText: (t) => measureTextWidth(t, `400 ${font}`), - minWidth: 40, - maxWidth: 600, - defaultWidth: densityParams.defaultColumnWidth, - }); - newWidths[key] = autoWidth; - }); - autoFitDoneRef.current = sig; - setColumnWidths((prev) => ({ ...newWidths, ...prev })); - }, [displayColumnNames, displayData, densityParams, measureTextWidth]); - - const autoFitColumnWidth = useCallback((key: string, headerEl?: HTMLElement | null) => { - const normalizedKey = String(key || '').trim(); - if (!normalizedKey) return; - const sampleCell = Array.from( - containerRef.current?.querySelectorAll('.ant-table-cell[data-col-name]') || [] - ).find((node) => (node as HTMLElement).getAttribute('data-col-name') === normalizedKey) as HTMLElement | undefined; - - const meta = columnMetaMap[normalizedKey] || columnMetaMapByLowerName[normalizedKey.toLowerCase()]; - const headerTexts = [normalizedKey]; - if (showColumnType && meta?.type) headerTexts.push(meta.type); - if (showColumnComment && meta?.comment) headerTexts.push(meta.comment); - - const defaultWidth = resolveDataTableColumnWidth({ - manualWidth: columnWidths[normalizedKey], - density: dataTableDensity, - }); - const containerWidth = containerRef.current?.clientWidth ?? 0; - const nextWidth = calculateAutoFitColumnWidth({ - headerTexts, - valueTexts: displayDataRef.current.slice(0, 200).map((row) => row?.[normalizedKey]), - measureHeaderText: buildAutoFitMeasurer(headerEl ?? null, `600 ${densityParams.dataFontSize}px ${DEFAULT_GRID_MONO_FONT_FAMILY}`), - measureCellText: buildAutoFitMeasurer(sampleCell ?? null, `400 ${densityParams.dataFontSize}px ${DEFAULT_GRID_MONO_FONT_FAMILY}`), - defaultWidth, - minWidth: 80, - maxWidth: Math.max(720, Math.floor(containerWidth * 0.85)), - }); - - setColumnWidths((prev) => ({ ...prev, [normalizedKey]: nextWidth })); - }, [ - buildAutoFitMeasurer, + const { + autoFitColumnWidth, + ghostRef, + handleResizeAutoFit, + handleResizeStart, + isResizingRef, + } = useDataGridColumnResize({ columnMetaMap, columnMetaMapByLowerName, columnWidths, + containerRef, dataTableDensity, - densityParams.dataFontSize, + densityParams, + displayColumnNames, + displayData, + displayDataRef, + setColumnWidths, showColumnComment, showColumnType, - ]); - - const handleResizeAutoFit = useCallback((key: string) => (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const handleEl = e.currentTarget as HTMLElement | null; - const headerEl = handleEl?.closest('th') as HTMLElement | null; - autoFitColumnWidth(key, headerEl); - }, [autoFitColumnWidth]); - - // 2. Drag Move (Global) - const handleResizeMove = useCallback((e: MouseEvent) => { - if (!draggingRef.current) return; - latestClientXRef.current = e.clientX; - if (resizeRafRef.current !== null) return; - resizeRafRef.current = requestAnimationFrame(flushGhostPosition); - }, [flushGhostPosition]); - - // 3. Drag Stop (Global) - const handleResizeStop = useCallback((e: MouseEvent) => { - if (!draggingRef.current) return; - - const { startX, startWidth, key } = draggingRef.current; - const deltaX = e.clientX - startX; - const newWidth = Math.max(50, startWidth + deltaX); - - // Commit State - setColumnWidths(prev => ({ ...prev, [key]: newWidth })); - - // Cleanup - if (resizeRafRef.current !== null) { - cancelAnimationFrame(resizeRafRef.current); - resizeRafRef.current = null; - } - latestClientXRef.current = null; - if (ghostRef.current) ghostRef.current.style.display = 'none'; - document.removeEventListener('mousemove', handleResizeMove); - document.removeEventListener('mouseup', handleResizeStop); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - draggingRef.current = null; - - // Release lock after a short delay to block subsequent click events (sorting) - setTimeout(() => { - isResizingRef.current = false; - }, 100); - }, []); + }); const handleCellSave = useCallback((row: any) => { const rowKey = row?.[GONAVI_ROW_KEY]; @@ -5755,802 +2882,115 @@ const DataGrid: React.FC = ({ copyToClipboard(text); }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, mergedDisplayData, translateDataGrid]); - const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeaderContextMenuActionKey) => { - const columnName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); - if (!columnName) { - void message.info(translateDataGrid('data_grid.message.no_field_name')); - setCellContextMenu(prev => ({ ...prev, visible: false })); - return; - } - - switch (action) { - case 'copy-field-name': - copyToClipboard(columnName); - break; - case 'copy-column-data': - handleCopyColumnData(columnName); - break; - case 'sort-asc': - applyColumnSort(columnName, 'ascend'); - break; - case 'sort-desc': - applyColumnSort(columnName, 'descend'); - break; - case 'clear-sort': - applyColumnSort(columnName, null); - break; - case 'auto-fit-column': - autoFitColumnWidth(columnName); - break; - case 'hide-column': - if (displayColumnNames.length <= 1) { - void message.info(translateDataGrid('data_grid.message.keep_one_visible_column')); - break; - } - toggleColumnVisibility(columnName, false); - break; - case 'show-column-type': - setQueryOptions({ showColumnType: true }); - break; - case 'hide-column-type': - setQueryOptions({ showColumnType: false }); - break; - case 'show-column-comment': - setQueryOptions({ showColumnComment: true }); - break; - case 'hide-column-comment': - setQueryOptions({ showColumnComment: false }); - break; - default: - break; - } - setCellContextMenu(prev => ({ ...prev, visible: false })); - }, [ - applyColumnSort, - autoFitColumnWidth, - cellContextMenu.dataIndex, - cellContextMenu.title, - copyToClipboard, - displayColumnNames.length, - handleCopyColumnData, - setQueryOptions, - translateDataGrid, - toggleColumnVisibility, - ]); - - const getClipboardRows = useCallback(() => ( - pickRowsForClipboard({ - rows: mergedDisplayData as Array>, - selectedRowKeys, - columnNames: displayOutputColumnNames, - rowKeyField: GONAVI_ROW_KEY, - rowKeyToString: rowKeyStr, - }) - ), [mergedDisplayData, selectedRowKeys, displayOutputColumnNames, rowKeyStr]); - - const getClipboardColumnNames = useCallback((rows: Array>) => { - if (rows.length === 0) return []; - return displayOutputColumnNames; - }, [displayOutputColumnNames]); - - const handleCopyQueryResultCsv = useCallback(() => { - const rows = getClipboardRows(); - const columns = getClipboardColumnNames(rows); - const text = buildClipboardCsv(rows, columns); - if (!text) { - void message.info(translateDataGrid('data_grid.message.result_set_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [copyToClipboard, getClipboardColumnNames, getClipboardRows, translateDataGrid]); - - const handleCopyQueryResultJson = useCallback(() => { - const rows = getClipboardRows(); - const text = buildClipboardJson(rows); - if (!text) { - void message.info(translateDataGrid('data_grid.message.result_set_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [copyToClipboard, getClipboardRows, translateDataGrid]); - - const handleCopyQueryResultMarkdown = useCallback(() => { - const rows = getClipboardRows(); - const columns = getClipboardColumnNames(rows); - const text = buildClipboardMarkdown(rows, columns); - if (!text) { - void message.info(translateDataGrid('data_grid.message.result_set_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [copyToClipboard, getClipboardColumnNames, getClipboardRows, translateDataGrid]); - - const handleCopyDdl = useCallback(() => { - if (!ddlText.trim()) { - void message.info(translateDataGrid('data_grid.message.no_ddl_to_copy')); - return; - } - navigator.clipboard.writeText(ddlText) - .then(() => message.success(translateDataGrid('data_grid.message.ddl_copied'))) - .catch(() => message.error(translateDataGrid('data_grid.message.ddl_copy_failed'))); - }, [ddlText, translateDataGrid]); - - const handleCopySelectedCellsToClipboard = useCallback(() => { - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - if (activeSelection.size === 0) { - void message.info(translateDataGrid('data_grid.message.drag_select_cells_to_copy')); - return; - } - - const parsed = Array.from(activeSelection) - .map((cellKey) => splitCellKey(cellKey)) - .filter((item): item is { rowKey: string; colName: string } => !!item); - if (parsed.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_copyable_cells')); - return; - } - - const text = buildSelectedCellClipboardText({ - selectedCells: parsed, - rows: mergedDisplayData as Array>, - columnOrder: displayColumnNames, - rowKeyField: GONAVI_ROW_KEY, - }); - if (!text) { - void message.info(translateDataGrid('data_grid.message.selection_no_copyable_content')); - return; - } - - copyToClipboard(text); - }, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard, translateDataGrid]); - - useEffect(() => { - if (!cellEditMode) return; - - const onKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement | null; - const tagName = String(activeElement?.tagName || '').toLowerCase(); - if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) { - return; - } - - if (event.key === 'Escape') { - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - event.preventDefault(); - if (activeSelection.size === 0) { - closeCellEditMode(); - return; - } - resetCellSelection(); - return; - } - - const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c'; - if (!isCopy) return; - - const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; - if (activeSelection.size === 0) return; - - event.preventDefault(); - handleCopySelectedCellsToClipboard(); - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode]); - - useEffect(() => { - if (!cellEditMode) return; - - const onPointerDown = (event: MouseEvent) => { - const root = rootRef.current; - const target = event.target instanceof Node ? event.target : null; - if (!root || !target || root.contains(target)) return; - if (target instanceof HTMLElement - && target.closest('.ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover')) { - return; - } - closeCellEditMode(); - }; - - document.addEventListener('mousedown', onPointerDown); - return () => document.removeEventListener('mousedown', onPointerDown); - }, [cellEditMode, closeCellEditMode]); - - const getTargets = useCallback((clickedRecord: any) => { - const selKeys = selectedRowKeysRef.current; - const currentData = displayDataRef.current; - const clickedKey = clickedRecord?.[GONAVI_ROW_KEY]; - if (clickedKey !== undefined && selKeys.includes(clickedKey)) { - return currentData.filter(d => selKeys.includes(d?.[GONAVI_ROW_KEY])); - } - return [clickedRecord]; - }, []); - - const getContextMenuTargetRows = useCallback((clickedRecord: any) => { - if (!clickedRecord) return []; - const selKeys = selectedRowKeysRef.current; - const clickedKey = clickedRecord?.[GONAVI_ROW_KEY]; - const clickedKeyStr = clickedKey === undefined || clickedKey === null ? '' : rowKeyStr(clickedKey); - const selectedKeyStrSet = new Set(selKeys.map(rowKeyStr)); - if (clickedKeyStr && selectedKeyStrSet.has(clickedKeyStr)) { - return mergedDisplayData.filter((row) => { - const rowKey = row?.[GONAVI_ROW_KEY]; - return rowKey !== undefined && rowKey !== null && selectedKeyStrSet.has(rowKeyStr(rowKey)); - }); - } - return [clickedRecord]; - }, [mergedDisplayData, rowKeyStr]); - - const translateCopySqlError = useCallback((error: CopySqlError): string => { - if (typeof error === 'string') { - return error; - } - switch (error.key) { - case 'data_grid.copy_sql.error.missing_table_name': - return translateDataGrid('data_grid.copy_sql.error.missing_table_name', error.params); - case 'data_grid.copy_sql.error.no_copyable_fields': - return translateDataGrid('data_grid.copy_sql.error.no_copyable_fields'); - case 'data_grid.copy_sql.error.missing_safe_where': - default: - return translateDataGrid('data_grid.copy_sql.error.missing_safe_where'); - } - }, [translateDataGrid]); - - const buildCopySqlBatchText = useCallback((mode: 'insert' | 'update' | 'delete', record: any): string | null => { - if (!supportsCopyInsert) { - void message.warning(translateDataGrid('data_grid.message.copy_sql_not_supported')); - return null; - } - const records = getTargets(record); - const orderedCols = displayOutputColumnNames; - if (mode === 'insert') { - return records.map((row: any) => buildCopyInsertSQL({ - dbType, - tableName, - orderedCols, - record: row, - columnTypesByLowerName: columnTypeMapByLowerName, - })).join('\n\n'); - } - - const sqlResults = records.map((row: any) => ( - mode === 'update' - ? buildCopyUpdateSQL({ - dbType, - tableName, - orderedCols, - record: row, - pkColumns, - uniqueKeyGroups, - allTableColumns: allTableColumnNames, - columnTypesByLowerName: columnTypeMapByLowerName, - }) - : buildCopyDeleteSQL({ - dbType, - tableName, - orderedCols, - record: row, - pkColumns, - uniqueKeyGroups, - allTableColumns: allTableColumnNames, - columnTypesByLowerName: columnTypeMapByLowerName, - }) - )); - const failedResult = sqlResults.find((result) => result.ok === false); - if (failedResult && failedResult.ok === false) { - void message.warning(translateCopySqlError(failedResult.error)); - return null; - } - const sqlTexts: string[] = []; - sqlResults.forEach((result) => { - if (result.ok) { - sqlTexts.push(result.sql); - } - }); - return sqlTexts.join('\n\n'); - }, [ - supportsCopyInsert, - getTargets, - displayOutputColumnNames, - dbType, - tableName, - columnTypeMapByLowerName, - pkColumns, - uniqueKeyGroups, - allTableColumnNames, - translateCopySqlError, - translateDataGrid, - ]); - - const handleCopyInsert = useCallback((record: any) => { - const batchText = buildCopySqlBatchText('insert', record); - if (!batchText) return; - copyToClipboard(batchText); - }, [buildCopySqlBatchText, copyToClipboard]); - - const handleCopyUpdate = useCallback((record: any) => { - const batchText = buildCopySqlBatchText('update', record); - if (!batchText) return; - copyToClipboard(batchText); - }, [buildCopySqlBatchText, copyToClipboard]); - - const handleCopyDelete = useCallback((record: any) => { - const batchText = buildCopySqlBatchText('delete', record); - if (!batchText) return; - copyToClipboard(batchText); - }, [buildCopySqlBatchText, copyToClipboard]); - - const handleCopyJson = useCallback((record: any) => { - const records = getTargets(record); - const cleanRecords = pickDataGridOutputRows(records, displayOutputColumnNames); - copyToClipboard(JSON.stringify(cleanRecords, null, 2)); - }, [getTargets, displayOutputColumnNames, copyToClipboard]); - - const handleCopyCsv = useCallback((record: any) => { - const records = getTargets(record); - const orderedCols = displayOutputColumnNames; - const header = orderedCols.map(c => `"${c}"`).join(','); - const lines = records.map((r: any) => { - const values = orderedCols.map(c => { - const v = r[c]; - if (v === null || v === undefined) return 'NULL'; - // CSV 标准:值中的双引号转义为两个双引号 - const escaped = String(v).replace(/"/g, '""'); - return `"${escaped}"`; - }); - return values.join(','); - }); - copyToClipboard([header, ...lines].join('\n')); - }, [getTargets, displayOutputColumnNames, copyToClipboard]); - - const handleCopyRowData = useCallback((record: any) => { - const rows = getContextMenuTargetRows(record); - const columns = displayOutputColumnNames; - const text = buildClipboardTsv( - rows, - columns, - (columnName) => (columnMetaMap[columnName] || columnMetaMapByLowerName[columnName.toLowerCase()])?.type, - currentConnConfig, - ); - if (!text) { - void message.info(translateDataGrid('data_grid.message.current_row_no_copyable_content')); - return; - } - copyToClipboard(text); - }, [columnMetaMap, columnMetaMapByLowerName, copyToClipboard, currentConnConfig, displayOutputColumnNames, getContextMenuTargetRows, translateDataGrid]); - - const buildConnConfig = useCallback(() => { - if (!connectionId) return null; - const conn = connections.find(c => c.id === connectionId); - if (!conn) return null; - return { - ...conn.config, - port: Number(conn.config.port), - password: conn.config.password || "", - database: conn.config.database || "", - useSSH: conn.config.useSSH || false, - ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } - }; - }, [connections, connectionId]); - - const exportByQuery = useCallback(async (sql: string, defaultName: string, options: DataExportFileOptions, totalRows?: number) => { - const config = buildConnConfig(); - if (!config) return; - const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0; - await runExportWithProgress({ - title: `导出 ${defaultName || '查询结果'}`, - targetName: defaultName || 'export', - format: options.format, - totalRows: totalRowsKnown ? Number(totalRows) : undefined, - run: (jobId) => ExportQueryWithOptions( - buildRpcConnectionConfig(config) as any, - dbName || '', - sql, - defaultName || 'export', - { - ...options, - jobId, - totalRowsHint: totalRowsKnown ? Number(totalRows) : 0, - totalRowsKnown, - } as any, - ), - }); - }, [buildConnConfig, dbName, runExportWithProgress]); - - const buildPkWhereSql = useCallback((rows: any[], dbType: string) => { - if (!tableName || pkColumns.length === 0) return ''; - const targets = (rows || []).filter(Boolean); - if (targets.length === 0) return ''; - - const clauses: string[] = []; - for (const r of targets) { - const andParts: string[] = []; - for (const pk of pkColumns) { - const col = quoteIdentPart(dbType, pk); - const v = r?.[pk]; - if (v === null || v === undefined) return ''; - andParts.push(`${col} = '${escapeLiteral(String(v))}'`); - } - if (andParts.length === pkColumns.length) { - clauses.push(`(${andParts.join(' AND ')})`); - } - } - if (clauses.length === 0) return ''; - return clauses.join(' OR '); - }, [pkColumns, tableName]); - - const buildCurrentPageSql = useCallback((dbType: string) => { - if (!tableName || !pagination) return ''; - const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); - const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); - const baseSql = buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - whereSql: whereSQL, - }); - const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); - const normalizedType = String(dbType || '').trim().toLowerCase(); - const hasSortForBuffer = hasExplicitSort(sortInfo); - const offset = (pagination.current - 1) * pagination.pageSize; - let sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, pagination.pageSize, offset); - if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { - sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); - } - return sql; - }, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); - - const buildAllRowsSql = useCallback((dbType: string) => { - if (!tableName) return ''; - return buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - }); - }, [tableName, displayOutputColumnNames]); - - const buildFilteredAllSql = useCallback((dbType: string) => { - if (!tableName) return ''; - const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); - const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); - if (!whereSQL) return ''; - let sql = buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - whereSql: whereSQL, - }); - sql += buildOrderBySQL(dbType, sortInfo, pkColumns); - const normalizedType = String(dbType || '').trim().toLowerCase(); - const hasSortForBuffer = hasExplicitSort(sortInfo); - if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { - sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); - } - return sql; - }, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); - - const queryResultCurrentPageRows = useMemo(() => { - if (isQueryResultExport) { - return mergedDisplayData; - } - if (!pagination) { - return mergedDisplayData; - } - const offset = Math.max(0, (pagination.current - 1) * pagination.pageSize); - return mergedDisplayData.slice(offset, offset + pagination.pageSize); - }, [isQueryResultExport, mergedDisplayData, pagination]); - - const exportQueryResultRows = useCallback(async (options: DataExportFileOptions, scope: Exclude) => { - if (scope === 'selected') { - const selectedKeySet = new Set(selectedRowKeys.map((key) => rowKeyStr(key))); - const rows = mergedDisplayData.filter((row) => { - const key = row?.[GONAVI_ROW_KEY]; - return key !== undefined && key !== null && selectedKeySet.has(rowKeyStr(key)); - }); - if (rows.length === 0) { - void message.info(translateDataGrid('data_grid.message.no_rows_selected')); - return; - } - await exportData(rows, options); - return; - } - if (scope === 'page') { - await exportData(queryResultCurrentPageRows, options); - return; - } - const exportAllSql = String(resultExportAllSql || '').trim(); - const fallbackAllSql = String(resultSql || '').trim(); - const backendExportSql = exportAllSql || fallbackAllSql; - if (backendExportSql && connectionId) { - const totalRows = pagination && pagination.totalKnown !== false ? Number(pagination.total) : undefined; - await exportByQuery(backendExportSql, tableName || 'query_result', options, totalRows); - return; - } - await exportData(mergedDisplayData, options); - }, [connectionId, exportByQuery, exportData, mergedDisplayData, pagination, queryResultCurrentPageRows, resultExportAllSql, resultSql, rowKeyStr, selectedRowKeys, tableName]); - - // Context Menu Export - const handleExportSelected = useCallback(async (options: DataExportFileOptions, record: any) => { - if (isQueryResultExport) { - await exportData(getContextMenuTargetRows(record), options); - return; - } - const records = getTargets(record); - if (!connectionId || !tableName) { - await exportData(records, options); - return; - } - - // 有未提交修改时,优先按界面数据导出,避免与数据库不一致。 - if (hasChanges) { - await exportData(records, options); - void message.warning(translateDataGrid('data_grid.message.export_with_uncommitted_changes')); - return; - } - - const config = buildConnConfig(); - if (!config) { - await exportData(records, options); - return; - } - - const dbType = resolveDataSourceType(config); - const pkWhere = buildPkWhereSql(records, dbType); - if (!pkWhere) { - await exportData(records, options); - return; - } - - const sql = buildDataGridSelectBaseSql({ - dbType, - tableName, - columnNames: displayOutputColumnNames, - whereSql: `WHERE ${pkWhere}`, - }); - await exportByQuery(sql, tableName || 'export', options, records.length); - }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames, translateDataGrid]); - - const handleV2CellContextMenuAction = useCallback((action: V2CellContextMenuActionKey) => { - const record = cellContextMenu.record; - const closeMenu = () => setCellContextMenu(prev => ({ ...prev, visible: false })); - - switch (action) { - case 'copy-field-name': - handleCopyContextMenuFieldName(); - return; - case 'copy-row-data': - if (record) handleCopyRowData(record); - closeMenu(); - return; - case 'copy-row-for-paste': - if (record) { - const rowKey = record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null) { - void message.info(translateDataGrid('data_grid.message.no_copyable_rows')); - } else { - setSelectedRowKeys([rowKey]); - copyRowsForPaste([rowKey]); - } - } - closeMenu(); - return; - case 'paste-row-as-new': - handlePasteCopiedRowsAsNew(); - closeMenu(); - return; - case 'copy-column-data': - handleCopyColumnData(cellContextMenu.dataIndex); - closeMenu(); - return; - case 'undo-cell-change': - handleUndoContextMenuCellChange(); - return; - case 'set-null': - handleCellSetNull(); - return; - case 'edit-row': - handleOpenContextMenuRowEditor(); - return; - case 'fill-selected': - if (selectedRowKeys.length > 0 && record) { - handleBatchFillToSelected(record, cellContextMenu.dataIndex); - } - closeMenu(); - return; - case 'paste-copied-columns': - if (copiedCellPatch) { - handlePasteCopiedColumnsToSelectedRows(record?.[GONAVI_ROW_KEY]); - } - closeMenu(); - return; - case 'copy-insert': - if (record) handleCopyInsert(record); - closeMenu(); - return; - case 'copy-update': - if (record) handleCopyUpdate(record); - closeMenu(); - return; - case 'copy-delete': - if (record) handleCopyDelete(record); - closeMenu(); - return; - case 'copy-json': - if (record) handleCopyJson(record); - closeMenu(); - return; - case 'copy-csv': - if (record) handleCopyCsv(record); - closeMenu(); - return; - case 'copy-markdown': - if (record) { - const records = getTargets(record); - const columns = getClipboardColumnNames(records); - copyToClipboard(buildClipboardMarkdown(records, columns)); - } - closeMenu(); - return; - case 'export-csv': - case 'export-xlsx': - case 'export-json': - case 'export-html': - if (record) { - const format = action.replace('export-', '') as DataExportDialogValues['format']; - handleExportSelected({ format }, record).catch(console.error); - } - closeMenu(); - return; - default: - closeMenu(); - } - }, [ - cellContextMenu.record, - cellContextMenu.dataIndex, - copiedCellPatch, - copyRowsForPaste, - copyToClipboard, - getClipboardColumnNames, - getTargets, - handleBatchFillToSelected, - handleCellSetNull, - handleUndoContextMenuCellChange, - handleCopyContextMenuFieldName, - handleCopyCsv, - handleCopyDelete, - handleCopyInsert, - handleCopyJson, - handleCopyColumnData, - handleCopyRowData, - handleCopyUpdate, - handleExportSelected, - handleOpenContextMenuRowEditor, - handlePasteCopiedColumnsToSelectedRows, - handlePasteCopiedRowsAsNew, - selectedRowKeys.length, - translateDataGrid, - ]); - - // Export - const handleOpenExportDialog = useCallback(async () => { - const selectedCount = selectedRowKeys.length; - const allRowsLabel = (resultExportAllSql || resultSql) - ? '全部结果(重新查询)' - : `全部结果(当前缓存 ${mergedDisplayData.length} 条)`; - const commonInitialValues: Partial = { - format: DEFAULT_DATA_EXPORT_FORMAT, - xlsxMaxRowsPerSheet: DEFAULT_XLSX_ROWS_PER_SHEET, - }; - - if (isQueryResultExport) { - const scopeOptions: DataExportScopeOption[] = [ - { - value: 'selected', - label: selectedCount > 0 ? `选中行 (${selectedCount} 条)` : '选中行', - description: '仅导出当前结果集中已勾选的行。', - disabled: selectedCount <= 0, - }, - { - value: 'page', - label: `当前页 (${queryResultCurrentPageRows.length} 条)`, - description: '直接按当前结果页缓存导出。', - }, - { - value: 'all', - label: allRowsLabel, - description: (resultExportAllSql || resultSql) - ? '后台会重新执行 SQL,避免只导出当前页或当前缓存。' - : '当前查询缺少可重放 SQL 时,将导出当前缓存的全部结果。', - }, - ]; - const values = await showDataExportDialog(modal, { - title: '导出查询结果', - scopeOptions, - initialValues: { - ...commonInitialValues, - scope: (resultExportAllSql || resultSql) ? 'all' : (selectedCount > 0 ? 'selected' : 'page'), - }, - }); - if (!values) return; - await exportQueryResultRows(values, values.scope as Exclude); - return; - } - - if (!connectionId) return; - const config = buildConnConfig(); - const dbType = config ? resolveDataSourceType(config) : ''; - const currentPageSql = config && !hasChanges ? buildCurrentPageSql(dbType) : ''; - const filteredAllSql = config && supportsSqlQueryExport ? buildFilteredAllSql(dbType) : ''; - const allRowsSql = config && objectType !== 'table' ? buildAllRowsSql(dbType) : ''; - const hasKnownFilteredTotal = hasFilteredExportSql && pagination && pagination.totalKnown !== false; - const hasKnownAllTotal = !hasFilteredExportSql && pagination && pagination.totalKnown !== false; - - addTab(buildTableExportTab({ - connectionId, - dbName, - tableName: tableName || 'export', - title: `导出 ${tableName || '数据'}`, - objectType, - scopeOptions: [ - { - value: 'page', - label: `当前页 (${displayData.length} 条)`, - description: currentPageSql - ? '后台按当前分页条件重新查询后导出当前页。' - : '当前页依赖前端临时状态,建议直接使用快捷导出。', - disabled: !currentPageSql, - }, - ...(hasFilteredExportSql ? [{ - value: 'filteredAll' as const, - label: '筛选结果(全部)', - description: filteredAllSql - ? '按当前筛选条件重新查询数据库并导出全部筛选结果。' - : '当前数据源或当前状态暂不支持在工作台重放筛选导出。', - disabled: !filteredAllSql, - }] : []), - { - value: 'all', - label: '全表数据', - description: '后台重新查询整张表并导出全部数据。', - }, - ], - initialScope: hasFilteredExportSql && filteredAllSql ? 'filteredAll' : 'all', - queryByScope: { - ...(currentPageSql ? { page: currentPageSql } : {}), - ...(filteredAllSql ? { filteredAll: filteredAllSql } : {}), - ...(allRowsSql ? { all: allRowsSql } : {}), - }, - rowCountByScope: { - page: displayData.length, - ...(hasKnownFilteredTotal ? { filteredAll: Number(pagination?.total) } : {}), - ...(hasKnownAllTotal ? { all: Number(pagination?.total) } : {}), - }, - })); - }, [ - addTab, - buildAllRowsSql, - buildConnConfig, - buildCurrentPageSql, - buildFilteredAllSql, - connectionId, - dbName, - displayData.length, - exportQueryResultRows, - hasFilteredExportSql, - objectType, - isQueryResultExport, - mergedDisplayData.length, - modal, - pagination, - queryResultCurrentPageRows.length, - resultExportAllSql, - resultSql, - selectedRowKeys.length, - supportsSqlQueryExport, - tableName, - hasChanges, - ]); + const { + handleV2ColumnHeaderContextMenuAction, + buildConnConfig, + buildCopySqlBatchText, + getTargets, + handleCopyCsv, + handleCopyDdl, + handleCopyDelete, + handleCopyInsert, + handleCopyJson, + handleCopyQueryResultCsv, + handleCopyQueryResultJson, + handleCopyQueryResultMarkdown, + handleCopyRowData, + handleCopySelectedCellsToClipboard, + handleCopyUpdate, + handleExportSelected, + handleV2CellContextMenuAction, + handleOpenExportDialog, + } = useDataGridV2Actions({ + GONAVI_ROW_KEY, + addTab, + allTableColumnNames, + applyColumnSort, + autoFitColumnWidth, + buildClipboardCsv, + buildClipboardJson, + buildClipboardMarkdown, + buildClipboardTsv, + buildCopyDeleteSQL, + buildCopyInsertSQL, + buildCopyUpdateSQL, + buildDataGridSelectBaseSql, + buildEffectiveFilterConditions, + buildOrderBySQL, + buildPaginatedSelectSQL, + buildRpcConnectionConfig, + buildSelectedCellClipboardText, + buildTableExportTab, + buildWhereSQL, + cellContextMenu, + cellEditMode, + closeCellEditMode, + columnMetaMap, + columnMetaMapByLowerName, + columnTypeMapByLowerName, + connectionId, + connections, + copiedCellPatch, + copyRowsForPaste, + copyToClipboard, + currentConnConfig, + currentSelectionRef, + dbName, + dbType, + ddlText, + displayColumnNames, + displayData, + displayDataRef, + displayOutputColumnNames, + escapeLiteral, + exportData, + filterConditions, + handleBatchFillToSelected, + handleCellSetNull, + handleCopyColumnData, + handleCopyContextMenuFieldName, + handleOpenContextMenuRowEditor, + handlePasteCopiedColumnsToSelectedRows, + handlePasteCopiedRowsAsNew, + handleUndoContextMenuCellChange, + hasChanges, + hasExplicitSort, + hasFilteredExportSql, + isQueryResultExport, + mergedDisplayData, + modal, + navigator, + objectType, + pagination, + pickDataGridOutputRows, + pickRowsForClipboard, + pkColumns, + quickWhereCondition, + quoteIdentPart, + resetCellSelection, + resolveContextMenuFieldName, + resolveDataSourceType, + resultExportAllSql, + resultSql, + rootRef, + rowKeyStr, + runExportWithProgress, + selectedCells, + selectedRowKeys, + selectedRowKeysRef, + setCellContextMenu, + setQueryOptions, + setSelectedRowKeys, + sortInfo, + splitCellKey, + supportsCopyInsert, + supportsSqlQueryExport, + tableName, + toggleColumnVisibility, + translateDataGrid, + uniqueKeyGroups, + withSortBufferTuningSQL, + }); const handleImport = async () => { if (!connectionId || !tableName) return; @@ -7613,799 +4053,308 @@ const DataGrid: React.FC = ({ fontWeight: 500, boxShadow: darkMode ? '0 2px 8px rgba(16,185,129,0.1)' : '0 2px 6px rgba(16,185,129,0.05)', }; - const renderDataTableView = () => ( -
-
- - - - - -
- - - - - - -
-
-
-
- ); - const pageFindContent = ( - } - pageFindText={pageFindText} - normalizedPageFindText={normalizedPageFindText} - hasMatches={pageFindMatches.length > 0} - activePageFindPosition={activePageFindPosition} - matchCount={pageFindMatches.length} - occurrenceCount={pageFindSummary.occurrenceCount} - matchedCellCount={pageFindSummary.matchedCellCount} - onPageFindTextChange={setPageFindText} - onCancel={() => setPageFindText('')} - onNavigatePrevious={() => handleNavigatePageFind('previous')} - onNavigateNext={() => handleNavigatePageFind('next')} - translate={translateDataGrid} - /> - ); - const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null; - const columnQuickFindContent = isTableSurfaceActive ? ( - } - value={columnQuickFindText} - options={columnQuickFindOptions} - hasTarget={!!resolveColumnQuickFindTarget(columnQuickFindText)} - translate={translateDataGrid} - onChange={setColumnQuickFindText} - onSubmit={handleSubmitColumnQuickFind} - /> - ) : null; - const resultViewSwitcher = ( - - ); - const paginationContent = ( - - ); - - const rowEditorFields = useMemo(() => ( - displayColumnNames.map((col) => { - const sample = rowEditorDisplayRef.current?.[col] ?? ''; - const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; - const isJson = looksLikeJsonText(sample); - const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; - const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const pickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); - const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); - const isWritable = isWritableResultColumn(col, effectiveEditLocator); - return { - columnName: col, - sample, - placeholder, - isJson, - useTextArea, - pickerType, - isTemporalValue, - isWritable, - }; - }) - ), [columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, displayColumnNames, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); - - const handleRefreshGrid = useCallback(() => { - clearAutoCommitTimer(); - autoCommitFailedTokenRef.current = -1; - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - setSelectedRowKeys([]); - const normalizedTableName = String(tableName || '').trim(); - const normalizedDbName = String(dbName || '').trim(); - if (connectionId && normalizedTableName) { - const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; - delete columnMetaCacheRef.current[cacheKey]; - delete foreignKeyCacheRef.current[cacheKey]; - delete uniqueKeyGroupsCacheRef.current[cacheKey]; - setMetadataReloadVersion((value) => value + 1); - } - if (onReload) onReload(); - }, [clearAutoCommitTimer, connectionId, dbName, onReload, tableName]); - - const handleResetPendingChanges = useCallback(() => { - clearAutoCommitTimer(); - autoCommitFailedTokenRef.current = -1; - setAddedRows([]); - setModifiedRows({}); - setDeletedRowKeys(new Set()); - setModifiedColumns({}); - }, [clearAutoCommitTimer]); - - const handleToggleFilterWithDefault = useCallback(() => { - if (!onToggleFilter) return; - onToggleFilter(); - if (filterConditions.length === 0 && !showFilter) addFilter(); - }, [onToggleFilter, filterConditions.length, showFilter]); - - const handleToggleCellEditMode = useCallback(() => { - const next = !cellEditMode; - if (!next) { - closeCellEditMode(); - } else { - cellEditModeRef.current = true; - setCellEditMode(true); - resetCellSelection(); - } - void message.info(next - ? translateDataGrid('data_grid.message.cell_edit_mode_entered') - : translateDataGrid('data_grid.message.cell_edit_mode_exited')).then(); - }, [cellEditMode, closeCellEditMode, resetCellSelection, translateDataGrid]); - - const handleRequestAiInsight = useCallback(() => { - const sampleData = mergedDisplayData.slice(0, 10); - const prompt = translateDataGrid('data_grid.ai_insight.prompt', { - count: sampleData.length, - json: JSON.stringify(sampleData, null, 2), - }); - const store = useStore.getState(); - const wasClosed = !store.aiPanelVisible; - if (wasClosed) store.setAIPanelVisible(true); - setTimeout(() => { - window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); - }, wasClosed ? 350 : 0); - }, [mergedDisplayData, translateDataGrid]); - - const handleToggleTotalCount = useCallback(() => { - if (!onRequestTotalCount) return; - if (pagination?.totalCountLoading) { - if (onCancelTotalCount) onCancelTotalCount(); - return; - } - onRequestTotalCount(); - }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]); - - return ( -
- } - filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE} - filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH} - queryResultCopyMenu={queryResultCopyMenu} - dbType={dbType} - onResetPendingChanges={handleResetPendingChanges} - onDataEditCommitModeChange={(mode) => setDataEditTransactionOptions({ commitMode: mode })} - onDataEditAutoCommitDelayChange={(delayMs) => setDataEditTransactionOptions({ autoCommitDelayMs: delayMs })} - onRefresh={handleRefreshGrid} - onToggleFilterClick={handleToggleFilterWithDefault} - onAddRow={handleAddRow} - onUndoDeleteSelected={handleUndoDeleteSelected} - onDeleteSelected={handleDeleteSelected} - onToggleCellEditMode={handleToggleCellEditMode} - onCopySelectedCellsToClipboard={handleCopySelectedCellsToClipboard} - onCopySelectedColumnsFromRow={handleCopySelectedColumnsFromRow} - onOpenBatchEditModal={openBatchEditModal} - onPasteCopiedColumnsToSelectedRows={() => handlePasteCopiedColumnsToSelectedRows()} - onCommit={handleCommit} - onPreviewChanges={handlePreviewChanges} - onImport={handleImport} - onOpenExportModal={handleOpenExportDialog} - onCopyQueryResultCsv={handleCopyQueryResultCsv} - onRequestAiInsight={handleRequestAiInsight} - onToggleTotalCount={handleToggleTotalCount} - onQuickWhereDraftChange={setQuickWhereDraft} - onQuickWhereSuggestionsOpenChange={setQuickWhereSuggestionsOpen} - onQuickWhereKeyDown={(event) => { - const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); - if (isClipboardShortcut) { - event.stopPropagation(); - return; - } - if (!shouldApplyQuickWhereOnEnter({ - key: event.key, - shiftKey: event.shiftKey, - isComposing: Boolean((event.nativeEvent as any)?.isComposing), - suggestionsOpen: quickWhereSuggestionsOpen, - suggestionCount: quickWhereSuggestionOptions.length, - activeSuggestionId: event.currentTarget.getAttribute('aria-activedescendant'), - })) { - return; - } - event.preventDefault(); - applyQuickWhereCondition(); - }} - onQuickWhereSelect={(value, option) => { - setQuickWhereDraft(resolveWhereConditionSelectedValue({ - selectedValue: value, - currentInput: quickWhereDraft, - insertText: (option as any)?.insertText, - })); - }} - onQuickWhereCopy={stopQuickWhereClipboardPropagation} - onQuickWhereCut={stopQuickWhereClipboardPropagation} - onQuickWherePaste={handleQuickWherePaste} - onApplyQuickWhere={() => applyQuickWhereCondition()} - onClearQuickWhere={clearQuickWhereCondition} - updateFilter={updateFilter} - removeFilter={removeFilter} - addFilter={addFilter} - isListOp={isListOp} - isBetweenOp={isBetweenOp} - isNoValueOp={isNoValueOp} - enableSortControls={!!onSort} - onApplySortInfo={applySortInfo} - onApplyFilters={applyFilters} - onEnableAllFilters={applyAllFiltersEnabled} - onDisableAllFilters={applyAllFiltersDisabled} - onClearFiltersAndSorts={clearAllFiltersAndSorts} - /> - -
- {contextHolder} - {exportProgressModal} - setDdlModalOpen(false)} - onCopyDdl={handleCopyDdl} - /> - - {viewMode === 'table' ? ( - renderDataTableView() - ) : isV2Ui && viewMode === 'fields' ? ( - canOpenObjectDesigner ? ( - - ) : ( - - ) - ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( - { - void handleOpenTableDdl({ asView: true }); - }} - onCopy={handleCopyDdl} - ddlSidebarWidth={ddlSidebarWidth} - ddlSidebarResizePreviewX={ddlSidebarResizePreviewX} - onResizeStart={handleDdlSidebarResizeStart} - /> - ) : isV2Ui && viewMode === 'ddl' ? ( - { - void handleOpenTableDdl({ asView: true }); - }} - onCopy={handleCopyDdl} - /> - ) : isV2Ui && viewMode === 'er' ? ( - - ) : viewMode === 'json' ? ( - - ) : ( - setTextRecordIndex(i => Math.max(0, i - 1))} - onNext={() => setTextRecordIndex(i => Math.min(textViewRows.length - 1, i + 1))} - onEditCurrent={openCurrentViewRowEditor} - formatTextViewValue={formatTextViewValue} - /> - )} - - { - handleDataPanelFormatJson((errorMessage) => { - void message.error(translateDataGrid('data_grid.json_editor.invalid_format', { error: errorMessage })); - }); - }} - onSave={handleDataPanelSave} - onValueChange={setDataPanelValue} - onDirtyChange={(dirty) => { - dataPanelDirtyRef.current = dirty; - }} - isDirtyComparedToOriginal={(value) => value !== dataPanelOriginalRef.current} - /> - - {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal( -
e.stopPropagation()} - > - {cellContextMenu.kind === 'column' ? (() => { - const fieldName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); - const meta = columnMetaMap[fieldName] || columnMetaMapByLowerName[fieldName.toLowerCase()]; - const activeSort = sortInfo.find((item) => item.columnKey === fieldName && item.enabled !== false); - return ( - - ); - })() : ( - - )} -
, - document.body - )} - - setCellContextMenu(prev => ({ ...prev, visible: false }))} - onCopyFieldName={handleCopyContextMenuFieldName} - onCopyRowData={() => { - if (cellContextMenu.record) handleCopyRowData(cellContextMenu.record); - }} - onCopyRowForPaste={() => { - const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; - if (rowKey === undefined || rowKey === null) { - void message.info(translateDataGrid('data_grid.message.no_copyable_rows')); - return; - } - setSelectedRowKeys([rowKey]); - copyRowsForPaste([rowKey]); - }} - onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} - onUndoCellChange={handleUndoContextMenuCellChange} - onSetNull={handleCellSetNull} - onEditRow={handleOpenContextMenuRowEditor} - onFillToSelected={() => { - if (selectedRowKeys.length > 0 && cellContextMenu.record) { - handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); - } - }} - onPasteCopiedColumns={() => { - const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; - handlePasteCopiedColumnsToSelectedRows(fallbackKey); - }} - onCopyInsert={() => { - if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); - }} - onCopyUpdate={() => { - if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record); - }} - onCopyDelete={() => { - if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record); - }} - onCopyJson={() => { - if (cellContextMenu.record) handleCopyJson(cellContextMenu.record); - }} - onCopyCsv={() => { - if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record); - }} - onCopyMarkdown={() => { - if (cellContextMenu.record) { - const records = getTargets(cellContextMenu.record); - const lines = records.map((r: any) => { - const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; - return `| ${Object.values(vals).join(' | ')} |`; - }); - copyToClipboard(lines.join('\n')); - } - }} - onExportCsv={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'csv' }, cellContextMenu.record).catch(console.error); - }} - onExportXlsx={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'xlsx' }, cellContextMenu.record).catch(console.error); - }} - onExportJson={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'json' }, cellContextMenu.record).catch(console.error); - }} - onExportHtml={() => { - if (cellContextMenu.record) handleExportSelected({ format: 'html' }, cellContextMenu.record).catch(console.error); - }} - /> -
- - { - void handleOpenTableDdl(); - }} - translate={translateDataGrid} - /> - - - - {/* Ghost Resize Line for Columns */} -
- - {/* Preview SQL Modal */} - setPreviewModalOpen(false)} - width={800} - footer={null} - > -
- {previewSqlData.deletes.length > 0 && ( -
-
- DELETE ({previewSqlData.deletes.length}) -
- {previewSqlData.deletes.map((sql, i) => ( -
-
{sql}
-
- ))} -
- )} - {previewSqlData.updates.length > 0 && ( -
-
- UPDATE ({previewSqlData.updates.length}) -
- {previewSqlData.updates.map((sql, i) => ( -
-
{sql}
-
- ))} -
- )} - {previewSqlData.inserts.length > 0 && ( -
-
- INSERT ({previewSqlData.inserts.length}) -
- {previewSqlData.inserts.map((sql, i) => ( -
-
{sql}
-
- ))} -
- )} - {previewSqlData.deletes.length === 0 && previewSqlData.updates.length === 0 && previewSqlData.inserts.length === 0 && ( -
- {translateDataGrid('data_grid.preview_sql.no_changes')} -
- )} -
-
- {translateDataGrid('data_grid.preview_sql.summary', { - deletes: previewSqlData.deletes.length, - updates: previewSqlData.updates.length, - inserts: previewSqlData.inserts.length - })} -
-
- - {/* Import Preview Modal */} - { - setImportPreviewVisible(false); - setImportFilePath(''); - }} - onSuccess={handleImportSuccess} - /> -
+ return ( + ); }; diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx new file mode 100644 index 0000000..047a3fa --- /dev/null +++ b/frontend/src/components/DataGridCore.tsx @@ -0,0 +1,1700 @@ +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, 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'; +import { CloseOutlined, ConsoleSqlOutlined, CopyOutlined, EditOutlined, ExportOutlined, FileTextOutlined, LeftOutlined, RightOutlined, SearchOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; +import { + DndContext, + DragEndEvent, + PointerSensor, + useSensor, + useSensors, + closestCenter +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + horizontalListSortingStrategy, + arrayMove +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { ImportData, ExportDataWithOptions, ExportQueryWithOptions, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App'; +import ImportPreviewModal from './ImportPreviewModal'; +import { useStore } from '../store'; +import { getCurrentLanguage, t } from '../i18n'; +import { useOptionalI18n } from '../i18n/provider'; +import type { ColumnDefinition, ForeignKeyDefinition, IndexDefinition } from '../types'; +import { v4 as generateUuid } from 'uuid'; +import 'react-resizable/css/styles.css'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; +import { + getDensityParams, + resolveDataTableColumnWidth, + resolveDataTableVerticalBorderColor, +} from '../utils/dataGridDisplay'; +import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination'; +import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort'; +import { + calculateExternalHorizontalScrollInnerWidth, + calculateTableBodyBottomPadding, + calculateVirtualTableScrollX, + resolveDataGridColumnQuickFindScrollLeft, + resolveDataGridHorizontalWheelDelta, +} from './dataGridLayout'; +import { + buildCopyDeleteSQL, + buildCopyInsertSQL, + buildCopyUpdateSQL, + normalizeTemporalLiteralText, + resolveUniqueKeyGroupsFromIndexes, + type CopySqlError, +} from './dataGridCopyInsert'; +import { calculateAutoFitColumnWidth } from './dataGridAutoWidth'; +import { buildSelectedCellClipboardText } from './dataGridSelectionCopy'; +import { buildCopiedRowsForPaste, buildPastedRowsFromCopiedRows } from './dataGridRowClipboard'; +import { + buildDataGridSelectBaseSql, + pickDataGridOutputRows, + resolveDataGridOutputColumnNames, +} from './dataGridOutput'; +import { + buildClipboardCsv, + buildClipboardJson, + buildClipboardMarkdown, + pickRowsForClipboard, +} from './dataGridClipboardExport'; +import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; +import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; +import { + TEMPORAL_FORMATS, + formatFromDayjs, + getTemporalPickerFormat, + getTemporalPickerType, + isTemporalColumnType, + parseToDayjs, + resolveTemporalEditorSaveValue, + type TemporalConnectionLike, + type TemporalPickerType, +} from './dataGridTemporal'; +import { + buildEffectiveFilterConditions, + normalizeQuickWhereCondition, + resolveWhereConditionSelectedValue, + resolveWhereConditionSuggestions, + shouldApplyQuickWhereOnEnter, + validateQuickWhereCondition, +} from '../utils/dataGridWhereFilter'; +import { + attachDataGridFindRenderVersion, + collectDataGridFindMatches, + findDataGridTextRanges, + hasDataGridFindRenderVersionChanged, + normalizeDataGridFindQuery, + resolveDataGridColumnQuickFindTarget, + resolveDataGridFindNavigationIndex, + summarizeDataGridFindMatches, + type DataGridFindMatch, + type DataGridFindNavigationDirection, +} from '../utils/dataGridFind'; +import { + filterHiddenLocatorColumns, + isWritableResultColumn, + resolveWritableColumnName, + resolveRowLocatorValues, + type EditRowLocator, + type RowLocatorMessages, +} from '../utils/rowLocator'; +import { + getColumnDefinitionComment, + getColumnDefinitionName, + getColumnDefinitionType, +} from '../utils/columnDefinition'; +import { + V2CellContextMenuView, + V2ColumnHeaderContextMenuView, + type V2CellContextMenuActionKey, + type V2ColumnHeaderContextMenuActionKey, +} from './V2TableContextMenu'; +import DataGridColumnTitle from './DataGridColumnTitle'; +import DataGridColumnInfoPopoverContent from './DataGridColumnInfoPopoverContent'; +import DataGridColumnQuickFind from './DataGridColumnQuickFind'; +import DataGridPageFind from './DataGridPageFind'; +import DataGridPaginationBar from './DataGridPaginationBar'; +import DataGridResultViewSwitcher from './DataGridResultViewSwitcher'; +import DataGridSecondaryActions from './DataGridSecondaryActions'; +import DataGridToolbarFrame from './DataGridToolbarFrame'; +import DataGridModals from './DataGridModals'; +import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; +import DataGridPreviewPanel from './DataGridPreviewPanel'; +import { + DEFAULT_DATA_EXPORT_FORMAT, + DEFAULT_XLSX_ROWS_PER_SHEET, + showDataExportDialog, + type DataExportDialogValues, + type DataExportFileOptions, + type DataExportScopeOption, +} from './DataExportDialog'; +import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews'; +import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace'; +import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; +import TableDesigner from './TableDesigner'; +import { useExportProgressDialog } from './ExportProgressModal'; +import { useDataGridFilters } from './useDataGridFilters'; +import { useDataGridDdlView } from './useDataGridDdlView'; +import { useDataGridModalEditors } from './useDataGridModalEditors'; +import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; +import { buildTableExportTab } from '../utils/tableExportTab'; +import { buildDataGridCssText } from './dataGridStyles'; + +// --- Error Boundary --- +interface DataGridErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +interface DataGridErrorBoundaryProps { + children: React.ReactNode; + i18nLanguage?: string; +} + +class DataGridErrorBoundary extends React.Component< + DataGridErrorBoundaryProps, + DataGridErrorBoundaryState +> { + constructor(props: DataGridErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): DataGridErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('DataGrid render error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+

{t('data_grid.error_boundary.title', undefined, this.props.i18nLanguage)}

+

{t('data_grid.error_boundary.description', undefined, this.props.i18nLanguage)}

+
+                        {this.state.error?.message}
+                    
+ +
+ ); + } + return this.props.children; + } +} + +// 内部行标识字段:避免与真实业务字段(如 `key` 列)冲突。 +export const GONAVI_ROW_KEY = '__gonavi_row_key__'; +export const GONAVI_ROW_NUMBER_COLUMN_KEY = '__gonavi_row_number__'; + +// Cell key helpers for batch selection/fill. +// Use a control character separator to avoid collisions with rowKey/columnName contents (e.g. `new-123`). +const CELL_KEY_SEP = '\u0001'; +const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; +const DATE_TIME_CACHE_LIMIT = 2000; +const TABLE_CELL_PREVIEW_MAX_CHARS = 240; +const ROW_NUMBER_COLUMN_WIDTH = 58; +const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ + { value: 3000, seconds: 3 }, + { value: 5000, seconds: 5 }, + { value: 10000, seconds: 10 }, + { value: 30000, seconds: 30 }, +]; +const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); +const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); +const DEFAULT_GRID_MONO_FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace'; +const normalizedDateTimeCache = new Map(); +const objectCellPreviewCache = new WeakMap(); +const useDataGridI18nLanguage = () => { + const i18n = useOptionalI18n(); + return i18n?.language ?? getCurrentLanguage(); +}; +const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`; +const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | null => { + const sepIndex = cellKey.indexOf(CELL_KEY_SEP); + if (sepIndex === -1) return null; + return { + rowKey: cellKey.slice(0, sepIndex), + colName: cellKey.slice(sepIndex + CELL_KEY_SEP.length), + }; +}; +export const resolveContextMenuFieldName = (dataIndex: string, title?: string): string => { + const name = String(dataIndex || title || '').trim(); + return name; +}; + +const trimSimpleCache = (cache: Map, limit: number) => { + if (cache.size < limit) return; + const firstKey = cache.keys().next().value; + if (typeof firstKey === 'string') { + cache.delete(firstKey); + } +}; + +const looksLikeDateTimeText = (val: string): boolean => { + if (!val) return false; + const len = val.length; + if (len < 19 || len > 48) return false; + const charCode0 = val.charCodeAt(0); + if (charCode0 < 48 || charCode0 > 57) return false; + return ( + val[4] === '-' && + val[7] === '-' && + (val[10] === ' ' || val[10] === 'T') && + val[13] === ':' && + val[16] === ':' + ); +}; + +// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing. +// Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. +// Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. +const normalizeDateTimeString = (val: string) => { + if (!looksLikeDateTimeText(val)) { + return val; + } + + const cached = normalizedDateTimeCache.get(val); + if (cached !== undefined) { + return cached; + } + + // 检查是否为无效日期时间(0000-00-00 或类似格式) + if (/^0{4}-0{2}-0{2}/.test(val)) { + return val; // 保持原样显示,不尝试转换 + } + + const match = val.match( + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + ); + const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val; + trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT); + normalizedDateTimeCache.set(val, normalized); + return normalized; +}; + +// --- Helper: Format Value --- +const normalizeBitHexDisplayText = (val: any, columnType?: string): string | null => { + const typeText = String(columnType || '').trim().toLowerCase(); + if (!/^varbit(?:\s*\(\s*\d+\s*\))?$/.test(typeText) + && !/^bit(?:\s+varying)?(?:\s*\(\s*\d+\s*\))?$/.test(typeText)) { + return null; + } + if (typeof val !== 'string') return null; + const raw = val.trim(); + if (!/^0x[0-9a-f]+$/i.test(raw)) return null; + try { + return BigInt(raw).toString(10); + } catch { + return null; + } +}; + +type CellDisplayConnectionLike = TemporalConnectionLike; + +const isDateOnlyColumnType = (columnType?: string): boolean => { + const normalized = String(columnType || '').trim().toLowerCase(); + if (!normalized) return false; + const base = normalized.split(/[ (]/)[0]; + return base === 'date' || base === 'newdate'; +}; + +const isOceanBaseOracleDisplayConnection = (connectionConfig?: CellDisplayConnectionLike): boolean => { + if (!connectionConfig) return false; + const type = String(connectionConfig.type || '').trim().toLowerCase(); + const driver = String(connectionConfig.driver || '').trim().toLowerCase(); + return (type === 'oceanbase' || driver === 'oceanbase') + && normalizeOceanBaseProtocol(connectionConfig.oceanBaseProtocol) === 'oracle'; +}; + +const normalizeOceanBaseOracleDateDisplayText = ( + val: string, + columnType?: string, + connectionConfig?: CellDisplayConnectionLike, +): string | null => { + if (!isDateOnlyColumnType(columnType) || !isOceanBaseOracleDisplayConnection(connectionConfig)) { + return null; + } + const trimmed = String(val || '').trim(); + if (!trimmed) return trimmed; + const match = trimmed.match( + /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?)?$/ + ); + if (!match) return null; + const [, datePart, timePart, fractionPart] = match; + if (!timePart) return datePart; + if (timePart === '00:00:00' && (!fractionPart || /^\.0+$/.test(fractionPart))) { + return datePart; + } + return null; +}; + +export const formatCellDisplayText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { + try { + if (val === null) return 'NULL'; + const bitText = normalizeBitHexDisplayText(val, columnType); + if (bitText !== null) return bitText; + if (typeof val === 'object') { + if (!Array.isArray(val) && !isPlainObject(val)) { + return String(val); + } + const cached = objectCellPreviewCache.get(val); + if (cached !== undefined) { + return cached; + } + const topLevelSize = Array.isArray(val) ? val.length : Object.keys(val || {}).length; + if (topLevelSize > 80) { + const summary = Array.isArray(val) ? `[Array(${topLevelSize})]` : `{Object(${topLevelSize})}`; + objectCellPreviewCache.set(val, summary); + return summary; + } + try { + const nextText = JSON.stringify(val); + const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText; + objectCellPreviewCache.set(val, previewText); + return previewText; + } catch { + return '[Object]'; + } + } + if (typeof val === 'string') { + const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); + if (oceanBaseDateOnly !== null) { + return oceanBaseDateOnly.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${oceanBaseDateOnly.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : oceanBaseDateOnly; + } + const normalized = normalizeDateTimeString(val); + return normalized.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${normalized.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : normalized; + } + return String(val); + } catch (e) { + console.error('formatCellValue error:', e); + return '[Error]'; + } +}; + +const formatClipboardCellText = (val: any, columnType?: string, connectionConfig?: CellDisplayConnectionLike): string => { + try { + if (val === null || val === undefined) return 'NULL'; + const bitText = normalizeBitHexDisplayText(val, columnType); + if (bitText !== null) return bitText; + if (typeof val === 'string') { + const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); + if (oceanBaseDateOnly !== null) return oceanBaseDateOnly; + return normalizeDateTimeString(val); + } + if (typeof val === 'object') { + try { + return JSON.stringify(val); + } catch { + return String(val); + } + } + return String(val); + } catch (e) { + console.error('formatClipboardCellText error:', e); + return '[Error]'; + } +}; + +const normalizeClipboardTsvCell = (text: string): string => text.replace(/\t/g, ' ').replace(/\r?\n/g, ' '); + +const buildClipboardTsv = ( + rows: Array>, + columnNames: string[], + getColumnType?: (columnName: string) => string | undefined, + connectionConfig?: CellDisplayConnectionLike, +): string => { + if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columnNames) || columnNames.length === 0) { + return ''; + } + const header = columnNames.map(normalizeClipboardTsvCell).join('\t'); + const lines = rows.map((row) => ( + columnNames + .map((columnName) => normalizeClipboardTsvCell(formatClipboardCellText(row?.[columnName], getColumnType?.(columnName), connectionConfig))) + .join('\t') + )); + return [header, ...lines].join('\n'); +}; + +const renderHighlightedCellText = (text: string, query: string): React.ReactNode => { + const ranges = findDataGridTextRanges(text, query); + if (ranges.length === 0) return text; + + const nodes: React.ReactNode[] = []; + let cursor = 0; + ranges.forEach((range, index) => { + if (range.start > cursor) { + nodes.push(text.slice(cursor, range.start)); + } + nodes.push( + + {text.slice(range.start, range.end)} + , + ); + cursor = range.end; + }); + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + return <>{nodes}; +}; + +const renderCellDisplayValue = (val: any, query: string, columnType?: string, connectionConfig?: CellDisplayConnectionLike): React.ReactNode => { + const text = formatCellDisplayText(val, columnType, connectionConfig); + const content = renderHighlightedCellText(text, query); + if (val === null) return {content}; + return content; +}; + +const formatCellValue = (val: any) => renderCellDisplayValue(val, ''); + +export const attachDataGridVirtualEditRenderVersion = ( + rows: T[], + editingCell: VirtualEditingCellState | null, +): T[] => { + if (!editingCell) return rows; + + return rows.map((row) => { + const rowKey = row?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null || String(rowKey) !== editingCell.rowKey) { + return row; + } + const nextRow = { ...(row as object) } as T; + Object.defineProperty(nextRow, DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, { + value: `${editingCell.rowKey}${CELL_KEY_SEP}${editingCell.dataIndex}`, + enumerable: true, + }); + return nextRow; + }); +}; + +export const attachDataGridDisplayRenderVersion = ( + rows: T[], + renderVersion: string, +): T[] => { + if (!renderVersion) return rows; + + return rows.map((row) => { + if (!row || typeof row !== 'object') return row; + const nextRow = { ...(row as object) } as T; + Object.defineProperty(nextRow, DATA_GRID_DISPLAY_RENDER_VERSION, { + value: renderVersion, + enumerable: true, + }); + return nextRow; + }); +}; + +export const hasDataGridDisplayRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { + const nextVersion = nextRecord && typeof nextRecord === 'object' + ? (nextRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] + : undefined; + const previousVersion = previousRecord && typeof previousRecord === 'object' + ? (previousRecord as Record)[DATA_GRID_DISPLAY_RENDER_VERSION] + : undefined; + return nextVersion !== previousVersion; +}; + +export const hasDataGridVirtualEditRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => { + const nextVersion = nextRecord && typeof nextRecord === 'object' + ? (nextRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] + : undefined; + const previousVersion = previousRecord && typeof previousRecord === 'object' + ? (previousRecord as Record)[DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION] + : undefined; + return nextVersion !== previousVersion; +}; + +const toEditableText = (val: any): string => { + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return val; + try { + return JSON.stringify(val, null, 2); + } catch { + return String(val); + } +}; + +const toFormText = (val: any): string => { + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return normalizeDateTimeString(val); + return toEditableText(val); +}; + +// 用于变更比较:NULL 与 undefined 视为同类空值;与空字符串严格区分。 +const isCellValueEqualForDiff = (left: any, right: any): boolean => { + if (left === right) return true; + const leftNullish = left === null || left === undefined; + const rightNullish = right === null || right === undefined; + if (leftNullish || rightNullish) return leftNullish && rightNullish; + return toFormText(left) === toFormText(right); +}; + +// 渲染阶段轻量比较:避免对象值在 shouldCellUpdate 中反复深度序列化导致卡顿。 +const isCellValueEqualForRender = (left: any, right: any): boolean => { + if (left === right) return true; + const leftNullish = left === null || left === undefined; + const rightNullish = right === null || right === undefined; + if (leftNullish || rightNullish) return leftNullish && rightNullish; + + const leftType = typeof left; + const rightType = typeof right; + if (leftType === 'object' || rightType === 'object') { + // 对象仅按引用比较;真正的值差异在提交保存时再做严格比对。 + return false; + } + + if (leftType === 'string' || rightType === 'string') { + return normalizeDateTimeString(String(left)) === normalizeDateTimeString(String(right)); + } + return left === right; +}; + +const INLINE_EDIT_MAX_CHARS = 2000; + +const shouldOpenModalEditor = (val: any): boolean => { + if (val === null || val === undefined) return false; + if (typeof val === 'string') { + if (val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n')) return true; + const trimmed = val.trimStart(); + return trimmed.startsWith('{') || trimmed.startsWith('['); + } + return typeof val === 'object'; +}; + +const getCellFieldName = (record: Item, dataIndex: string) => { + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) return dataIndex; + return [String(rowKey), dataIndex]; +}; + +const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => { + if (!form) return; + if (Array.isArray(fieldName)) { + const [rowKey, colKey] = fieldName; + form.setFieldsValue({ [rowKey]: { [colKey]: value } }); + return; + } + form.setFieldsValue({ [fieldName]: value }); +}; + +const looksLikeJsonText = (text: string): boolean => { + const raw = (text || '').trim(); + if (!raw) return false; + const first = raw[0]; + const last = raw[raw.length - 1]; + return (first === '{' && last === '}') || (first === '[' && last === ']'); +}; + +const isPlainObject = (value: any): value is Record => { + return Object.prototype.toString.call(value) === '[object Object]'; +}; + +const normalizeValueForJsonView = (value: any): any => { + if (value === null || value === undefined) return value; + + if (typeof value === 'string') { + const normalizedText = normalizeDateTimeString(value); + if (!looksLikeJsonText(normalizedText)) return normalizedText; + try { + return normalizeValueForJsonView(JSON.parse(normalizedText)); + } catch { + return normalizedText; + } + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeValueForJsonView(item)); + } + + if (isPlainObject(value)) { + const next: Record = {}; + Object.entries(value).forEach(([key, val]) => { + next[key] = normalizeValueForJsonView(val); + }); + return next; + } + + return value; +}; + +const isJsonViewValueEqual = (left: any, right: any): boolean => { + const leftNormalized = normalizeValueForJsonView(left); + const rightNormalized = normalizeValueForJsonView(right); + + if (leftNormalized === rightNormalized) return true; + if (leftNormalized === null || rightNormalized === null) return leftNormalized === rightNormalized; + if (leftNormalized === undefined || rightNormalized === undefined) return leftNormalized === rightNormalized; + + if (typeof leftNormalized !== 'object' && typeof rightNormalized !== 'object') { + return String(leftNormalized) === String(rightNormalized); + } + + try { + return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized); + } catch { + return false; + } +}; + +const coerceJsonEditorValueForStorage = (currentValue: any, editedValue: any): any => { + if (typeof currentValue === 'string') { + const raw = currentValue.trim(); + const parsedCurrent = looksLikeJsonText(raw); + if (parsedCurrent && (isPlainObject(editedValue) || Array.isArray(editedValue))) { + return JSON.stringify(editedValue); + } + } + return editedValue; +}; + +// --- Resizable Header (Native Implementation) --- +const ResizableTitle = React.forwardRef((props, ref) => { + const { onResizeStart, onResizeAutoFit, width, ...restProps } = props; + + const nextStyle = { ...(restProps.style || {}) } as React.CSSProperties; + if (width) { + nextStyle.width = width; + } + + // 注意:virtual table 模式下,rc-table 会依赖 header cell 的 width 样式来渲染选择列。 + // 若这里丢失 width,可能导致左上角“全选”checkbox 不显示。 + if (!width || typeof onResizeStart !== 'function') { + return
+ ); +}); + +// --- Sortable Header Cell --- +interface SortableHeaderCellProps extends React.HTMLAttributes { + id?: string; +} + +// --- Sortable Header Cell --- +interface SortableHeaderCellProps extends React.HTMLAttributes { + id?: string; +} + +// 静态 CSS 移到组件外,强制去除 th 内边距并确保指针穿透 +const sortableHeaderStaticStyles = ` + .gonavi-sortable-header-cell { + padding: 0 !important; + overflow: hidden; + } + .gonavi-sortable-header-cell[data-cursor-grabbing="true"], + .gonavi-sortable-header-cell[data-cursor-grabbing="true"] *, + .gonavi-sortable-header-cell.is-dragging, + .gonavi-sortable-header-cell.is-dragging * { + cursor: grabbing !important; + } + .sortable-header-cell-drag-handle { + display: flex; + align-items: center; + width: 100%; + height: 100%; + min-height: var(--gonavi-header-min-height, 40px); + padding: 0 10px; + user-select: none; + cursor: inherit; + overflow: hidden; + } +`; + +const SortableHeaderCell: React.FC = React.memo((props) => { + const { id, children, style: propStyle, className: propClassName, ...restProps } = props; + const [isPressed, setIsPressed] = useState(false); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: id || '' }); + + const style: React.CSSProperties = { + ...propStyle, + transform: CSS.Transform.toString(transform), + transition, + ...(isDragging ? { + position: 'relative', + zIndex: 9999, + opacity: 0.6, + backgroundColor: 'rgba(24, 144, 255, 0.15)', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)' + } : {}), + touchAction: 'none', + willChange: 'transform', + // 核心修复:将指针直接绑定到 th 级别,并由 isPressed 控制 + cursor: (isDragging || isPressed) ? 'grabbing' : 'pointer', + }; + + useEffect(() => { + const handleGlobalMouseUp = () => setIsPressed(false); + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + if (!id || id === 'GONAVI_SELECTION_COLUMN') { + return {children}; + } + + return ( + { + setIsPressed(true); + if (listeners?.onPointerDown) listeners.onPointerDown(e); + }} + > + +
+
+ {children} +
+
+
+ ); +}); + +// --- Contexts --- +const EditableContext = React.createContext(null); +const CellContextMenuContext = React.createContext<{ + showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void; + handleBatchFillToSelected: (record: Item, dataIndex: string) => void; +} | null>(null); +const DataContext = React.createContext<{ + selectedRowKeysRef: React.MutableRefObject; + displayDataRef: React.MutableRefObject; + handleCopyInsert: (r: any) => void; + handleCopyUpdate: (r: any) => void; + handleCopyDelete: (r: any) => void; + handleCopyJson: (r: any) => void; + handleCopyCsv: (r: any) => void; + handleExportSelected: (options: DataExportFileOptions, r: any) => Promise; + copyToClipboard: (t: string) => void; + tableName?: string; + enableRowContextMenu: boolean; + supportsCopyInsert: boolean; +} | null>(null); + +interface Item { + [key: string]: any; +} + +interface EditableCellProps { + title: React.ReactNode; + editable: boolean; + children: React.ReactNode; + dataIndex: string; + record: Item; + handleSave: (record: Item) => void; + focusCell?: (record: Item, dataIndex: string, title: React.ReactNode) => void; + columnType?: string; + dbType?: string; + connectionConfig?: CellDisplayConnectionLike; + inputCellPadding?: React.CSSProperties; + as?: any; + modifiedColumns?: Record>; + rowKeyStr?: (k: React.Key) => string; + deletedRowKeys?: Set; + darkMode?: boolean; + [key: string]: any; +} + +// 模块级变量:绕过 React 渲染链条,在事件处理器中直接读取最新删除状态。 +// EditableCell 内部通过 React.memo 包裹,且 Ant Design rc-table 有多层 memo 缓存, +// 仅靠 props 传递 deletedRowKeys 可能因缓存而不触发重渲染。 +let globalDeletedRowKeys: Set = new Set(); +const setGlobalDeletedRowKeys = (next: Set) => { + globalDeletedRowKeys = next; +}; + +const resolveEditableCellRowKey = ( + record: Item | undefined, + rowKeyStr?: (k: React.Key) => string, +): string | null => { + const rowKey = record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null || typeof rowKeyStr !== 'function') { + return null; + } + return rowKeyStr(rowKey); +}; + +const isEditableCellDeleted = ( + record: Item | undefined, + deletedRowKeys?: Set, + rowKeyStr?: (k: React.Key) => string, +): boolean => { + const rowKey = resolveEditableCellRowKey(record, rowKeyStr); + return rowKey ? !!deletedRowKeys?.has(rowKey) : false; +}; + +const isEditableCellModified = ( + record: Item | undefined, + dataIndex: string, + modifiedColumns?: Record>, + rowKeyStr?: (k: React.Key) => string, +): boolean => { + const rowKey = resolveEditableCellRowKey(record, rowKeyStr); + return rowKey ? !!modifiedColumns?.[rowKey]?.has(dataIndex) : false; +}; + +const areEditableCellPropsEqual = (prevProps: EditableCellProps, nextProps: EditableCellProps): boolean => { + if (prevProps.editable !== nextProps.editable) return false; + if (prevProps.dataIndex !== nextProps.dataIndex) return false; + if (prevProps.title !== nextProps.title) return false; + if (prevProps.columnType !== nextProps.columnType) return false; + if (prevProps.dbType !== nextProps.dbType) return false; + if ((prevProps.connectionConfig?.type ?? null) !== (nextProps.connectionConfig?.type ?? null)) return false; + if ((prevProps.connectionConfig?.driver ?? null) !== (nextProps.connectionConfig?.driver ?? null)) return false; + if ((prevProps.connectionConfig?.oceanBaseProtocol ?? null) !== (nextProps.connectionConfig?.oceanBaseProtocol ?? null)) return false; + if (prevProps.darkMode !== nextProps.darkMode) return false; + if (prevProps.as !== nextProps.as) return false; + if (prevProps.handleSave !== nextProps.handleSave) return false; + if (prevProps.focusCell !== nextProps.focusCell) return false; + if ((prevProps.inputCellPadding?.padding ?? null) !== (nextProps.inputCellPadding?.padding ?? null)) return false; + if (prevProps.style !== nextProps.style) return false; + + const prevRecord = prevProps.record; + const nextRecord = nextProps.record; + if (resolveEditableCellRowKey(prevRecord, prevProps.rowKeyStr) !== resolveEditableCellRowKey(nextRecord, nextProps.rowKeyStr)) { + return false; + } + if (hasDataGridFindRenderVersionChanged(nextRecord, prevRecord)) { + return false; + } + if (!isCellValueEqualForRender(prevRecord?.[prevProps.dataIndex], nextRecord?.[nextProps.dataIndex])) { + return false; + } + if (isEditableCellDeleted(prevRecord, prevProps.deletedRowKeys, prevProps.rowKeyStr) !== isEditableCellDeleted(nextRecord, nextProps.deletedRowKeys, nextProps.rowKeyStr)) { + return false; + } + if (isEditableCellModified(prevRecord, prevProps.dataIndex, prevProps.modifiedColumns, prevProps.rowKeyStr) !== isEditableCellModified(nextRecord, nextProps.dataIndex, nextProps.modifiedColumns, nextProps.rowKeyStr)) { + return false; + } + + return true; +}; + +const EditableCell: React.FC = React.memo(({ + title, + editable, + children, + dataIndex, + record, + handleSave, + focusCell, + columnType, + dbType, + connectionConfig, + inputCellPadding, + as: Component = 'td', + modifiedColumns, + rowKeyStr, + deletedRowKeys, + darkMode, + ...restProps +}) => { + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + const cellRef = useRef(null); + const pickerOpenRef = useRef(false); + const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); + const form = useContext(EditableContext); + const cellContextMenuContext = useContext(CellContextMenuContext); + const i18nLanguage = useDataGridI18nLanguage(); + const dateTimePickerNowLabel = t('data_grid.datetime_picker.now', undefined, i18nLanguage); + + /** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */ + const lockTableScroll = useCallback((lock: boolean) => { + if (lock) { + // 查找虚拟滚动容器或常规滚动容器 + const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null; + if (tableWrapper) { + const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); }; + tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false }); + scrollLockRef.current = { el: tableWrapper, handler }; + } + } else if (scrollLockRef.current) { + const { el, handler } = scrollLockRef.current; + el.removeEventListener('wheel', handler, { capture: true } as any); + scrollLockRef.current = null; + } + }, []); + + useEffect(() => { + if (editing) { + // 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值) + const raw = record[dataIndex]; + const fieldName = getCellFieldName(record, dataIndex); + if (isDateTimeField) { + const dayjsVal = parseToDayjs(raw, pickerType); + setCellFieldValue(form, fieldName, dayjsVal); + } else { + const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + setCellFieldValue(form, fieldName, initialValue); + } + inputRef.current?.focus(); + } + }, [editing]); + + const toggleEdit = () => { + setEditing(!editing); + }; + + const save = async (pickerValue?: dayjs.Dayjs | null) => { + try { + if (!form || !editing) return; + const fieldName = getCellFieldName(record, dataIndex); + await form.validateFields([fieldName]); + let nextValue = form.getFieldValue(fieldName); + if (isDateTimeField) { + nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType); + } + toggleEdit(); + // 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。 + if (!isCellValueEqualForDiff(record?.[dataIndex], nextValue)) { + handleSave({ ...record, [dataIndex]: nextValue }); + } + // 保存后移除焦点 + if (inputRef.current) { + inputRef.current.blur(); + } + } catch (errInfo) { + console.log('Save failed:', errInfo); + // 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态 + if (isDateTimeField && editing) setEditing(false); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + if (!cellContextMenuContext) return; + e.preventDefault(); + e.stopPropagation(); // 阻止冒泡到行级菜单 + cellContextMenuContext.showMenu(e, record, dataIndex, title); + }; + + let childNode = children; + + const pickerType = getTemporalPickerType(columnType, dbType, connectionConfig); + const isDateTimeField = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(record?.[dataIndex] || ''))); + + const isRowDeleted = deletedRowKeys && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined + ? deletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY])) + : false; + + const isModified = !editing && modifiedColumns && rowKeyStr && record?.[GONAVI_ROW_KEY] !== undefined + ? modifiedColumns[rowKeyStr(record[GONAVI_ROW_KEY])]?.has(dataIndex) + : false; + + const modifiedStyle: React.CSSProperties | undefined = isModified + ? { backgroundColor: darkMode ? 'rgba(255, 214, 102, 0.16)' : '#FFF3B0' } + : undefined; + + if (editable) { + childNode = editing ? ( + + {isDateTimeField ? ( + pickerType === 'time' ? ( + setTimeout(() => { void save(value); }, 0)} + onOpenChange={lockTableScroll} + onBlur={() => setTimeout(() => { void save(); }, 0)} + needConfirm={false} + /> + ) : pickerType === 'datetime' ? ( + ( + { + // 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。 + // 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。 + const fieldName = getCellFieldName(record, dataIndex); + setCellFieldValue(form, fieldName, dayjs()); + }} + >{dateTimePickerNowLabel} + )} + onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} + onOpenChange={(open) => { + pickerOpenRef.current = open; + lockTableScroll(open); + // 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮(onOk)触发保存 + if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0); + }} + onBlur={() => { + // 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。 + // 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。 + setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150); + }} + needConfirm + /> + ) : ( + setTimeout(() => { void save(value); }, 0)} + onOpenChange={lockTableScroll} + onBlur={() => setTimeout(() => { void save(); }, 0)} + needConfirm={false} + /> + ) + ) : ( + { void save(); }} + onBlur={() => { void save(); }} + onFocus={(e) => { + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + onDoubleClick={(e) => { + e.stopPropagation(); + try { + (e.target as HTMLInputElement)?.select?.(); + } catch { + // ignore + } + }} + /> + )} + + ) : ( +
+ {children} +
+ ); + } else if (cellContextMenuContext) { + // 非编辑模式(只读查询结果)也绑定右键菜单,支持复制为 INSERT/JSON/CSV 等操作 + childNode = ( +
+ {children} +
+ ); + } else if (isModified) { + childNode = ( +
+ {children} +
+ ); + } + + const handleDoubleClick = () => { + if (!editable) return; + if (isRowDeleted) return; + // 模块级检查:绕过 React 渲染链条,确保即使组件因 memo 缓存未重渲染也能拿到最新状态 + if (record?.[GONAVI_ROW_KEY] !== undefined + && rowKeyStr + && globalDeletedRowKeys.has(rowKeyStr(record[GONAVI_ROW_KEY]))) return; + // 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。 + if (editing) return; + const raw = record?.[dataIndex]; + if (focusCell && shouldOpenModalEditor(raw)) { + focusCell(record, dataIndex, title); + return; + } + toggleEdit(); + }; + + return ( + + {childNode} + + ); +}, areEditableCellPropsEqual); + +const ContextMenuRow = React.memo(({ children, record, ...props }: any) => { + const context = useContext(DataContext); + + if (!record || !context) return
{children}; + + const { + selectedRowKeysRef, + displayDataRef, + handleCopyInsert, + handleCopyUpdate, + handleCopyDelete, + handleCopyJson, + handleCopyCsv, + handleExportSelected, + copyToClipboard, + enableRowContextMenu, + supportsCopyInsert, + } = context; + + if (!enableRowContextMenu) { + return {children}; + } + + const getTargets = () => { + const keys = selectedRowKeysRef.current; + const recordKey = record?.[GONAVI_ROW_KEY]; + if (recordKey !== undefined && keys.includes(recordKey)) { + return displayDataRef.current.filter(d => keys.includes(d?.[GONAVI_ROW_KEY])); + } + return [record]; + }; + + const menuItems: MenuProps['items'] = [ + ...(supportsCopyInsert ? [{ + key: 'insert', + label: t('data_grid.context_menu.copy_as_insert'), + icon: , + onClick: () => handleCopyInsert(record), + }, { + key: 'update', + label: t('data_grid.context_menu.copy_as_update'), + icon: , + onClick: () => handleCopyUpdate(record), + }, { + key: 'delete', + label: t('data_grid.context_menu.copy_as_delete'), + icon: , + onClick: () => handleCopyDelete(record), + }] : []), + { key: 'json', label: t('data_grid.context_menu.copy_as_json'), icon: , onClick: () => handleCopyJson(record) }, + { key: 'csv', label: t('data_grid.context_menu.copy_as_csv'), icon: , onClick: () => handleCopyCsv(record) }, + { key: 'copy', label: t('data_grid.context_menu.copy_as_markdown'), icon: , onClick: () => { + const records = getTargets(); + const orderedCols = displayDataRef.current.length > 0 + ? Object.keys(displayDataRef.current[0]).filter(c => c !== GONAVI_ROW_KEY) + : []; + const header = `| ${orderedCols.join(' | ')} |`; + const separator = `| ${orderedCols.map(() => '---').join(' | ')} |`; + const rows = records.map((r: any) => { + const values = orderedCols.map(c => { + const v = r[c]; + if (v === null || v === undefined) return 'NULL'; + return String(v).replace(/\|/g, '\\|').replace(/\n/g, ' '); + }); + return `| ${values.join(' | ')} |`; + }); + copyToClipboard([header, separator, ...rows].join('\n')); + } }, + { type: 'divider' }, + { + key: 'export-selected', + label: t('data_grid.context_menu.export_selected'), + icon: , + children: [ + { key: 'exp-csv', label: 'CSV', onClick: () => handleExportSelected({ format: 'csv' }, record).catch(console.error) }, + { key: 'exp-xlsx', label: 'Excel', onClick: () => handleExportSelected({ format: 'xlsx' }, record).catch(console.error) }, + { key: 'exp-json', label: 'JSON', onClick: () => handleExportSelected({ format: 'json' }, record).catch(console.error) }, + { key: 'exp-md', label: 'Markdown', onClick: () => handleExportSelected({ format: 'md' }, record).catch(console.error) }, + { key: 'exp-html', label: 'HTML', onClick: () => handleExportSelected({ format: 'html' }, record).catch(console.error) }, + ] + } + ]; + + return ( + document.body} autoAdjustOverflow> + {children} + + ); +}); + +interface DataGridProps { + data: any[]; + columnNames: string[]; + loading: boolean; + tableName?: string; + objectType?: 'table' | 'view' | 'materialized-view'; + exportScope?: 'table' | 'queryResult'; + resultSql?: string; + resultExportAllSql?: string; + dbName?: string; + connectionId?: string; + pkColumns?: string[]; + editLocator?: EditRowLocator; + readOnly?: boolean; + showRowNumberColumn?: boolean; + onReload?: () => void; + onSort?: (field: string, order: string) => void; + onPageChange?: (page: number, size: number) => void; + pagination?: { + current: number, + pageSize: number, + total: number, + totalKnown?: boolean, + totalApprox?: boolean, + approximateTotal?: number, + totalCountLoading?: boolean, + totalCountCancelled?: boolean, + }; + onRequestTotalCount?: () => void; + onCancelTotalCount?: () => void; + sortInfoExternal?: Array<{ columnKey: string, order: string, enabled?: boolean }>; + // Filtering + showFilter?: boolean; + onToggleFilter?: () => void; + exportSqlWithFilter?: string; + onApplyFilter?: (conditions: GridFilterCondition[]) => void; + appliedFilterConditions?: FilterCondition[]; + quickWhereCondition?: string; + onApplyQuickWhereCondition?: (condition: string) => void; + scrollSnapshot?: { top: number; left: number }; + onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; + toolbarExtraActions?: React.ReactNode; +} + +type GridFilterCondition = FilterCondition & { + id: number; + column: string; + op: string; + value: string; + value2?: string; +}; + +type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; +type DdlViewLayoutMode = 'bottom' | 'side'; +type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll'; +type VirtualEditingCellState = { + rowKey: string; + dataIndex: string; + title: React.ReactNode; + columnType?: string; +}; + +type ColumnMeta = { + type: string; + comment: string; +}; + +const buildColumnMetaMap = (columns: ColumnDefinition[]): Record => { + const nextMap: Record = {}; + (columns || []).forEach((column: any) => { + const name = getColumnDefinitionName(column); + if (!name) return; + nextMap[name] = { + type: getColumnDefinitionType(column), + comment: getColumnDefinitionComment(column), + }; + }); + return nextMap; +}; + +const hasUsableColumnMeta = (metaMap: Record): boolean => ( + Object.values(metaMap || {}).some((meta) => { + const type = String(meta?.type || '').trim(); + const comment = String(meta?.comment || '').trim(); + return type.length > 0 || comment.length > 0; + }) +); + +type ForeignKeyTarget = { + columnName: string; + refTableName: string; + refColumnName: string; + constraintName: string; +}; + +type VirtualTableScrollReference = TableReference & { + scrollTo: (config: { left?: number; top?: number; index?: number; key?: React.Key }) => void; +}; + +const EXACT_GRID_FILTER_OPERATOR = '='; +const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS'; +const FILTER_FIELD_SELECT_STYLE: React.CSSProperties = { + width: 320, + flex: '0 1 320px', + minWidth: 260, + maxWidth: 'min(460px, 100%)', +}; +const FILTER_FIELD_POPUP_WIDTH = 520; +const FILTER_FIELD_OPTION_STYLE: React.CSSProperties = { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; +const STRING_LIKE_GRID_FILTER_TYPES = new Set([ + 'bpchar', + 'char', + 'character', + 'character varying', + 'citext', + 'clob', + 'fixedstring', + 'long nvarchar', + 'long varchar', + 'longtext', + 'mediumtext', + 'nchar', + 'nclob', + 'ntext', + 'nvarchar', + 'nvarchar2', + 'string', + 'text', + 'tinytext', + 'varchar', + 'varchar2', +]); + +const normalizeGridFilterColumnType = (columnType: unknown): string => { + let normalized = String(columnType ?? '').trim().toLowerCase().replace(/\s+/g, ' '); + for (let i = 0; i < 4; i += 1) { + const wrapped = normalized.match(/^(?:nullable|lowcardinality)\((.+)\)$/); + if (!wrapped) break; + normalized = wrapped[1].trim().replace(/\s+/g, ' '); + } + return normalized; +}; + +export const isStringLikeGridFilterColumnType = (columnType: unknown): boolean => { + const normalized = normalizeGridFilterColumnType(columnType); + if (!normalized) return false; + const baseType = normalized.replace(/\(.*/, '').trim(); + return STRING_LIKE_GRID_FILTER_TYPES.has(baseType); +}; + +export const resolveDefaultGridFilterOperator = (columnType: unknown): string => ( + isStringLikeGridFilterColumnType(columnType) ? CONTAINS_GRID_FILTER_OPERATOR : EXACT_GRID_FILTER_OPERATOR +); + +export const resolveNextGridFilterOperatorForColumnChange = ({ + currentOperator, + previousColumnType, + nextColumnType, +}: { + currentOperator: unknown; + previousColumnType: unknown; + nextColumnType: unknown; +}): string => { + const current = String(currentOperator || '').trim(); + if (!current) return resolveDefaultGridFilterOperator(nextColumnType); + const previousDefault = resolveDefaultGridFilterOperator(previousColumnType); + return current === previousDefault ? resolveDefaultGridFilterOperator(nextColumnType) : current; +}; + +export const buildGridFieldSelectOptions = (columnNames: string[]) => ( + (columnNames || []).map((columnName) => { + const text = String(columnName || ''); + return { + value: text, + label: text, + title: text, + }; + }) +); + +const renderGridFieldSelectOption = (option: { label?: React.ReactNode; value?: unknown; title?: unknown }) => { + const text = String(option?.title ?? option?.label ?? option?.value ?? ''); + return ( + + {text} + + ); +}; + +type NormalizeCommitCellValue = (columnName: string, value: any, mode: 'insert' | 'update') => any; + +type DataGridCommitChangeSet = { + inserts: any[]; + updates: any[]; + deletes: any[]; +}; + +export const buildDataGridCommitChangeSet = ({ + addedRows, + modifiedRows, + deletedRowKeys, + data, + editLocator, + visibleColumnNames, + rowKeyToString, + normalizeCommitCellValue, + shouldCommitColumn, + rowLocatorMessages, +}: { + addedRows: any[]; + modifiedRows: Record; + deletedRowKeys: Set; + data: any[]; + editLocator?: EditRowLocator; + visibleColumnNames: string[]; + rowKeyToString: (key: any) => string; + normalizeCommitCellValue: NormalizeCommitCellValue; + shouldCommitColumn: (columnName: string) => boolean; + rowLocatorMessages?: RowLocatorMessages; +}): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => { + if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') { + return { ok: false, error: editLocator?.reason || rowLocatorMessages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' }; + } + + const normalizeValues = (values: Record, mode: 'insert' | 'update') => { + const normalizedValues: Record = {}; + Object.entries(values).forEach(([col, val]) => { + if (!shouldCommitColumn(col)) return; + const commitColumnName = resolveWritableColumnName(col, editLocator); + if (!commitColumnName) return; + const normalizedVal = normalizeCommitCellValue(col, val, mode); + if (normalizedVal !== undefined) { + normalizedValues[commitColumnName] = normalizedVal; + } + }); + return normalizedValues; + }; + + const originalRowsByKey = new Map(); + data.forEach((row) => { + const key = row?.[GONAVI_ROW_KEY]; + if (key === undefined || key === null) return; + originalRowsByKey.set(rowKeyToString(key), row); + }); + + const inserts: any[] = []; + const updates: any[] = []; + const deletes: any[] = []; + + addedRows.forEach(row => { + const key = row?.[GONAVI_ROW_KEY]; + if (key !== undefined && key !== null && deletedRowKeys.has(rowKeyToString(key))) return; + inserts.push(normalizeValues(row, 'insert')); + }); + + for (const keyStr of deletedRowKeys) { + const originalRow = originalRowsByKey.get(keyStr); + if (!originalRow) continue; + const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); + if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; + deletes.push(locatorValues.values); + } + + for (const [keyStr, newRow] of Object.entries(modifiedRows)) { + if (deletedRowKeys.has(keyStr)) continue; + const originalRow = originalRowsByKey.get(keyStr); + if (!originalRow) continue; + + const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); + if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; + + const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY); + let values: Record = {}; + if (!hasRowKey) { + values = { ...(newRow as any) }; + } else { + visibleColumnNames.forEach((col) => { + const nextVal = (newRow as any)?.[col]; + const prevVal = (originalRow as any)?.[col]; + if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal; + }); + } + + const normalizedValues = normalizeValues(values, 'update'); + if (Object.keys(normalizedValues).length === 0) continue; + updates.push({ keys: locatorValues.values, values: normalizedValues }); + } + + return { ok: true, changes: { inserts, updates, deletes } }; +}; + +// P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象 +const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, width: '100%' }; +const VIRTUAL_CELL_TEXT_STYLE: React.CSSProperties = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + width: '100%', +}; +const READONLY_CELL_WRAP_STYLE: React.CSSProperties = { minHeight: 20, display: 'flex', alignItems: 'center', width: '100%', minWidth: 0 }; +const INLINE_EDIT_FORM_ITEM_STYLE: React.CSSProperties = { margin: 0, width: '100%', minWidth: 0 }; +const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { + margin: 0, + padding: 0, + display: 'flex', + flex: '1 1 auto', + alignItems: 'center', + width: '100%', + minWidth: 0, + minHeight: 'calc(28px * var(--gn-ui-scale, 1))', + height: 'calc(28px * var(--gn-ui-scale, 1))', + overflow: 'visible', + whiteSpace: 'nowrap', + boxSizing: 'border-box', +}; + + +export { + DataGridErrorBoundary, + CELL_KEY_SEP, + CELL_SELECTION_DRAG_THRESHOLD_PX, + DATE_TIME_CACHE_LIMIT, + TABLE_CELL_PREVIEW_MAX_CHARS, + ROW_NUMBER_COLUMN_WIDTH, + DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS, + DATA_GRID_DISPLAY_RENDER_VERSION, + DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION, + DEFAULT_GRID_MONO_FONT_FAMILY, + normalizedDateTimeCache, + objectCellPreviewCache, + useDataGridI18nLanguage, + makeCellKey, + splitCellKey, + trimSimpleCache, + looksLikeDateTimeText, + normalizeDateTimeString, + normalizeBitHexDisplayText, + isDateOnlyColumnType, + isOceanBaseOracleDisplayConnection, + normalizeOceanBaseOracleDateDisplayText, + formatClipboardCellText, + normalizeClipboardTsvCell, + buildClipboardTsv, + renderHighlightedCellText, + renderCellDisplayValue, + formatCellValue, + toEditableText, + toFormText, + isCellValueEqualForDiff, + isCellValueEqualForRender, + INLINE_EDIT_MAX_CHARS, + shouldOpenModalEditor, + getCellFieldName, + setCellFieldValue, + looksLikeJsonText, + isPlainObject, + normalizeValueForJsonView, + isJsonViewValueEqual, + coerceJsonEditorValueForStorage, + ResizableTitle, + sortableHeaderStaticStyles, + SortableHeaderCell, + EditableContext, + CellContextMenuContext, + DataContext, + setGlobalDeletedRowKeys, + resolveEditableCellRowKey, + isEditableCellDeleted, + isEditableCellModified, + areEditableCellPropsEqual, + EditableCell, + ContextMenuRow, + buildColumnMetaMap, + hasUsableColumnMeta, + EXACT_GRID_FILTER_OPERATOR, + CONTAINS_GRID_FILTER_OPERATOR, + FILTER_FIELD_SELECT_STYLE, + FILTER_FIELD_POPUP_WIDTH, + FILTER_FIELD_OPTION_STYLE, + STRING_LIKE_GRID_FILTER_TYPES, + normalizeGridFilterColumnType, + renderGridFieldSelectOption, + CELL_ELLIPSIS_STYLE, + VIRTUAL_CELL_TEXT_STYLE, + READONLY_CELL_WRAP_STYLE, + INLINE_EDIT_FORM_ITEM_STYLE, + VIRTUAL_EDITING_CELL_STYLE, +}; + +export type { + DataGridErrorBoundaryState, + DataGridErrorBoundaryProps, + CellDisplayConnectionLike, + SortableHeaderCellProps, + Item, + EditableCellProps, + DataGridProps, + GridFilterCondition, + GridViewMode, + DdlViewLayoutMode, + DataGridExportScope, + VirtualEditingCellState, + ColumnMeta, + ForeignKeyTarget, + VirtualTableScrollReference, + NormalizeCommitCellValue, + DataGridCommitChangeSet, +}; diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx new file mode 100644 index 0000000..3f063e5 --- /dev/null +++ b/frontend/src/components/DataGridShell.tsx @@ -0,0 +1,1123 @@ +import React from 'react'; +import { Button, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import { createPortal } from 'react-dom'; + +import Modal from './common/ResizableDraggableModal'; +import ImportPreviewModal from './ImportPreviewModal'; +import DataGridModals from './DataGridModals'; +import DataGridPreviewPanel from './DataGridPreviewPanel'; +import DataGridSecondaryActions from './DataGridSecondaryActions'; +import DataGridToolbarFrame from './DataGridToolbarFrame'; +import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews'; +import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace'; +import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews'; +import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; +import TableDesigner from './TableDesigner'; +import { V2CellContextMenuView, V2ColumnHeaderContextMenuView } from './V2TableContextMenu'; +import { + FILTER_FIELD_POPUP_WIDTH, + FILTER_FIELD_SELECT_STYLE, + GONAVI_ROW_KEY, +} from './DataGridCore'; + +type DataGridShellProps = Record; + +const DataGridShell: React.FC = (props) => { + const { + CellContextMenuContext, + CustomEvent, + DataContext, + DataGridColumnQuickFind, + DataGridPageFind, + DataGridPaginationBar, + DataGridResultViewSwitcher, + DndContext, + EditableContext, + Form, + JSON, + Set, + SortableContext, + Table, + activePageFindPosition, + activeShortcutPlatform, + addFilter, + aiShortcutLabel, + allSelectedAreDeleted, + applyAllFiltersDisabled, + applyAllFiltersEnabled, + applyExternalScrollToTableTargets, + applyFilters, + applyJsonEditor, + applyQuickWhereCondition, + applyRowEditor, + applySortInfo, + autoCommitFailedTokenRef, + autoCommitRemainingSeconds, + batchEditModalOpen, + batchEditSetNull, + batchEditValue, + bgContent, + bgContextMenu, + bgFilter, + canCopyQueryResult, + canExport, + canImport, + canModifyData, + canOpenObjectDesigner, + canUndoContextMenuCellChange, + canViewDdl, + cellContextMenu, + cellContextMenuPortalRef, + cellContextMenuValue, + cellEditMode, + cellEditModeRef, + cellEditorIsJson, + cellEditorMeta, + cellEditorOpen, + cellEditorValue, + clearAllFiltersAndSorts, + clearAutoCommitTimer, + clearQuickWhereCondition, + closeBatchEditModal, + closeCellEditMode, + closeCellEditor, + closeJsonEditor, + closeRowEditor, + closestCenter, + columnInfoSettingContent, + columnMetaCacheRef, + columnMetaMap, + columnMetaMapByLowerName, + columnQuickFindOptions, + columnQuickFindText, + connectionId, + containerRef, + contextHolder, + copiedCellPatch, + copiedRowsForPaste, + copyRowsForPaste, + copyToClipboard, + currentConnConfig, + currentTextRow, + darkMode, + dataContextValue, + dataEditAutoCommitDelayMs, + dataEditCommitMode, + dataPanelDirtyRef, + dataPanelIsJson, + dataPanelOpen, + dataPanelOriginalRef, + dataPanelValue, + dbName, + dbType, + ddlLoading, + ddlModalOpen, + ddlSidebarResizePreviewX, + ddlSidebarWidth, + ddlText, + ddlViewLayout, + displayColumnNames, + displayOutputColumnNames, + effectiveEditLocator, + enableVirtual, + exportProgressModal, + externalHorizontalScrollRef, + externalScrollbarMinWidth, + filterConditions, + filterLogicOptions, + filterOpOptions, + filterPanelRef, + filterTopPadding, + focusedCellInfo, + focusedCellWritable, + foreignKeyCacheRef, + form, + formatTextViewValue, + getTargets, + getTemporalPickerType, + ghostRef, + gridCssText, + gridFieldSelectOptions, + gridId, + handleAddRow, + handleBatchFillCells, + handleBatchFillToSelected, + handleCellEditorSave, + handleCellSetNull, + handleCommit, + handleCopyContextMenuFieldName, + handleCopyCsv, + handleCopyDdl, + handleCopyDelete, + handleCopyInsert, + handleCopyJson, + handleCopyQueryResultCsv, + handleCopyRowData, + handleCopySelectedCellsToClipboard, + handleCopySelectedColumnsFromRow, + handleCopyUpdate, + handleDataPanelFormatJson, + handleDataPanelSave, + handleDdlSidebarResizeStart, + handleDeleteSelected, + handleDragEnd, + handleExportSelected, + handleFormatJsonEditor, + handleFormatJsonInEditor, + handleImport, + handleImportSuccess, + handleNavigatePageFind, + handleOpenContextMenuRowEditor, + handleOpenExportDialog, + handleOpenJsonEditor, + handleOpenTableDdl, + handlePageSizeChange, + handlePasteCopiedColumnsToSelectedRows, + handlePasteCopiedRowsAsNew, + handlePreviewChanges, + handleQuickWherePaste, + handleSubmitColumnQuickFind, + handleTableChange, + handleUndoContextMenuCellChange, + handleUndoDeleteSelected, + handleV2CellContextMenuAction, + handleV2ColumnHeaderContextMenuAction, + handleV2PageStep, + handleViewModeChange, + handleVirtualTableClickCapture, + handleVirtualTableContextMenuCapture, + handleVirtualTableDoubleClickCapture, + hasChanges, + headerCellMinHeight, + horizontalListSortingStrategy, + horizontalScrollVisible, + horizontalScrollWidth, + importFilePath, + importPreviewVisible, + isBetweenOp, + isListOp, + isNoValueOp, + isQueryResultExport, + isTableSurfaceActive, + isV2Ui, + isWritableResultColumn, + jsonEditorOpen, + jsonEditorValue, + jsonViewText, + legacyAiButtonStyle, + loading, + localizedDataEditAutoCommitDelayOptions, + looksLikeJsonText, + mergedDisplayData, + noAutoCapInputProps, + normalizedPageFindText, + onCancelTotalCount, + onPageChange, + onReload, + onRequestTotalCount, + onSort, + onToggleFilter, + openBatchEditModal, + openCurrentViewRowEditor, + openRowEditorFieldEditor, + pageFindMatches, + pageFindSummary, + pageFindText, + pagination, + paginationControlTotal, + paginationHasKnownTotalPages, + paginationPageSizeOptions, + paginationPageText, + paginationSummaryText, + paginationTotalPages, + paginationV2SummaryText, + panelFrameColor, + panelOuterGap, + panelPaddingX, + panelPaddingY, + panelRadius, + pendingChangeCount, + pkColumns, + prefersManualTotalCount, + previewModalOpen, + previewSqlData, + queryResultCopyMenu, + quickWhereCondition, + quickWhereDraft, + quickWhereSuggestionOptions, + quickWhereSuggestionsOpen, + readOnly, + removeFilter, + renderGridFieldSelectOption, + resetCellSelection, + resolveColumnQuickFindTarget, + resolveContextMenuFieldName, + resolveWhereConditionSelectedValue, + rootRef, + rowClassName, + rowEditorDisplayRef, + rowEditorForm, + rowEditorNullColsRef, + rowEditorOpen, + rowEditorRowKey, + rowSelectionConfig, + selectedCells, + selectedRowKeys, + selectionAccentHex, + sensors, + setAddedRows, + setBatchEditSetNull, + setBatchEditValue, + setCellContextMenu, + setCellEditMode, + setCellEditorValue, + setColumnQuickFindText, + setDataEditTransactionOptions, + setDataPanelValue, + setDdlModalOpen, + setDdlViewLayout, + setDeletedRowKeys, + setImportFilePath, + setImportPreviewVisible, + setJsonEditorValue, + setMetadataReloadVersion, + setModifiedColumns, + setModifiedRows, + setPageFindText, + setPreviewModalOpen, + setQuickWhereDraft, + setQuickWhereSuggestionsOpen, + setSelectedRowKeys, + setTextRecordIndex, + setTimeout, + shouldApplyQuickWhereOnEnter, + showColumnComment, + showColumnType, + showFilter, + sortInfo, + stopQuickWhereClipboardPropagation, + supportsCopyInsert, + tableBodyBottomPadding, + tableColumns, + tableComponents, + tableContainerRef, + tableName, + tableOnRow, + tableRef, + tableRenderData, + tableScrollConfig, + textRecordIndex, + textViewRows, + toggleDataPanel, + toolbarBottomPadding, + toolbarDividerColor, + toolbarExtraActions, + translateDataGrid, + uniqueKeyGroupsCacheRef, + updateFilter, + useCallback, + useMemo, + useStore, + viewMode, + virtualListItemHeight, + window, + } = props; + +const renderDataTableView = () => ( +
+
+ + + + + +
; + } + + return ( + + {restProps.children} + { + e.stopPropagation(); + // Pass the header element reference implicitly via event target + onResizeStart(e); + }} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (typeof onResizeAutoFit === 'function') { + onResizeAutoFit(e); + } + }} + onPointerDown={(e) => { + // 阻止 pointerdown 冒泡到 @dnd-kit 的 PointerSensor, + // 避免调整列宽时意外触发列拖拽排序 + e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + title={t('data_grid.column.resize_tooltip')} + style={{ + position: 'absolute', + right: 0, // Align to right edge + bottom: 0, + top: 0, + width: 10, + cursor: 'col-resize', + zIndex: 10, + touchAction: 'none' + }} + /> +
+ + + + + + +
+
+
+
+ ); + const pageFindContent = ( + } + pageFindText={pageFindText} + normalizedPageFindText={normalizedPageFindText} + hasMatches={pageFindMatches.length > 0} + activePageFindPosition={activePageFindPosition} + matchCount={pageFindMatches.length} + occurrenceCount={pageFindSummary.occurrenceCount} + matchedCellCount={pageFindSummary.matchedCellCount} + onPageFindTextChange={setPageFindText} + onCancel={() => setPageFindText('')} + onNavigatePrevious={() => handleNavigatePageFind('previous')} + onNavigateNext={() => handleNavigatePageFind('next')} + translate={translateDataGrid} + /> + ); + const visiblePageFindContent = viewMode === 'table' ? pageFindContent : null; + const columnQuickFindContent = isTableSurfaceActive ? ( + } + value={columnQuickFindText} + options={columnQuickFindOptions} + hasTarget={!!resolveColumnQuickFindTarget(columnQuickFindText)} + translate={translateDataGrid} + onChange={setColumnQuickFindText} + onSubmit={handleSubmitColumnQuickFind} + /> + ) : null; + const resultViewSwitcher = ( + + ); + const paginationContent = ( + + ); + + const rowEditorFields = useMemo(() => ( + displayColumnNames.map((col: string) => { + const sample = rowEditorDisplayRef.current?.[col] ?? ''; + const placeholder = rowEditorNullColsRef.current?.has(col) ? '(NULL)' : undefined; + const isJson = looksLikeJsonText(sample); + const useTextArea = isJson || sample.includes('\n') || sample.length >= 160; + const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const pickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); + const isTemporalValue = !!pickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || ''))); + const isWritable = isWritableResultColumn(col, effectiveEditLocator); + return { + columnName: col, + sample, + placeholder, + isJson, + useTextArea, + pickerType, + isTemporalValue, + isWritable, + }; + }) + ), [columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, displayColumnNames, effectiveEditLocator, rowEditorOpen, rowEditorRowKey]); + + const handleRefreshGrid = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + setSelectedRowKeys([]); + const normalizedTableName = String(tableName || '').trim(); + const normalizedDbName = String(dbName || '').trim(); + if (connectionId && normalizedTableName) { + const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; + delete columnMetaCacheRef.current[cacheKey]; + delete foreignKeyCacheRef.current[cacheKey]; + delete uniqueKeyGroupsCacheRef.current[cacheKey]; + setMetadataReloadVersion((value: number) => value + 1); + } + if (onReload) onReload(); + }, [clearAutoCommitTimer, connectionId, dbName, onReload, tableName]); + + const handleResetPendingChanges = useCallback(() => { + clearAutoCommitTimer(); + autoCommitFailedTokenRef.current = -1; + setAddedRows([]); + setModifiedRows({}); + setDeletedRowKeys(new Set()); + setModifiedColumns({}); + }, [clearAutoCommitTimer]); + + const handleToggleFilterWithDefault = useCallback(() => { + if (!onToggleFilter) return; + onToggleFilter(); + if (filterConditions.length === 0 && !showFilter) addFilter(); + }, [onToggleFilter, filterConditions.length, showFilter]); + + const handleToggleCellEditMode = useCallback(() => { + const next = !cellEditMode; + if (!next) { + closeCellEditMode(); + } else { + cellEditModeRef.current = true; + setCellEditMode(true); + resetCellSelection(); + } + void message.info(next + ? translateDataGrid('data_grid.message.cell_edit_mode_entered') + : translateDataGrid('data_grid.message.cell_edit_mode_exited')).then(); + }, [cellEditMode, closeCellEditMode, resetCellSelection, translateDataGrid]); + + const handleRequestAiInsight = useCallback(() => { + const sampleData = mergedDisplayData.slice(0, 10); + const prompt = translateDataGrid('data_grid.ai_insight.prompt', { + count: sampleData.length, + json: JSON.stringify(sampleData, null, 2), + }); + const store = useStore.getState(); + const wasClosed = !store.aiPanelVisible; + if (wasClosed) store.setAIPanelVisible(true); + setTimeout(() => { + window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); + }, wasClosed ? 350 : 0); + }, [mergedDisplayData, translateDataGrid]); + + const handleToggleTotalCount = useCallback(() => { + if (!onRequestTotalCount) return; + if (pagination?.totalCountLoading) { + if (onCancelTotalCount) onCancelTotalCount(); + return; + } + onRequestTotalCount(); + }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]); + + return ( +
+ } + filterFieldSelectStyle={FILTER_FIELD_SELECT_STYLE} + filterFieldPopupWidth={FILTER_FIELD_POPUP_WIDTH} + queryResultCopyMenu={queryResultCopyMenu} + dbType={dbType} + onResetPendingChanges={handleResetPendingChanges} + onDataEditCommitModeChange={(mode) => setDataEditTransactionOptions({ commitMode: mode })} + onDataEditAutoCommitDelayChange={(delayMs) => setDataEditTransactionOptions({ autoCommitDelayMs: delayMs })} + onRefresh={handleRefreshGrid} + onToggleFilterClick={handleToggleFilterWithDefault} + onAddRow={handleAddRow} + onUndoDeleteSelected={handleUndoDeleteSelected} + onDeleteSelected={handleDeleteSelected} + onToggleCellEditMode={handleToggleCellEditMode} + onCopySelectedCellsToClipboard={handleCopySelectedCellsToClipboard} + onCopySelectedColumnsFromRow={handleCopySelectedColumnsFromRow} + onOpenBatchEditModal={openBatchEditModal} + onPasteCopiedColumnsToSelectedRows={() => handlePasteCopiedColumnsToSelectedRows()} + onCommit={handleCommit} + onPreviewChanges={handlePreviewChanges} + onImport={handleImport} + onOpenExportModal={handleOpenExportDialog} + onCopyQueryResultCsv={handleCopyQueryResultCsv} + onRequestAiInsight={handleRequestAiInsight} + onToggleTotalCount={handleToggleTotalCount} + onQuickWhereDraftChange={setQuickWhereDraft} + onQuickWhereSuggestionsOpenChange={setQuickWhereSuggestionsOpen} + onQuickWhereKeyDown={(event) => { + const isClipboardShortcut = (event.metaKey || event.ctrlKey) && !event.altKey && ['c', 'v', 'x'].includes(String(event.key || '').toLowerCase()); + if (isClipboardShortcut) { + event.stopPropagation(); + return; + } + if (!shouldApplyQuickWhereOnEnter({ + key: event.key, + shiftKey: event.shiftKey, + isComposing: Boolean((event.nativeEvent as any)?.isComposing), + suggestionsOpen: quickWhereSuggestionsOpen, + suggestionCount: quickWhereSuggestionOptions.length, + activeSuggestionId: event.currentTarget.getAttribute('aria-activedescendant'), + })) { + return; + } + event.preventDefault(); + applyQuickWhereCondition(); + }} + onQuickWhereSelect={(value, option) => { + setQuickWhereDraft(resolveWhereConditionSelectedValue({ + selectedValue: value, + currentInput: quickWhereDraft, + insertText: (option as any)?.insertText, + })); + }} + onQuickWhereCopy={stopQuickWhereClipboardPropagation} + onQuickWhereCut={stopQuickWhereClipboardPropagation} + onQuickWherePaste={handleQuickWherePaste} + onApplyQuickWhere={() => applyQuickWhereCondition()} + onClearQuickWhere={clearQuickWhereCondition} + updateFilter={updateFilter} + removeFilter={removeFilter} + addFilter={addFilter} + isListOp={isListOp} + isBetweenOp={isBetweenOp} + isNoValueOp={isNoValueOp} + enableSortControls={!!onSort} + onApplySortInfo={applySortInfo} + onApplyFilters={applyFilters} + onEnableAllFilters={applyAllFiltersEnabled} + onDisableAllFilters={applyAllFiltersDisabled} + onClearFiltersAndSorts={clearAllFiltersAndSorts} + /> + +
+ {contextHolder} + {exportProgressModal} + setDdlModalOpen(false)} + onCopyDdl={handleCopyDdl} + /> + + {viewMode === 'table' ? ( + renderDataTableView() + ) : isV2Ui && viewMode === 'fields' ? ( + canOpenObjectDesigner ? ( + + ) : ( + + ) + ) : isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side' ? ( + { + void handleOpenTableDdl({ asView: true }); + }} + onCopy={handleCopyDdl} + ddlSidebarWidth={ddlSidebarWidth} + ddlSidebarResizePreviewX={ddlSidebarResizePreviewX} + onResizeStart={handleDdlSidebarResizeStart} + /> + ) : isV2Ui && viewMode === 'ddl' ? ( + { + void handleOpenTableDdl({ asView: true }); + }} + onCopy={handleCopyDdl} + /> + ) : isV2Ui && viewMode === 'er' ? ( + + ) : viewMode === 'json' ? ( + + ) : ( + setTextRecordIndex((i: number) => Math.max(0, i - 1))} + onNext={() => setTextRecordIndex((i: number) => Math.min(textViewRows.length - 1, i + 1))} + onEditCurrent={openCurrentViewRowEditor} + formatTextViewValue={formatTextViewValue} + /> + )} + + { + handleDataPanelFormatJson((errorMessage: string) => { + void message.error(translateDataGrid('data_grid.json_editor.invalid_format', { error: errorMessage })); + }); + }} + onSave={handleDataPanelSave} + onValueChange={setDataPanelValue} + onDirtyChange={(dirty) => { + dataPanelDirtyRef.current = dirty; + }} + isDirtyComparedToOriginal={(value) => value !== dataPanelOriginalRef.current} + /> + + {isTableSurfaceActive && isV2Ui && cellContextMenu.visible && createPortal( +
e.stopPropagation()} + > + {cellContextMenu.kind === 'column' ? (() => { + const fieldName = resolveContextMenuFieldName(cellContextMenu.dataIndex, cellContextMenu.title); + const meta = columnMetaMap[fieldName] || columnMetaMapByLowerName[fieldName.toLowerCase()]; + const activeSort = sortInfo.find((item: any) => item.columnKey === fieldName && item.enabled !== false); + return ( + + ); + })() : ( + + )} +
, + document.body + )} + + setCellContextMenu((prev: any) => ({ ...prev, visible: false }))} + onCopyFieldName={handleCopyContextMenuFieldName} + onCopyRowData={() => { + if (cellContextMenu.record) handleCopyRowData(cellContextMenu.record); + }} + onCopyRowForPaste={() => { + const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; + if (rowKey === undefined || rowKey === null) { + void message.info(translateDataGrid('data_grid.message.no_copyable_rows')); + return; + } + setSelectedRowKeys([rowKey]); + copyRowsForPaste([rowKey]); + }} + onPasteCopiedRowsAsNew={handlePasteCopiedRowsAsNew} + onUndoCellChange={handleUndoContextMenuCellChange} + onSetNull={handleCellSetNull} + onEditRow={handleOpenContextMenuRowEditor} + onFillToSelected={() => { + if (selectedRowKeys.length > 0 && cellContextMenu.record) { + handleBatchFillToSelected(cellContextMenu.record, cellContextMenu.dataIndex); + } + }} + onPasteCopiedColumns={() => { + const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; + handlePasteCopiedColumnsToSelectedRows(fallbackKey); + }} + onCopyInsert={() => { + if (cellContextMenu.record) handleCopyInsert(cellContextMenu.record); + }} + onCopyUpdate={() => { + if (cellContextMenu.record) handleCopyUpdate(cellContextMenu.record); + }} + onCopyDelete={() => { + if (cellContextMenu.record) handleCopyDelete(cellContextMenu.record); + }} + onCopyJson={() => { + if (cellContextMenu.record) handleCopyJson(cellContextMenu.record); + }} + onCopyCsv={() => { + if (cellContextMenu.record) handleCopyCsv(cellContextMenu.record); + }} + onCopyMarkdown={() => { + if (cellContextMenu.record) { + const records = getTargets(cellContextMenu.record); + const lines = records.map((r: any) => { + const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = r; + return `| ${Object.values(vals).join(' | ')} |`; + }); + copyToClipboard(lines.join('\n')); + } + }} + onExportCsv={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'csv' }, cellContextMenu.record).catch(console.error); + }} + onExportXlsx={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'xlsx' }, cellContextMenu.record).catch(console.error); + }} + onExportJson={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'json' }, cellContextMenu.record).catch(console.error); + }} + onExportHtml={() => { + if (cellContextMenu.record) handleExportSelected({ format: 'html' }, cellContextMenu.record).catch(console.error); + }} + /> +
+ + { + void handleOpenTableDdl(); + }} + translate={translateDataGrid} + /> + + + + {/* Ghost Resize Line for Columns */} +
+ + {/* Preview SQL Modal */} + setPreviewModalOpen(false)} + width={800} + footer={null} + > +
+ {previewSqlData.deletes.length > 0 && ( +
+
+ DELETE ({previewSqlData.deletes.length}) +
+ {previewSqlData.deletes.map((sql: string, i: number) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.updates.length > 0 && ( +
+
+ UPDATE ({previewSqlData.updates.length}) +
+ {previewSqlData.updates.map((sql: string, i: number) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.inserts.length > 0 && ( +
+
+ INSERT ({previewSqlData.inserts.length}) +
+ {previewSqlData.inserts.map((sql: string, i: number) => ( +
+
{sql}
+
+ ))} +
+ )} + {previewSqlData.deletes.length === 0 && previewSqlData.updates.length === 0 && previewSqlData.inserts.length === 0 && ( +
+ {translateDataGrid('data_grid.preview_sql.no_changes')} +
+ )} +
+
+ {translateDataGrid('data_grid.preview_sql.summary', { + deletes: previewSqlData.deletes.length, + updates: previewSqlData.updates.length, + inserts: previewSqlData.inserts.length + })} +
+
+ + {/* Import Preview Modal */} + { + setImportPreviewVisible(false); + setImportFilePath(''); + }} + onSuccess={handleImportSuccess} + /> +
+ ); +}; + +export default DataGridShell; diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 757a363..4329786 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { readFileSync } from 'node:fs'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readV2ThemeCss } from '../test/readV2ThemeCss'; import { setCurrentLanguage } from '../i18n'; import type { SavedQuery, TabData } from '../types'; @@ -618,6 +619,7 @@ describe('QueryEditor external SQL save', () => { editorState.position = { lineNumber: 1, column: 1 }; editorState.selection = null; editorState.domNode.style.cursor = ''; + editorState.providers = []; editorState.hoverProviders = []; editorState.contentChangeListeners = []; editorState.cursorPositionListeners = []; @@ -4919,2237 +4921,4 @@ describe('QueryEditor external SQL save', () => { renderer?.unmount(); }); - it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => { - storeState.connections[0].config.type = 'oracle'; - storeState.connections[0].config.database = 'ORCLPDB1'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }], - }); - const plsql = [ - 'BEGIN', - " INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');", - " UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';", - " DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';", - 'END;', - ].join('\n'); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1'); - expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({ - sql: plsql, - status: 'success', - })); - renderer?.unmount(); - }); - - it('renders result grid for sqlserver exec statements that return rows', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['SPID', 'STATUS'], rows: [{ SPID: 52, STATUS: 'RUNNABLE' }] }], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:'); - expect(dataGridState.latestProps?.columnNames).toEqual(['SPID', 'STATUS']); - expect(Array.isArray(dataGridState.latestProps?.data)).toBe(true); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ SPID: 52, STATUS: 'RUNNABLE' }); - }); - - it('renders standalone message result for sqlserver statistics statements', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ - columns: [], - rows: [], - messages: ["Table 'users'. Scan count 1, logical reads 3."], - }], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('消息 1'); - expect(textContent(renderer!.toJSON())).toContain("Table 'users'. Scan count 1, logical reads 3."); - expect(dataGridState.latestProps?.columnNames).not.toEqual([]); - }); - - it('keeps multiple result sets from a single sqlserver statement', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(dataGridState.latestProps?.columnNames).toEqual(['name']); - }); - - it('prefers the first displayable sqlserver procedure result when empty result sets are returned', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: [], rows: [] }, - { - statementIndex: 1, - columns: ['insert_sql'], - rows: [ - { insert_sql: "insert into c_user(userid) values('168')" }, - { insert_sql: "insert into c_user(userid) values('169')" }, - ], - }, - { statementIndex: 1, columns: [], rows: [] }, - { statementIndex: 1, columns: [], rows: [] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 4'); - expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ - insert_sql: "insert into c_user(userid) values('168')", - }); - }); - - it('prefers concrete sqlserver procedure rows over affected-row status results', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] }, - { statementIndex: 1, columns: [], rows: [] }, - { - statementIndex: 1, - columns: ['insert_sql'], - rows: [ - { insert_sql: "insert into c_user(userid) values('168')" }, - ], - }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ - insert_sql: "insert into c_user(userid) values('168')", - }); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); - }); - - it('prefers sqlserver print output messages over affected-row status results', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] }, - { - statementIndex: 1, - columns: [], - rows: [], - messages: [ - "insert into c_dyscript(projectid,name) values (1,'demo')", - "insert into c_dyscript(projectid,name) values (2,'next')", - ], - }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('消息 2'); - expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')"); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); - expect(dataGridState.latestProps).toBeNull(); - }); - - it('renders top-level sqlserver print messages when result sets contain only status rows', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'hydee'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] }, - ], - messages: [ - "insert into c_dyscript(projectid,name) values (1,'demo')", - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('消息 2'); - expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')"); - expect(textContent(renderer!.toJSON())).not.toContain('影响行数:0'); - expect(dataGridState.latestProps).toBeNull(); - }); - - it('keeps both tabs when rerunning the same single sqlserver statement with multiple result sets', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'tempdb' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const tabLabels = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - }); - expect(tabLabels).toHaveLength(2); - expect(dataGridState.latestProps?.columnNames).toEqual(['name']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ name: 'tempdb' }); - }); - - it('reloads the active secondary result set for a single sqlserver statement', async () => { - storeState.connections[0].config.type = 'sqlserver'; - storeState.connections[0].config.database = 'master'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] }, - { statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const resultTabButtons = renderer!.root.findAll((node) => node.type === 'button' && node.props['data-tab-key']); - expect(resultTabButtons).toHaveLength(2); - - await act(async () => { - resultTabButtons[1].props.onClick(); - }); - - expect(dataGridState.latestProps?.columnNames).toEqual(['owner']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'sa' }); - - await act(async () => { - await dataGridState.latestProps?.onReload?.(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(dataGridState.latestProps?.columnNames).toEqual(['owner']); - expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'dbo' }); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'master' })])); - }); - - it('localizes the non-Oracle no-safe-locator read-only warning in English while preserving the raw table name', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'NAME', key: '' }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.pkColumns).toEqual([]); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'none', - readOnly: true, - reason: 'No primary key or usable unique index was detected, so changes cannot be committed safely.', - }); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith( - 'Query results remain read-only: main.users No primary key or usable unique index was detected, so changes cannot be committed safely.', - ); - expect(messageApi.warning).not.toHaveBeenCalledWith( - '查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。', - ); - }); - - it('localizes the non-Oracle index-metadata-unavailable read-only warning in English while preserving the raw table name', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'NAME', key: '' }], - }); - backendApp.DBGetIndexes.mockResolvedValueOnce({ - success: false, - data: [], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'none', - readOnly: true, - reason: 'Unable to load unique index metadata, so changes cannot be committed safely.', - }); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith( - 'Query results remain read-only: main.users Unable to load unique index metadata, so changes cannot be committed safely.', - ); - expect(messageApi.warning).not.toHaveBeenCalledWith( - '查询结果保持只读:main.users 无法加载唯一索引元数据,无法安全提交修改。', - ); - }); - - it('localizes the table-locator-metadata-unavailable read-only warning in English while preserving the raw table name', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: false, - data: [], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'none', - readOnly: true, - reason: 'Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.', - }); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith( - 'Query results remain read-only: Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.', - ); - expect(messageApi.warning).not.toHaveBeenCalledWith( - '查询结果保持只读:无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。', - ); - }); - - it('keeps MySQL information_schema routine results read-only without a locator warning', async () => { - const sql = [ - 'SELECT ROUTINE_SCHEMA, ROUTINE_NAME, DEFINER, SECURITY_TYPE', - 'FROM information_schema.ROUTINES', - "WHERE ROUTINE_SCHEMA = 'mkefu_location_dev_local'", - " AND ROUTINE_NAME = 'init_orgi'", - ].join('\n'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ - columns: ['ROUTINE_SCHEMA', 'ROUTINE_NAME', 'DEFINER', 'SECURITY_TYPE'], - rows: [{ - ROUTINE_SCHEMA: 'mkefu_location_dev_local', - ROUTINE_NAME: 'init_orgi', - DEFINER: 'root@%', - SECURITY_TYPE: 'DEFINER', - }], - }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('ROUTINES'); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(backendApp.DBGetColumns).not.toHaveBeenCalled(); - expect(backendApp.DBGetIndexes).not.toHaveBeenCalled(); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 2, column: 8 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.(); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); - expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({ - sql: expect.stringContaining('select 2 as two'), - })); - }); - - it('keeps cursor statement execution available in v2 UI', async () => { - storeState.appearance.uiVersion = 'v2'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 2, column: 8 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.(); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); - }); - - it('renders V2 empty state copy for the active non-Chinese language', async () => { - storeState.appearance.uiVersion = 'v2'; - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - const rendered = textContent(renderer!.toJSON()); - expect(rendered).toContain('Awaiting SQL execution'); - expect(rendered).toContain('Run a query to display results below in the new data grid.'); - expect(rendered).not.toContain('等待执行 SQL'); - expect(rendered).not.toContain('运行查询后,结果会在下方以新版数据网格展示。'); - }); - - it('uses the last editor cursor position when the run button takes focus', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } }); - }); - editorState.hasTextFocus = false; - editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - }); - - it('prefers the last editor cursor event even if Monaco still reports text focus', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['two'], rows: [{ two: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } }); - }); - editorState.hasTextFocus = true; - editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - }); - - it('uses Monaco active selection position when run button focus drifts onto a blank line', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.selection = { - startLineNumber: 2, - startColumn: 'select 2 as b;'.length + 1, - endLineNumber: 2, - endColumn: 'select 2 as b;'.length + 1, - positionLineNumber: 2, - positionColumn: 'select 2 as b;'.length + 1, - }; - editorState.position = { lineNumber: 3, column: 1 }; - - await act(async () => { - const runButton = findButton(renderer!, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); - }); - - it('keeps cursor statement execution when CRLF line endings put the cursor after a semicolon', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; - editorState.selection = { - startLineNumber: 2, - startColumn: 'select 2 as b;'.length + 1, - endLineNumber: 2, - endColumn: 'select 2 as b;'.length + 1, - positionLineNumber: 2, - positionColumn: 'select 2 as b;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); - }); - - it('shows "No executable SQL." in English when the cursor is on a blank line', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - editorState.selection = { - startLineNumber: 1, - startColumn: 'select 1 as a;'.length + 1, - endLineNumber: 1, - endColumn: 'select 1 as a;'.length + 1, - positionLineNumber: 1, - positionColumn: 'select 1 as a;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - backendApp.DBQueryMulti.mockClear(); - messageApi.info.mockClear(); - - editorState.position = { lineNumber: 3, column: 1 }; - editorState.selection = { - startLineNumber: 3, - startColumn: 1, - endLineNumber: 3, - endColumn: 1, - positionLineNumber: 3, - positionColumn: 1, - }; - editorState.cursorPositionListeners.forEach((listener) => { - listener({ position: { lineNumber: 3, column: 1 } }); - }); - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); - expect(messageApi.info).toHaveBeenCalledWith('No executable SQL.'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。'); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); - }); - - it('shows "Select a database first." in English before running without a database', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Select a database first.'); - expect(messageApi.error).not.toHaveBeenCalledWith('请先选择数据库'); - }); - - it('shows "Connection not found." in English before running without a matching connection', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections = []; - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Connection not found.'); - expect(messageApi.error).not.toHaveBeenCalledWith('Connection not found'); - }); - - it('shows the unsupported source guard in English before running', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'redis'; - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith( - 'This data source does not support the SQL query editor. Use its dedicated page instead.', - ); - expect(messageApi.error).not.toHaveBeenCalledWith('当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。'); - }); - - describe('execution toast localization', () => { - it('shows the Mongo multi-statement success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'mongodb'; - const query = 'db.users.find({});\ndb.logs.find({});'; - backendApp.DBQueryWithCancel - .mockResolvedValueOnce({ success: true, data: [{ _id: 1 }], fields: ['_id'] }) - .mockResolvedValueOnce({ success: true, data: [{ _id: 2 }], fields: ['_id'] }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 2, - endColumn: 'db.logs.find({});'.length + 1, - positionLineNumber: 2, - positionColumn: 'db.logs.find({});'.length + 1, - }; - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.success).toHaveBeenCalledWith('Executed 2 statements and produced 2 result sets.'); - expect(messageApi.success).not.toHaveBeenCalledWith('已执行 2 条语句,生成 2 个结果集。'); - }); - - it('shows the Mongo multi-statement failure prefix localization in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'mongodb'; - const query = 'db.users.find({});\ndb.logs.find({});'; - backendApp.DBQueryWithCancel - .mockResolvedValueOnce({ success: true, data: [{ _id: 1 }], fields: ['_id'] }) - .mockResolvedValueOnce({ success: false, message: 'driver exploded' }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 2, - endColumn: 'db.logs.find({});'.length + 1, - positionLineNumber: 2, - positionColumn: 'db.logs.find({});'.length + 1, - }; - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const rendered = textContent(renderer.toJSON()); - expect(rendered).toContain('Statement 2 failed: driver exploded'); - expect(rendered).not.toContain('第 2 条语句执行失败:driver exploded'); - }); - - it('shows the Mongo zero-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - storeState.connections[0].config.type = 'mongodb'; - const query = '{"ping":1}'; - backendApp.DBQueryWithCancel.mockResolvedValueOnce({ success: true, data: { ok: 1 } }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.position = { lineNumber: 1, column: query.length + 1 }; - - await act(async () => { - await findButton(renderer, 'Run').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.'); - expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。'); - }); - - it('shows the non-Mongo multi-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1 as a; select 2 as b;'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['a'], rows: [{ a: 1 }] }, - { columns: ['b'], rows: [{ b: 2 }] }, - ], - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 2 as b'); - expect(messageApi.success).toHaveBeenCalledWith('Execution finished and produced 2 result sets.'); - expect(messageApi.success).not.toHaveBeenCalledWith('已执行完成,生成 2 个结果集。'); - }); - - it('shows the non-Mongo zero-result success toast in English', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'update users set active = 1 where 1 = 0;'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ success: true, data: [] }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('update users set active = 1 where 1 = 0'); - expect(messageApi.success).toHaveBeenCalledWith('Execution succeeded.'); - expect(messageApi.success).not.toHaveBeenCalledWith('执行成功。'); - }); - - it('shows the wrapped execution failure toast in English while preserving raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1;'; - backendApp.DBQueryMulti.mockRejectedValueOnce(new Error('driver exploded')); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryWithCancel).not.toHaveBeenCalled(); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1'); - expect(messageApi.error).toHaveBeenCalledWith('Query execution failed: driver exploded'); - expect(messageApi.error).not.toHaveBeenCalledWith('Error executing query: driver exploded'); - }); - }); - - describe('result refresh toast localization', () => { - const renderAndRunQuery = async (query: string) => { - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - editorState.selection = { - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: query.length + 1, - positionLineNumber: 1, - positionColumn: query.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer, 'Run'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.onReload).toEqual(expect.any(Function)); - }; - - it('shows the response refresh failure toast in English while preserving raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1 as a;'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ success: false, message: 'network down' }); - - await renderAndRunQuery(query); - messageApi.error.mockClear(); - - await act(async () => { - await dataGridState.latestProps.onReload(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Refresh failed: network down'); - expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: network down'); - }); - - it('shows the rejected refresh failure toast in English while preserving raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - const query = 'select 1 as a;'; - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockRejectedValueOnce(new Error('socket closed')); - - await renderAndRunQuery(query); - messageApi.error.mockClear(); - - await act(async () => { - await dataGridState.latestProps.onReload(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Refresh failed: socket closed'); - expect(messageApi.error).not.toHaveBeenCalledWith('刷新失败: socket closed'); - }); - }); - - it('shows "No running query to cancel." in English when stop is clicked before a query id exists', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - backendApp.GenerateQueryID.mockReturnValueOnce(new Promise(() => {})); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - findButton(renderer, 'Run').props.onClick(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer, 'Stop').props.onClick(); - }); - - expect(messageApi.warning).toHaveBeenCalledWith('No running query to cancel.'); - expect(messageApi.warning).not.toHaveBeenCalledWith('没有正在运行的查询可取消'); - }); - - it('shows "Query canceled." in English when stop cancels a running query', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - backendApp.GenerateQueryID.mockResolvedValueOnce('query-1'); - backendApp.DBQueryMulti.mockReturnValueOnce(new Promise(() => {})); - backendApp.CancelQuery.mockResolvedValueOnce({ success: true }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - findButton(renderer, 'Run').props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer, 'Stop').props.onClick(); - }); - - expect(messageApi.success).toHaveBeenCalledWith('Query canceled.'); - expect(messageApi.success).not.toHaveBeenCalledWith('查询已取消'); - }); - - it('shows "Failed to cancel query" in English while preserving the raw error detail', async () => { - storeState.languagePreference = 'en-US'; - setCurrentLanguage('en-US'); - - backendApp.GenerateQueryID.mockResolvedValueOnce('query-1'); - backendApp.DBQueryMulti.mockReturnValueOnce(new Promise(() => {})); - backendApp.CancelQuery.mockRejectedValueOnce(new Error('network down')); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - findButton(renderer, 'Run').props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - await findButton(renderer, 'Stop').props.onClick(); - }); - - expect(messageApi.error).toHaveBeenCalledWith('Failed to cancel query: network down'); - expect(messageApi.error).not.toHaveBeenCalledWith('取消查询失败: network down'); - }); - - it('runs only appended SQL and keeps existing results after a full editor execution', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - editorState.value = 'select 1 as a;\nselect 2 as b;'; - editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-count') && textContent(node) === '1'; - })).toHaveLength(2); - }); - - it('replaces existing result tabs when rerunning the same formatted SQL', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['id'], rows: [{ id: 1 }, { id: 2 }, { id: 3 }] }, - { columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 1 })) }, - ], - }) - .mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['id'], rows: [{ id: 11 }, { id: 12 }, { id: 13 }] }, - { columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 11 })) }, - ], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'SELECT * FROM fs_org_auth_application;'.length + 1 }; - editorState.selection = null; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - - editorState.value = [ - 'SELECT', - ' *', - 'FROM', - ' fs_org_auth_application;', - '', - 'SELECT', - ' *', - 'FROM', - ' fs_bcp_auth_info;', - ].join('\n'); - editorState.position = { lineNumber: 4, column: ' fs_org_auth_application;'.length + 1 }; - editorState.selection = null; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 3'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 4'); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(2); - }); - - it('provides context menu actions for query result tabs', async () => { - backendApp.DBQueryMulti.mockResolvedValue({ - success: true, - data: [ - { columns: ['a'], rows: [{ a: 1 }] }, - { columns: ['b'], rows: [{ b: 2 }] }, - { columns: ['c'], rows: [{ c: 3 }] }, - ], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(3); - - await act(async () => { - renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭右侧')[1].props.onClick(); - }); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(2); - expect(textContent(renderer!.toJSON())).not.toContain('结果 3'); - - await act(async () => { - renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭左侧')[1].props.onClick(); - }); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(1); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })])); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ c: 3 })])); - - await act(async () => { - renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭所有')[0].props.onClick(); - }); - expect(renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - })).toHaveLength(0); - }); - - it('replaces the current result when rerunning the same cursor SQL', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 10 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - editorState.selection = { - startLineNumber: 1, - startColumn: 'select 1 as a;'.length + 1, - endLineNumber: 1, - endColumn: 'select 1 as a;'.length + 1, - positionLineNumber: 1, - positionColumn: 'select 1 as a;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 ')); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 2'); - expect(tabLabels.length).toBeGreaterThan(0); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })])); - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 2 as b'); - }); - - it('appends a result when running a different cursor SQL after an existing result', async () => { - backendApp.DBQueryMulti - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['a'], rows: [{ a: 1 }] }], - }) - .mockResolvedValueOnce({ - success: true, - data: [{ columns: ['b'], rows: [{ b: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 }; - editorState.selection = { - startLineNumber: 1, - startColumn: 'select 1 as a;'.length + 1, - endLineNumber: 1, - endColumn: 'select 1 as a;'.length + 1, - positionLineNumber: 1, - positionColumn: 'select 1 as a;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 }; - editorState.selection = { - startLineNumber: 2, - startColumn: 'select 2 as b;'.length + 1, - endLineNumber: 2, - endColumn: 'select 2 as b;'.length + 1, - positionLineNumber: 2, - positionColumn: 'select 2 as b;'.length + 1, - }; - - await act(async () => { - const runButton = findButton(renderer!, '运行'); - runButton.props.onMouseDown?.({ preventDefault: vi.fn() }); - await runButton.props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); - expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c'); - expect(textContent(renderer!.toJSON())).toContain('结果 1'); - expect(textContent(renderer!.toJSON())).toContain('结果 2'); - expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })])); - expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })])); - }); - - it('renders compact result tab labels with row counts outside the title text', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [ - { columns: ['a'], rows: [{ a: 1 }, { a: 2 }] }, - { columns: ['b'], rows: [{ b: 3 }] }, - ], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const tabLabels = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-label'); - }); - const counts = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-count'); - }); - const titles = renderer!.root.findAll((node) => { - const className = String(node.props?.className || ''); - return className.includes('query-result-tab-text'); - }); - - expect(tabLabels).toHaveLength(2); - expect(titles.map((node) => textContent(node))).toEqual(['结果 1', '结果 2']); - expect(counts.map((node) => textContent(node))).toEqual(['2', '1']); - expect(textContent(renderer!.toJSON())).not.toContain('结果 1 (2)'); - }); - - it('keeps query result tabs compact, centered, and readable in v2 UI', () => { - const source = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - - expect(source).toContain('.query-result-tabs .ant-tabs-tab {'); - expect(source).toContain('width: auto !important;'); - expect(source).toContain('max-width: 148px !important;'); - expect(source).toContain('height: 30px !important;'); - expect(source).toContain('align-items: center !important;'); - expect(source).toContain('font-size: 14px !important;'); - expect(source).toContain('.query-result-tab-text {'); - expect(source).toContain('user-select: none;'); - expect(source).toContain('font-weight: 700;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab-btn {'); - expect(css).toContain('user-select: none;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {'); - }); - - it('keeps the v2 query editor toolbar grouped and compact', () => { - const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); - const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); - const resultsPanelSource = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'); - const transactionSettingsSource = readFileSync(new URL('./QueryEditorTransactionSettings.tsx', import.meta.url), 'utf8'); - const transactionToolbarSource = readFileSync(new URL('./QueryEditorTransactionToolbar.tsx', import.meta.url), 'utf8'); - const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); - - expect(source).toContain('QueryEditorToolbar'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-selects'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-actions'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-connection-select'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-database-select'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-max-rows-select'); - expect(toolbarSource).toContain('QueryEditorTransactionSettings'); - expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-mode-select'); - expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-delay-select'); - expect(transactionSettingsSource).toContain('query_editor.transaction.mode.tooltip'); - expect(transactionSettingsSource).toContain('query_editor.transaction.mode.manual'); - expect(transactionSettingsSource).toContain('query_editor.transaction.mode.auto'); - expect(transactionSettingsSource).not.toContain("label: '手动提交'"); - expect(transactionSettingsSource).not.toContain("label: '自动提交'"); - expect(transactionSettingsSource).toContain('query_editor.transaction.delay.immediate'); - expect(transactionSettingsSource).toContain("label: '3s'"); - expect(source).toContain('QueryEditorTransactionToolbar'); - expect(transactionToolbarSource).toContain("className={isV2Ui ? 'gn-v2-query-transaction-toolbar' : undefined}"); - expect(transactionToolbarSource).toContain(": null;"); - expect(transactionToolbarSource).toContain('gn-v2-query-transaction-commit-button'); - expect(transactionToolbarSource).toContain('gn-v2-toolbar-kbd'); - expect(transactionToolbarSource).toContain('query_editor.transaction.status.auto_committing'); - expect(transactionToolbarSource).toContain('onFinish'); - expect(toolbarSource).toContain('{isV2Ui && pendingTransactionToolbar}'); - expect(toolbarSource).not.toContain('gn-v2-query-toolbar-transaction-row'); - expect(resultsPanelSource).not.toContain('transactionToolbar?: React.ReactNode;'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-action-group'); - expect(toolbarSource).toContain('gn-v2-query-toolbar-action-pair'); - expect(toolbarSource).toContain('const aiMenuItems'); - expect(toolbarSource).toContain('key: "toggle-result-panel"'); - expect(toolbarSource).toContain('{!isV2Ui && ('); - expect(toolbarSource).toContain('trigger={["click"]}'); - expect(toolbarSource.indexOf('onClick={onQuickSave}')).toBeLessThan(toolbarSource.indexOf('menu={{ items: aiMenuItems }}')); - expect(toolbarSource.indexOf('menu={{ items: aiMenuItems }}')).toBeLessThan(toolbarSource.indexOf('menu={{ items: moreMenuItems }}')); - expect(toolbarSource.indexOf('menu={{ items: moreMenuItems }}')).toBeLessThan(toolbarSource.indexOf('icon={}')); - expect(transactionSettingsSource).toContain('style={isV2Ui ? undefined : { width: 78 }}'); - expect(transactionSettingsSource).toContain('style={isV2Ui ? undefined : { width: 68 }}'); - expect(toolbarSource).toContain('style={isV2Ui ? undefined : { width: 200 }}'); - expect(toolbarSource).toContain('style={isV2Ui ? undefined : { width: 170 }}'); - - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-actions'); - expect(css).toContain('width: 74px !important;'); - expect(css).toContain('width: 62px !important;'); - expect(css).toContain('flex: 0 0 auto !important;'); - expect(css).toContain('justify-content: flex-start;'); - expect(css).toContain('height: 32px !important;'); - expect(css).toContain('line-height: 30px !important;'); - expect(css).toContain('display: inline-flex !important;'); - expect(css).toContain('gap: 6px;'); - expect(css).toContain('overflow-x: auto;'); - expect(css).toContain('overflow-y: hidden;'); - expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair'); - expect(css).toContain('gap: 8px;'); - expect(css).toContain('margin-left: 0 !important;'); - expect(css).toContain('max-width: 760px;'); - expect(css).toContain('width: 140px !important;'); - expect(css).toContain('width: 166px !important;'); - expect(css).toContain('width: 132px !important;'); - expect(css).toContain('width: 34px !important;'); - expect(css).toContain('@media (max-width: 900px)'); - expect(css).not.toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-row {'); - - const queryToolbarMainCss = css.slice(css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-main {'), css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects {')); - expect(queryToolbarMainCss).toContain('flex-wrap: nowrap;'); - expect(queryToolbarMainCss).toContain('width: max-content;'); - expect(queryToolbarMainCss).not.toContain('flex-wrap: wrap;'); - expect(queryToolbarMainCss).not.toContain('margin-left: auto;'); - expect(queryToolbarMainCss).not.toContain('justify-content: flex-end;'); - }); - - it('keeps custom SQL snippet syntax help editable and uses it in completion details', () => { - const modalSource = readFileSync(new URL('./SnippetSettingsModal.tsx', import.meta.url), 'utf8'); - const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); - - expect(modalSource).toContain('片段语法说明(可编辑)'); - expect(modalSource).toContain('data-sql-snippet-syntax-help-editor="true"'); - expect(modalSource).toContain("defaultActiveKey={['snippet-help']}"); - expect(modalSource).toContain('footer={null}'); - expect(modalSource).toContain('data-sql-snippet-action-row="true"'); - expect(modalSource).toContain('body: { paddingTop: 8, paddingBottom: 24 }'); - expect(modalSource).toContain("size=\"large\""); - expect(modalSource).toContain('minWidth: 96'); - expect(modalSource).toContain('syntaxHelp'); - expect(modalSource).toContain('占位符语法参考'); - expect(source).toContain('s.syntaxHelp || s.description || s.body'); - }); - - it('coalesces editor result splitter dragging through requestAnimationFrame', async () => { - const moveListeners: Array<(event: MouseEvent) => void> = []; - const upListeners: Array<() => void> = []; - const frameCallbacks: FrameRequestCallback[] = []; - vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => { - if (type === 'mousemove') moveListeners.push(listener); - if (type === 'mouseup') upListeners.push(listener); - }); - vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => { - frameCallbacks.push(callback); - return frameCallbacks.length; - }); - - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度'); - await act(async () => { - resizer.props.onMouseDown({ clientY: 300, preventDefault: vi.fn() }); - moveListeners.forEach((listener) => listener({ clientY: 340 } as MouseEvent)); - moveListeners.forEach((listener) => listener({ clientY: 380 } as MouseEvent)); - }); - - expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); - expect(editorState.editor.layout).not.toHaveBeenCalled(); - - await act(async () => { - frameCallbacks.splice(0).forEach((callback) => callback(16)); - }); - expect(editorState.editor.layout).toHaveBeenCalledTimes(1); - - await act(async () => { - upListeners.forEach((listener) => listener()); - }); - expect(editorState.editor.layout).toHaveBeenCalledTimes(2); - expect(document.removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function)); - expect(document.removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function)); - }); - - it('inserts sidebar object text when dropped into the SQL editor', async () => { - const domListeners: Record void)[]> = {}; - editorState.domNode = { - style: { cursor: '' }, - addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { - domListeners[type] ||= []; - domListeners[type].push(listener); - }), - removeEventListener: vi.fn(), - } as any; - - await act(async () => { - create(); - }); - - editorState.position = { lineNumber: 1, column: 'select * from '.length + 1 }; - - await act(async () => { - domListeners.drop?.forEach((listener) => listener({ - clientX: 10, - clientY: 10, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - getData: (type: string) => { - if (type === 'application/x-gonavi-sql-object') { - return JSON.stringify({ text: 'reporting.active_users' }); - } - if (type === 'text/plain') { - return 'reporting.active_users'; - } - return ''; - }, - }, - })); - }); - - expect(editorState.editor.executeEdits).toHaveBeenCalledWith( - 'gonavi-sidebar-drop', - [expect.objectContaining({ text: 'reporting.active_users' })], - ); - expect(editorState.value).toContain('reporting.active_users'); - }); - - it('prevents Monaco native drag marker and keeps metadata hover after sidebar object drops', async () => { - const domListeners: Record void)[]> = {}; - editorState.domNode = { - style: { cursor: '' }, - addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { - domListeners[type] ||= []; - domListeners[type].push(listener); - }), - removeEventListener: vi.fn(), - } as any; - editorState.editor.getTargetAtClientPoint = vi.fn(() => ({ - position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 }, - })); - editorState.value = 'SELECT * FROM '; - autoFetchState.visible = true; - backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'front_end_sys' }] }); - backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] }); - backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); - - await act(async () => { - create(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const dragOverEvent = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - dropEffect: 'none', - getData: vi.fn(() => ''), - }, - }; - await act(async () => { - domListeners.dragover?.forEach((listener) => listener(dragOverEvent)); - }); - - expect(dragOverEvent.preventDefault).toHaveBeenCalled(); - expect(dragOverEvent.stopPropagation).toHaveBeenCalled(); - expect(dragOverEvent.dataTransfer.dropEffect).toBe('copy'); - expect(dragOverEvent.dataTransfer.getData).not.toHaveBeenCalled(); - - await act(async () => { - domListeners.drop?.forEach((listener) => listener({ - clientX: 10, - clientY: 10, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - getData: (type: string) => { - if (type === 'application/x-gonavi-sql-object') { - return JSON.stringify({ text: 'fs_mkefu_regist_record' }); - } - if (type === 'text/plain') { - return 'fs_mkefu_regist_record'; - } - return ''; - }, - }, - })); - }); - - const hover = editorState.hoverProviders[0]?.provideHover( - editorState.editor.getModel(), - { lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length }, - ); - expect(editorState.value).toContain('fs_mkefu_regist_record'); - expect(hover?.contents?.[0]?.value).toContain('**表** `fs_mkefu_regist_record`'); - - await act(async () => { - editorState.mouseDownListeners[0]?.({ - target: { position: { lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length } }, - event: { - leftButton: true, - ctrlKey: true, - metaKey: false, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }, - }); - }); - - expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' }); - expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - type: 'table', - connectionId: 'conn-1', - dbName: 'front_end_sys', - tableName: 'fs_mkefu_regist_record', - objectType: 'table', - })); - }); - - it('keeps sidebar object navigation tied to the dragged database after drop', async () => { - const domListeners: Record void)[]> = {}; - editorState.domNode = { - style: { cursor: '' }, - addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { - domListeners[type] ||= []; - domListeners[type].push(listener); - }), - removeEventListener: vi.fn(), - } as any; - editorState.editor.getTargetAtClientPoint = vi.fn(() => ({ - position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 }, - })); - editorState.value = 'SELECT * FROM '; - autoFetchState.visible = true; - backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'front_end_sys' }] }); - backendApp.DBGetTables - .mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] }) - .mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] }); - backendApp.DBGetAllColumns - .mockResolvedValueOnce({ success: true, data: [] }) - .mockResolvedValueOnce({ success: true, data: [] }); - - await act(async () => { - create(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - domListeners.drop?.forEach((listener) => listener({ - clientX: 10, - clientY: 10, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - dataTransfer: { - types: ['application/x-gonavi-sql-object', 'text/plain'], - getData: (type: string) => { - if (type === 'application/x-gonavi-sql-object') { - return JSON.stringify({ - text: 'fs_mkefu_regist_record', - nodeType: 'table', - connectionId: 'conn-1', - dbName: 'front_end_sys', - }); - } - if (type === 'text/plain') { - return 'fs_mkefu_regist_record'; - } - return ''; - }, - }, - })); - }); - - expect(editorState.value).toContain('front_end_sys.fs_mkefu_regist_record'); - - await act(async () => { - editorState.mouseDownListeners[0]?.({ - target: { position: { lineNumber: 1, column: 'SELECT * FROM front_end_sys.fs_mkefu_regist_record'.length } }, - event: { - leftButton: true, - ctrlKey: true, - metaKey: false, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }, - }); - }); - - expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' }); - expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - type: 'table', - connectionId: 'conn-1', - dbName: 'front_end_sys', - tableName: 'fs_mkefu_regist_record', - objectType: 'table', - })); - }); - - it('runs selected SQL before cursor SQL', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['selected'], rows: [{ selected: 2 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - editorState.position = { lineNumber: 1, column: 4 }; - editorState.selection = { - startLineNumber: 2, - startColumn: 1, - endLineNumber: 2, - endColumn: 'select 2 as selected'.length + 1, - }; - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as selected'), 'query-1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1'); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3'); - }); - - it('allows editable table columns while leaving expression columns out of commits', async () => { - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ - columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'], - rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }], - }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('users'); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'primary-key', - columns: ['ID'], - valueColumns: ['__gonavi_locator_1_ID'], - hiddenColumns: ['__gonavi_locator_1_ID'], - writableColumns: { - DISPLAY_NAME: 'NAME', - }, - readOnly: false, - }); - expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`'); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it('keeps DuckDB qualified table query results writable when primary key metadata arrives', async () => { - storeState.connections[0].config.type = 'duckdb'; - storeState.connections[0].config.database = 'main'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME', '__gonavi_locator_1_id'], rows: [{ NAME: 'launch', __gonavi_locator_1_id: 7 }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'main.events'); - expect(dataGridState.latestProps?.tableName).toBe('main.events'); - expect(dataGridState.latestProps?.pkColumns).toEqual(['id']); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'primary-key', - columns: ['id'], - valueColumns: ['__gonavi_locator_1_id'], - hiddenColumns: ['__gonavi_locator_1_id'], - writableColumns: { - NAME: 'name', - }, - readOnly: false, - }); - expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"id" AS "__gonavi_locator_1_id"'); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it('uses hidden DuckDB rowid when query results have no primary or unique key', async () => { - storeState.connections[0].config.type = 'duckdb'; - storeState.connections[0].config.database = 'main'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['NAME', '__gonavi_duckdb_rowid__'], rows: [{ NAME: 'launch', __gonavi_duckdb_rowid__: 17 }] }], - }); - backendApp.DBGetColumns.mockResolvedValueOnce({ - success: true, - data: [{ name: 'name', key: '' }], - }); - backendApp.DBGetIndexes.mockResolvedValueOnce({ - success: true, - data: [], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(dataGridState.latestProps?.tableName).toBe('main.events'); - expect(dataGridState.latestProps?.pkColumns).toEqual([]); - expect(dataGridState.latestProps?.editLocator).toMatchObject({ - strategy: 'duckdb-rowid', - columns: ['rowid'], - valueColumns: ['__gonavi_duckdb_rowid__'], - hiddenColumns: ['__gonavi_duckdb_rowid__'], - writableColumns: { - NAME: 'name', - }, - readOnly: false, - }); - expect(dataGridState.latestProps?.readOnly).toBe(false); - expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('rowid AS "__gonavi_duckdb_rowid__"'); - expect(messageApi.warning).not.toHaveBeenCalled(); - }); - - it.each([ - 'mysql', - 'mariadb', - 'oceanbase', - 'diros', - 'sphinx', - 'postgres', - 'kingbase', - 'highgo', - 'vastbase', - 'opengauss', - 'gaussdb', - 'sqlserver', - 'sqlite', - 'duckdb', - 'oracle', - 'dameng', - 'tdengine', - 'clickhouse', - ])( - 'keeps aggregate query results silently read-only for %s', - async (dbType) => { - storeState.connections[0].config.type = dbType; - storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main'; - const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse'; - backendApp.DBQueryMulti.mockResolvedValueOnce({ - success: true, - data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }], - }); - - let renderer: ReactTestRenderer; - await act(async () => { - renderer = create(); - }); - - await act(async () => { - await findButton(renderer!, '运行').props.onClick(); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - const expectedTableName = dbType === 'oracle' || dbType === 'dameng' ? 'USERS' : 'users'; - expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : expectedTableName); - expect(dataGridState.latestProps?.editLocator).toBeUndefined(); - expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(backendApp.DBGetColumns).not.toHaveBeenCalled(); - expect(backendApp.DBGetIndexes).not.toHaveBeenCalled(); - expect(messageApi.warning).not.toHaveBeenCalled(); - }, - ); }); diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx new file mode 100644 index 0000000..b339419 --- /dev/null +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -0,0 +1,2890 @@ +import React from 'react'; +import { readFileSync } from 'node:fs'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readV2ThemeCss } from '../test/readV2ThemeCss'; + +import { setCurrentLanguage } from '../i18n'; +import type { SavedQuery, TabData } from '../types'; +import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; +import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; +import QueryEditor, { + collectQueryEditorObjectDecorationCandidates, + resolveQueryEditorNavigationDecorations, + resolveQueryEditorNavigationTarget, +} from './QueryEditor'; + +const storeState = vi.hoisted(() => ({ + connections: [ + { + id: 'conn-1', + name: 'local', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + password: '', + database: 'main', + }, + }, + ], + addSqlLog: vi.fn(), + addTab: vi.fn(), + setActiveContext: vi.fn(), + updateQueryTabDraft: vi.fn(), + savedQueries: [] as SavedQuery[], + saveQuery: vi.fn(), + theme: 'light', + languagePreference: 'zh-CN' as 'zh-CN' | 'en-US', + appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' }, + sqlFormatOptions: { keywordCase: 'upper' as const }, + setSqlFormatOptions: vi.fn(), + queryOptions: { + maxRows: 5000, + showColumnComment: true, + showColumnType: true, + showQueryResultsPanel: false, + }, + setQueryOptions: vi.fn(), + sqlEditorTransactionOptions: { + commitMode: 'manual' as 'manual' | 'auto', + autoCommitDelayMs: 0, + }, + setSqlEditorTransactionOptions: vi.fn(), + sqlEditorPendingTransactions: {} as Record, + setSqlEditorPendingTransaction: vi.fn(), + shortcutOptions: { + runQuery: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, + selectCurrentStatement: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, + saveQuery: { + mac: { enabled: true, combo: 'Meta+S' }, + windows: { enabled: true, combo: 'Ctrl+S' }, + }, + toggleQueryResultsPanel: { + mac: { enabled: true, combo: 'Meta+Shift+M' }, + windows: { enabled: true, combo: 'Ctrl+Shift+M' }, + }, + }, + activeTabId: 'tab-1', + aiPanelVisible: false, + setAIPanelVisible: vi.fn(), + sqlSnippets: [] as any[], +})); + +const storeSubscribers = vi.hoisted(() => new Set<() => void>()); + +const notifyStoreSubscribers = () => { + storeSubscribers.forEach((subscriber) => subscriber()); +}; + +const backendApp = vi.hoisted(() => ({ + DBQuery: vi.fn(), + DBQueryWithCancel: vi.fn(), + DBQueryMulti: vi.fn(), + DBQueryMultiTransactional: vi.fn(), + DBCommitTransaction: vi.fn(), + DBRollbackTransaction: vi.fn(), + DBGetTables: vi.fn(), + DBGetAllColumns: vi.fn(), + DBGetDatabases: vi.fn(), + DBGetColumns: vi.fn(), + DBGetIndexes: vi.fn(), + CancelQuery: vi.fn(), + GenerateQueryID: vi.fn(), + WriteSQLFile: vi.fn(), + ExportSQLFile: vi.fn(), +})); + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), +})); + +const dataGridState = vi.hoisted(() => ({ + latestProps: null as any, +})); + +const tabsState = vi.hoisted(() => ({ + activeKey: undefined as string | undefined, +})); + +const autoFetchState = vi.hoisted(() => ({ + visible: false, +})); + +const editorState = vi.hoisted(() => { + const state = { + value: '', + editor: null as any, + domNode: { style: { cursor: '' }, addEventListener: vi.fn(), removeEventListener: vi.fn() }, + position: { lineNumber: 1, column: 1 }, + selection: null as any, + providers: [] as any[], + hoverProviders: [] as any[], + contentChangeListeners: [] as Array<() => void>, + cursorPositionListeners: [] as Array<(event: any) => void>, + modelContentListeners: [] as Array<(event: any) => void>, + mouseMoveListeners: [] as Array<(event: any) => void>, + mouseDownListeners: [] as Array<(event: any) => void>, + mouseLeaveListeners: [] as Array<() => void>, + hasTextFocus: true, + decorationIds: [] as string[], + contentHoverCalls: [] as any[], + latestOnChange: null as null | ((value?: string) => void), + }; + const offsetAt = (position: { lineNumber: number; column: number }) => { + const text = state.value; + let offset = 0; + for (let lineNumber = 1; lineNumber < Math.max(1, position.lineNumber); lineNumber++) { + const nextLineBreak = text.indexOf('\n', offset); + if (nextLineBreak === -1) { + return text.length; + } + offset = nextLineBreak + 1; + } + return Math.min(text.length, offset + Math.max(0, position.column - 1)); + }; + const positionAt = (offset: number) => { + const text = state.value.replace(/\r\n/g, '\n'); + const safeOffset = Math.max(0, Math.min(text.length, Number(offset) || 0)); + const prefix = text.slice(0, safeOffset); + const lines = prefix.split('\n'); + return { lineNumber: lines.length, column: (lines[lines.length - 1]?.length || 0) + 1 }; + }; + const valueInRange = (range: any) => { + if (!range) return ''; + const start = offsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }); + const end = offsetAt({ lineNumber: range.endLineNumber, column: range.endColumn }); + return state.value.slice(Math.min(start, end), Math.max(start, end)); + }; + const model = { + getValue: vi.fn(() => state.value), + getValueLength: vi.fn(() => state.value.length), + setValue: (value: string) => { + state.value = value; + }, + getValueInRange: valueInRange, + getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '', + getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length, + getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1, + getWordUntilPosition: (position: { lineNumber: number; column: number }) => { + const lineContent = model.getLineContent(position.lineNumber); + const beforeCursor = lineContent.slice(0, Math.max(0, position.column - 1)); + const word = beforeCursor.match(/[A-Za-z0-9_$]*$/)?.[0] || ''; + return { + startColumn: position.column - word.length, + endColumn: position.column, + word, + }; + }, + getOffsetAt: offsetAt, + getPositionAt: positionAt, + }; + state.editor = { + getValue: vi.fn(() => state.value), + setValue: vi.fn((value: string) => { + state.value = value; + }), + getModel: vi.fn(() => model), + getPosition: vi.fn(() => state.position), + setPosition: vi.fn((position: any) => { + state.position = position; + }), + getSelection: vi.fn(() => state.selection), + getDomNode: vi.fn(() => state.domNode), + getContribution: vi.fn((id: string) => { + if (id === 'editor.contrib.contentHover') { + return { + showContentHover: vi.fn((range: any, mode: any, source: any, focus: any) => { + state.contentHoverCalls.push({ range, mode, source, focus }); + }), + }; + } + return null; + }), + setSelection: vi.fn((selection: any) => { + state.selection = selection; + }), + executeEdits: vi.fn((_source: string, edits: any[]) => { + edits.forEach((edit) => { + const start = offsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn }); + const end = offsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn }); + state.value = state.value.slice(0, start) + edit.text + state.value.slice(end); + }); + }), + addAction: vi.fn(), + onDidChangeModelContent: vi.fn((listener: (event?: any) => void) => { + state.contentChangeListeners.push(listener); + state.modelContentListeners.push(listener); + return { dispose: vi.fn() }; + }), + onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => { + state.cursorPositionListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseMove: vi.fn((listener: (event: any) => void) => { + state.mouseMoveListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseDown: vi.fn((listener: (event: any) => void) => { + state.mouseDownListeners.push(listener); + return { dispose: vi.fn() }; + }), + onMouseLeave: vi.fn((listener: () => void) => { + state.mouseLeaveListeners.push(listener); + return { dispose: vi.fn() }; + }), + deltaDecorations: vi.fn((oldDecorations: string[], newDecorations: any[]) => { + state.decorationIds = newDecorations.map((_: any, index: number) => `decoration-${index + 1}`); + return state.decorationIds; + }), + updateOptions: vi.fn(), + pushUndoStop: vi.fn(), + onDidDispose: vi.fn(), + hasTextFocus: vi.fn(() => state.hasTextFocus), + revealLineInCenterIfOutsideViewport: vi.fn(), + revealRangeInCenterIfOutsideViewport: vi.fn(), + layout: vi.fn(), + focus: vi.fn(), + trigger: vi.fn(), + }; + return state; +}); + +vi.mock('../store', () => { + const useStore = Object.assign( + (selector: (state: typeof storeState) => any) => React.useSyncExternalStore( + (subscriber) => { + storeSubscribers.add(subscriber); + return () => { + storeSubscribers.delete(subscriber); + }; + }, + () => selector(storeState), + () => selector(storeState), + ), + { getState: () => storeState }, + ); + return { useStore }; +}); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('../utils/autoFetchVisibility', () => ({ + useAutoFetchVisibility: () => autoFetchState.visible, +})); + +vi.mock('@monaco-editor/react', () => ({ + default: ({ defaultValue, onChange, onMount }: any) => { + React.useEffect(() => { + editorState.value = String(defaultValue || ''); + editorState.latestOnChange = onChange; + onMount?.(editorState.editor, { + editor: { setTheme: vi.fn() }, + KeyMod: { CtrlCmd: 2048, WinCtrl: 256 }, + KeyCode: { KeyM: 77, KeyQ: 81, KeyS: 83 }, + languages: { + CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, + CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, + registerCompletionItemProvider: vi.fn((_language: string, provider: any) => { + editorState.providers.push(provider); + return { dispose: vi.fn() }; + }), + registerHoverProvider: vi.fn((_language: string, provider: any) => { + editorState.hoverProviders.push(provider); + return { dispose: vi.fn() }; + }), + }, + Range: class { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) { + this.startLineNumber = startLineNumber; + this.startColumn = startColumn; + this.endLineNumber = endLineNumber; + this.endColumn = endColumn; + } + }, + MarkdownString: class { + value: string; + constructor(value: string) { + this.value = value; + } + }, + Position: class { + lineNumber: number; + column: number; + constructor(lineNumber: number, column: number) { + this.lineNumber = lineNumber; + this.column = column; + } + }, + }); + }, []); + return