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 type { RadioChangeEvent } from 'antd'; 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 './MonacoEditor'; import type { DataNode } from 'antd/es/tree'; import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter, } from '../utils/appearance'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { applyRenamedRedisKeyState, applyTreeNodeCheck, buildLeafNodeKey, buildCheckedTreeNodeState, buildRedisKeyTree, isGroupFullyChecked, parseRawKeyFromNodeKey, type RedisTreeDataNode, } from './redisViewerTree'; import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern'; import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay'; const { Search } = Input; const REDIS_TREE_KEY_TYPE_WIDTH = 92; const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84; const REDIS_TREE_KEY_TTL_WIDTH = 92; const REDIS_TREE_HIDE_TTL_THRESHOLD = 460; const REDIS_KEY_INITIAL_LOAD_COUNT = 2000; const REDIS_KEY_LOAD_MORE_COUNT = 2000; 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; redisDB: number; } // 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿 const ResizableDivider: React.FC<{ onResizeEnd: (newWidth: number) => void; targetRef: React.RefObject; minWidth?: number; }> = ({ onResizeEnd, targetRef, minWidth = 300 }) => { const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const target = targetRef.current; if (!target) return; const startX = e.clientX; const startWidth = target.offsetWidth; const containerWidth = target.parentElement?.offsetWidth || window.innerWidth; const maxWidth = containerWidth - 350; // 右侧至少留 350px // 创建遮罩层防止文本选择和其他交互 const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;'; document.body.appendChild(overlay); let currentWidth = startWidth; const handleMouseMove = (moveEvent: MouseEvent) => { moveEvent.preventDefault(); const delta = moveEvent.clientX - startX; currentWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta)); // 直接操作 DOM,不触发 React 重渲染 target.style.width = `${currentWidth}px`; target.style.flexBasis = `${currentWidth}px`; }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.removeChild(overlay); // 拖拽结束时才更新 React state onResizeEnd(currentWidth); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; return (
); }; const getRedisScanLoadCount = (pattern: string, append: boolean): number => { const normalizedPattern = pattern.trim() || '*'; if (normalizedPattern === '*') { return append ? REDIS_KEY_LOAD_MORE_COUNT : REDIS_KEY_INITIAL_LOAD_COUNT; } return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT; }; const normalizeRedisCursor = (value: unknown): string => { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed === '' ? '0' : trimmed; } if (typeof value === 'number') { if (!Number.isFinite(value)) { return '0'; } return Math.trunc(value).toString(); } if (typeof value === 'bigint') { return value.toString(); } return '0'; }; const isRedisKeyGoneErrorMessage = (messageText: string): boolean => { return messageText.includes(REDIS_KEY_GONE_MESSAGE); }; const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const blur = normalizeBlurForPlatform(resolvedAppearance.blur); const disableLocalBackdropFilter = isMacLikePlatform(); const connection = connections.find(c => c.id === connectionId); const workbenchTheme = useMemo( () => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }), [blur, darkMode, disableLocalBackdropFilter, opacity], ); const workbenchBackdropFilter = useMemo( () => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter), [blur, disableLocalBackdropFilter], ); const keyAccentColor = workbenchTheme.accent; const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff'; const valueToolbarBg = workbenchTheme.panelBgStrong; const valueToolbarBorder = workbenchTheme.panelBorder; const valueToolbarText = workbenchTheme.textMuted; const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); const [searchInput, setSearchInput] = useState(''); const [searchPattern, setSearchPattern] = useState('*'); const [searchMode, setSearchMode] = useState('fuzzy'); const [cursor, setCursor] = useState('0'); const [hasMore, setHasMore] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [keyValue, setKeyValue] = useState(null); const [valueLoading, setValueLoading] = useState(false); 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'); // JSON 编辑弹窗状态 const [jsonEditModalOpen, setJsonEditModalOpen] = useState(false); const [jsonEditConfig, setJsonEditConfig] = useState<{ title: string; value: string; isJson: boolean; onSave: (newValue: string) => Promise; } | null>(null); const jsonEditValueRef = useRef(''); const latestLoadRequestIdRef = useRef(0); // 面板宽度状态和 ref - 默认占据 50% 宽度 const [leftPanelWidth, setLeftPanelWidth] = useState('50%'); const leftPanelRef = useRef(null); const treeContainerRef = useRef(null); const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true); 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 { ...connection.config, port: Number(connection.config.port), password: connection.config.password || "", useSSH: connection.config.useSSH || false, ssh: connection.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }, redisDB: redisDB }; }, [connection, redisDB]); const loadKeys = useCallback(async ( pattern: string = '*', fromCursor: string = '0', append: boolean = false, targetCount?: number ) => { const config = getConfig(); if (!config) return; const normalizedPattern = pattern.trim() || '*'; const effectiveTargetCount = targetCount ?? getRedisScanLoadCount(normalizedPattern, append); const requestId = latestLoadRequestIdRef.current + 1; latestLoadRequestIdRef.current = requestId; setLoading(true); try { const res = await (window as any).go.app.App.RedisScanKeys(buildRpcConnectionConfig(config), normalizedPattern, fromCursor, effectiveTargetCount); if (requestId !== latestLoadRequestIdRef.current) { return; } if (res.success) { const result = res.data; const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; const nextCursor = normalizeRedisCursor(result?.cursor); if (append) { setKeys(prev => { const keyMap = new Map(); prev.forEach(item => keyMap.set(item.key, item)); scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item)); return Array.from(keyMap.values()); }); } else { setKeys(scannedKeys); } setCursor(nextCursor); setHasMore(nextCursor !== '0'); } else { message.error('加载 Key 失败: ' + res.message); } } catch (e: any) { if (requestId !== latestLoadRequestIdRef.current) { return; } message.error('加载 Key 失败: ' + (e?.message || String(e))); } finally { if (requestId === latestLoadRequestIdRef.current) { setLoading(false); } } }, [getConfig]); useEffect(() => { loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); }, [loadKeys, redisDB]); const executeSearch = useCallback((value: string, mode: RedisSearchMode = searchMode) => { const normalized = normalizeRedisSearchInput(value, mode); setSearchInput(normalized.keyword); setSearchPattern(normalized.pattern); setCursor('0'); loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false)); }, [loadKeys, searchMode]); const handleSearch = (value: string) => { executeSearch(value); }; const handleSearchInputChange = (event: React.ChangeEvent) => { const normalized = normalizeRedisSearchDraftChange(event.target.value, searchMode); setSearchInput(normalized.keyword); if (!normalized.shouldSearchImmediately) { return; } setSearchPattern(normalized.pattern); setCursor('0'); loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false)); }; const handleSearchModeChange = useCallback((event: RadioChangeEvent) => { const nextMode = event.target.value as RedisSearchMode; setSearchMode(nextMode); executeSearch(searchInput, nextMode); }, [executeSearch, searchInput]); const handleLoadMore = () => { if (!hasMore || loading) { return; } loadKeys(searchPattern, cursor, true, getRedisScanLoadCount(searchPattern, true)); }; const handleRefresh = () => { setCursor('0'); 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; setValueLoading(true); try { const res = await (window as any).go.app.App.RedisGetValue(buildRpcConnectionConfig(config), key); if (res.success) { setKeyValue(res.data); setSelectedKey(key); } else { const messageText = String(res.message || ''); if (isRedisKeyGoneErrorMessage(messageText)) { removeMissingKeyFromView(key); message.warning('Key 已不存在或已过期,已从列表移除'); } else { message.error('获取值失败: ' + messageText); } } } catch (e: any) { const messageText = e?.message || String(e); if (isRedisKeyGoneErrorMessage(messageText)) { removeMissingKeyFromView(key); message.warning('Key 已不存在或已过期,已从列表移除'); } else { message.error('获取值失败: ' + messageText); } } finally { setValueLoading(false); } }; const handleDeleteKeys = async (keysToDelete: string[]) => { const config = getConfig(); if (!config) return; try { const res = await (window as any).go.app.App.RedisDeleteKeys(buildRpcConnectionConfig(config), keysToDelete); if (res.success) { message.success(`已删除 ${res.data.deleted} 个 Key`); setKeys(prev => prev.filter(k => !keysToDelete.includes(k.key))); if (selectedKey && keysToDelete.includes(selectedKey)) { setSelectedKey(null); setKeyValue(null); } setSelectedKeys([]); } else { message.error('删除失败: ' + res.message); } } catch (e: any) { message.error('删除失败: ' + (e?.message || String(e))); } }; const handleDeleteCurrentKey = async () => { if (!selectedKey) return; await handleDeleteKeys([selectedKey]); }; const handleSetTTL = async () => { const config = getConfig(); if (!config || !selectedKey) return; try { const values = await ttlForm.validateFields(); const res = await (window as any).go.app.App.RedisSetTTL(buildRpcConnectionConfig(config), selectedKey, values.ttl); if (res.success) { message.success('TTL 设置成功'); setTtlModalOpen(false); loadKeyValue(selectedKey); handleRefresh(); } else { message.error('设置失败: ' + res.message); } } catch (e: any) { message.error('设置失败: ' + (e?.message || String(e))); } }; const handleSaveString = async () => { const config = getConfig(); if (!config || !selectedKey) return; try { const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), selectedKey, editValue, keyValue?.ttl || -1); if (res.success) { message.success('保存成功'); setEditModalOpen(false); loadKeyValue(selectedKey); } else { message.error('保存失败: ' + res.message); } } catch (e: any) { message.error('保存失败: ' + (e?.message || String(e))); } }; const handleCreateKey = async () => { const config = getConfig(); if (!config) return; try { const values = await newKeyForm.validateFields(); const res = await (window as any).go.app.App.RedisSetString(buildRpcConnectionConfig(config), values.key, values.value, values.ttl || -1); if (res.success) { message.success('创建成功'); setNewKeyModalOpen(false); newKeyForm.resetFields(); handleRefresh(); } else { message.error('创建失败: ' + res.message); } } catch (e: any) { message.error('创建失败: ' + (e?.message || String(e))); } }; 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(buildRpcConnectionConfig(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(buildRpcConnectionConfig(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'; case 'hash': return 'blue'; case 'list': return 'orange'; case 'set': return 'purple'; case 'zset': return 'magenta'; case 'stream': return 'cyan'; default: return 'default'; } }; const formatTTL = (ttl: number) => { if (ttl === -1) return '永久'; if (ttl === -2) return '已过期'; if (ttl < 60) return `${ttl}秒`; if (ttl < 3600) return `${Math.floor(ttl / 60)}分${ttl % 60}秒`; if (ttl < 86400) return `${Math.floor(ttl / 3600)}时${Math.floor((ttl % 3600) / 60)}分`; return `${Math.floor(ttl / 86400)}天${Math.floor((ttl % 86400) / 3600)}时`; }; useEffect(() => { const target = leftPanelRef.current; if (!target) return; const updateTTLVisibility = (width: number) => { const nextShowTTL = width > REDIS_TREE_HIDE_TTL_THRESHOLD; setShowTreeKeyTTL((prev) => (prev === nextShowTTL ? prev : nextShowTTL)); }; updateTTLVisibility(Math.round(target.getBoundingClientRect().width)); if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver((entries) => { const width = Math.round(entries[0]?.contentRect.width || target.getBoundingClientRect().width); updateTTLVisibility(width); }); observer.observe(target); return () => observer.disconnect(); } const handleWindowResize = () => { updateTTLVisibility(Math.round(target.getBoundingClientRect().width)); }; window.addEventListener('resize', handleWindowResize); return () => window.removeEventListener('resize', handleWindowResize); }, []); useEffect(() => { const target = treeContainerRef.current; if (!target) return; const updateTreeHeight = (nextHeight: number) => { if (nextHeight <= 0) return; setTreeHeight((prev) => (prev === nextHeight ? prev : nextHeight)); }; updateTreeHeight(Math.round(target.getBoundingClientRect().height)); if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver((entries) => { const nextHeight = Math.round(entries[0]?.contentRect.height || target.getBoundingClientRect().height); updateTreeHeight(nextHeight); }); observer.observe(target); return () => observer.disconnect(); } const handleWindowResize = () => { updateTreeHeight(Math.round(target.getBoundingClientRect().height)); }; window.addEventListener('resize', handleWindowResize); return () => window.removeEventListener('resize', handleWindowResize); }, []); const isLargeKeyspace = keys.length >= REDIS_LARGE_KEYSPACE_THRESHOLD; const keyTree = useMemo(() => { return buildRedisKeyTree(keys, !isLargeKeyspace); }, [isLargeKeyspace, keys]); const groupKeySet = useMemo(() => new Set(keyTree.groupKeys), [keyTree.groupKeys]); const selectedTreeNodeKeys = useMemo(() => { if (!selectedKey) { return [] as string[]; } return [buildLeafNodeKey(selectedKey)]; }, [selectedKey]); const checkedTreeNodeKeys = useMemo(() => { return buildCheckedTreeNodeState(selectedKeys, keyTree); }, [keyTree, selectedKeys]); useEffect(() => { const existingKeySet = new Set(keys.map(item => item.key)); setSelectedKeys(prev => prev.filter(rawKey => existingKeySet.has(rawKey))); }, [keys]); useEffect(() => { setExpandedGroupKeys((prev) => { const validKeys = prev.filter(nodeKey => groupKeySet.has(nodeKey)); if (!isLargeKeyspace) { return validKeys; } return validKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS); }); }, [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; } const rawKey = parseRawKeyFromNodeKey(nodeKeys[0]); if (!rawKey) { return; } loadKeyValue(rawKey); }; 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.SyntheticEvent) => { 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 (
{ stopTreeTitleEvent(event); handleToggleGroupExpand(groupNodeKey); }} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') { return; } stopTreeTitleEvent(event); handleToggleGroupExpand(groupNodeKey); }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%', minWidth: 0, padding: '2px 0', cursor: 'pointer', }} > {treeNode.groupName} ({treeNode.groupLeafCount ?? 0})
); } const leafLabel = treeNode.leafLabel ?? ''; const rawKey = treeNode.rawKey ?? parseRawKeyFromNodeKey(treeNode.key ?? '') ?? ''; const keyType = treeNode.keyType ?? 'unknown'; const ttl = typeof treeNode.ttl === 'number' ? treeNode.ttl : -1; if (isLargeKeyspace) { return (
{leafLabel} [{keyType}] {showTreeKeyTTL && ( {formatTTL(ttl)} )}
); } return (
{leafLabel}
{keyType} {showTreeKeyTTL && ( {formatTTL(ttl)} )}
); }, [expandedGroupKeys, formatTTL, getTypeColor, handleSelectGroupDescendants, handleToggleGroupExpand, isLargeKeyspace, keyAccentColor, selectedKeys, showTreeKeyTTL, workbenchTheme]); const handleTreeExpand = (nextExpandedKeys: React.Key[]) => { const validGroupKeys = nextExpandedKeys .map(key => String(key)) .filter(nodeKey => groupKeySet.has(nodeKey)); if (isLargeKeyspace) { setExpandedGroupKeys(validGroupKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS)); return; } setExpandedGroupKeys(validGroupKeys); }; const renderValueEditor = () => { const processValueForCurrentView = (value: string) => { if (viewMode === 'hex') { return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' }; } if (viewMode === 'text') { return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' }; } if (viewMode === 'utf8') { return { displayValue: decodeRedisUtf8Value(value), isBinary: false, isJson: false, encoding: 'UTF-8' }; } return formatRedisStringValue(value); }; if (!keyValue || !selectedKey) { return (
选择一个 Key 查看详情
); } const renderStringValue = () => { const strValue = String(keyValue.value); const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue); return (
{encoding && `编码: ${encoding}`}
{!isBinary && viewMode === 'auto' && ( )} {(isBinary || viewMode !== 'auto') && ( {viewMode !== 'auto' ? '切换到"自动"模式以编辑' : '二进制数据不支持编辑'} )}
); }; const renderHashValue = () => { const data = Object.entries(keyValue.value as Record).map(([field, value]) => { const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value); return { field, value, displayValue, isBinary, isJson, encoding }; }); const handleEditHashField = async (field: string, newValue: string) => { const config = getConfig(); if (!config) return; try { const res = await (window as any).go.app.App.RedisSetHashField(buildRpcConnectionConfig(config), selectedKey, field, newValue); if (res.success) { message.success('修改成功'); loadKeyValue(selectedKey); } else { message.error('修改失败: ' + res.message); } } catch (e: any) { message.error('修改失败: ' + (e?.message || String(e))); } }; const handleDeleteHashField = async (field: string) => { const config = getConfig(); if (!config) return; try { const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]); if (res.success) { message.success('删除成功'); loadKeyValue(selectedKey); } else { message.error('删除失败: ' + res.message); } } catch (e: any) { message.error('删除失败: ' + (e?.message || String(e))); } }; return (
{ const tooltipContent = record.encoding && record.encoding !== 'UTF-8' ? `[${record.encoding}]\n${text}` : text; return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> {text} ); } }, { title: '操作', key: 'action', width: 120, render: (_: any, record: any) => (
{ const tooltipContent = record.encoding && record.encoding !== 'UTF-8' ? `[${record.encoding}]\n${text}` : text; return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> {text} ); } }, { title: '操作', key: 'action', width: 80, render: (_: any, record: any) => (
{ const tooltipContent = record.encoding && record.encoding !== 'UTF-8' ? `[${record.encoding}]\n${text}` : text; return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> {text} ); } }, { title: '操作', key: 'action', width: 80, render: (_: any, record: any) => (
{ const tooltipContent = record.encoding && record.encoding !== 'UTF-8' ? `[${record.encoding}]\n${text}` : text; return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> {text} ); } }, { title: '操作', key: 'action', width: 120, render: (_: any, record: any) => (
{ const tooltipContent = record.encoding && record.encoding !== 'UTF-8' ? `[${record.encoding}]\n${text}` : text; return ( {tooltipContent}} styles={{ root: { maxWidth: 720 } }}> {text} ); } }, { title: '操作', key: 'action', width: 140, render: (_: any, record: any) => (
查看模式 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()}
); }; if (!connection) { return
连接不存在
; } return (
{/* Left: Key List */}
Key Explorer
db{redisDB}
{keys.length} Keys
模糊 精确 } />
handleDeleteKeys(selectedKeys)} disabled={selectedKeys.length === 0} >
{isLargeKeyspace && (
已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组)
)}
命名空间 / Key 类型 / TTL
null} checkable checkStrictly selectable virtual height={Math.max(treeHeight - 8, 220)} treeData={keyTree.treeData} titleRender={renderTreeNodeTitle} selectedKeys={selectedTreeNodeKeys} checkedKeys={checkedTreeNodeKeys} expandedKeys={expandedGroupKeys} onExpand={handleTreeExpand} onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)} onCheck={(checked, info) => handleTreeCheck(checked, info)} onRightClick={handleTreeRightClick} style={{ padding: '8px 6px' }} />
{hasMore && (
)}
{/* Resizable Divider */} {/* Right: Value Viewer */}
{valueLoading ? (
加载中...
) : ( renderValueEditor() )}
{/* Edit String Modal */} setEditModalOpen(false)} width={800} styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > setEditValue(value || '')} options={{ minimap: { enabled: false }, lineNumbers: 'on', wordWrap: 'on', scrollBeyondLastLine: false, automaticLayout: true, folding: true }} /> {/* New Key Modal */} setNewKeyModalOpen(false)} styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} >
{/* 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' } }} >
{/* JSON Edit Modal with Monaco Editor */} { if (jsonEditConfig?.onSave) { await jsonEditConfig.onSave(jsonEditValueRef.current); } setJsonEditModalOpen(false); }} onCancel={() => setJsonEditModalOpen(false)} width={800} styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { height: 500, paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > { jsonEditValueRef.current = value || ''; }} onMount={(editor) => { jsonEditValueRef.current = jsonEditConfig?.value || ''; }} options={{ minimap: { enabled: false }, lineNumbers: 'on', wordWrap: 'on', scrollBeyondLastLine: false, automaticLayout: true, folding: true, formatOnPaste: true }} /> {treeContextMenu && typeof document !== 'undefined' && createPortal((
event.stopPropagation()} >
), document.body)}
); }; export default RedisViewer;