diff --git a/.gitignore b/.gitignore index 12d734c..902ca17 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ dist/ GoNavi-Wails GoNavi-Wails.exe .ace-tool/ +.superpowers/ .claude/ tmpclaude-* diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index e91f7e7..24b8e5b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -37,6 +37,91 @@ body, #root { padding-right: 8px; } +.redis-viewer-workbench .ant-tree { + background: transparent; +} + +.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner, +.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode { + width: 100% !important; +} + +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper { + min-height: 36px; + border-radius: 14px; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + background: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; + flex: 1 1 auto; + min-width: 0; + width: auto !important; +} + +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover, +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active, +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus, +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible, +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected, +.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + outline: none !important; +} + +.redis-viewer-workbench .ant-tree .ant-tree-treenode { + padding: 2px 0; + width: 100%; + border-radius: 14px; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + border: none; + align-items: center; + position: relative; + z-index: 0; + display: flex !important; + box-sizing: border-box; +} + +.redis-viewer-workbench .ant-tree .ant-tree-switcher { + width: 0 !important; + min-width: 0 !important; + margin-inline-end: 0 !important; + padding: 0 !important; + overflow: hidden !important; + background: transparent !important; +} + +.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover, +.redis-viewer-workbench .ant-tree .ant-tree-switcher:active, +.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus { + background: transparent !important; +} + +.redis-viewer-workbench .redis-tree-expander-button:hover, +.redis-viewer-workbench .redis-tree-expander-button:focus-visible { + background: transparent !important; + outline: none; +} + +.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper { + border-radius: 10px; + margin-inline-end: 6px; +} + +.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child { + margin-inline-end: 0; +} + +.redis-viewer-workbench .ant-table { + background: transparent; +} + +.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th { + font-weight: 700; +} + /* Scrollbar styling for dark mode */ body[data-theme='dark'] ::-webkit-scrollbar { width: 10px; @@ -97,6 +182,16 @@ body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-s color: rgba(255, 236, 179, 0.98) !important; } +body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover { + background: rgba(255, 255, 255, 0.05) !important; +} + +body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected, +body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover { + background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important; + border: 1px solid rgba(246, 196, 83, 0.24) !important; +} + body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner { background-color: #f6c453 !important; border-color: #f6c453 !important; @@ -135,6 +230,41 @@ body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:h background: rgba(246, 196, 83, 0.26) !important; } +body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); + color: rgba(230, 234, 242, 0.9); +} + +body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + background: rgba(246, 196, 83, 0.16); + border-color: rgba(246, 196, 83, 0.3); + color: #f6c453; +} + +body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover { + background: rgba(15, 23, 42, 0.04) !important; +} + +body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected, +body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover { + color: rgba(15, 23, 42, 0.92) !important; + background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important; + border: 1px solid rgba(22, 119, 255, 0.18) !important; +} + +body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper { + background: rgba(255, 255, 255, 0.72); + border-color: rgba(15, 23, 42, 0.08); + color: rgba(51, 65, 85, 0.88); +} + +body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + background: rgba(22, 119, 255, 0.1); + border-color: rgba(22, 119, 255, 0.22); + color: #1677ff; +} + /* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */ .connection-modal-wrap { overflow: hidden !important; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58b3a96..183106a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; +import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime'; @@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel'; import { useStore } from './store'; import { SavedConnection } from './types'; import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; +import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -477,15 +478,7 @@ function App() { justifyContent: 'center', gap: 6, }), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]); - const utilityDropdownShellStyle = useMemo(() => ({ - borderRadius: 14, - padding: 6, - background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)', - backdropFilter: darkMode ? 'blur(16px)' : 'none', - overflow: 'hidden', - }), [darkMode]); + const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); const sidebarQuickActionBaseStyle = useMemo(() => ({ height: Math.max(34, Math.round(36 * effectiveUiScale)), @@ -517,63 +510,56 @@ function App() { color: '#2a1f00', }), [sidebarQuickActionBaseStyle]); - const utilityMenuTheme = useMemo(() => ({ - components: { - Menu: { - popupBg: 'transparent', - darkPopupBg: 'transparent', - itemBg: 'transparent', - darkItemBg: 'transparent', - subMenuItemBg: 'transparent', - itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033', - itemHoverColor: darkMode ? '#fff7d6' : '#0f172a', - itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)', - itemSelectedColor: darkMode ? '#ffd666' : '#1677ff', - itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)', - itemBorderRadius: 10, - itemMarginBlock: 4, - itemMarginInline: 0, - itemPaddingInline: 12, - itemHeight: 40, - groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)', - }, - }, - }), [darkMode]); - const renderUtilityDropdown = (menu: React.ReactNode) => ( - -
- {menu} -
-
- ); const utilityModalShellStyle = useMemo(() => ({ - background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)', - backdropFilter: darkMode ? 'blur(18px)' : 'none', - }), [darkMode]); + background: overlayTheme.shellBg, + border: overlayTheme.shellBorder, + boxShadow: overlayTheme.shellShadow, + backdropFilter: overlayTheme.shellBackdropFilter, + }), [overlayTheme]); const utilityPanelStyle = useMemo(() => ({ padding: 16, borderRadius: 14, - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', - }), [darkMode]); + border: overlayTheme.sectionBorder, + background: overlayTheme.sectionBg, + }), [overlayTheme]); const utilityMutedTextStyle = useMemo(() => ({ - color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', + color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, - }), [darkMode]); + }), [overlayTheme]); const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => (
-
+
{icon}
-
{title}
-
{description}
+
{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 sidebarHorizontalPadding = 10; @@ -975,40 +961,7 @@ function App() { } }; - const toolsMenu: MenuProps['items'] = [ - { - key: 'import', - label: '导入连接配置', - icon: , - onClick: handleImportConnections - }, - { - key: 'export', - label: '导出连接配置', - icon: , - onClick: handleExportConnections - }, - { - key: 'sync', - label: '数据同步', - icon: , - onClick: () => setIsSyncModalOpen(true) - }, - { - key: 'drivers', - label: '驱动管理', - icon: , - onClick: () => setIsDriverModalOpen(true) - }, - { type: 'divider' }, - { - key: 'shortcut-settings', - label: '快捷键管理', - icon: , - onClick: () => setIsShortcutModalOpen(true) - } - ]; - + const [isToolsModalOpen, setIsToolsModalOpen] = useState(false); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme'); const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); @@ -1493,9 +1446,7 @@ function App() {
- - - + @@ -1589,6 +1540,79 @@ function App() { initialValues={editingConnection} onOpenDriverManager={handleOpenDriverManagerFromConnection} /> + , '工具中心', '集中处理连接配置、同步、驱动和快捷键相关操作。')} + open={isToolsModalOpen} + onCancel={() => setIsToolsModalOpen(false)} + footer={null} + width={560} + styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} + > +
+ {[ + { + key: 'import', + icon: , + title: '导入连接配置', + description: '从本地文件恢复连接列表。', + onClick: () => { + setIsToolsModalOpen(false); + void handleImportConnections(); + }, + }, + { + key: 'export', + icon: , + title: '导出连接配置', + description: '导出当前连接与可见配置字段。', + onClick: () => { + setIsToolsModalOpen(false); + void handleExportConnections(); + }, + }, + { + key: 'sync', + icon: , + title: '数据同步', + description: '进入跨源同步工作流。', + onClick: () => { + setIsToolsModalOpen(false); + setIsSyncModalOpen(true); + }, + }, + { + key: 'drivers', + icon: , + title: '驱动管理', + description: '安装、更新或移除数据库驱动。', + onClick: () => { + setIsToolsModalOpen(false); + setIsDriverModalOpen(true); + }, + }, + { + key: 'shortcut-settings', + icon: , + title: '快捷键管理', + description: '查看并调整全局快捷键绑定。', + onClick: () => { + setIsToolsModalOpen(false); + setIsShortcutModalOpen(true); + }, + }, + ].map((item) => ( + + ))} +
+
setIsSyncModalOpen(false)} diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 1f9d9b5..ab3738c 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons'; import { useStore } from '../store'; +import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; @@ -157,6 +158,7 @@ const ConnectionModal: React.FC<{ const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT; const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff'; const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff'; + const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); const tunnelSectionStyle: React.CSSProperties = { padding: '12px', @@ -168,35 +170,33 @@ const ConnectionModal: React.FC<{ const modalShellStyle = useMemo(() => ({ - background: darkMode - ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' - : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)', - backdropFilter: darkMode ? 'blur(18px)' : 'none', - }), [darkMode]); + background: overlayTheme.shellBg, + border: overlayTheme.shellBorder, + boxShadow: overlayTheme.shellShadow, + backdropFilter: overlayTheme.shellBackdropFilter, + }), [overlayTheme]); const modalInnerSectionStyle = useMemo(() => ({ padding: 14, borderRadius: 14, - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', - }), [darkMode]); + border: overlayTheme.sectionBorder, + background: overlayTheme.sectionBg, + }), [overlayTheme]); const modalMutedTextStyle = useMemo(() => ({ - color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', + color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, - }), [darkMode]); + }), [overlayTheme]); const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
-
+
{icon}
-
{title}
-
{description}
+
{title}
+
{description}
); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0a35d9f..a450ba5 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -31,6 +31,7 @@ import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; +import { calculateTableBodyBottomPadding } from './dataGridLayout'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -919,12 +920,14 @@ const DataGrid: React.FC = ({ const toolbarBottomPadding = 6; const filterTopPadding = 2; const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)'; - const floatingScrollbarGap = 6; + const floatingScrollbarGap = 8; + const floatingScrollbarBottomOffset = 0; const floatingScrollbarInset = 10; const floatingScrollbarHeight = 10; - const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.34)' : 'rgba(0,0,0,0.22)'; - const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.32)'; - const floatingScrollbarThumbShadow = darkMode ? '0 4px 12px rgba(0,0,0,0.28)' : '0 4px 10px rgba(0,0,0,0.12)'; + const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)'; + const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)'; + const floatingScrollbarThumbShadow = darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)'; + const verticalScrollbarTrackBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; const horizontalScrollbarTrackBg = 'transparent'; const horizontalScrollbarTrackBorderColor = 'transparent'; const horizontalScrollbarTrackShadow = 'none'; @@ -1012,6 +1015,7 @@ const DataGrid: React.FC = ({ // 批量编辑模式状态 const [cellEditMode, setCellEditMode] = useState(false); const [selectedCells, setSelectedCells] = useState>(new Set()); + const [copiedCellPatch, setCopiedCellPatch] = useState<{ sourceRowKey: string; values: Record } | null>(null); const [batchEditModalOpen, setBatchEditModalOpen] = useState(false); const [batchEditValue, setBatchEditValue] = useState(''); const [batchEditSetNull, setBatchEditSetNull] = useState(false); @@ -1309,19 +1313,33 @@ const DataGrid: React.FC = ({ const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN; const headerHeight = Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42; + const paginationEl = target.querySelector('.data-grid-pagination-wrap') as HTMLElement | null; + const rawPaginationHeight = paginationEl ? paginationEl.getBoundingClientRect().height : 0; + const paginationHeight = + Number.isFinite(rawPaginationHeight) && rawPaginationHeight > 0 ? rawPaginationHeight : 0; const bodyEl = target.querySelector('.ant-table-body') as HTMLElement | null; - const virtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null; - const scrollableEl = virtualHolderEl || bodyEl; + const virtualBodyEl = target.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; + const rcVirtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const virtualScrollbarEl = target.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null; + const scrollableEl = virtualBodyEl || rcVirtualHolderEl || bodyEl; const hasHorizontalOverflow = !!scrollableEl && (scrollableEl.scrollWidth - scrollableEl.clientWidth > 1); - // 外部横向滚动条采用悬浮覆盖,不再通过压缩表格高度制造独立底部空白层; - // 只给 body 增加底部内边距,确保最后一行可以完整滚到胶囊条上方。 - const nextBodyBottomPadding = hasHorizontalOverflow - ? floatingScrollbarHeight + floatingScrollbarGap + 4 - : 0; + // 普通表格可通过 body 底部内边距避开悬浮横向滚动条; + // 但虚拟表格的内部横向滚动轨道会直接覆盖在可视区底部,需要同时从 y 高度里扣掉安全区。 + const nextBodyBottomPadding = calculateTableBodyBottomPadding({ + hasHorizontalOverflow, + floatingScrollbarHeight, + floatingScrollbarGap, + }); setTableBodyBottomPadding(nextBodyBottomPadding); const extraBottom = 2; - const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom)); + const virtualScrollbarViewportReserve = hasHorizontalOverflow && !!virtualScrollbarEl + ? Math.ceil(virtualScrollbarEl.getBoundingClientRect().height || (floatingScrollbarHeight + floatingScrollbarGap + 4)) + : 0; + const nextHeight = Math.max( + 100, + Math.floor(height - headerHeight - paginationHeight - extraBottom - virtualScrollbarViewportReserve) + ); setTableHeight(nextHeight); }, [floatingScrollbarGap, floatingScrollbarHeight]); @@ -1407,6 +1425,7 @@ const DataGrid: React.FC = ({ setModifiedRows({}); setDeletedRowKeys(new Set()); setSelectedRowKeys([]); + setCopiedCellPatch(null); setRowEditorOpen(false); setRowEditorRowKey(''); rowEditorBaseRawRef.current = {}; @@ -1775,6 +1794,163 @@ const DataGrid: React.FC = ({ }; }, [cellEditMode, displayColumnNames, columnIndexMap, updateCellSelection]); + const handleCopySelectedColumnsFromRow = useCallback(() => { + const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells; + if (activeSelection.size === 0) { + void message.info('请先在同一行选中要复制的单元格'); + 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('未识别到可复制的单元格'); + return; + } + + const sourceRowKeySet = new Set(parsed.map((item) => item.rowKey)); + if (sourceRowKeySet.size !== 1) { + void message.info('复制列值时请只选择同一行的单元格'); + return; + } + + const sourceRowKey = parsed[0].rowKey; + const selectedColumnNames = Array.from(new Set(parsed.map((item) => item.colName))); + if (selectedColumnNames.length === 0) { + void message.info('未识别到可复制的列'); + 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(`已复制 ${selectedColumnNames.length} 列,可粘贴到目标行`); + }, [selectedCells, rowKeyStr, addedRows, modifiedRows]); + + const handlePasteCopiedColumnsToSelectedRows = useCallback((fallbackRowKey?: React.Key) => { + if (!copiedCellPatch || Object.keys(copiedCellPatch.values).length === 0) { + void message.info('请先复制列值'); + 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('请先选择目标行'); + return; + } + + targetKeySet.delete(copiedCellPatch.sourceRowKey); + if (targetKeySet.size === 0) { + void message.info('目标行不能仅为源行,请选择其他行'); + 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(copiedCellPatch.values).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('目标行无需更新'); + 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(`已粘贴到 ${patchesByRow.size} 行,共 ${updatedCellCount} 个单元格`); + setCellContextMenu(prev => ({ ...prev, visible: false })); + }, [copiedCellPatch, addedRows, modifiedRows, rowKeyStr]); + // 批量填充到选中行 const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => { const sourceValue = sourceRecord[dataIndex]; @@ -3083,7 +3259,7 @@ const DataGrid: React.FC = ({ // macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。 return Math.max(baseWidth, tableViewportWidth + 2); }, [totalWidth, isMacLike, tableViewportWidth]); - const horizontalScrollVisible = viewMode === 'table' && !enableVirtual && tableScrollX > tableViewportWidth + 1; + const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1; const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX); const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]); const tableComponents = useMemo(() => { @@ -3100,11 +3276,41 @@ const DataGrid: React.FC = ({ }, [enableInlineEditableCell, useContextMenuRow]); const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]); + const resolveVirtualHorizontalElements = useCallback((tableContainer: HTMLElement) => { + const holderEl = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; + const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null; + const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null; + return { holderEl, innerEl, headerEl }; + }, []); + + const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => { + const { innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer); + const marginLeft = innerEl ? Math.abs(parseFloat(innerEl.style.marginLeft) || 0) : 0; + const headerLeft = headerEl ? Math.max(0, headerEl.scrollLeft) : 0; + return Math.max(marginLeft, headerLeft); + }, [resolveVirtualHorizontalElements]); + + const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => { + const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer); + if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) { + return false; + } + + const maxScroll = Math.max(0, tableScrollX - holderEl.clientWidth); + const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset)); + innerEl.style.marginLeft = `${-clampedOffset}px`; + if (headerEl) { + headerEl.scrollLeft = clampedOffset; + } + return true; + }, [resolveVirtualHorizontalElements, tableScrollX]); + const pickHorizontalScrollTargets = useCallback((tableContainer: HTMLElement): HTMLElement[] => { + const virtualBody = tableContainer.querySelector('.ant-table-tbody-virtual-holder'); const body = tableContainer.querySelector('.ant-table-body'); const content = tableContainer.querySelector('.ant-table-content'); const virtualHolder = tableContainer.querySelector('.rc-virtual-list-holder'); - const candidates = [virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement); + const candidates = [virtualBody, virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement); if (candidates.length === 0) { return []; } @@ -3124,6 +3330,19 @@ const DataGrid: React.FC = ({ if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { return; } + const tableContainer = tableContainerRef.current; + if (enableVirtual && tableContainer instanceof HTMLElement) { + const nextScrollLeft = readVirtualHorizontalOffset(tableContainer); + if (Math.abs(lastTableScrollLeftRef.current - nextScrollLeft) < 1 && Math.abs(externalScroll.scrollLeft - nextScrollLeft) < 1) { + return; + } + lastTableScrollLeftRef.current = nextScrollLeft; + if (Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + return; + } const nextTargets = targets && targets.length > 0 ? targets : tableScrollTargetsRef.current; if (!nextTargets || nextTargets.length === 0) { return; @@ -3141,7 +3360,7 @@ const DataGrid: React.FC = ({ externalScroll.scrollLeft = nextScrollLeft; lastExternalScrollLeftRef.current = nextScrollLeft; } - }, []); + }, [enableVirtual, readVirtualHorizontalOffset]); const applyExternalScrollToTableTargets = useCallback(() => { const externalScroll = externalHorizontalScrollRef.current; @@ -3163,6 +3382,14 @@ const DataGrid: React.FC = ({ lastExternalScrollLeftRef.current = externalScroll.scrollLeft; horizontalSyncSourceRef.current = 'external'; + const tableContainer = tableContainerRef.current; + if (enableVirtual && tableContainer instanceof HTMLElement) { + if (applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft)) { + lastTableScrollLeftRef.current = externalScroll.scrollLeft; + } + horizontalSyncSourceRef.current = ''; + return; + } liveTargets.forEach((target) => { if (target.scrollWidth <= target.clientWidth + 1) { return; @@ -3173,9 +3400,9 @@ const DataGrid: React.FC = ({ }); lastTableScrollLeftRef.current = externalScroll.scrollLeft; horizontalSyncSourceRef.current = ''; - }, []); + }, [applyVirtualHorizontalOffset, enableVirtual]); - // 非虚拟模式:外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) + // 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) useEffect(() => { const externalScroll = externalHorizontalScrollRef.current; if (!externalScroll || !horizontalScrollVisible) return; @@ -3200,10 +3427,10 @@ const DataGrid: React.FC = ({ }; }, [horizontalScrollVisible]); - // 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 - // 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body,导致只能在表头/底部滚动条区域滚动。 + // 支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 + // 虚拟表格与普通表格统一走外部横向滚动条,避免内部轨道覆盖最后一行。 useEffect(() => { - if (viewMode !== 'table' || enableVirtual) return; + if (viewMode !== 'table') return; const container = tableContainerRef.current; if (!(container instanceof HTMLElement)) return; @@ -3230,20 +3457,47 @@ const DataGrid: React.FC = ({ if (!isTableDataAreaTarget(event.target)) return; const targets = pickHorizontalScrollTargets(container); - const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; - if (!(activeTarget instanceof HTMLElement)) return; - - const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); - if (maxScrollLeft <= 0) return; - - const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); - if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return; - event.preventDefault(); event.stopPropagation(); horizontalSyncSourceRef.current = 'table'; - activeTarget.scrollLeft = nextScrollLeft; + let nextScrollLeft = 0; + if (enableVirtual) { + const currentOffset = readVirtualHorizontalOffset(container); + const { holderEl } = resolveVirtualHorizontalElements(container); + if (!(holderEl instanceof HTMLElement)) { + horizontalSyncSourceRef.current = ''; + return; + } + const maxScrollLeft = Math.max(0, tableScrollX - holderEl.clientWidth); + if (maxScrollLeft <= 0) { + horizontalSyncSourceRef.current = ''; + return; + } + nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, currentOffset + horizontalDelta)); + if (Math.abs(nextScrollLeft - currentOffset) < 1) { + horizontalSyncSourceRef.current = ''; + return; + } + applyVirtualHorizontalOffset(container, nextScrollLeft); + } else { + const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; + if (!(activeTarget instanceof HTMLElement)) { + horizontalSyncSourceRef.current = ''; + return; + } + const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); + if (maxScrollLeft <= 0) { + horizontalSyncSourceRef.current = ''; + return; + } + nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); + if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) { + horizontalSyncSourceRef.current = ''; + return; + } + activeTarget.scrollLeft = nextScrollLeft; + } lastTableScrollLeftRef.current = nextScrollLeft; const externalScroll = externalHorizontalScrollRef.current; @@ -3258,13 +3512,13 @@ const DataGrid: React.FC = ({ return () => { container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); }; - }, [viewMode, enableVirtual, pickHorizontalScrollTargets]); + }, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, resolveVirtualHorizontalElements, tableScrollX, viewMode]); useEffect(() => { if (viewMode !== 'table') return; const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); return () => cancelAnimationFrame(rafId); - }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); + }, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]); useEffect(() => { if (viewMode !== 'table' || !onScrollSnapshotChange) return; @@ -3356,71 +3610,6 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]); - // 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动 - useEffect(() => { - if (viewMode !== 'table' || !enableVirtual) return; - const container = tableContainerRef.current; - if (!container) return; - - // 滚动条区域高度:滚动条高度 + 间距 + 容错 - const scrollbarZoneHeight = floatingScrollbarHeight + floatingScrollbarGap + 8; - - const handleContainerWheel = (e: WheelEvent) => { - // 判断鼠标是否在底部滚动条区域 - const containerRect = container.getBoundingClientRect(); - if (e.clientY < containerRect.bottom - scrollbarZoneHeight) return; - - // 适配 antd 的虚拟列表类名 - const holderEl = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; - const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null; - - if (!innerEl || !holderEl) return; - - const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - if (Math.abs(dominantDelta) < 0.5) return; - - e.preventDefault(); - e.stopPropagation(); - - // 读取当前 marginLeft(负值表示向右偏移) - const currentMarginLeft = parseFloat(innerEl.style.marginLeft) || 0; - const contentWidth = tableScrollX; - const viewportWidth = holderEl.clientWidth; - const maxScroll = Math.max(0, contentWidth - viewportWidth); - - const currentOffset = Math.abs(currentMarginLeft); - const newOffset = Math.min(maxScroll, Math.max(0, currentOffset + dominantDelta)); - - // 直接更新内容位置 - innerEl.style.marginLeft = `${-newOffset}px`; - - // 同步 scrollbar thumb 位置 - const scrollbarEl = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null; - if (scrollbarEl && maxScroll > 0) { - const thumbEl = scrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null; - if (thumbEl) { - const ratio = newOffset / maxScroll; - const thumbWidth = parseFloat(thumbEl.style.width) || thumbEl.offsetWidth; - const trackWidth = scrollbarEl.clientWidth; - const thumbMaxOffset = trackWidth - thumbWidth; - thumbEl.style.left = `${ratio * thumbMaxOffset}px`; - } - } - - // 同步表头水平位置 - const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null; - if (headerEl) { - headerEl.scrollLeft = newOffset; - } - }; - - container.addEventListener('wheel', handleContainerWheel, { passive: false, capture: true }); - - return () => { - container.removeEventListener('wheel', handleContainerWheel, { capture: true } as EventListenerOptions); - }; - }, [viewMode, enableVirtual, tableScrollX, floatingScrollbarHeight, floatingScrollbarGap]); - useEffect(() => { if (viewMode !== 'table') return; const tableContainer = tableContainerRef.current; @@ -3576,15 +3765,35 @@ const DataGrid: React.FC = ({ {cellEditMode && selectedCells.size > 0 && ( <> + + + )} + {cellEditMode && copiedCellPatch && ( + <> + + + 已复制 {Object.keys(copiedCellPatch.values).length} 列 + )}
@@ -3786,7 +3995,7 @@ const DataGrid: React.FC = ({ )}
-
+
{contextHolder} = ({
@@ -4105,6 +4320,26 @@ const DataGrid: React.FC = ({ 填充到选中行 ({selectedRowKeys.length})
+
{ + if (copiedCellPatch) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'; + }} + onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} + onClick={() => { + if (!copiedCellPatch) return; + const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY]; + handlePasteCopiedColumnsToSelectedRows(fallbackKey); + }} + > + + 粘贴已复制列(同名列) +
)} @@ -4244,7 +4479,7 @@ const DataGrid: React.FC = ({
{pagination && ( -
+
结果集 @@ -4357,6 +4592,16 @@ const DataGrid: React.FC = ({ box-sizing: border-box; scroll-padding-bottom: ${tableBodyBottomPadding}px; } + .${gridId} .ant-table-tbody-virtual-holder, + .${gridId} .rc-virtual-list-holder { + padding-bottom: ${tableBodyBottomPadding}px; + box-sizing: border-box; + scroll-padding-bottom: ${tableBodyBottomPadding}px; + } + .${gridId} .ant-table-tbody-virtual-holder-inner { + padding-bottom: ${tableBodyBottomPadding}px; + box-sizing: border-box; + } .${gridId} .data-grid-table-wrap { width: 100%; max-width: 100%; @@ -4366,22 +4611,7 @@ const DataGrid: React.FC = ({ display: none !important; } .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal { - height: ${floatingScrollbarHeight + 4}px !important; - bottom: ${floatingScrollbarGap}px !important; - left: ${floatingScrollbarInset}px !important; - right: ${floatingScrollbarInset}px !important; - background: transparent !important; - visibility: visible !important; - pointer-events: auto !important; - z-index: 24; - } - .${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal .ant-table-tbody-virtual-scrollbar-thumb { - background: ${horizontalScrollbarThumbBg} !important; - border: 1px solid ${horizontalScrollbarThumbBorderColor} !important; - border-radius: 999px !important; - box-shadow: ${horizontalScrollbarThumbShadow} !important; - height: ${floatingScrollbarHeight}px !important; - margin-top: 2px; + display: none !important; } .${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content { overflow-x: hidden !important; @@ -4390,6 +4620,10 @@ const DataGrid: React.FC = ({ 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; @@ -4399,8 +4633,9 @@ const DataGrid: React.FC = ({ height: 0; } .${gridId} .ant-table-body::-webkit-scrollbar-track { - background: transparent; + background: ${verticalScrollbarTrackBg}; margin: 8px 0; + border-radius: 999px; } .${gridId} .ant-table-body::-webkit-scrollbar-thumb { background: ${floatingScrollbarThumbBg}; @@ -4417,8 +4652,9 @@ const DataGrid: React.FC = ({ height: 0; } .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track { - background: transparent; + background: ${verticalScrollbarTrackBg}; margin: 8px 0; + border-radius: 999px; } .${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb { background: ${floatingScrollbarThumbBg}; @@ -4430,7 +4666,7 @@ const DataGrid: React.FC = ({ position: absolute; left: ${floatingScrollbarInset}px; right: ${floatingScrollbarInset}px; - bottom: ${floatingScrollbarGap}px; + bottom: ${floatingScrollbarBottomOffset}px; height: ${floatingScrollbarHeight + 4}px; overflow-x: auto; overflow-y: hidden; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 46acfe1..afa3e7e 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -247,6 +247,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const currentConnCaps = getDataSourceCapabilities(currentConnConfig); const currentConnType = currentConnCaps.type; const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult; + const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial) => { + const normalizedTabId = String(tabId || '').trim(); + if (!normalizedTabId) return; + viewerFilterSnapshotsByTab.set(normalizedTabId, { + showFilter, + conditions: normalizeViewerFilterConditions(filterConditions), + currentPage: pagination.current, + pageSize: pagination.pageSize, + sortInfo, + scrollTop: scrollSnapshotRef.current.top, + scrollLeft: scrollSnapshotRef.current.left, + ...overrides, + }); + }, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]); useEffect(() => { const snapshot = getViewerFilterSnapshot(tab.id); @@ -258,16 +272,14 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab.id]); useEffect(() => { - viewerFilterSnapshotsByTab.set(tab.id, { - showFilter, - conditions: normalizeViewerFilterConditions(filterConditions), - currentPage: pagination.current, - pageSize: pagination.pageSize, - sortInfo, - scrollTop: scrollSnapshotRef.current.top, - scrollLeft: scrollSnapshotRef.current.left, - }); - }, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]); + persistViewerSnapshot(tab.id); + }, [tab.id, persistViewerSnapshot]); + + useEffect(() => { + return () => { + persistViewerSnapshot(tab.id); + }; + }, [tab.id, persistViewerSnapshot]); useEffect(() => { const snapshot = getViewerFilterSnapshot(tab.id); @@ -298,13 +310,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => { scrollSnapshotRef.current = snapshot; - const cached = getViewerFilterSnapshot(tab.id); - viewerFilterSnapshotsByTab.set(tab.id, { - ...cached, + persistViewerSnapshot(tab.id, { scrollTop: snapshot.top, scrollLeft: snapshot.left, }); - }, [tab.id]); + }, [tab.id, persistViewerSnapshot]); const handleDuckDBManualCount = useCallback(async () => { if (latestDbTypeRef.current !== 'duckdb') { diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index d5ebc0f..9329c33 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -1,16 +1,26 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd'; -import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons'; +import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { RedisKeyInfo, RedisValue, StreamEntry } from '../types'; import Editor from '@monaco-editor/react'; import type { DataNode } from 'antd/es/tree'; -import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { + applyRenamedRedisKeyState, + applyTreeNodeCheck, + buildLeafNodeKey, + buildCheckedTreeNodeState, + buildRedisKeyTree, + isGroupFullyChecked, + parseRawKeyFromNodeKey, + type RedisTreeDataNode, +} from './redisViewerTree'; +import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; const { Search } = Input; -const KEY_GROUP_DELIMITER = ':'; -const EMPTY_SEGMENT_LABEL = '(empty)'; const REDIS_TREE_KEY_TYPE_WIDTH = 92; const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84; const REDIS_TREE_KEY_TTL_WIDTH = 92; @@ -21,6 +31,7 @@ const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600; const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000; const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000; const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200; +const REDIS_KEY_GONE_MESSAGE = 'Redis Key 不存在或已过期'; interface RedisViewerProps { connectionId: string; @@ -234,45 +245,6 @@ const ResizableDivider: React.FC<{ ); }; -// 可拖拽列头组件 - 纯 DOM 操作实现 -type RedisKeyTreeLeaf = { - keyInfo: RedisKeyInfo; - label: string; -}; - -type RedisKeyTreeGroup = { - name: string; - path: string; - children: Map; - leaves: RedisKeyTreeLeaf[]; - leafCount: number; -}; - -type RedisKeyTreeResult = { - treeData: RedisTreeDataNode[]; - groupKeys: string[]; -}; - -type RedisTreeDataNode = DataNode & { - nodeType: 'group' | 'leaf'; - groupName?: string; - groupLeafCount?: number; - leafLabel?: string; - rawKey?: string; - keyType?: string; - ttl?: number; -}; - -const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`; - -const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => { - const keyText = String(nodeKey); - if (!keyText.startsWith('key:')) { - return null; - } - return keyText.slice(4); -}; - const getRedisScanLoadCount = (pattern: string, append: boolean): number => { const normalizedPattern = pattern.trim() || '*'; if (normalizedPattern === '*') { @@ -298,100 +270,8 @@ const normalizeRedisCursor = (value: unknown): string => { return '0'; }; -const normalizeKeySegment = (segment: string): string => { - return segment === '' ? EMPTY_SEGMENT_LABEL : segment; -}; - -const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => { - return { name, path, children: new Map(), leaves: [], leafCount: 0 }; -}; - -const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => { - let count = group.leaves.length; - group.children.forEach((child) => { - count += calculateGroupLeafCount(child); - }); - group.leafCount = count; - return count; -}; - -const buildRedisKeyTree = ( - keys: RedisKeyInfo[], - sortLeafNodes: boolean -): RedisKeyTreeResult => { - const root = createTreeGroup('__root__', '__root__'); - - keys.forEach((keyInfo) => { - const segments = keyInfo.key.split(KEY_GROUP_DELIMITER); - if (segments.length <= 1) { - root.leaves.push({ keyInfo, label: keyInfo.key }); - return; - } - - const groupSegments = segments.slice(0, -1); - const leafLabel = normalizeKeySegment(segments[segments.length - 1]); - let current = root; - const pathParts: string[] = []; - - groupSegments.forEach((segment) => { - const normalized = normalizeKeySegment(segment); - pathParts.push(normalized); - const groupPath = pathParts.join(KEY_GROUP_DELIMITER); - let child = current.children.get(normalized); - if (!child) { - child = createTreeGroup(normalized, groupPath); - current.children.set(normalized, child); - } - current = child; - }); - - current.leaves.push({ keyInfo, label: leafLabel }); - }); - calculateGroupLeafCount(root); - - const groupKeys: string[] = []; - - const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => { - const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name)); - const childLeaves = sortLeafNodes - ? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key)) - : group.leaves; - - const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => { - const groupNodeKey = `group:${child.path}`; - groupKeys.push(groupNodeKey); - return { - key: groupNodeKey, - title: child.name, - nodeType: 'group', - groupName: child.name, - groupLeafCount: child.leafCount, - selectable: false, - disableCheckbox: true, - children: toTreeNodes(child), - }; - }); - - const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => { - return { - key: buildLeafNodeKey(leaf.keyInfo.key), - isLeaf: true, - title: leaf.label, - nodeType: 'leaf', - leafLabel: leaf.label, - rawKey: leaf.keyInfo.key, - keyType: leaf.keyInfo.type, - ttl: leaf.keyInfo.ttl, - }; - }); - - return [...groupNodes, ...leafNodes]; - }; - - return { - treeData: toTreeNodes(root), - groupKeys, - }; +const isRedisKeyGoneErrorMessage = (messageText: string): boolean => { + return messageText.includes(REDIS_KEY_GONE_MESSAGE); }; const RedisViewer: React.FC = ({ connectionId, redisDB }) => { @@ -401,16 +281,14 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const blur = normalizeBlurForPlatform(resolvedAppearance.blur); const connection = connections.find(c => c.id === connectionId); - const keyAccentColor = darkMode ? '#ffd666' : '#1677ff'; + const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]); + const keyAccentColor = workbenchTheme.accent; const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff'; - const valueToolbarBg = darkMode - ? `rgba(38, 38, 38, ${opacity})` - : `rgba(245, 245, 245, ${opacity})`; - const valueToolbarBorder = darkMode - ? `1px solid rgba(255, 255, 255, ${Math.max(0.12, Math.min(0.24, opacity * 0.22))})` - : `1px solid rgba(0, 0, 0, ${Math.max(0.08, Math.min(0.2, opacity * 0.12))})`; - const valueToolbarText = darkMode ? 'rgba(255, 255, 255, 0.78)' : '#666'; + const valueToolbarBg = workbenchTheme.panelBgStrong; + const valueToolbarBorder = workbenchTheme.panelBorder; + const valueToolbarText = workbenchTheme.textMuted; const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); @@ -423,10 +301,14 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [editModalOpen, setEditModalOpen] = useState(false); const [newKeyModalOpen, setNewKeyModalOpen] = useState(false); const [newKeyForm] = Form.useForm(); + const [renameKeyModalOpen, setRenameKeyModalOpen] = useState(false); + const [renameKeyForm] = Form.useForm(); + const [renameTargetKey, setRenameTargetKey] = useState(null); const [ttlModalOpen, setTtlModalOpen] = useState(false); const [ttlForm] = Form.useForm(); const [selectedKeys, setSelectedKeys] = useState([]); const [editValue, setEditValue] = useState(''); + const [treeContextMenu, setTreeContextMenu] = useState<{ x: number; y: number; rawKey: string } | null>(null); // 视图模式状态(用于所有数据类型) const [viewMode, setViewMode] = useState<'auto' | 'text' | 'utf8' | 'hex'>('auto'); @@ -450,6 +332,75 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [treeHeight, setTreeHeight] = useState(500); const [expandedGroupKeys, setExpandedGroupKeys] = useState([]); + const workbenchCardStyle = useMemo(() => ({ + background: workbenchTheme.panelBg, + border: workbenchTheme.panelBorder, + boxShadow: `${workbenchTheme.panelInset}, ${workbenchTheme.shadow}`, + borderRadius: 18, + backdropFilter: workbenchTheme.backdropFilter, + WebkitBackdropFilter: workbenchTheme.backdropFilter, + }), [workbenchTheme]); + + const workbenchSubCardStyle = useMemo(() => ({ + background: workbenchTheme.panelBgStrong, + border: workbenchTheme.panelBorder, + boxShadow: workbenchTheme.panelInset, + borderRadius: 16, + backdropFilter: workbenchTheme.backdropFilter, + WebkitBackdropFilter: workbenchTheme.backdropFilter, + }), [workbenchTheme]); + + const actionButtonStyle = useMemo(() => ({ + height: 36, + borderRadius: 12, + background: workbenchTheme.actionSecondaryBg, + borderColor: workbenchTheme.actionSecondaryBorder, + color: workbenchTheme.textPrimary, + fontWeight: 600, + boxShadow: 'none', + }), [workbenchTheme]); + + const primaryActionButtonStyle = useMemo(() => ({ + ...actionButtonStyle, + background: workbenchTheme.toolbarPrimaryBg, + borderColor: workbenchTheme.accentBorder, + color: workbenchTheme.accent, + }), [actionButtonStyle, workbenchTheme]); + + const dangerActionButtonStyle = useMemo(() => ({ + ...actionButtonStyle, + background: workbenchTheme.actionDangerBg, + borderColor: workbenchTheme.actionDangerBorder, + color: workbenchTheme.actionDangerText, + }), [actionButtonStyle, workbenchTheme]); + + const pillTagStyle = useMemo(() => ({ + margin: 0, + borderRadius: 999, + borderColor: workbenchTheme.statusTagBorder, + background: workbenchTheme.statusTagBg, + color: workbenchTheme.isDark ? '#9bc2ff' : '#165dca', + fontWeight: 600, + paddingInline: 10, + }), [workbenchTheme]); + + const mutedPillTagStyle = useMemo(() => ({ + margin: 0, + borderRadius: 999, + borderColor: workbenchTheme.statusTagMutedBorder, + background: workbenchTheme.statusTagMutedBg, + color: workbenchTheme.textSecondary, + fontWeight: 500, + paddingInline: 10, + }), [workbenchTheme]); + const redisModalContentStyle = useMemo(() => ({ + background: workbenchTheme.panelBgStrong, + border: workbenchTheme.panelBorder, + boxShadow: `${workbenchTheme.panelInset}, ${workbenchTheme.shadow}`, + backdropFilter: workbenchTheme.backdropFilter, + WebkitBackdropFilter: workbenchTheme.backdropFilter, + }), [workbenchTheme]); + const getConfig = useCallback(() => { if (!connection) return null; return { @@ -536,6 +487,21 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); }; + const handleSelectAllLoadedKeys = useCallback(() => { + setSelectedKeys(keys.map((item) => item.key)); + }, [keys]); + + const handleClearAllSelectedKeys = useCallback(() => { + setSelectedKeys([]); + }, []); + + const removeMissingKeyFromView = useCallback((missingKey: string) => { + setKeys(prev => prev.filter(item => item.key !== missingKey)); + setSelectedKeys(prev => prev.filter(item => item !== missingKey)); + setSelectedKey(null); + setKeyValue(null); + }, []); + const loadKeyValue = async (key: string) => { const config = getConfig(); if (!config) return; @@ -547,10 +513,22 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setKeyValue(res.data); setSelectedKey(key); } else { - message.error('获取值失败: ' + res.message); + const messageText = String(res.message || ''); + if (isRedisKeyGoneErrorMessage(messageText)) { + removeMissingKeyFromView(key); + message.warning('Key 已不存在或已过期,已从列表移除'); + } else { + message.error('获取值失败: ' + messageText); + } } } catch (e: any) { - message.error('获取值失败: ' + (e?.message || String(e))); + const messageText = e?.message || String(e); + if (isRedisKeyGoneErrorMessage(messageText)) { + removeMissingKeyFromView(key); + message.warning('Key 已不存在或已过期,已从列表移除'); + } else { + message.error('获取值失败: ' + messageText); + } } finally { setValueLoading(false); } @@ -641,6 +619,69 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } }; + const openRenameKeyModal = useCallback((rawKey: string) => { + setTreeContextMenu(null); + setRenameTargetKey(rawKey); + renameKeyForm.setFieldsValue({ key: rawKey }); + setRenameKeyModalOpen(true); + }, [renameKeyForm]); + + const handleRenameKey = async () => { + const config = getConfig(); + if (!config || !renameTargetKey) return; + + try { + const values = await renameKeyForm.validateFields(); + const nextKey = String(values.key || '').trim(); + if (!nextKey) { + message.warning('请输入新的 Key 名称'); + return; + } + if (nextKey === renameTargetKey) { + message.warning('新的 Key 名称不能与原值相同'); + return; + } + + const existsRes = await (window as any).go.app.App.RedisKeyExists(config, nextKey); + if (!existsRes?.success) { + message.error('校验目标 Key 失败: ' + (existsRes?.message || '未知错误')); + return; + } + if (existsRes?.data?.exists) { + message.error(`目标 Key 已存在: ${nextKey}`); + return; + } + + const res = await (window as any).go.app.App.RedisRenameKey(config, renameTargetKey, nextKey); + if (res.success) { + const nextState = applyRenamedRedisKeyState( + { + keys, + selectedKey, + selectedKeys, + }, + renameTargetKey, + nextKey + ); + setKeys(nextState.keys); + setSelectedKey(nextState.selectedKey); + setSelectedKeys(Array.from(new Set(nextState.selectedKeys))); + setRenameKeyModalOpen(false); + setRenameTargetKey(null); + renameKeyForm.resetFields(); + message.success('Key 重命名成功'); + if (selectedKey === renameTargetKey) { + void loadKeyValue(nextKey); + } + handleRefresh(); + } else { + message.error('重命名失败: ' + res.message); + } + } catch (e: any) { + message.error('重命名失败: ' + (e?.message || String(e))); + } + }; + const getTypeColor = (type: string) => { switch (type) { case 'string': return 'green'; @@ -732,8 +773,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }, [selectedKey]); const checkedTreeNodeKeys = useMemo(() => { - return selectedKeys.map(rawKey => buildLeafNodeKey(rawKey)); - }, [selectedKeys]); + return buildCheckedTreeNodeState(selectedKeys, keyTree); + }, [keyTree, selectedKeys]); useEffect(() => { const existingKeySet = new Set(keys.map(item => item.key)); @@ -750,6 +791,21 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }); }, [groupKeySet, isLargeKeyspace]); + useEffect(() => { + if (!treeContextMenu) { + return; + } + const handleDismiss = () => setTreeContextMenu(null); + window.addEventListener('click', handleDismiss); + window.addEventListener('scroll', handleDismiss, true); + window.addEventListener('contextmenu', handleDismiss); + return () => { + window.removeEventListener('click', handleDismiss); + window.removeEventListener('scroll', handleDismiss, true); + window.removeEventListener('contextmenu', handleDismiss); + }; + }, [treeContextMenu]); + const handleTreeSelect = (nodeKeys: React.Key[]) => { if (nodeKeys.length === 0) { return; @@ -761,24 +817,127 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { loadKeyValue(rawKey); }; - const handleTreeCheck = (checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }) => { - const checkedNodeKeys = Array.isArray(checked) ? checked : checked.checked; - const rawKeys = checkedNodeKeys - .map(nodeKey => parseRawKeyFromNodeKey(nodeKey)) - .filter((rawKey): rawKey is string => Boolean(rawKey)); - setSelectedKeys(rawKeys); + const handleTreeCheck = ( + _checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }, + info: { checked: boolean; node: DataNode } + ) => { + const node = info.node as RedisTreeDataNode; + setSelectedKeys((prev) => applyTreeNodeCheck(prev, node, info.checked)); + }; + + const handleTreeRightClick = ({ event, node }: { event: React.MouseEvent; node: DataNode }) => { + event.preventDefault(); + event.stopPropagation(); + const treeNode = node as RedisTreeDataNode; + if (treeNode.nodeType !== 'leaf' || !treeNode.rawKey) { + setTreeContextMenu(null); + return; + } + + setTreeContextMenu({ + x: event.clientX, + y: event.clientY, + rawKey: treeNode.rawKey, + }); + }; + + const handleSelectGroupDescendants = useCallback((treeNode: RedisTreeDataNode) => { + setSelectedKeys((prev) => applyTreeNodeCheck(prev, treeNode, !isGroupFullyChecked(treeNode, prev))); + }, []); + + const handleToggleGroupExpand = useCallback((groupNodeKey: string) => { + setExpandedGroupKeys((prev) => { + const exists = prev.includes(groupNodeKey); + const nextKeys = exists + ? prev.filter((nodeKey) => nodeKey !== groupNodeKey) + : [...prev, groupNodeKey]; + + if (isLargeKeyspace) { + return nextKeys.slice(-REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS); + } + + return nextKeys; + }); + }, [isLargeKeyspace]); + + const stopTreeTitleEvent = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); }; const renderTreeNodeTitle = useCallback((nodeData: DataNode) => { const treeNode = nodeData as RedisTreeDataNode; if (treeNode.nodeType === 'group') { + const groupFullyChecked = isGroupFullyChecked(treeNode, selectedKeys); + const groupNodeKey = String(treeNode.key ?? ''); + const isExpanded = expandedGroupKeys.includes(groupNodeKey); return ( - - - {treeNode.groupName} - ({treeNode.groupLeafCount ?? 0}) - +
+ + + + + {treeNode.groupName} + + ({treeNode.groupLeafCount ?? 0}) + + +
); } @@ -789,11 +948,11 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { if (isLargeKeyspace) { return ( -
+
{leafLabel} - [{keyType}] + [{keyType}] {showTreeKeyTTL && ( - {formatTTL(ttl)} + {formatTTL(ttl)} )}
); @@ -841,7 +1000,9 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { marginInlineEnd: 0, width: showTreeKeyTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW, textAlign: 'center', - flexShrink: 0 + flexShrink: 0, + borderRadius: 999, + fontWeight: 600, }} > {keyType} @@ -851,7 +1012,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { style={{ width: REDIS_TREE_KEY_TTL_WIDTH, fontSize: 12, - color: '#999', + color: workbenchTheme.textMuted, textAlign: 'left', whiteSpace: 'nowrap', flexShrink: 0, @@ -864,7 +1025,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { )}
); - }, [formatTTL, getTypeColor, isLargeKeyspace, showTreeKeyTTL]); + }, [expandedGroupKeys, formatTTL, getTypeColor, handleSelectGroupDescendants, handleToggleGroupExpand, isLargeKeyspace, keyAccentColor, selectedKeys, showTreeKeyTTL, workbenchTheme]); const handleTreeExpand = (nextExpandedKeys: React.Key[]) => { const validGroupKeys = nextExpandedKeys @@ -879,7 +1040,22 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const renderValueEditor = () => { if (!keyValue || !selectedKey) { - return
选择一个 Key 查看详情
; + return ( +
+ 选择一个 Key 查看详情 +
+ ); } const renderStringValue = () => { @@ -919,18 +1095,11 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { background: valueToolbarBg, borderBottom: valueToolbarBorder, display: 'flex', - justifyContent: 'space-between', alignItems: 'center' }}> {encoding && `编码: ${encoding}`} - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => {
- - - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { }; return ( -
-
-
- - {selectedKey} - - -
- - - + - + - +
-
- {keyValue.type === 'string' && renderStringValue()} - {keyValue.type === 'hash' && renderHashValue()} - {keyValue.type === 'list' && renderListValue()} - {keyValue.type === 'set' && renderSetValue()} - {keyValue.type === 'zset' && renderZSetValue()} - {keyValue.type === 'stream' && renderStreamValue()} +
+ 查看模式 + setViewMode(e.target.value)}> + 自动 + 原始文本 + UTF-8 + 十六进制 + +
+
+
+ {keyValue.type === 'string' && renderStringValue()} + {keyValue.type === 'hash' && renderHashValue()} + {keyValue.type === 'list' && renderListValue()} + {keyValue.type === 'set' && renderSetValue()} + {keyValue.type === 'zset' && renderZSetValue()} + {keyValue.type === 'stream' && renderStreamValue()} +
); @@ -1892,10 +2049,17 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } return ( -
+
{/* Left: Key List */} -
-
+
+
+
+
+
Key Explorer
+
db{redisDB}
+
+ {keys.length} Keys +
= ({ connectionId, redisDB }) => { enterButton={} /> -
- - - +
+ + + + + handleDeleteKeys(selectedKeys)} disabled={selectedKeys.length === 0} > -
-
+
{isLargeKeyspace && ( -
+
已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组)
)} -
+
+ 命名空间 / Key + 类型 / TTL +
+
null} checkable checkStrictly selectable @@ -1943,14 +2114,15 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { expandedKeys={expandedGroupKeys} onExpand={handleTreeExpand} onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)} - onCheck={(checked) => handleTreeCheck(checked)} + onCheck={(checked, info) => handleTreeCheck(checked, info)} + onRightClick={handleTreeRightClick} style={{ padding: '8px 6px' }} />
{hasMore && ( -
- +
+
)}
@@ -1962,7 +2134,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {/* Right: Value Viewer */}
{valueLoading ? ( -
加载中...
+
加载中...
) : ( renderValueEditor() )} @@ -1975,7 +2147,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { onOk={handleSaveString} onCancel={() => setEditModalOpen(false)} width={800} - styles={{ body: { height: 500 } }} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > = ({ connectionId, redisDB }) => { open={newKeyModalOpen} onOk={handleCreateKey} onCancel={() => setNewKeyModalOpen(false)} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > @@ -2015,11 +2188,35 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {/* TTL Modal */} + { + setRenameKeyModalOpen(false); + setRenameTargetKey(null); + renameKeyForm.resetFields(); + }} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} + > + + + + + + + setTtlModalOpen(false)} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} >
@@ -2040,7 +2237,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }} onCancel={() => setJsonEditModalOpen(false)} width={800} - styles={{ body: { height: 500 } }} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > = ({ connectionId, redisDB }) => { }} />
+ {treeContextMenu && typeof document !== 'undefined' && createPortal(( +
event.stopPropagation()} + > + + +
+ ), document.body)}
); }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9fc732b..99caff9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -32,7 +32,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, CheckOutlined, FilterOutlined } from '@ant-design/icons'; - import { useStore } from '../store'; +import { useStore } from '../store'; +import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection } from '../types'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -121,41 +122,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return `rgba(${r}, ${g}, ${b}, ${opacity})`; }; const bgMain = getBg('#141414'); + const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); const modalPanelStyle = useMemo(() => ({ - background: darkMode - ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' - : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)', - backdropFilter: darkMode ? 'blur(18px)' : 'none', - }), [darkMode]); + background: overlayTheme.shellBg, + border: overlayTheme.shellBorder, + boxShadow: overlayTheme.shellShadow, + backdropFilter: overlayTheme.shellBackdropFilter, + }), [overlayTheme]); const modalSectionStyle = useMemo(() => ({ padding: 14, borderRadius: 14, - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', - }), [darkMode]); + border: overlayTheme.sectionBorder, + background: overlayTheme.sectionBg, + }), [overlayTheme]); const modalScrollSectionStyle = useMemo(() => ({ maxHeight: 400, overflow: 'auto' as const, - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + border: overlayTheme.sectionBorder, borderRadius: 14, padding: 12, - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)', - }), [darkMode]); + background: overlayTheme.sectionBg, + }), [overlayTheme]); const modalHintTextStyle = useMemo(() => ({ - color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', + color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, - }), [darkMode]); + }), [overlayTheme]); const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
-
+
{icon}
-
{title}
-
{description}
+
{title}
+
{description}
); @@ -2535,12 +2535,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const searchScopePopoverContent = useMemo(() => { const smartSelected = searchScopes.includes('smart'); const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart'); - const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)'; - const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)'; - const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033'; - const panelBg = darkMode - ? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)' - : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)'; + const borderColor = overlayTheme.sectionBorder.replace('1px solid ', ''); + const mutedTextColor = overlayTheme.mutedText; + const titleColor = overlayTheme.titleText; + const panelBg = overlayTheme.shellBg; const smartBg = smartSelected ? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)') : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)'); @@ -2591,7 +2589,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
-
+
手动范围
@@ -2628,7 +2626,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
); - }, [darkMode, searchScopes]); + }, [darkMode, overlayTheme, searchScopes]); const parseHostOnlyToken = (value: unknown): string[] => { const raw = String(value || '').trim(); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 555bf15..dd60446 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -144,12 +144,8 @@ const TabManager: React.FC = () => { const items = useMemo(() => tabs.map((tab, index) => { const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name; const displayTitle = buildTabDisplayTitle(tab, connectionName); - const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command'; - const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive; let content; - if (!shouldRenderContent) { - content = null; - } else if (tab.type === 'query') { + if (tab.type === 'query') { content = ; } else if (tab.type === 'table') { content = ; @@ -203,7 +199,7 @@ const TabManager: React.FC = () => { key: tab.id, children: content, }; - }), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); + }), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); return ( <> @@ -297,6 +293,7 @@ const TabManager: React.FC = () => { { if (Date.now() < suppressClickUntilRef.current) return; onChange(newActiveKey); diff --git a/frontend/src/components/dataGridLayout.test.ts b/frontend/src/components/dataGridLayout.test.ts new file mode 100644 index 0000000..2752dd0 --- /dev/null +++ b/frontend/src/components/dataGridLayout.test.ts @@ -0,0 +1,35 @@ +import { strict as assert } from 'node:assert'; + +import { calculateTableBodyBottomPadding } from './dataGridLayout'; + +assert.equal( + calculateTableBodyBottomPadding({ + hasHorizontalOverflow: false, + floatingScrollbarHeight: 10, + floatingScrollbarGap: 6, + }), + 0, + '无横向滚动条时不应增加底部间距' +); + +assert.equal( + calculateTableBodyBottomPadding({ + hasHorizontalOverflow: true, + floatingScrollbarHeight: 10, + floatingScrollbarGap: 6, + }), + 28, + '默认悬浮滚动条应预留滚动条高度、间距和额外安全区' +); + +assert.equal( + calculateTableBodyBottomPadding({ + hasHorizontalOverflow: true, + floatingScrollbarHeight: 14, + floatingScrollbarGap: 4, + }), + 30, + '较粗滚动条场景下应同步放大底部安全区' +); + +console.log('dataGridLayout tests passed'); diff --git a/frontend/src/components/dataGridLayout.ts b/frontend/src/components/dataGridLayout.ts new file mode 100644 index 0000000..d88cfbf --- /dev/null +++ b/frontend/src/components/dataGridLayout.ts @@ -0,0 +1,23 @@ +export interface TableBodyBottomPaddingOptions { + hasHorizontalOverflow: boolean; + floatingScrollbarHeight: number; + floatingScrollbarGap: number; +} + +const MIN_SCROLLBAR_CLEARANCE = 8; +const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4; + +export const calculateTableBodyBottomPadding = ({ + hasHorizontalOverflow, + floatingScrollbarHeight, + floatingScrollbarGap, +}: TableBodyBottomPaddingOptions): number => { + if (!hasHorizontalOverflow) { + return 0; + } + + const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight)); + const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap)); + + return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE; +}; diff --git a/frontend/src/components/redisViewerTree.test.ts b/frontend/src/components/redisViewerTree.test.ts new file mode 100644 index 0000000..0db9242 --- /dev/null +++ b/frontend/src/components/redisViewerTree.test.ts @@ -0,0 +1,105 @@ +import type { RedisKeyInfo } from '../types'; +import { + applyRenamedRedisKeyState, + applyTreeNodeCheck, + buildCheckedTreeNodeState, + buildRedisKeyTree, + isGroupFullyChecked, +} from './redisViewerTree'; + +const assert = (condition: unknown, message: string) => { + if (!condition) { + throw new Error(message); + } +}; + +const assertEqual = (actual: unknown, expected: unknown, message: string) => { + const actualText = JSON.stringify(actual); + const expectedText = JSON.stringify(expected); + if (actualText !== expectedText) { + throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`); + } +}; + +const sampleKeys: RedisKeyInfo[] = [ + { key: 'app:user:1', type: 'string', ttl: -1 }, + { key: 'app:user:2', type: 'string', ttl: -1 }, + { key: 'app:order:1', type: 'hash', ttl: 120 }, + { key: 'misc', type: 'set', ttl: -1 }, +]; + +const tree = buildRedisKeyTree(sampleKeys, true); +const appGroup = tree.treeData.find((node) => node.key === 'group:app'); +const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user'); + +assert(appGroup, '应生成 group:app 节点'); +assert(userGroup, '应生成 group:app:user 节点'); +assertEqual( + appGroup?.descendantRawKeys, + ['app:order:1', 'app:user:1', 'app:user:2'], + 'app 分组应收集全部后代 key' +); + +const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true); +assertEqual( + selectedAfterGroupCheck, + ['app:order:1', 'app:user:1', 'app:user:2'], + '勾选分组应递归选中全部后代 key' +); + +const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree); +assertEqual( + checkedState.checked, + ['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'], + '全部后代已选中时,父分组和叶子都应进入 checked' +); +assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked'); +assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked'); + +const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false); +assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key'); +assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked'); + +const partialState = buildCheckedTreeNodeState(['app:user:1'], tree); +assertEqual( + partialState.halfChecked, + ['group:app:user', 'group:app'], + '仅部分后代选中时,相关分组应进入 halfChecked' +); +assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked'); + +const renamedState = applyRenamedRedisKeyState( + { + keys: sampleKeys, + selectedKey: 'app:user:2', + selectedKeys: ['app:user:1', 'app:user:2', 'misc'], + }, + 'app:user:2', + 'app:user:200' +); + +assertEqual( + renamedState.keys.map((item) => item.key), + ['app:user:1', 'app:user:200', 'app:order:1', 'misc'], + '重命名后 keys 列表应替换旧 key' +); +assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key'); +assertEqual( + renamedState.selectedKeys, + ['app:user:1', 'app:user:200', 'misc'], + '批量选中集合中的旧 key 应映射为新 key' +); + +const unrelatedRenameState = applyRenamedRedisKeyState( + { + keys: sampleKeys, + selectedKey: 'misc', + selectedKeys: ['app:user:1'], + }, + 'app:order:1', + 'app:order:9' +); +assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey'); +assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合'); + +console.log('redisViewerTree tests passed'); diff --git a/frontend/src/components/redisViewerTree.ts b/frontend/src/components/redisViewerTree.ts new file mode 100644 index 0000000..07fb325 --- /dev/null +++ b/frontend/src/components/redisViewerTree.ts @@ -0,0 +1,260 @@ +import type { DataNode } from 'antd/es/tree'; +import type { RedisKeyInfo } from '../types'; + +const KEY_GROUP_DELIMITER = ':'; +const EMPTY_SEGMENT_LABEL = '(empty)'; + +type RedisKeyTreeLeaf = { + keyInfo: RedisKeyInfo; + label: string; +}; + +type RedisKeyTreeGroup = { + name: string; + path: string; + children: Map; + leaves: RedisKeyTreeLeaf[]; + leafCount: number; +}; + +export type RedisTreeDataNode = DataNode & { + nodeType: 'group' | 'leaf'; + groupName?: string; + groupLeafCount?: number; + leafLabel?: string; + rawKey?: string; + keyType?: string; + ttl?: number; + descendantRawKeys?: string[]; +}; + +export type RedisKeyTreeResult = { + treeData: RedisTreeDataNode[]; + groupKeys: string[]; +}; + +export type RedisTreeCheckedState = { + checked: string[]; + halfChecked: string[]; +}; + +export type RenamedRedisKeyStateInput = { + keys: RedisKeyInfo[]; + selectedKey: string | null; + selectedKeys: string[]; +}; + +export type RenamedRedisKeyStateResult = { + keys: RedisKeyInfo[]; + selectedKey: string | null; + selectedKeys: string[]; +}; + +const normalizeKeySegment = (segment: string): string => { + return segment === '' ? EMPTY_SEGMENT_LABEL : segment; +}; + +const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => { + return { name, path, children: new Map(), leaves: [], leafCount: 0 }; +}; + +const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => { + let count = group.leaves.length; + group.children.forEach((child) => { + count += calculateGroupLeafCount(child); + }); + group.leafCount = count; + return count; +}; + +export const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`; + +export const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => { + const keyText = String(nodeKey); + if (!keyText.startsWith('key:')) { + return null; + } + return keyText.slice(4); +}; + +export const buildRedisKeyTree = ( + keys: RedisKeyInfo[], + sortLeafNodes: boolean +): RedisKeyTreeResult => { + const root = createTreeGroup('__root__', '__root__'); + + keys.forEach((keyInfo) => { + const segments = keyInfo.key.split(KEY_GROUP_DELIMITER); + if (segments.length <= 1) { + root.leaves.push({ keyInfo, label: keyInfo.key }); + return; + } + + const groupSegments = segments.slice(0, -1); + const leafLabel = normalizeKeySegment(segments[segments.length - 1]); + let current = root; + const pathParts: string[] = []; + + groupSegments.forEach((segment) => { + const normalized = normalizeKeySegment(segment); + pathParts.push(normalized); + const groupPath = pathParts.join(KEY_GROUP_DELIMITER); + let child = current.children.get(normalized); + if (!child) { + child = createTreeGroup(normalized, groupPath); + current.children.set(normalized, child); + } + current = child; + }); + + current.leaves.push({ keyInfo, label: leafLabel }); + }); + + calculateGroupLeafCount(root); + const groupKeys: string[] = []; + + const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => { + const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name)); + const childLeaves = sortLeafNodes + ? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key)) + : group.leaves; + + const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => { + const children = toTreeNodes(child); + const descendantRawKeys = children.flatMap((node) => { + if (node.nodeType === 'leaf') { + return node.rawKey ? [node.rawKey] : []; + } + return node.descendantRawKeys || []; + }); + const groupNodeKey = `group:${child.path}`; + groupKeys.push(groupNodeKey); + return { + key: groupNodeKey, + title: child.name, + nodeType: 'group', + groupName: child.name, + groupLeafCount: child.leafCount, + selectable: false, + descendantRawKeys, + children, + }; + }); + + const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => { + return { + key: buildLeafNodeKey(leaf.keyInfo.key), + isLeaf: true, + title: leaf.label, + nodeType: 'leaf', + leafLabel: leaf.label, + rawKey: leaf.keyInfo.key, + keyType: leaf.keyInfo.type, + ttl: leaf.keyInfo.ttl, + }; + }); + + return [...groupNodes, ...leafNodes]; + }; + + return { + treeData: toTreeNodes(root), + groupKeys, + }; +}; + +export const applyTreeNodeCheck = ( + selectedKeys: string[], + node: RedisTreeDataNode, + checked: boolean +): string[] => { + if (node.nodeType === 'leaf') { + if (!node.rawKey) { + return selectedKeys; + } + if (checked) { + return Array.from(new Set([...selectedKeys, node.rawKey])); + } + return selectedKeys.filter((item) => item !== node.rawKey); + } + + const descendantRawKeys = node.descendantRawKeys || []; + if (descendantRawKeys.length === 0) { + return selectedKeys; + } + if (checked) { + return Array.from(new Set([...selectedKeys, ...descendantRawKeys])); + } + const removeSet = new Set(descendantRawKeys); + return selectedKeys.filter((item) => !removeSet.has(item)); +}; + +const walkGroupStates = ( + nodes: RedisTreeDataNode[], + selectedKeySet: Set, + checked: string[], + halfChecked: string[] +) => { + nodes.forEach((node) => { + if (node.nodeType === 'leaf') { + if (node.rawKey && selectedKeySet.has(node.rawKey)) { + checked.push(String(node.key)); + } + return; + } + + walkGroupStates((node.children || []) as RedisTreeDataNode[], selectedKeySet, checked, halfChecked); + const descendantRawKeys = node.descendantRawKeys || []; + if (descendantRawKeys.length === 0) { + return; + } + + const selectedCount = descendantRawKeys.filter((rawKey) => selectedKeySet.has(rawKey)).length; + if (selectedCount === descendantRawKeys.length) { + checked.push(String(node.key)); + return; + } + if (selectedCount > 0) { + halfChecked.push(String(node.key)); + } + }); +}; + +export const buildCheckedTreeNodeState = ( + selectedKeys: string[], + keyTree: RedisKeyTreeResult +): RedisTreeCheckedState => { + const selectedKeySet = new Set(selectedKeys); + const checked: string[] = []; + const halfChecked: string[] = []; + + walkGroupStates(keyTree.treeData, selectedKeySet, checked, halfChecked); + return { checked, halfChecked }; +}; + +export const isGroupFullyChecked = ( + node: RedisTreeDataNode, + selectedKeys: string[] +): boolean => { + if (node.nodeType !== 'group') { + return false; + } + const descendantRawKeys = node.descendantRawKeys || []; + if (descendantRawKeys.length === 0) { + return false; + } + const selectedKeySet = new Set(selectedKeys); + return descendantRawKeys.every((rawKey) => selectedKeySet.has(rawKey)); +}; + +export const applyRenamedRedisKeyState = ( + state: RenamedRedisKeyStateInput, + oldKey: string, + newKey: string +): RenamedRedisKeyStateResult => { + return { + keys: state.keys.map((item) => (item.key === oldKey ? { ...item, key: newKey } : item)), + selectedKey: state.selectedKey === oldKey ? newKey : state.selectedKey, + selectedKeys: state.selectedKeys.map((item) => (item === oldKey ? newKey : item)), + }; +}; diff --git a/frontend/src/components/redisViewerWorkbenchTheme.test.ts b/frontend/src/components/redisViewerWorkbenchTheme.test.ts new file mode 100644 index 0000000..4ed9a5a --- /dev/null +++ b/frontend/src/components/redisViewerWorkbenchTheme.test.ts @@ -0,0 +1,34 @@ +import { strict as assert } from 'node:assert'; + +import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; + +const darkTheme = buildRedisWorkbenchTheme({ + darkMode: true, + opacity: 0.72, + blur: 14, +}); + +assert.equal(darkTheme.isDark, true); +assert.match(darkTheme.panelBg, /^rgba\(/); +assert.match(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/); +assert.notEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg); +assert.notEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg); +assert.match(darkTheme.appBg, /rgba\(15, 15, 17,/); +assert.match(darkTheme.panelBg, /rgba\(24, 24, 28,/); +assert.match(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/); +assert.equal(darkTheme.backdropFilter, 'blur(14px)'); + +const lightTheme = buildRedisWorkbenchTheme({ + darkMode: false, + opacity: 1, + blur: 0, +}); + +assert.equal(lightTheme.isDark, false); +assert.match(lightTheme.panelBg, /^rgba\(/); +assert.match(lightTheme.contentEmptyBg, /^linear-gradient\(/); +assert.notEqual(lightTheme.textPrimary, lightTheme.textSecondary); +assert.notEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg); +assert.equal(lightTheme.backdropFilter, 'none'); + +console.log('redisViewerWorkbenchTheme tests passed'); diff --git a/frontend/src/components/redisViewerWorkbenchTheme.ts b/frontend/src/components/redisViewerWorkbenchTheme.ts new file mode 100644 index 0000000..9c24cf0 --- /dev/null +++ b/frontend/src/components/redisViewerWorkbenchTheme.ts @@ -0,0 +1,129 @@ +type RedisWorkbenchThemeInput = { + darkMode: boolean; + opacity: number; + blur: number; +}; + +type RedisWorkbenchTheme = { + isDark: boolean; + appBg: string; + panelBg: string; + panelBgStrong: string; + panelBgSubtle: string; + panelBorder: string; + panelInset: string; + toolbarPrimaryBg: string; + contentEmptyBg: string; + textPrimary: string; + textSecondary: string; + textMuted: string; + accent: string; + accentSoft: string; + accentBorder: string; + actionSecondaryBg: string; + actionSecondaryBorder: string; + actionDangerBg: string; + actionDangerBorder: string; + actionDangerText: string; + statusTagBg: string; + statusTagBorder: string; + statusTagMutedBg: string; + statusTagMutedBorder: string; + treeHoverBg: string; + treeSelectedBg: string; + treeSelectedBorder: string; + divider: string; + shadow: string; + backdropFilter: string; +}; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +export const buildRedisWorkbenchTheme = ({ + darkMode, + opacity, + blur, +}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => { + const normalizedOpacity = clamp(opacity, 0.1, 1); + const normalizedBlur = Math.max(0, Math.round(blur)); + const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0; + + if (darkMode) { + const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92; + const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96; + const panelAlpha = isTranslucent ? Math.max(0.06, Math.min(0.16, normalizedOpacity * 0.1)) : 0.34; + const strongAlpha = isTranslucent ? Math.max(0.1, Math.min(0.22, normalizedOpacity * 0.16)) : 0.42; + const subtleAlpha = isTranslucent ? Math.max(0.03, Math.min(0.08, normalizedOpacity * 0.05)) : 0.08; + return { + isDark: true, + appBg: `linear-gradient(180deg, rgba(15, 15, 17, ${appTopAlpha}) 0%, rgba(11, 11, 13, ${appBottomAlpha}) 100%)`, + panelBg: `rgba(24, 24, 28, ${panelAlpha})`, + panelBgStrong: `rgba(31, 31, 36, ${strongAlpha})`, + panelBgSubtle: `rgba(255, 255, 255, ${subtleAlpha})`, + panelBorder: `1px solid rgba(255, 255, 255, ${isTranslucent ? Math.max(0.12, Math.min(0.24, normalizedOpacity * 0.2)) : 0.08})`, + panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? Math.max(0.05, Math.min(0.12, normalizedOpacity * 0.1)) : 0.04})`, + toolbarPrimaryBg: `linear-gradient(135deg, rgba(246,196,83,0.22) 0%, rgba(246,196,83,0.12) 100%)`, + contentEmptyBg: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%)`, + textPrimary: 'rgba(245, 247, 251, 0.96)', + textSecondary: 'rgba(218, 224, 235, 0.82)', + textMuted: 'rgba(168, 177, 194, 0.72)', + accent: '#f6c453', + accentSoft: 'rgba(246, 196, 83, 0.18)', + accentBorder: 'rgba(246, 196, 83, 0.3)', + actionSecondaryBg: 'rgba(255, 255, 255, 0.04)', + actionSecondaryBorder: 'rgba(255, 255, 255, 0.09)', + actionDangerBg: 'rgba(255, 95, 95, 0.12)', + actionDangerBorder: 'rgba(255, 95, 95, 0.28)', + actionDangerText: '#ff8f8f', + statusTagBg: 'rgba(25, 106, 255, 0.16)', + statusTagBorder: 'rgba(25, 106, 255, 0.28)', + statusTagMutedBg: 'rgba(255, 255, 255, 0.04)', + statusTagMutedBorder: 'rgba(255, 255, 255, 0.08)', + treeHoverBg: 'rgba(255, 255, 255, 0.045)', + treeSelectedBg: 'linear-gradient(90deg, rgba(246,196,83,0.2) 0%, rgba(246,196,83,0.08) 100%)', + treeSelectedBorder: 'rgba(246, 196, 83, 0.24)', + divider: 'rgba(255, 255, 255, 0.07)', + shadow: '0 20px 48px rgba(0, 0, 0, 0.26)', + backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none', + }; + } + + const appTopAlpha = isTranslucent ? Math.max(0.16, Math.min(0.36, normalizedOpacity * 0.24)) : 0.98; + const appBottomAlpha = isTranslucent ? Math.max(0.22, Math.min(0.44, normalizedOpacity * 0.32)) : 0.96; + const panelAlpha = isTranslucent ? Math.max(0.18, Math.min(0.4, normalizedOpacity * 0.26)) : 0.94; + const strongAlpha = isTranslucent ? Math.max(0.26, Math.min(0.52, normalizedOpacity * 0.34)) : 0.98; + return { + isDark: false, + appBg: `linear-gradient(180deg, rgba(248, 250, 252, ${appTopAlpha}) 0%, rgba(242, 245, 248, ${appBottomAlpha}) 100%)`, + panelBg: `rgba(255, 255, 255, ${panelAlpha})`, + panelBgStrong: `rgba(255, 255, 255, ${strongAlpha})`, + panelBgSubtle: 'rgba(15, 23, 42, 0.03)', + panelBorder: `1px solid rgba(15, 23, 42, ${isTranslucent ? Math.max(0.1, Math.min(0.18, normalizedOpacity * 0.12)) : 0.08})`, + panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? 0.38 : 0.72})`, + toolbarPrimaryBg: 'linear-gradient(135deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.06) 100%)', + contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)', + textPrimary: 'rgba(15, 23, 42, 0.92)', + textSecondary: 'rgba(51, 65, 85, 0.82)', + textMuted: 'rgba(100, 116, 139, 0.76)', + accent: '#1677ff', + accentSoft: 'rgba(22, 119, 255, 0.12)', + accentBorder: 'rgba(22, 119, 255, 0.22)', + actionSecondaryBg: 'rgba(255, 255, 255, 0.72)', + actionSecondaryBorder: 'rgba(15, 23, 42, 0.08)', + actionDangerBg: 'rgba(255, 77, 79, 0.08)', + actionDangerBorder: 'rgba(255, 77, 79, 0.24)', + actionDangerText: '#cf1322', + statusTagBg: 'rgba(22, 119, 255, 0.1)', + statusTagBorder: 'rgba(22, 119, 255, 0.16)', + statusTagMutedBg: 'rgba(15, 23, 42, 0.04)', + statusTagMutedBorder: 'rgba(15, 23, 42, 0.08)', + treeHoverBg: 'rgba(15, 23, 42, 0.035)', + treeSelectedBg: 'linear-gradient(90deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.05) 100%)', + treeSelectedBorder: 'rgba(22, 119, 255, 0.18)', + divider: 'rgba(15, 23, 42, 0.08)', + shadow: '0 22px 52px rgba(15, 23, 42, 0.08)', + backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none', + }; +}; + +export type { RedisWorkbenchTheme, RedisWorkbenchThemeInput }; diff --git a/frontend/src/utils/overlayWorkbenchTheme.test.ts b/frontend/src/utils/overlayWorkbenchTheme.test.ts new file mode 100644 index 0000000..1657759 --- /dev/null +++ b/frontend/src/utils/overlayWorkbenchTheme.test.ts @@ -0,0 +1,17 @@ +import { strict as assert } from 'node:assert'; + +import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme'; + +const darkTheme = buildOverlayWorkbenchTheme(true); +assert.equal(darkTheme.isDark, true); +assert.match(darkTheme.shellBg, /rgba\(15, 15, 17,/); +assert.match(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/); +assert.equal(darkTheme.iconColor, '#ffd666'); + +const lightTheme = buildOverlayWorkbenchTheme(false); +assert.equal(lightTheme.isDark, false); +assert.match(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/); +assert.match(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/); +assert.equal(lightTheme.iconColor, '#1677ff'); + +console.log('overlayWorkbenchTheme tests passed'); diff --git a/frontend/src/utils/overlayWorkbenchTheme.ts b/frontend/src/utils/overlayWorkbenchTheme.ts new file mode 100644 index 0000000..9fd09f1 --- /dev/null +++ b/frontend/src/utils/overlayWorkbenchTheme.ts @@ -0,0 +1,59 @@ +type OverlayWorkbenchTheme = { + isDark: boolean; + shellBg: string; + shellBorder: string; + shellShadow: string; + shellBackdropFilter: string; + sectionBg: string; + sectionBorder: string; + mutedText: string; + titleText: string; + iconBg: string; + iconColor: string; + hoverBg: string; + selectedBg: string; + selectedText: string; + divider: string; +}; + +export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => { + if (darkMode) { + return { + isDark: true, + shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)', + shellBorder: '1px solid rgba(255,255,255,0.08)', + shellShadow: '0 24px 56px rgba(0,0,0,0.34)', + shellBackdropFilter: 'blur(18px)', + sectionBg: 'rgba(255,255,255,0.03)', + sectionBorder: '1px solid rgba(255,255,255,0.08)', + mutedText: 'rgba(255,255,255,0.5)', + titleText: '#f5f7ff', + iconBg: 'rgba(255,214,102,0.12)', + iconColor: '#ffd666', + hoverBg: 'rgba(255,214,102,0.10)', + selectedBg: 'rgba(255,214,102,0.14)', + selectedText: '#ffd666', + divider: 'rgba(255,255,255,0.08)', + }; + } + + return { + isDark: false, + shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', + shellBorder: '1px solid rgba(16,24,40,0.08)', + shellShadow: '0 18px 42px rgba(15,23,42,0.12)', + shellBackdropFilter: 'none', + sectionBg: 'rgba(255,255,255,0.84)', + sectionBorder: '1px solid rgba(16,24,40,0.08)', + mutedText: 'rgba(16,24,40,0.55)', + titleText: '#162033', + iconBg: 'rgba(24,144,255,0.1)', + iconColor: '#1677ff', + hoverBg: 'rgba(24,144,255,0.08)', + selectedBg: 'rgba(24,144,255,0.12)', + selectedText: '#1677ff', + divider: 'rgba(16,24,40,0.08)', + }; +}; + +export type { OverlayWorkbenchTheme }; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index cc7c818..0dd7bc6 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -131,6 +131,8 @@ export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise; +export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise; + export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index e2efe54..1ca5060 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -254,6 +254,10 @@ export function RedisGetValue(arg1, arg2) { return window['go']['app']['App']['RedisGetValue'](arg1, arg2); } +export function RedisKeyExists(arg1, arg2) { + return window['go']['app']['App']['RedisKeyExists'](arg1, arg2); +} + export function RedisListPush(arg1, arg2, arg3) { return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3); } diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 3bf8956..8b4a0b0 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -453,6 +453,23 @@ func (a *App) RedisRenameKey(config connection.ConnectionConfig, oldKey, newKey return connection.QueryResult{Success: true, Message: "重命名成功"} } +// RedisKeyExists checks whether a key already exists +func (a *App) RedisKeyExists(config connection.ConnectionConfig, key string) connection.QueryResult { + config.Type = "redis" + client, err := a.getRedisClient(config) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + exists, err := client.KeyExists(key) + if err != nil { + logger.Error(err, "RedisKeyExists 检查失败:key=%s", key) + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{Success: true, Data: map[string]bool{"exists": exists}} +} + // RedisDeleteHashField deletes fields from a hash func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult { config.Type = "redis" diff --git a/internal/db/kingbase_identifier_utils.go b/internal/db/kingbase_identifier_utils.go index f3412ac..09e8a1e 100644 --- a/internal/db/kingbase_identifier_utils.go +++ b/internal/db/kingbase_identifier_utils.go @@ -162,3 +162,45 @@ func findKingbaseQualifiedSeparator(raw string) int { return -1 } + +// buildKingbaseSearchPathCommon 统一构建 Kingbase search_path。 +// 返回 search_path SQL 片段和规范化后的 schema 列表(用于调试/扩展)。 +func buildKingbaseSearchPathCommon(rawSchemas []string) (string, []string) { + if len(rawSchemas) == 0 { + return "", nil + } + + seen := make(map[string]struct{}, len(rawSchemas)+1) + quotedParts := make([]string, 0, len(rawSchemas)+1) + normalizedSchemas := make([]string, 0, len(rawSchemas)+1) + + appendSchema := func(raw string) { + cleaned := normalizeKingbaseIdentCommon(raw) + if cleaned == "" { + return + } + if strings.EqualFold(cleaned, "public") { + cleaned = "public" + } + key := strings.ToLower(cleaned) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + normalizedSchemas = append(normalizedSchemas, cleaned) + escaped := strings.ReplaceAll(cleaned, `"`, `""`) + quotedParts = append(quotedParts, `"`+escaped+`"`) + } + + for _, raw := range rawSchemas { + appendSchema(raw) + } + if _, ok := seen["public"]; !ok { + appendSchema("public") + } + + if len(quotedParts) == 0 { + return "", normalizedSchemas + } + return strings.Join(quotedParts, ", "), normalizedSchemas +} diff --git a/internal/db/kingbase_identifier_utils_test.go b/internal/db/kingbase_identifier_utils_test.go index 69e2b2e..7e8cec8 100644 --- a/internal/db/kingbase_identifier_utils_test.go +++ b/internal/db/kingbase_identifier_utils_test.go @@ -50,3 +50,43 @@ func TestSplitKingbaseQualifiedNameCommon(t *testing.T) { }) } } + +func TestBuildKingbaseSearchPathCommon(t *testing.T) { + tests := []struct { + name string + in []string + want string + wantLen int + }{ + { + name: "normal schemas", + in: []string{"ldf_server", "public"}, + want: `"ldf_server", "public"`, + wantLen: 2, + }, + { + name: "quoted and escaped schemas should not be double quoted", + in: []string{`"ldf_server"`, `""bcs_barcode""`, `\"public\"`}, + want: `"ldf_server", "bcs_barcode", "public"`, + wantLen: 3, + }, + { + name: "dedupe ignoring case and keep public fallback", + in: []string{"LDF_SERVER", "ldf_server", "PUBLIC"}, + want: `"LDF_SERVER", "public"`, + wantLen: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, parts := buildKingbaseSearchPathCommon(tt.in) + if got != tt.want { + t.Fatalf("buildKingbaseSearchPathCommon(%v)=%q,want=%q", tt.in, got, tt.want) + } + if len(parts) != tt.wantLen { + t.Fatalf("buildKingbaseSearchPathCommon(%v) parts=%v, len=%d, wantLen=%d", tt.in, parts, len(parts), tt.wantLen) + } + }) + } +} diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index d4eda20..77a4ac3 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -198,7 +198,7 @@ func (k *KingbaseDB) getSearchPathStr() string { } defer rows.Close() - var schemas []string + var rawSchemas []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { @@ -206,17 +206,12 @@ func (k *KingbaseDB) getSearchPathStr() string { } name = strings.TrimSpace(name) if name != "" { - // 使用 SQL 标准的双引号包裹标识符 - escaped := strings.ReplaceAll(name, `"`, `""`) - schemas = append(schemas, `"`+escaped+`"`) + rawSchemas = append(rawSchemas, name) } } - if len(schemas) == 0 { - return "" - } - - return strings.Join(schemas, ", ") + searchPath, _ := buildKingbaseSearchPathCommon(rawSchemas) + return searchPath } func (k *KingbaseDB) Close() error { diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go index 07fd7d3..9316a1b 100644 --- a/internal/db/optional_driver_agent_impl.go +++ b/internal/db/optional_driver_agent_impl.go @@ -582,27 +582,8 @@ func (d *OptionalDriverAgentDB) listKingbaseSchemas(ctx context.Context) ([]stri } func buildKingbaseSearchPathFromSchemas(schemas []string) string { - if len(schemas) == 0 { - return "" - } - seen := make(map[string]struct{}, len(schemas)+1) - parts := make([]string, 0, len(schemas)+1) - for _, name := range schemas { - trimmed := normalizeKingbaseAgentIdent(name) - if trimmed == "" { - continue - } - key := strings.ToLower(trimmed) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - parts = append(parts, quoteKingbaseAgentIdent(trimmed)) - } - if _, ok := seen["public"]; !ok { - parts = append(parts, "public") - } - return strings.Join(parts, ", ") + searchPath, _ := buildKingbaseSearchPathCommon(schemas) + return searchPath } func quoteKingbaseAgentIdent(name string) string { diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 93db691..2662979 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -3,6 +3,7 @@ package redis import ( "context" "crypto/tls" + "errors" "fmt" "net" "net/url" @@ -18,6 +19,8 @@ import ( "github.com/redis/go-redis/v9" ) +var ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期") + // RedisClientImpl implements RedisClient using go-redis type RedisClientImpl struct { client redis.UniversalClient @@ -472,20 +475,29 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) if ttlErr != nil && ttlErr != redis.Nil { ttlValue = -2 } + ttlSeconds := toRedisTTLSeconds(ttlValue) + if isRedisKeyGone(keyType, ttlSeconds) { + continue + } result = append(result, RedisKeyInfo{ Key: r.toDisplayKey(key), Type: keyType, - TTL: toRedisTTLSeconds(ttlValue), + TTL: ttlSeconds, }) } return result } for i, key := range keys { + keyType := typeResults[i].Val() + ttlSeconds := toRedisTTLSeconds(ttlResults[i].Val()) + if isRedisKeyGone(keyType, ttlSeconds) { + continue + } result = append(result, RedisKeyInfo{ Key: r.toDisplayKey(key), - Type: typeResults[i].Val(), - TTL: toRedisTTLSeconds(ttlResults[i].Val()), + Type: keyType, + TTL: ttlSeconds, }) } return result @@ -501,6 +513,17 @@ func toRedisTTLSeconds(ttl time.Duration) int64 { return int64(ttl.Seconds()) } +func isRedisKeyGone(keyType string, ttl int64) bool { + return keyType == "none" || ttl == -2 +} + +func normalizeRedisGetValueError(keyType string, ttl int64) error { + if isRedisKeyGone(keyType, ttl) { + return ErrRedisKeyGone + } + return nil +} + // GetKeyType returns the type of a key func (r *RedisClientImpl) GetKeyType(key string) (string, error) { if r.client == nil { @@ -594,6 +617,9 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) { } ttl, _ := r.GetTTL(key) + if err := normalizeRedisGetValueError(keyType, ttl); err != nil { + return nil, err + } physicalKey := r.toPhysicalKey(key) result := &RedisValue{ diff --git a/internal/redis/redis_impl_test.go b/internal/redis/redis_impl_test.go index 7014ab8..dafb991 100644 --- a/internal/redis/redis_impl_test.go +++ b/internal/redis/redis_impl_test.go @@ -1,6 +1,9 @@ package redis -import "testing" +import ( + "errors" + "testing" +) func TestSanitizeRedisPassword(t *testing.T) { tests := []struct { @@ -79,3 +82,40 @@ func TestSanitizeRedisPassword(t *testing.T) { }) } } + +func TestIsRedisKeyGone(t *testing.T) { + tests := []struct { + name string + keyType string + ttl int64 + want bool + }{ + {name: "type none", keyType: "none", ttl: -2, want: true}, + {name: "type none without ttl", keyType: "none", ttl: -1, want: true}, + {name: "missing by ttl", keyType: "string", ttl: -2, want: true}, + {name: "normal string", keyType: "string", ttl: 30, want: false}, + {name: "permanent hash", keyType: "hash", ttl: -1, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRedisKeyGone(tt.keyType, tt.ttl); got != tt.want { + t.Fatalf("isRedisKeyGone(%q, %d)=%v, want %v", tt.keyType, tt.ttl, got, tt.want) + } + }) + } +} + +func TestNormalizeRedisGetValueError(t *testing.T) { + err := normalizeRedisGetValueError("none", -2) + if !errors.Is(err, ErrRedisKeyGone) { + t.Fatalf("expected ErrRedisKeyGone, got %v", err) + } + if err == nil || err.Error() != "Redis Key 不存在或已过期" { + t.Fatalf("unexpected error text: %v", err) + } + + if normalizeRedisGetValueError("hash", -1) != nil { + t.Fatal("expected nil for supported existing key") + } +}