diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 7dd0894..02fefc3 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -28,6 +28,7 @@ import { import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from '../utils/redisSearchPattern'; +import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay'; const { Search } = Input; @@ -48,148 +49,6 @@ interface RedisViewerProps { redisDB: number; } -// 尝试多种方式解码二进制数据 -const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => { - if (!value || value.length === 0) { - return { displayValue: '', encoding: 'UTF-8', needsHex: false }; - } - - // 统计字节分布 - let nullCount = 0; - let printableCount = 0; - let highByteCount = 0; - const sampleSize = Math.min(value.length, 200); - - for (let i = 0; i < sampleSize; i++) { - const code = value.charCodeAt(i); - if (code === 0) { - nullCount++; - } else if (code >= 32 && code < 127) { - printableCount++; - } else if (code >= 128) { - highByteCount++; - } - } - - // 如果超过30%是null字节,很可能是二进制数据,显示十六进制 - if (nullCount / sampleSize > 0.3) { - return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true }; - } - - // 如果超过70%是可打印ASCII字符,直接显示 - if (printableCount / sampleSize > 0.7) { - return { displayValue: value, encoding: 'UTF-8', needsHex: false }; - } - - // 尝试UTF-8解码 - if (highByteCount > 0) { - try { - const bytes = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - bytes[i] = value.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - - // 检查解码质量 - let validChars = 0; - let replacementChars = 0; - let controlChars = 0; - - for (let i = 0; i < Math.min(decoded.length, 200); i++) { - const code = decoded.charCodeAt(i); - if (code === 0xFFFD) { - replacementChars++; - } else if (code < 32 && code !== 9 && code !== 10 && code !== 13) { - controlChars++; - } else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) { - // ASCII可打印字符、中文字符、中文标点 - validChars++; - } - } - - const totalChecked = Math.min(decoded.length, 200); - - // 如果替换字符超过10%或控制字符超过20%,说明不是有效的UTF-8文本 - if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) { - return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true }; - } - - // 如果有效字符超过50%,使用UTF-8解码 - if (validChars / totalChecked > 0.5) { - return { displayValue: decoded, encoding: 'UTF-8', needsHex: false }; - } - } catch (e) { - // UTF-8解码失败 - } - } - - // 默认显示十六进制 - return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true }; -}; - -// 检测是否为二进制数据(包含大量不可打印字符) -const isBinaryData = (value: string): boolean => { - if (!value || value.length === 0) return false; - // 检查前 100 个字符中不可打印字符的比例 - const sampleSize = Math.min(value.length, 100); - let nonPrintableCount = 0; - for (let i = 0; i < sampleSize; i++) { - const code = value.charCodeAt(i); - // 不可打印字符:控制字符(0-31,除了 9, 10, 13)和 DEL(127) - if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127 || code > 255) { - nonPrintableCount++; - } - } - // 如果超过 10% 是不可打印字符,认为是二进制数据 - return nonPrintableCount / sampleSize > 0.1; -}; - -// 将字符串转换为十六进制显示 -const toHexDisplay = (value: string): string => { - const bytes: string[] = []; - const ascii: string[] = []; - let result = ''; - - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - bytes.push(code.toString(16).padStart(2, '0').toUpperCase()); - // 可打印 ASCII 字符显示原字符,否则显示点 - ascii.push(code >= 32 && code < 127 ? value[i] : '.'); - - if (bytes.length === 16 || i === value.length - 1) { - const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase(); - const hexPart = bytes.join(' ').padEnd(47, ' '); - const asciiPart = ascii.join(''); - result += `${offset} ${hexPart} |${asciiPart}|\n`; - bytes.length = 0; - ascii.length = 0; - } - } - return result; -}; - -// 尝试解析并格式化 JSON -const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => { - try { - const parsed = JSON.parse(value); - return { isJson: true, formatted: JSON.stringify(parsed, null, 2) }; - } catch { - return { isJson: false, formatted: value }; - } -}; - -// 格式化字符串值 - 支持 JSON、二进制数据检测和智能解码 -const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding?: string } => { - // 先检测是否为二进制数据 - if (isBinaryData(value)) { - const { displayValue, encoding, needsHex } = tryDecodeValue(value); - return { displayValue, isBinary: needsHex, isJson: false, encoding }; - } - // 尝试 JSON 格式化 - const { isJson, formatted } = tryFormatJson(value); - return { displayValue: formatted, isBinary: false, isJson, encoding: 'UTF-8' }; -}; - // 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿 const ResizableDivider: React.FC<{ onResizeEnd: (newWidth: number) => void; @@ -1074,6 +933,22 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; 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 (
= ({ connectionId, redisDB }) => { const renderStringValue = () => { const strValue = String(keyValue.value); - - // 根据查看模式生成显示内容 - const getDisplayContent = () => { - if (viewMode === 'hex') { - return { displayValue: toHexDisplay(strValue), isBinary: true, encoding: 'HEX' }; - } else if (viewMode === 'text') { - return { displayValue: strValue, isBinary: false, encoding: 'Text' }; - } else if (viewMode === 'utf8') { - try { - const bytes = new Uint8Array(strValue.length); - for (let i = 0; i < strValue.length; i++) { - bytes[i] = strValue.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - return { displayValue: decoded, isBinary: false, encoding: 'UTF-8' }; - } catch (e) { - return { displayValue: strValue, isBinary: false, encoding: 'UTF-8 (失败)' }; - } - } else { - // auto mode - const { displayValue, isBinary, isJson, encoding } = formatStringValue(strValue); - return { displayValue, isBinary, encoding }; - } - }; - - const { displayValue, isBinary, encoding } = getDisplayContent(); - const isJson = viewMode === 'auto' && formatStringValue(strValue).isJson; + const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(strValue); return (
@@ -1180,31 +1029,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const renderHashValue = () => { - // 根据查看模式处理值 - const processValue = (value: string) => { - if (viewMode === 'hex') { - return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' }; - } else if (viewMode === 'text') { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' }; - } else if (viewMode === 'utf8') { - try { - const bytes = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - bytes[i] = value.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' }; - } catch (e) { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' }; - } - } else { - // auto mode - return formatStringValue(value); - } - }; - const data = Object.entries(keyValue.value as Record).map(([field, value]) => { - const { displayValue, isBinary, isJson, encoding } = processValue(value); + const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value); return { field, value, displayValue, isBinary, isJson, encoding }; }); @@ -1341,31 +1167,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const renderListValue = () => { - // 根据查看模式处理值 - const processValue = (value: string) => { - if (viewMode === 'hex') { - return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' }; - } else if (viewMode === 'text') { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' }; - } else if (viewMode === 'utf8') { - try { - const bytes = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - bytes[i] = value.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' }; - } catch (e) { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' }; - } - } else { - // auto mode - return formatStringValue(value); - } - }; - const data = (keyValue.value as string[]).map((value, index) => { - const { displayValue, isBinary, isJson, encoding } = processValue(value); + const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(value); return { index, value, displayValue, isBinary, isJson, encoding }; }); @@ -1511,31 +1314,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const renderSetValue = () => { - // 根据查看模式处理值 - const processValue = (value: string) => { - if (viewMode === 'hex') { - return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' }; - } else if (viewMode === 'text') { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' }; - } else if (viewMode === 'utf8') { - try { - const bytes = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - bytes[i] = value.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' }; - } catch (e) { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' }; - } - } else { - // auto mode - return formatStringValue(value); - } - }; - const data = (keyValue.value as string[]).map((member, index) => { - const { displayValue, isBinary, isJson, encoding } = processValue(member); + const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(member); return { index, member, displayValue, isBinary, isJson, encoding }; }); @@ -1648,31 +1428,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const renderZSetValue = () => { - // 根据查看模式处理值 - const processValue = (value: string) => { - if (viewMode === 'hex') { - return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' }; - } else if (viewMode === 'text') { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' }; - } else if (viewMode === 'utf8') { - try { - const bytes = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - bytes[i] = value.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' }; - } catch (e) { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' }; - } - } else { - // auto mode - return formatStringValue(value); - } - }; - const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => { - const { displayValue, isBinary, isJson, encoding } = processValue(item.member); + const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(item.member); return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding }; }); @@ -1813,30 +1570,9 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const renderStreamValue = () => { - const processValue = (value: string) => { - if (viewMode === 'hex') { - return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' }; - } else if (viewMode === 'text') { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' }; - } else if (viewMode === 'utf8') { - try { - const bytes = new Uint8Array(value.length); - for (let i = 0; i < value.length; i++) { - bytes[i] = value.charCodeAt(i) & 0xFF; - } - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); - return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' }; - } catch (e) { - return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' }; - } - } else { - return formatStringValue(value); - } - }; - const data = (keyValue.value as StreamEntry[]).map((item, index) => { const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2); - const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText); + const { displayValue, isBinary, isJson, encoding } = processValueForCurrentView(rawFieldsText); return { index, id: item.id, @@ -2189,7 +1925,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { > setEditValue(value || '')} diff --git a/frontend/src/utils/redisValueDisplay.test.ts b/frontend/src/utils/redisValueDisplay.test.ts new file mode 100644 index 0000000..499c31e --- /dev/null +++ b/frontend/src/utils/redisValueDisplay.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeRedisUtf8Value, formatRedisStringValue } from './redisValueDisplay'; + +const toRedisByteString = (text: string): string => ( + Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join('') +); + +describe('redisValueDisplay', () => { + it('keeps already decoded unicode text in utf8 mode', () => { + expect(decodeRedisUtf8Value('中文内容')).toBe('中文内容'); + }); + + it('decodes utf8 byte strings in auto mode', () => { + expect(formatRedisStringValue(toRedisByteString('中文内容'))).toMatchObject({ + displayValue: '中文内容', + isBinary: false, + isJson: false, + encoding: 'UTF-8', + }); + }); + + it('falls back to hex for obvious binary values', () => { + expect(formatRedisStringValue('\u0000\u0001\u0002abc')).toMatchObject({ + isBinary: true, + encoding: 'HEX', + }); + }); +}); diff --git a/frontend/src/utils/redisValueDisplay.ts b/frontend/src/utils/redisValueDisplay.ts new file mode 100644 index 0000000..fdf7a77 --- /dev/null +++ b/frontend/src/utils/redisValueDisplay.ts @@ -0,0 +1,153 @@ +const hasDecodedUnicodeText = (value: string): boolean => { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0xFF) { + return true; + } + } + + return false; +}; + +const toByteArray = (value: string): Uint8Array => { + const bytes = new Uint8Array(value.length); + for (let i = 0; i < value.length; i++) { + bytes[i] = value.charCodeAt(i) & 0xFF; + } + + return bytes; +}; + +const decodeUtf8Bytes = (value: string): string => ( + new TextDecoder('utf-8', { fatal: false }).decode(toByteArray(value)) +); + +const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => { + if (!value || value.length === 0) { + return { displayValue: '', encoding: 'UTF-8', needsHex: false }; + } + + if (hasDecodedUnicodeText(value)) { + return { displayValue: value, encoding: 'UTF-8', needsHex: false }; + } + + let nullCount = 0; + let printableCount = 0; + let highByteCount = 0; + const sampleSize = Math.min(value.length, 200); + + for (let i = 0; i < sampleSize; i++) { + const code = value.charCodeAt(i); + if (code === 0) { + nullCount++; + } else if (code >= 32 && code < 127) { + printableCount++; + } else if (code >= 128) { + highByteCount++; + } + } + + if (nullCount / sampleSize > 0.3) { + return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true }; + } + + if (highByteCount === 0 && printableCount / sampleSize > 0.7) { + return { displayValue: value, encoding: 'UTF-8', needsHex: false }; + } + + if (highByteCount > 0) { + try { + const decoded = decodeUtf8Bytes(value); + let validChars = 0; + let replacementChars = 0; + let controlChars = 0; + + for (let i = 0; i < Math.min(decoded.length, 200); i++) { + const code = decoded.charCodeAt(i); + if (code === 0xFFFD) { + replacementChars++; + } else if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + controlChars++; + } else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) { + validChars++; + } + } + + const totalChecked = Math.max(1, Math.min(decoded.length, 200)); + if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) { + return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true }; + } + + if (validChars / totalChecked > 0.5) { + return { displayValue: decoded, encoding: 'UTF-8', needsHex: false }; + } + } catch { + // ignore decode failure + } + } + + return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true }; +}; + +const tryFormatJson = (value: string): { isJson: boolean; formatted: string } => { + try { + const parsed = JSON.parse(value); + return { isJson: true, formatted: JSON.stringify(parsed, null, 2) }; + } catch { + return { isJson: false, formatted: value }; + } +}; + +export const toHexDisplay = (value: string): string => { + const bytes: string[] = []; + const ascii: string[] = []; + let result = ''; + + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + bytes.push(code.toString(16).padStart(2, '0').toUpperCase()); + ascii.push(code >= 32 && code < 127 ? value[i] : '.'); + + if (bytes.length === 16 || i === value.length - 1) { + const offset = (Math.floor(i / 16) * 16).toString(16).padStart(8, '0').toUpperCase(); + const hexPart = bytes.join(' ').padEnd(47, ' '); + const asciiPart = ascii.join(''); + result += `${offset} ${hexPart} |${asciiPart}|\n`; + bytes.length = 0; + ascii.length = 0; + } + } + + return result; +}; + +export const decodeRedisUtf8Value = (value: string): string => { + if (!value || value.length === 0) { + return ''; + } + + if (hasDecodedUnicodeText(value)) { + return value; + } + + try { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0x7F) { + return decodeUtf8Bytes(value); + } + } + + return value; + } catch { + return value; + } +}; + +export const formatRedisStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding: string } => { + const { displayValue, encoding, needsHex } = tryDecodeValue(value); + if (needsHex) { + return { displayValue, isBinary: true, isJson: false, encoding }; + } + + const { isJson, formatted } = tryFormatJson(displayValue); + return { displayValue: formatted, isBinary: false, isJson, encoding }; +};