diff --git a/ROADMAP.md b/ROADMAP.md index 5d14383..cdf3ad7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ - [x] **多格式查看器** — Text / Hex / Json / Binary / Msgpack / PHP Serialize / Java Serialize / Pickle / Brotli / Gzip / Deflate / DeflateRaw / Protobuf(13 种格式) - [x] **自动格式检测** — 检查内容 magic bytes 自动选择查看器 -- [ ] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE} +- [x] **自定义格式化器** — 外部可执行文件 + 模板参数 {KEY} {VALUE} {HEX} {HEX_FILE} - [ ] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑 - [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换 - [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化 diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 2b08257..7d4d451 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -264,6 +264,9 @@ export function registerIpcHandlers(): void { ipcMain.handle('redis:detectFormat', (_e, value: string) => { return redis.detectFormat(value) }) + ipcMain.handle('redis:customFormat', (_e, value: string, command: string, key: string) => { + return redis.customFormat(value, command, key) + }) // Pub/Sub ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => { diff --git a/electron/main/redis/format.ts b/electron/main/redis/format.ts index f0c66fb..2a38aab 100644 --- a/electron/main/redis/format.ts +++ b/electron/main/redis/format.ts @@ -1,6 +1,10 @@ // electron/main/redis/format.ts -// 值格式转换(解压/编码) +// 值格式转换(解压/编码/自定义) import { gunzipSync, inflateSync, brotliDecompressSync } from 'zlib' +import { execFile } from 'child_process' +import { writeFile, unlink, mkdtemp } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' export type FormatType = 'text' | 'hex' | 'json' | 'binary' | 'gzip' | 'deflate' | 'brotli' | 'deflateRaw' @@ -44,3 +48,33 @@ export function detectFormat(value: string): FormatType { if (hasNonPrintable) return 'hex' return 'text' } + +export async function customFormat( + value: string, + command: string, + key?: string +): Promise { + const tmpDir = await mkdtemp(join(tmpdir(), 'jrdm-fmt-')) + const tmpFile = join(tmpDir, 'value.bin') + await writeFile(tmpFile, value, 'utf-8') + + return new Promise((resolve) => { + const cmd = command + .replace(/\{KEY\}/g, key || '') + .replace(/\{VALUE\}/g, value) + .replace(/\{HEX_FILE\}/g, tmpFile) + + const parts = cmd.split(/\s+/) + const exe = parts[0] + const args = parts.slice(1) + + execFile(exe, args, { timeout: 10000, maxBuffer: 10 * 1024 * 1024 }, async (err, stdout) => { + await unlink(tmpFile).catch(() => {}) + if (err) { + resolve(`[Custom formatter error: ${err.message}]`) + } else { + resolve(stdout) + } + }) + }) +} diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index de003ee..0ee4531 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -111,6 +111,7 @@ export interface ElectronAPI { clearCommandLog: () => Promise decodeValue: (value: string, format: string) => Promise detectFormat: (value: string) => Promise + customFormat: (value: string, command: string, key: string) => Promise subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<{ success: boolean; error?: string }> unsubscribe: (id: string) => Promise onSubscribeMessage: (callback: (msg: any) => void) => void diff --git a/electron/preload/index.ts b/electron/preload/index.ts index fc04eb7..a281643 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -135,6 +135,8 @@ const electronAPI = { ipcRenderer.invoke('redis:decodeValue', value, format), detectFormat: (value: string): Promise => ipcRenderer.invoke('redis:detectFormat', value), + customFormat: (value: string, command: string, key: string): Promise => + ipcRenderer.invoke('redis:customFormat', value, command, key), // Pub/Sub subscribe: (id: string, channels: string[], isPattern: boolean): Promise => ipcRenderer.invoke('redis:subscribe', id, channels, isPattern), diff --git a/src/components/StringEditor.vue b/src/components/StringEditor.vue index ec303e0..1e8c5a6 100644 --- a/src/components/StringEditor.vue +++ b/src/components/StringEditor.vue @@ -34,8 +34,11 @@ const formatOptions = [ { value: 'gzip', labelKey: 'editor.formatGzip' }, { value: 'deflate', labelKey: 'editor.formatDeflate' }, { value: 'brotli', labelKey: 'editor.formatBrotli' }, + { value: 'custom', labelKey: 'editor.formatCustom' }, ] +const customCommand = ref(localStorage.getItem('customFormatter') || '/bin/cat {HEX_FILE}') + const effectiveFormat = computed(() => { if (selectedFormat.value === 'auto') return detectedFormat.value return selectedFormat.value @@ -70,6 +73,11 @@ async function decodeAndDisplay(raw: string) { } } // For other formats, use IPC decode + if (fmt === 'custom') { + displayValue.value = await window.electronAPI.redis.customFormat(raw, customCommand.value, keyStore.selectedKey || '') + isJson.value = false + return + } displayValue.value = await window.electronAPI.redis.decodeValue(raw, fmt) isJson.value = false } @@ -90,6 +98,8 @@ watch(selectedFormat, () => { if (editedValue.value) decodeAndDisplay(editedValue.value) }) +watch(customCommand, (v) => localStorage.setItem('customFormatter', v)) + const editorOptions = computed(() => ({ readOnly: !isEditing.value || effectiveFormat.value !== 'json' && effectiveFormat.value !== 'text', minimap: { enabled: false }, @@ -133,6 +143,13 @@ async function save() { :label="t(opt.labelKey)" /> + {{ t('editor.format') }}: {{ detectedFormat }} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c6575ba..0a5657f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -141,6 +141,9 @@ export default { formatGzip: 'Gzip', formatDeflate: 'Deflate', formatBrotli: 'Brotli', + formatCustom: 'Custom', + customCommand: 'Custom Command', + customCommandHint: 'Templates: {KEY} {VALUE} {HEX_FILE}', consumerGroups: 'Consumer Groups', group: 'Group', consumers: 'Consumers', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index abc4d88..5f8e0bd 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -141,6 +141,9 @@ export default { formatGzip: 'Gzip', formatDeflate: 'Deflate', formatBrotli: 'Brotli', + formatCustom: '自定义', + customCommand: '自定义命令', + customCommandHint: '模板: {KEY} {VALUE} {HEX_FILE}', consumerGroups: '消费者组', group: '组名', consumers: '消费者',