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/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/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/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/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")
+ }
+}