fix: patch all P2 quality/performance issues from project review

- ipc-handlers: withLog extracts actual connection ID from args[0] (P2#20)
- updater: add initialized guard to prevent duplicate IPC registration (P2#25)
- win-state: log writeFile errors instead of empty callback (P2#26)
- format: detectFormat samples up to 4096 chars instead of 100 (P2#28)
- commandLogger: add 30+ missing write commands to WRITE_COMMANDS (P2#32)
- ReJsonEditor: bind Monaco theme to monacoTheme computed (P2#19)
- keys: normalize empty SCAN pattern to '*' (P2#21)
- SlowLogView: remove duplicate <style scoped> block (P2#29)
- CliView: deduplicate XGROUP in writeCommands Set (P2#33)
- key store: use connectionStore instead of IPC for separator (P2#22)
- NewConnectionDialog: add visible guard after async watch (P2#30)
- KeyList: remove dangerouslyUseHTMLString, use plain text + i18n (P2#31)
- KeyList: batch DUMP export with Promise.allSettled (batch=20) (P2#23)
- HashEditor: lazy load field TTL on click instead of bulk HTTL (P2#24)
- P2#27 already fixed in P0#2; P2#34 is feature (N4); P2#35 not a bug
- docs: update PROJECT_REVIEW.md with P2 fix status
This commit is contained in:
2026-07-12 14:30:18 +08:00
parent 70db25b337
commit 1a5e4dd706
19 changed files with 94 additions and 110 deletions
+22 -22
View File
@@ -4,7 +4,7 @@
> 审查方式:并行扫描主进程、渲染进程、IPC 契约一致性
> 审查范围:主进程 10 文件、渲染进程 ~40 文件、IPC 65 通道
> 发现问题:54 个(P0×6, P1×12, P2×17, P3×19)
> 已修复:**P0×6 + P1×11 全部修复** (2026-07-12)
> 已修复:**P0×6 + P1×11 + P2×14 全部修复** (2026-07-12)
> 可开发需求:26 项(高优先级 6,中 11,低 9)
---
@@ -49,27 +49,27 @@
| 17 | `StreamEditor.vue:357-361` | **Trim 对话框无确认按钮**:添加 Cancel/Confirm 按钮调用 `trimStream()` | **已修复** |
| 18 | `KeyBrowser.vue` + `DbSelector.vue` | **双重扫描**:移除 KeyBrowser watch 的 `{ immediate: true }` | **已修复** |
### P2 - 应修复(质量/性能)
### P2 - 应修复(质量/性能) - 全部已处理
| # | 位置 | 问题 |
|---|------|------|
| 19 | `ReJsonEditor.vue:88` | Monaco 主题硬编码 `vs-dark`,亮色主题下对比度极差。 |
| 20 | `ipc-handlers.ts:13,16` | `withLog` 记录 `'current'` 而非实际连接 ID,命令日志无用。 |
| 21 | `keys.ts:96` | 空模式 `''` 导致 SCAN 匹配空字符串键,应规范化为 `'*'` |
| 22 | `key.ts:115-119` | 每次扫描都调 `getConnections()` IPC,连接信息已在 store 中。 |
| 23 | `KeyList.vue:143-180` | 批量导出串行 DUMP,1000 key = 1000 次 IPC 往返。 |
| 24 | `HashEditor.vue:80-89` | 逐个字段 HTTL,大 hash 数百次 IPC。 |
| 25 | `updater.ts:15,25,35` | 重复调用 `initUpdater` `ipcMain.handle` 抛重复注册错误。 |
| 26 | `win-state.ts:47` | 写错误回调静默吞噬。 |
| 27 | `format.ts:56-58` | 临时目录 `mkdtemp` 创建后成功路径不清理,`/tmp/jrdm-fmt-*/` 累积。 |
| 28 | `format.ts:46` | `detectFormat` 检查前 100 字符,前 100 为 ASCII 的二进制值误判为 text。 |
| 29 | `SlowLogView.vue:241-283` | 重复 `<style scoped>` |
| 30 | `NewConnectionDialog.vue:46-95` | 异步 watch 返回后可能对话框已关闭仍在设状态。 |
| 31 | `KeyList.vue:254` | `dangerouslyUseHTMLString: true` 潜在 XSS(依赖 escapeHtml 完整性)。 |
| 32 | `commandLogger.ts:16-25` | `WRITE_COMMANDS` `JSON.SET`/`JSON.DEL`/`COPY`/`RESTORE` 等。 |
| 33 | `CliView.vue:167-183` | `writeCommands` Set 重复 `XGROUP` 三次。 |
| 34 | 所有编辑器 | 大数据量无虚拟滚动,数万条记录一次性渲染。 |
| 35 | `StatusView.vue:144` | `refreshTimer` 模块级变量,重新挂载丢引用。 |
| # | 位置 | 问题 | 状态 |
|---|------|------|------|
| 19 | `ReJsonEditor.vue:88` | Monaco 主题改为 `:theme="monacoTheme"` 动态绑定 | **已修复** |
| 20 | `ipc-handlers.ts:13,16` | `withLog` `args[0]` 提取实际连接 ID | **已修复** |
| 21 | `keys.ts:96` | 空模式规范化为 `'*'` | **已修复** |
| 22 | `key.ts:115-119` | 改从 `useConnectionStore()` 获取 separator,消除 IPC 往返 | **已修复** |
| 23 | `KeyList.vue:143-180` | 批量导出改为 `Promise.allSettled` 分批并行(每批 20) | **已修复** |
| 24 | `HashEditor.vue:80-89` | HTTL 改为点击字段时按需加载(延迟加载) | **已修复** |
| 25 | `updater.ts` | `initUpdater` 添加 `initialized` 守卫防重复注册 | **已修复** |
| 26 | `win-state.ts:47` | 写错误回调改为 `console.error` | **已修复** |
| 27 | `format.ts:56-58` | 临时目录清理(随 P0#2 修复) | **已修复** |
| 28 | `format.ts:46` | `detectFormat` 检查范围扩大到 4096 字符 | **已修复** |
| 29 | `SlowLogView.vue:241-283` | 删除重复 `<style scoped>` | **已修复** |
| 30 | `NewConnectionDialog.vue:46-95` | async watch 中 await 后加 `if (!props.visible) return` 守卫 | **已修复** |
| 31 | `KeyList.vue:254` | 移除 `dangerouslyUseHTMLString`,改用纯文本 + i18n | **已修复** |
| 32 | `commandLogger.ts:16-25` | `WRITE_COMMANDS` 补充 30+ 缺失写命令 | **已修复** |
| 33 | `CliView.vue:167-183` | `writeCommands` Set 去除重复 `XGROUP` | **已修复** |
| 34 | 所有编辑器 | 大数据量无虚拟滚动(属需求 N4,不在修复范围) | **需求** |
| 35 | `StatusView.vue:144` | `refreshTimer` 已在 `<script setup>` 内,`onUnmounted` 正确清理 | **非 bug** |
### P3 - 代码质量改进
@@ -191,7 +191,7 @@
- **审查范围**:主进程 10 文件、渲染进程 ~40 文件、IPC 65 通道
- **发现问题**:54 个(P0×6, P1×12, P2×17, P3×19)
- **已修复**:P0×6 + P1×11 = **17** (2026-07-12)
- **已修复**:P0×6 + P1×11 + P2×14 = **31 个** (2026-07-12)
- **安全问题**:6 个(P0 全部已修复)
- **死代码**:6 处
- **可开发需求**:26 项(高优先级 6,中 11,低 9)
+5 -4
View File
@@ -6,14 +6,15 @@ import * as redis from './redis'
// Helper: wrap handler with command logging
function withLog(cmd: string, fn: (...args: any[]) => any) {
return async (...args: any[]) => {
return async (_e: any, ...args: any[]) => {
const start = Date.now()
const connId = args[0] || 'unknown'
try {
const result = await fn(...args)
redis.log('current', cmd, Date.now() - start)
const result = await fn(_e, ...args)
redis.log(connId, cmd, Date.now() - start)
return result
} catch (err) {
redis.log('current', cmd, Date.now() - start)
redis.log(connId, cmd, Date.now() - start)
throw err
}
}
+9
View File
@@ -22,6 +22,15 @@ const WRITE_COMMANDS = new Set([
'LPOP', 'RPOP', 'BLPOP', 'BRPOP',
'ZINCRBY', 'ZREMRANGEBYRANK', 'ZREMRANGEBYSCORE',
'UNLINK', 'MOVE', 'RENAMENX',
'JSON.SET', 'JSON.DEL', 'JSON.ARRAPPEND', 'JSON.ARRINDEX', 'JSON.ARRINSERT',
'JSON.ARRTRIM', 'JSON.ARRPOP', 'JSON.NUMINCRBY', 'JSON.STRAPPEND', 'JSON.STRLEN',
'JSON.OBJSET', 'JSON.OBJDEL', 'COPY', 'RESTORE', 'MIGRATE',
'PEXPIRE', 'PEXPIREAT', 'PSETEX', 'SETNX',
'GEOADD', 'BITFIELD', 'SETBIT', 'HSETNX',
'LINSERT', 'RPOPLPUSH', 'LMOVE',
'SPOP', 'SMOVE', 'SDIFFSTORE', 'SINTERSTORE', 'SUNIONSTORE',
'ZPOPMAX', 'ZPOPMIN', 'ZREMRANGEBYLEX',
'XGROUP', 'XCLAIM', 'XSETID', 'SWAPDB',
])
export function log(connection: string, command: string, duration: number): void {
+2 -1
View File
@@ -43,7 +43,8 @@ export function detectFormat(value: string): FormatType {
// Deflate (zlib) magic: 78 01/9c/da
if (value.charCodeAt(0) === 0x78 && [0x01, 0x9c, 0xda, 0x5e].includes(value.charCodeAt(1))) return 'deflate'
// Check for non-printable characters → binary/hex
const hasNonPrintable = /[\x00-\x08\x0e-\x1f]/.test(value.substring(0, 100))
const sample = value.length > 4096 ? value.substring(0, 4096) : value
const hasNonPrintable = /[\x00-\x08\x0e-\x1f]/.test(sample)
if (hasNonPrintable) return 'hex'
return 'text'
}
+1
View File
@@ -93,6 +93,7 @@ export async function scanKeys(
count: number
): Promise<{ cursor: string; keys: string[] }> {
const client = requireClient(id)
if (!pattern || pattern === '') pattern = '*'
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
return { cursor: nextCursor, keys }
}
+3
View File
@@ -6,8 +6,11 @@ import electronUpdater from 'electron-updater'
import { BrowserWindow, ipcMain } from 'electron'
let mainWindow: BrowserWindow | null = null
let initialized = false
export function initUpdater(win: BrowserWindow): void {
if (initialized) return
initialized = true
mainWindow = win
const autoUpdater = electronUpdater.autoUpdater
+3 -1
View File
@@ -44,7 +44,9 @@ function getLastState(): WinState {
function saveState(win: BrowserWindow): void {
const bounds = win.getBounds()
const state = { ...bounds, maximized: win.isMaximized() }
writeFile(STATE_FILE, JSON.stringify(state), 'utf-8', () => {})
writeFile(STATE_FILE, JSON.stringify(state), 'utf-8', (err) => {
if (err) console.error('Failed to save window state:', err)
})
}
export function restoreAndTrack(win: BrowserWindow): void {
@@ -49,6 +49,7 @@ watch(() => props.visible, async (v) => {
if (props.connection) {
isEdit.value = true
const decryptedAuth = await connStore.decryptPassword(props.connection.auth || '')
if (!props.visible) return
Object.assign(form, {
name: props.connection.name,
host: props.connection.host,
@@ -59,9 +59,7 @@ async function refresh() {
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
keyStore.keyData = result.fields
await checkHexpireSupport()
if (supportsHexpire.value) {
await loadFieldTtls()
}
// TTL is loaded lazily on field focus instead of upfront
} finally {
loading.value = false
}
@@ -77,15 +75,20 @@ async function checkHexpireSupport() {
}
}
async function loadFieldTtls() {
async function loadFieldTtl(field: HashField) {
if (!connStore.activeId || !keyStore.selectedKey) return
for (const field of fields.value) {
try {
const ttlResult = await window.electronAPI.redis.execute(connStore.activeId, 'HTTL', [keyStore.selectedKey, 'FIELDS', '1', field.field])
field.ttl = Number(ttlResult)
} catch {
field.ttl = null
}
try {
const ttlResult = await window.electronAPI.redis.execute(connStore.activeId, 'HTTL', [keyStore.selectedKey, 'FIELDS', '1', field.field])
field.ttl = Number(ttlResult)
} catch {
field.ttl = null
}
}
function onFieldClick(row: HashField) {
// Lazily load TTL for the clicked field if not already loaded
if (supportsHexpire.value && row.ttl === null) {
loadFieldTtl(row)
}
}
@@ -148,7 +151,7 @@ async function addField() {
</div>
<div class="hash-table-wrap">
<el-table :data="filteredFields" size="small" class="hash-table" height="100%">
<el-table :data="filteredFields" size="small" class="hash-table" height="100%" @cell-click="onFieldClick">
<el-table-column prop="field" :label="t('editor.field')" min-width="150">
<template #default="{ row }">
<el-input v-model="row.field" size="small" class="hash-input" />
@@ -85,7 +85,7 @@ async function save() {
<div class="editor-content">
<vue-monaco-editor
v-model:value="editedValue"
theme="vs-dark"
:theme="monacoTheme"
language="json"
:options="editorOptions"
height="100%"
+22 -21
View File
@@ -145,16 +145,28 @@ async function exportKeys() {
const keysArray = Array.from(selectedKeys.value)
const rows: string[] = []
let exported = 0
const BATCH_SIZE = 20
for (const key of keysArray) {
try {
const dumpResult = await window.electronAPI.redis.execute(connStore.activeId, 'DUMP', [key])
const ttl = await window.electronAPI.redis.getKeyTTL(connStore.activeId, key)
const hexKey = strToHex(key)
const hexValue = binaryToHex(dumpResult)
rows.push(`${hexKey},${hexValue},${ttl}`)
exported++
} catch {
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
const batch = keysArray.slice(i, i + BATCH_SIZE)
const batchResults = await Promise.allSettled(
batch.map(key =>
Promise.all([
window.electronAPI.redis.execute(connStore.activeId, 'DUMP', [key]),
window.electronAPI.redis.getKeyTTL(connStore.activeId, key)
])
)
)
for (let j = 0; j < batchResults.length; j++) {
const result = batchResults[j]
if (result.status === 'fulfilled') {
const [dumpResult, ttl] = result.value
const key = batch[j]
const hexKey = strToHex(key)
const hexValue = binaryToHex(dumpResult)
rows.push(`${hexKey},${hexValue},${ttl}`)
exported++
}
// Skip keys that fail to dump
}
}
@@ -235,23 +247,12 @@ async function batchDelete() {
if (!connStore.activeId || selectedKeys.value.size === 0) return
const count = selectedKeys.value.size
const keysArray = Array.from(selectedKeys.value)
const previewKeys = keysArray.slice(0, 100)
const remaining = count - 100
const keyListHtml = previewKeys.map(k => `<div class="batch-preview-key">${escapeHtml(formatKeyName(k))}</div>`).join('')
const hintText = t('key.batchPreviewHint', { n: count })
const andMore = remaining > 0 ? `<div class="batch-preview-more">${t('key.andMore', { n: remaining })}</div>` : ''
try {
await ElMessageBox.confirm(
`<div class="batch-preview">
<div class="batch-preview-hint">${hintText}</div>
<div class="batch-preview-list">${keyListHtml}</div>
${andMore}
</div>`,
t('key.batchDeleteConfirm', { n: count }),
t('key.batchPreview'),
{
dangerouslyUseHTMLString: true,
confirmButtonText: t('key.batchDelete'),
cancelButtonText: t('common.cancel'),
type: 'warning',
@@ -170,7 +170,7 @@ async function execute() {
'LPUSH', 'LPUSHX', 'RPUSH', 'RPUSHX', 'LSET', 'LINSERT', 'LREM', 'LPOP', 'RPOP',
'SADD', 'SREM', 'SMOVE', 'SPOP', 'SINTERSTORE', 'SUNIONSTORE', 'SDIFFSTORE',
'ZADD', 'ZREM', 'ZINCRBY', 'ZPOPMIN', 'ZPOPMAX', 'ZINTERSTORE', 'ZUNIONSTORE',
'XADD', 'XDEL', 'XTRIM', 'XSETID', 'XGROUP', 'XGROUP', 'XGROUP',
'XADD', 'XDEL', 'XTRIM', 'XSETID', 'XGROUP',
'DEL', 'UNLINK', 'RENAME', 'RENAMENX',
'EXPIRE', 'EXPIREAT', 'PEXPIRE', 'PEXPIREAT', 'PERSIST',
'FLUSHDB', 'FLUSHALL', 'SWAPDB', 'MOVE', 'COPY',
@@ -238,46 +238,3 @@ defineExpose({ refresh })
}
</style>
<style scoped>
.slow-log {
height: 100%;
display: flex;
flex-direction: column;
}
.toolbar {
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.content {
flex: 1;
padding: 12px;
overflow: auto;
}
.cmd-text {
font-family: 'Cascadia Code', monospace;
font-size: 12px;
}
.config-footer {
display: flex;
gap: 24px;
padding: 8px 12px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
</style>
+1
View File
@@ -112,6 +112,7 @@ export default {
batchConfirm: '{n} Schlüssel löschen?',
batchDeleted: '{n} Schlüssel gelöscht',
batchPreview: 'Löschvorschau',
batchDeleteConfirm: '{n} Schlüssel löschen?',
batchPreviewHint: '{n} Schlüssel werden gelöscht',
andMore: '...und {n} weitere',
selected: '{n} ausgewählt',
+1
View File
@@ -112,6 +112,7 @@ export default {
batchConfirm: 'Delete {n} key(s)?',
batchDeleted: 'Deleted {n} key(s)',
batchPreview: 'Delete Preview',
batchDeleteConfirm: 'Delete {n} key(s)?',
batchPreviewHint: 'About to delete {n} keys',
andMore: '...and {n} more',
selected: '{n} selected',
+1
View File
@@ -112,6 +112,7 @@ export default {
batchConfirm: '{n} 個のキーを削除しますか?',
batchDeleted: '{n} 個のキーを削除しました',
batchPreview: '削除プレビュー',
batchDeleteConfirm: '{n} 個のキーを削除しますか?',
batchPreviewHint: '{n} 個のキーを削除しようとしています',
andMore: '...他 {n} 個',
selected: '{n} 個選択済み',
+1
View File
@@ -112,6 +112,7 @@ export default {
batchConfirm: '{n}개 키를 삭제하시겠습니까?',
batchDeleted: '{n}개 키가 삭제되었습니다',
batchPreview: '삭제 미리보기',
batchDeleteConfirm: '{n}개 키를 삭제하시겠습니까?',
batchPreviewHint: '{n}개 키를 삭제하려고 합니다',
andMore: '...그 외 {n}개',
selected: '{n}개 선택됨',
+1
View File
@@ -112,6 +112,7 @@ export default {
batchConfirm: '删除 {n} 个键?',
batchDeleted: '已删除 {n} 个键',
batchPreview: '删除预览',
batchDeleteConfirm: '确定删除 {n} 个键?',
batchPreviewHint: '即将删除以下 {n} 个键',
andMore: '...以及其他 {n} 个',
selected: '已选 {n} 项',
+4 -4
View File
@@ -113,8 +113,8 @@ export const useKeyStore = defineStore('key', () => {
hasMore.value = result.cursor !== '0'
if (useTree.value) {
const conn = (await window.electronAPI.storage.getConnections())
.find((c: any) => c.id === connId)
const connectionStore = useConnectionStore()
const conn = connectionStore.connections.find(c => c.id === connId)
const sep = conn?.separator || ':'
treeKeys.value = buildTree(keys.value.map(k => k.name), sep)
}
@@ -149,8 +149,8 @@ export const useKeyStore = defineStore('key', () => {
keys.value = keys.value.filter(k => k.name !== key)
// Rebuild tree if needed
if (useTree.value) {
const conn = (await window.electronAPI.storage.getConnections())
.find((c: any) => c.id === connId)
const connectionStore = useConnectionStore()
const conn = connectionStore.connections.find(c => c.id === connId)
const sep = conn?.separator || ':'
treeKeys.value = buildTree(keys.value.map(k => k.name), sep)
}