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() {
-
- } title="工具" style={utilityButtonStyle}>工具
-
+
} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}>工具
} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>代理
} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>主题
} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>关于
@@ -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 && (
+ <>
+ }
+ disabled={selectedRowKeys.length === 0}
+ onClick={() => handlePasteCopiedColumnsToSelectedRows()}
>
- 批量填充 ({selectedCells.size})
+ 粘贴到选中行 ({selectedRowKeys.length})
+
+ 已复制 {Object.keys(copiedCellPatch.values).length} 列
+
>
)}
@@ -3786,7 +3995,7 @@ const DataGrid: React.FC = ({
)}
-
+
{contextHolder}
= ({
+ {
+ 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 (
- } onClick={() => {
+ } onClick={() => {
Modal.confirm({
title: '添加字段',
content: (
@@ -1061,12 +1230,6 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
}
});
}}>添加字段
- setViewMode(e.target.value)}>
- 自动
- 原始文本
- UTF-8
- 十六进制
-
= ({ connectionId, redisDB }) => {
- } onClick={() => {
+ } onClick={() => {
Modal.confirm({
title: '添加元素',
content: (
@@ -1223,7 +1386,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
}
});
}}>添加到尾部
-
- setViewMode(e.target.value)}>
- 自动
- 原始文本
- UTF-8
- 十六进制
-
= ({ connectionId, redisDB }) => {
return (
- } onClick={() => {
+ } onClick={() => {
Modal.confirm({
title: '添加成员',
content: (
@@ -1396,12 +1553,6 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
}
});
}}>添加成员
- setViewMode(e.target.value)}>
- 自动
- 原始文本
- UTF-8
- 十六进制
-
= ({ connectionId, redisDB }) => {
return (
- } onClick={() => {
+ } onClick={() => {
Modal.confirm({
title: '添加成员',
content: (
@@ -1549,12 +1700,6 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
}
});
}}>添加成员
- setViewMode(e.target.value)}>
- 自动
- 原始文本
- UTF-8
- 十六进制
-
= ({ connectionId, redisDB }) => {
return (
- } onClick={() => {
+ } onClick={() => {
Modal.confirm({
title: '添加 Stream 消息',
width: 680,
@@ -1757,12 +1902,6 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
}
});
}}>添加消息
- setViewMode(e.target.value)}>
- 自动
- 原始文本
- UTF-8
- 十六进制
-
= ({ connectionId, redisDB }) => {
};
return (
-
-
-
-
- {selectedKey}
-
-
- }
- style={{ padding: '0 4px', display: 'flex', alignItems: 'center' }}
- onClick={() => {
- navigator.clipboard.writeText(selectedKey).then(() => {
- message.success('已复制 Key 名称');
- }).catch(() => {
- message.error('复制失败');
- });
- }}
- />
-
-
{keyValue.type}
-
} style={{ margin: 0 }}>{formatTTL(keyValue.ttl)}
- {keyValue.length > 0 &&
长度: {keyValue.length}}
+
+
+
+
+ Active Key
+
+
+
+
+ {selectedKey}
+
+
+
+ }
+ style={{ padding: '0 4px', display: 'flex', alignItems: 'center', color: workbenchTheme.textMuted }}
+ onClick={() => {
+ navigator.clipboard.writeText(selectedKey).then(() => {
+ message.success('已复制 Key 名称');
+ }).catch(() => {
+ message.error('复制失败');
+ });
+ }}
+ />
+
+ {keyValue.type}
+ } style={mutedPillTagStyle}>{formatTTL(keyValue.ttl)}
+ {keyValue.length > 0 && 长度: {keyValue.length}}
+
-
- {
+
+
{
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
setTtlModalOpen(true);
}}>设置 TTL
-
loadKeyValue(selectedKey)} icon={}>刷新
+
loadKeyValue(selectedKey)} icon={}>刷新
- }>删除 Key
+ }>删除 Key
-
+
-
- {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={}
/>
-
-
- } onClick={handleRefresh}>刷新
- } onClick={() => setNewKeyModalOpen(true)}>新建
+
+
+ } onClick={handleRefresh}>刷新
+ } onClick={() => setNewKeyModalOpen(true)}>新建
+ 全选全部
+ 取消全选
handleDeleteKeys(selectedKeys)}
disabled={selectedKeys.length === 0}
>
- } disabled={selectedKeys.length === 0}>
+ } disabled={selectedKeys.length === 0}>
删除选中({selectedKeys.length})
-
+
{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()}
+ >
+ }
+ onClick={() => openRenameKeyModal(treeContextMenu.rawKey)}
+ >
+ 重命名 Key
+
+ }
+ onClick={async () => {
+ try {
+ await navigator.clipboard.writeText(treeContextMenu.rawKey);
+ setTreeContextMenu(null);
+ message.success('已复制 Key 名称');
+ } catch {
+ message.error('复制失败');
+ }
+ }}
+ >
+ 复制 Key 名称
+
+
+ ), 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")
+ }
+}