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() {
- - - + @@ -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 (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => {
- - - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { return (
- - setViewMode(e.target.value)}> - 自动 - 原始文本 - UTF-8 - 十六进制 -
= ({ connectionId, redisDB }) => { }; return ( -
-
-
- - {selectedKey} - - -
- - - + - + - +
-
- {keyValue.type === 'string' && renderStringValue()} - {keyValue.type === 'hash' && renderHashValue()} - {keyValue.type === 'list' && renderListValue()} - {keyValue.type === 'set' && renderSetValue()} - {keyValue.type === 'zset' && renderZSetValue()} - {keyValue.type === 'stream' && renderStreamValue()} +
+ 查看模式 + setViewMode(e.target.value)}> + 自动 + 原始文本 + UTF-8 + 十六进制 + +
+
+
+ {keyValue.type === 'string' && renderStringValue()} + {keyValue.type === 'hash' && renderHashValue()} + {keyValue.type === 'list' && renderListValue()} + {keyValue.type === 'set' && renderSetValue()} + {keyValue.type === 'zset' && renderZSetValue()} + {keyValue.type === 'stream' && renderStreamValue()} +
); @@ -1892,10 +2049,17 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } return ( -
+
{/* Left: Key List */} -
-
+
+
+
+
+
Key Explorer
+
db{redisDB}
+
+ {keys.length} Keys +
= ({ connectionId, redisDB }) => { enterButton={} /> -
- - - +
+ + + + + handleDeleteKeys(selectedKeys)} disabled={selectedKeys.length === 0} > -
-
+
{isLargeKeyspace && ( -
+
已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组)
)} -
+
+ 命名空间 / Key + 类型 / TTL +
+
null} checkable checkStrictly selectable @@ -1943,14 +2114,15 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { expandedKeys={expandedGroupKeys} onExpand={handleTreeExpand} onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)} - onCheck={(checked) => handleTreeCheck(checked)} + onCheck={(checked, info) => handleTreeCheck(checked, info)} + onRightClick={handleTreeRightClick} style={{ padding: '8px 6px' }} />
{hasMore && ( -
- +
+
)}
@@ -1962,7 +2134,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {/* Right: Value Viewer */}
{valueLoading ? ( -
加载中...
+
加载中...
) : ( renderValueEditor() )} @@ -1975,7 +2147,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { onOk={handleSaveString} onCancel={() => setEditModalOpen(false)} width={800} - styles={{ body: { height: 500 } }} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > = ({ connectionId, redisDB }) => { open={newKeyModalOpen} onOk={handleCreateKey} onCancel={() => setNewKeyModalOpen(false)} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} >
@@ -2015,11 +2188,35 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { {/* TTL Modal */} + { + setRenameKeyModalOpen(false); + setRenameTargetKey(null); + renameKeyForm.resetFields(); + }} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} + > + + + + + + + setTtlModalOpen(false)} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} >
@@ -2040,7 +2237,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }} onCancel={() => setJsonEditModalOpen(false)} width={800} - styles={{ body: { height: 500 } }} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > = ({ connectionId, redisDB }) => { }} />
+ {treeContextMenu && typeof document !== 'undefined' && createPortal(( +
event.stopPropagation()} + > + + +
+ ), document.body)}
); }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9fc732b..99caff9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -32,7 +32,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, CheckOutlined, FilterOutlined } from '@ant-design/icons'; - import { useStore } from '../store'; +import { useStore } from '../store'; +import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection } from '../types'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -121,41 +122,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return `rgba(${r}, ${g}, ${b}, ${opacity})`; }; const bgMain = getBg('#141414'); + const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); const modalPanelStyle = useMemo(() => ({ - background: darkMode - ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' - : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)', - backdropFilter: darkMode ? 'blur(18px)' : 'none', - }), [darkMode]); + background: overlayTheme.shellBg, + border: overlayTheme.shellBorder, + boxShadow: overlayTheme.shellShadow, + backdropFilter: overlayTheme.shellBackdropFilter, + }), [overlayTheme]); const modalSectionStyle = useMemo(() => ({ padding: 14, borderRadius: 14, - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', - }), [darkMode]); + border: overlayTheme.sectionBorder, + background: overlayTheme.sectionBg, + }), [overlayTheme]); const modalScrollSectionStyle = useMemo(() => ({ maxHeight: 400, overflow: 'auto' as const, - border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + border: overlayTheme.sectionBorder, borderRadius: 14, padding: 12, - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)', - }), [darkMode]); + background: overlayTheme.sectionBg, + }), [overlayTheme]); const modalHintTextStyle = useMemo(() => ({ - color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', + color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, - }), [darkMode]); + }), [overlayTheme]); const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
-
+
{icon}
-
{title}
-
{description}
+
{title}
+
{description}
); @@ -2535,12 +2535,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const searchScopePopoverContent = useMemo(() => { const smartSelected = searchScopes.includes('smart'); const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart'); - const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)'; - const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)'; - const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033'; - const panelBg = darkMode - ? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)' - : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)'; + const borderColor = overlayTheme.sectionBorder.replace('1px solid ', ''); + const mutedTextColor = overlayTheme.mutedText; + const titleColor = overlayTheme.titleText; + const panelBg = overlayTheme.shellBg; const smartBg = smartSelected ? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)') : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)'); @@ -2591,7 +2589,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
-
+
手动范围
@@ -2628,7 +2626,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
); - }, [darkMode, searchScopes]); + }, [darkMode, overlayTheme, searchScopes]); const parseHostOnlyToken = (value: unknown): string[] => { const raw = String(value || '').trim(); diff --git a/frontend/src/components/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") + } +}