🐛 fix(redis-viewer): 修正 Redis 值自动与 UTF-8 展示不一致

- 新增 redisValueDisplay 工具,统一自动、UTF-8 与十六进制模式的展示判断
- 修正已解码 Unicode 文本被重复按字节解码导致的乱码问题
- 补充 Redis 值展示回归测试,并让各数据类型复用同一套展示逻辑

Fixes #386
This commit is contained in:
Syngnat
2026-04-17 18:31:04 +08:00
parent 9613b2a8eb
commit 8eae39c2c2
3 changed files with 206 additions and 288 deletions

View File

@@ -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和 DEL127
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<RedisViewerProps> = ({ 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 (
<div
@@ -1095,33 +970,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ 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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -1180,31 +1029,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ 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<string, string>).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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ connectionId, redisDB }) => {
>
<Editor
height="450px"
language={tryFormatJson(editValue).isJson ? 'json' : 'plaintext'}
language={formatRedisStringValue(editValue).isJson ? 'json' : 'plaintext'}
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={editValue}
onChange={(value) => setEditValue(value || '')}

View File

@@ -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',
});
});
});

View File

@@ -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 };
};