Files
MyGoNavi/frontend/src/components/RedisViewer.tsx
Syngnat cfbfda4de3 ️ perf(webview): 降低首屏加载与 WebView2 内存占用
- Monaco Editor 改为首次使用时按需初始化
- AI 面板改为懒加载,延后加载 Markdown 和图表渲染依赖
- 增加 Windows 低内存视觉模式,支持关闭透明 WebView 和 Acrylic
- 补充低内存启动说明与模式解析测试
2026-05-16 11:18:48 +08:00

2116 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>;
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 (
<div
onMouseDown={handleMouseDown}
style={{
width: 5,
cursor: 'col-resize',
background: 'transparent',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
}}
title="拖动调整宽度"
>
</div>
);
};
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<RedisViewerProps> = ({ 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<RedisKeyInfo[]>([]);
const [loading, setLoading] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchPattern, setSearchPattern] = useState('*');
const [searchMode, setSearchMode] = useState<RedisSearchMode>('fuzzy');
const [cursor, setCursor] = useState<string>('0');
const [hasMore, setHasMore] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyValue, setKeyValue] = useState<RedisValue | null>(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<string | null>(null);
const [ttlModalOpen, setTtlModalOpen] = useState(false);
const [ttlForm] = Form.useForm();
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
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<void>;
} | null>(null);
const jsonEditValueRef = useRef<string>('');
const latestLoadRequestIdRef = useRef(0);
// 面板宽度状态和 ref - 默认占据 50% 宽度
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
const leftPanelRef = useRef<HTMLDivElement>(null);
const treeContainerRef = useRef<HTMLDivElement>(null);
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
const [treeHeight, setTreeHeight] = useState(500);
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
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<string, RedisKeyInfo>();
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<HTMLInputElement>) => {
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<HTMLElement>) => {
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 (
<div
role="button"
tabIndex={0}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
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',
}}
>
<Space size={6} style={{ minWidth: 0, overflow: 'hidden' }}>
<button
type="button"
className="redis-tree-expander-button"
aria-label={isExpanded ? '折叠分组' : '展开分组'}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleToggleGroupExpand(groupNodeKey);
}}
style={{
width: 18,
height: 18,
padding: 0,
border: 'none',
background: 'transparent',
color: workbenchTheme.textMuted,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
cursor: 'pointer',
flexShrink: 0,
}}
>
{isExpanded ? <DownOutlined style={{ fontSize: 11 }} /> : <RightOutlined style={{ fontSize: 11 }} />}
</button>
<FolderOpenOutlined style={{ color: workbenchTheme.textMuted }} />
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{treeNode.groupName}
</span>
<span style={{ fontSize: 12, color: workbenchTheme.textMuted, flexShrink: 0 }}>({treeNode.groupLeafCount ?? 0})</span>
</Space>
<Button
size="small"
style={{
paddingInline: 10,
height: 26,
borderRadius: 999,
flexShrink: 0,
borderColor: workbenchTheme.accentBorder,
background: workbenchTheme.accentSoft,
color: workbenchTheme.accent,
fontWeight: 600,
}}
onMouseDown={stopTreeTitleEvent}
onClick={(event) => {
stopTreeTitleEvent(event);
handleSelectGroupDescendants(treeNode);
}}
>
{groupFullyChecked ? '取消全选' : '全选'}
</Button>
</div>
);
}
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 (
<div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: workbenchTheme.textPrimary }}>
<span>{leafLabel}</span>
<span style={{ marginLeft: 8, color: workbenchTheme.textMuted, fontSize: 12 }}>[{keyType}]</span>
{showTreeKeyTTL && (
<span style={{ marginLeft: 8, color: workbenchTheme.textMuted, fontSize: 12 }}>{formatTTL(ttl)}</span>
)}
</div>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
width: '100%',
overflow: 'hidden',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
flex: 1,
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: keyAccentColor, flexShrink: 0 }} />
<Tooltip title={rawKey}>
<span
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{leafLabel}
</span>
</Tooltip>
</div>
<Tag
color={getTypeColor(keyType)}
style={{
marginInlineEnd: 0,
width: showTreeKeyTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
textAlign: 'center',
flexShrink: 0,
borderRadius: 999,
fontWeight: 600,
}}
>
{keyType}
</Tag>
{showTreeKeyTTL && (
<span
style={{
width: REDIS_TREE_KEY_TTL_WIDTH,
fontSize: 12,
color: workbenchTheme.textMuted,
textAlign: 'left',
whiteSpace: 'nowrap',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{formatTTL(ttl)}
</span>
)}
</div>
);
}, [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 (
<div
style={{
...workbenchCardStyle,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: workbenchTheme.contentEmptyBg,
color: workbenchTheme.textMuted,
padding: 24,
}}
>
Key
</div>
);
}
const renderStringValue = () => {
const strValue = String(keyValue.value);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '4px 8px',
background: valueToolbarBg,
borderBottom: valueToolbarBorder,
display: 'flex',
alignItems: 'center'
}}>
<span style={{ fontSize: 12, color: valueToolbarText }}>
{encoding && `编码: ${encoding}`}
</span>
</div>
<Editor
height="calc(100% - 72px)"
language={isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={displayValue}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: isBinary ? 'off' : 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true,
formatOnPaste: true,
fontFamily: isBinary ? 'monospace' : undefined
}}
/>
<div style={{ padding: '8px 0', flexShrink: 0 }}>
<Space>
<Button icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(strValue).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}}></Button>
{!isBinary && viewMode === 'auto' && (
<Button icon={<EditOutlined />} onClick={() => {
setEditValue(displayValue);
setEditModalOpen(true);
}}></Button>
)}
{(isBinary || viewMode !== 'auto') && (
<span style={{ color: '#999', fontSize: 12 }}>
{viewMode !== 'auto' ? '切换到"自动"模式以编辑' : '二进制数据不支持编辑'}
</span>
)}
</Space>
</div>
</div>
);
};
const renderHashValue = () => {
const data = Object.entries(keyValue.value as Record<string, string>).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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加字段',
content: (
<Form id="add-hash-field-form" layout="vertical">
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
<Input id="new-hash-field" {...noAutoCapInputProps} />
</Form.Item>
<Form.Item label="值" name="value" rules={[{ required: true }]}>
<Input.TextArea id="new-hash-value" rows={4} />
</Form.Item>
</Form>
),
onOk: async () => {
const field = (document.getElementById('new-hash-field') as HTMLInputElement)?.value;
const value = (document.getElementById('new-hash-value') as HTMLTextAreaElement)?.value;
if (field && value !== undefined) {
await handleEditHashField(field, value);
}
}
});
}}></Button>
</div>
<Table
dataSource={data}
columns={[
{ title: 'Field', dataIndex: 'field', key: 'field', width: 200, ellipsis: true },
{
title: 'Value',
dataIndex: 'displayValue',
key: 'value',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制值">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.value).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
{!record.isBinary && (
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
// 如果是 JSON格式化显示
const editContent = record.isJson ? record.displayValue : record.value;
setJsonEditConfig({
title: `编辑字段: ${record.field}`,
value: editContent,
isJson: record.isJson,
onSave: async (newValue: string) => {
await handleEditHashField(record.field, newValue);
}
});
setJsonEditModalOpen(true);
}} />
)}
<Popconfirm title="确定删除此字段?" onConfirm={() => handleDeleteHashField(record.field)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="field"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderListValue = () => {
const data = (keyValue.value as string[]).map((value, index) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value);
return { index, value, displayValue, isBinary, isJson, encoding };
});
const handleEditListItem = async (index: number, newValue: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisListSet(buildRpcConnectionConfig(config), selectedKey, index, newValue);
if (res.success) {
message.success('修改成功');
loadKeyValue(selectedKey);
} else {
message.error('修改失败: ' + res.message);
}
} catch (e: any) {
message.error('修改失败: ' + (e?.message || String(e)));
}
};
const handleAddListItem = async (value: string, position: 'left' | 'right') => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisListPush(buildRpcConnectionConfig(config), selectedKey, { values: [value], position });
if (res.success) {
message.success('添加成功');
loadKeyValue(selectedKey);
} else {
message.error('添加失败: ' + res.message);
}
} catch (e: any) {
message.error('添加失败: ' + (e?.message || String(e)));
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加元素',
content: (
<div>
<Input.TextArea id="new-list-value" rows={4} placeholder="输入新元素值" />
</div>
),
onOk: async () => {
const value = (document.getElementById('new-list-value') as HTMLTextAreaElement)?.value;
if (value) {
await handleAddListItem(value, 'right');
}
}
});
}}></Button>
<Button size="small" style={actionButtonStyle} onClick={() => {
Modal.confirm({
title: '添加元素到头部',
content: (
<div>
<Input.TextArea id="new-list-value-left" rows={4} placeholder="输入新元素值" />
</div>
),
onOk: async () => {
const value = (document.getElementById('new-list-value-left') as HTMLTextAreaElement)?.value;
if (value) {
await handleAddListItem(value, 'left');
}
}
});
}}></Button>
</Space>
</div>
<Table
dataSource={data}
columns={[
{ title: '索引', dataIndex: 'index', key: 'index', width: 80 },
{
title: '值',
dataIndex: 'displayValue',
key: 'value',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 80,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制值">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.value).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
{!record.isBinary && (
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
// 如果是 JSON格式化显示
const editContent = record.isJson ? record.displayValue : record.value;
setJsonEditConfig({
title: `编辑索引 ${record.index}`,
value: editContent,
isJson: record.isJson,
onSave: async (newValue: string) => {
await handleEditListItem(record.index, newValue);
}
});
setJsonEditModalOpen(true);
}} />
)}
</Space>
)
}
]}
rowKey="index"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderSetValue = () => {
const data = (keyValue.value as string[]).map((member, index) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member);
return { index, member, displayValue, isBinary, isJson, encoding };
});
const handleAddSetMember = async (member: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetAdd(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success('添加成功');
loadKeyValue(selectedKey);
} else {
message.error('添加失败: ' + res.message);
}
} catch (e: any) {
message.error('添加失败: ' + (e?.message || String(e)));
}
};
const handleRemoveSetMember = async (member: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加成员',
content: (
<Input.TextArea id="new-set-member" rows={4} placeholder="输入新成员值" />
),
onOk: async () => {
const member = (document.getElementById('new-set-member') as HTMLTextAreaElement)?.value;
if (member) {
await handleAddSetMember(member);
}
}
});
}}></Button>
</div>
<Table
dataSource={data}
columns={[
{
title: '成员',
dataIndex: 'displayValue',
key: 'member',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 80,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制值">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.member).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Popconfirm title="确定删除此成员?" onConfirm={() => handleRemoveSetMember(record.member)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="index"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderZSetValue = () => {
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member);
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
});
const handleAddZSetMember = async (member: string, score: number) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisZSetAdd(buildRpcConnectionConfig(config), selectedKey, [{ member, score }]);
if (res.success) {
message.success('添加成功');
loadKeyValue(selectedKey);
} else {
message.error('添加失败: ' + res.message);
}
} catch (e: any) {
message.error('添加失败: ' + (e?.message || String(e)));
}
};
const handleRemoveZSetMember = async (member: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisZSetRemove(buildRpcConnectionConfig(config), selectedKey, [member]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加成员',
content: (
<div>
<div style={{ marginBottom: 8 }}>
<label></label>
<InputNumber id="new-zset-score" defaultValue={0} style={{ width: '100%' }} />
</div>
<div>
<label></label>
<Input.TextArea id="new-zset-member" rows={4} placeholder="输入成员值" />
</div>
</div>
),
onOk: async () => {
const score = parseFloat((document.getElementById('new-zset-score') as HTMLInputElement)?.value || '0');
const member = (document.getElementById('new-zset-member') as HTMLTextAreaElement)?.value;
if (member) {
await handleAddZSetMember(member, score);
}
}
});
}}></Button>
</div>
<Table
dataSource={data}
columns={[
{ title: '分数', dataIndex: 'score', key: 'score', width: 120 },
{
title: '成员',
dataIndex: 'displayMember',
key: 'member',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制值">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.member).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
{!record.isBinary && (
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => {
Modal.confirm({
title: '修改分数',
content: (
<div>
<label></label>
<InputNumber id="edit-zset-score" defaultValue={record.score} style={{ width: '100%' }} />
</div>
),
onOk: async () => {
const newScore = parseFloat((document.getElementById('edit-zset-score') as HTMLInputElement)?.value || '0');
await handleAddZSetMember(record.member, newScore);
}
});
}} />
)}
<Popconfirm title="确定删除此成员?" onConfirm={() => handleRemoveZSetMember(record.member)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="index"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
const renderStreamValue = () => {
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText);
return {
index,
id: item.id,
rawFieldsText,
displayFields: displayValue,
isBinary,
isJson,
encoding,
};
});
const handleAddStreamEntry = async (fieldsText: string, id: string) => {
const config = getConfig();
if (!config) return;
let parsed: unknown;
try {
parsed = JSON.parse(fieldsText);
} catch (e) {
message.error('字段 JSON 格式不正确');
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('字段必须是 JSON 对象');
return;
}
const fieldMap: Record<string, string> = {};
Object.entries(parsed as Record<string, unknown>).forEach(([field, value]) => {
fieldMap[field] = value == null ? '' : String(value);
});
if (Object.keys(fieldMap).length === 0) {
message.error('至少提供一个字段');
return;
}
try {
const res = await (window as any).go.app.App.RedisStreamAdd(buildRpcConnectionConfig(config), selectedKey, fieldMap, id || '*');
if (res.success) {
const newID = res.data?.id ? ` (${res.data.id})` : '';
message.success(`添加成功${newID}`);
loadKeyValue(selectedKey);
} else {
message.error('添加失败: ' + res.message);
}
} catch (e: any) {
message.error('添加失败: ' + (e?.message || String(e)));
}
};
const handleDeleteStreamEntry = async (id: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisStreamDelete(buildRpcConnectionConfig(config), selectedKey, [id]);
if (res.success) {
const deleted = Number(res.data?.deleted ?? 0);
if (deleted > 0) {
message.success('删除成功');
} else {
message.warning('未删除任何消息,可能已不存在');
}
loadKeyValue(selectedKey);
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加 Stream 消息',
width: 680,
content: (
<div>
<div style={{ marginBottom: 8 }}>
<label>ID *</label>
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
</div>
<div>
<label> JSON</label>
<Input.TextArea id="new-stream-fields" rows={8} defaultValue={'{\n "field": "value"\n}'} />
</div>
</div>
),
onOk: async () => {
const id = (document.getElementById('new-stream-id') as HTMLInputElement)?.value?.trim() || '*';
const fieldsText = (document.getElementById('new-stream-fields') as HTMLTextAreaElement)?.value || '{}';
await handleAddStreamEntry(fieldsText, id);
}
});
}}></Button>
</div>
<Table
dataSource={data}
columns={[
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 240,
ellipsis: true,
},
{
title: '字段',
dataIndex: 'displayFields',
key: 'fields',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? jsonAccentColor : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 140,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制 ID">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.id).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Tooltip title="复制字段 JSON">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.rawFieldsText).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Popconfirm title="确定删除此消息?" onConfirm={() => handleDeleteStreamEntry(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="id"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 18, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<span style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>
Active Key
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 }}>
<Tooltip title={selectedKey}>
<strong style={{ maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 26, color: workbenchTheme.textPrimary }}>
{selectedKey}
</strong>
</Tooltip>
<Tooltip title="复制 Key 名称">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
style={{ padding: '0 4px', display: 'flex', alignItems: 'center', color: workbenchTheme.textMuted }}
onClick={() => {
navigator.clipboard.writeText(selectedKey).then(() => {
message.success('已复制 Key 名称');
}).catch(() => {
message.error('复制失败');
});
}}
/>
</Tooltip>
<Tag color={getTypeColor(keyValue.type)} style={pillTagStyle}>{keyValue.type}</Tag>
<Tag icon={<ClockCircleOutlined />} style={mutedPillTagStyle}>{formatTTL(keyValue.ttl)}</Tag>
{keyValue.length > 0 && <Tag style={mutedPillTagStyle}>: {keyValue.length}</Tag>}
</div>
</div>
<div style={{ ...workbenchSubCardStyle, padding: 4, display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Button size="small" style={actionButtonStyle} onClick={() => {
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
setTtlModalOpen(true);
}}> TTL</Button>
<Button size="small" style={actionButtonStyle} onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}></Button>
<Popconfirm title={`确定删除 Key "${selectedKey}"`} onConfirm={handleDeleteCurrentKey}>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />}> Key</Button>
</Popconfirm>
</div>
</div>
<div style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={{ paddingInline: 10, fontSize: 12, color: workbenchTheme.textMuted }}></span>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto"></Radio.Button>
<Radio.Button value="text"></Radio.Button>
<Radio.Button value="utf8">UTF-8</Radio.Button>
<Radio.Button value="hex"></Radio.Button>
</Radio.Group>
</div>
<div style={{ ...workbenchCardStyle, padding: 14, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', height: '100%' }}>
{keyValue.type === 'string' && renderStringValue()}
{keyValue.type === 'hash' && renderHashValue()}
{keyValue.type === 'list' && renderListValue()}
{keyValue.type === 'set' && renderSetValue()}
{keyValue.type === 'zset' && renderZSetValue()}
{keyValue.type === 'stream' && renderStreamValue()}
</div>
</div>
</div>
);
};
if (!connection) {
return <div style={{ padding: 20 }}></div>;
}
return (
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
{/* Left: Key List */}
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>Key Explorer</div>
<div style={{ fontSize: 24, fontWeight: 700, color: workbenchTheme.textPrimary, marginTop: 4 }}>db{redisDB}</div>
</div>
<Tag style={mutedPillTagStyle}>{keys.length} Keys</Tag>
</div>
<Space.Compact style={{ width: '100%' }}>
<Radio.Group
value={searchMode}
onChange={handleSearchModeChange}
buttonStyle="solid"
style={{ flexShrink: 0 }}
>
<Radio.Button value="fuzzy"></Radio.Button>
<Radio.Button value="exact"></Radio.Button>
</Radio.Group>
<Search
{...noAutoCapInputProps}
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key模糊匹配'}
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}
allowClear
enterButton={<SearchOutlined />}
/>
</Space.Compact>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Space wrap size={8}>
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}></Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}></Button>
<Button size="small" style={primaryActionButtonStyle} onClick={handleSelectAllLoadedKeys} disabled={keys.length === 0}></Button>
<Button size="small" style={actionButtonStyle} onClick={handleClearAllSelectedKeys} disabled={selectedKeys.length === 0}></Button>
</Space>
<Popconfirm
title={`确定删除选中的 ${selectedKeys.length} 个 Key`}
onConfirm={() => handleDeleteKeys(selectedKeys)}
disabled={selectedKeys.length === 0}
>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />} disabled={selectedKeys.length === 0}>
({selectedKeys.length})
</Button>
</Popconfirm>
</div>
</div>
<div style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
{isLargeKeyspace && (
<div style={{ padding: '8px 10px', fontSize: 12, color: workbenchTheme.textMuted, marginBottom: 8, borderRadius: 12, background: workbenchTheme.panelBgSubtle, border: workbenchTheme.panelBorder }}>
{REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px 10px 8px', color: workbenchTheme.textMuted, fontSize: 12, textTransform: 'uppercase', letterSpacing: '.06em' }}>
<span> / Key</span>
<span> / TTL</span>
</div>
<div ref={treeContainerRef} style={{ ...workbenchSubCardStyle, flex: 1, minHeight: 0, overflow: 'hidden', padding: 6 }}>
<Spin spinning={loading} size="small" style={{ width: '100%' }}>
<Tree
blockNode
showIcon={false}
switcherIcon={() => 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' }}
/>
</Spin>
</div>
{hasMore && (
<div style={{ padding: 10, textAlign: 'center' }}>
<Button style={actionButtonStyle} onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}></Button>
</div>
)}
</div>
</div>
{/* Resizable Divider */}
<ResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} />
{/* Right: Value Viewer */}
<div style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
{valueLoading ? (
<div style={{ ...workbenchCardStyle, padding: 20, textAlign: 'center', color: workbenchTheme.textMuted }}>...</div>
) : (
renderValueEditor()
)}
</div>
{/* Edit String Modal */}
<Modal
title="编辑值"
open={editModalOpen}
onOk={handleSaveString}
onCancel={() => 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' } }}
>
<Editor
height="450px"
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={editValue}
onChange={(value) => setEditValue(value || '')}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: true
}}
/>
</Modal>
{/* New Key Modal */}
<Modal
title="新建 Key"
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' } }}
>
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
<Input {...noAutoCapInputProps} placeholder="key name" />
</Form.Item>
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
<Input.TextArea rows={4} placeholder="value" />
</Form.Item>
<Form.Item name="ttl" label="TTL (秒)" help="-1 表示永不过期">
<InputNumber style={{ width: '100%' }} min={-1} />
</Form.Item>
</Form>
</Modal>
{/* TTL Modal */}
<Modal
title="重命名 Key"
open={renameKeyModalOpen}
onOk={handleRenameKey}
onCancel={() => {
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' } }}
>
<Form form={renameKeyForm} layout="vertical">
<Form.Item
name="key"
label="新的 Key 名称"
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
extra={renameTargetKey ? `原始 Key${renameTargetKey}` : undefined}
>
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
</Form.Item>
</Form>
</Modal>
<Modal
title="设置 TTL"
open={ttlModalOpen}
onOk={handleSetTTL}
onCancel={() => setTtlModalOpen(false)}
styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<Form form={ttlForm} layout="vertical">
<Form.Item name="ttl" label="TTL (秒)" help="-1 表示永不过期">
<InputNumber style={{ width: '100%' }} min={-1} />
</Form.Item>
</Form>
</Modal>
{/* JSON Edit Modal with Monaco Editor */}
<Modal
title={jsonEditConfig?.title || '编辑'}
open={jsonEditModalOpen}
onOk={async () => {
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' } }}
>
<Editor
height="450px"
language={jsonEditConfig?.isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
defaultValue={jsonEditConfig?.value || ''}
onChange={(value) => { 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
}}
/>
</Modal>
{treeContextMenu && typeof document !== 'undefined' && createPortal((
<div
style={{
position: 'fixed',
left: typeof window !== 'undefined' ? Math.min(treeContextMenu.x + 4, Math.max(16, window.innerWidth - 220)) : treeContextMenu.x,
top: typeof window !== 'undefined' ? Math.min(treeContextMenu.y + 4, Math.max(16, window.innerHeight - 140)) : treeContextMenu.y,
zIndex: 1200,
minWidth: 188,
padding: 8,
borderRadius: 14,
background: workbenchTheme.panelBgStrong,
border: workbenchTheme.panelBorder,
boxShadow: `${workbenchTheme.panelInset}, ${workbenchTheme.shadow}`,
backdropFilter: workbenchTheme.backdropFilter,
WebkitBackdropFilter: workbenchTheme.backdropFilter,
}}
onClick={(event) => event.stopPropagation()}
>
<Button
type="text"
style={{ width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
icon={<EditOutlined />}
onClick={() => openRenameKeyModal(treeContextMenu.rawKey)}
>
Key
</Button>
<Button
type="text"
style={{ width: '100%', justifyContent: 'flex-start', height: 40, borderRadius: 10, color: workbenchTheme.textPrimary, fontWeight: 600 }}
icon={<CopyOutlined />}
onClick={async () => {
try {
await navigator.clipboard.writeText(treeContextMenu.rawKey);
setTreeContextMenu(null);
message.success('已复制 Key 名称');
} catch {
message.error('复制失败');
}
}}
>
Key
</Button>
</div>
), document.body)}
</div>
);
};
export default RedisViewer;