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:
+22
-22
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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%"
|
||||
|
||||
@@ -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)
|
||||
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++
|
||||
} catch {
|
||||
}
|
||||
// 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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '{n} 個のキーを削除しますか?',
|
||||
batchDeleted: '{n} 個のキーを削除しました',
|
||||
batchPreview: '削除プレビュー',
|
||||
batchDeleteConfirm: '{n} 個のキーを削除しますか?',
|
||||
batchPreviewHint: '{n} 個のキーを削除しようとしています',
|
||||
andMore: '...他 {n} 個',
|
||||
selected: '{n} 個選択済み',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '{n}개 키를 삭제하시겠습니까?',
|
||||
batchDeleted: '{n}개 키가 삭제되었습니다',
|
||||
batchPreview: '삭제 미리보기',
|
||||
batchDeleteConfirm: '{n}개 키를 삭제하시겠습니까?',
|
||||
batchPreviewHint: '{n}개 키를 삭제하려고 합니다',
|
||||
andMore: '...그 외 {n}개',
|
||||
selected: '{n}개 선택됨',
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
batchConfirm: '删除 {n} 个键?',
|
||||
batchDeleted: '已删除 {n} 个键',
|
||||
batchPreview: '删除预览',
|
||||
batchDeleteConfirm: '确定删除 {n} 个键?',
|
||||
batchPreviewHint: '即将删除以下 {n} 个键',
|
||||
andMore: '...以及其他 {n} 个',
|
||||
selected: '已选 {n} 项',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user