feat: key export/import, command import, virtualized scrolling

- P2-6: KeyList virtualized scrolling using @tanstack/vue-virtual (>100 keys)
- P2-8: Key export to CSV (DUMP + PTTL, hex-encoded)
- P2-9: Key import from CSV (RESTORE ... REPLACE)
- P2-10: CLI command import from text file
- i18n keys for all features (zh-CN + en)
This commit is contained in:
2026-07-07 22:14:22 +08:00
parent 90d56f2a12
commit 6ac5636944
7 changed files with 339 additions and 28 deletions
+97
View File
@@ -165,6 +165,94 @@ async function execute() {
}
}
async function importCommands() {
if (!connStore.activeId) return
const result = await window.electronAPI.dialog.openFile({
title: t('cli.importCommands'),
filters: [{ name: 'Text Files', extensions: ['txt', 'conf'] }],
})
if (!result || !result.content) return
const lines = result.content.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))
let executed = 0
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
// Parse command respecting quoted strings
const args: string[] = []
let current = ''
let inQuote = false
let quoteChar = ''
for (const ch of trimmed) {
if (inQuote) {
if (ch === quoteChar) {
inQuote = false
if (current) args.push(current)
current = ''
} else {
current += ch
}
} else if (ch === '"' || ch === "'") {
inQuote = true
quoteChar = ch
} else if (ch === ' ') {
if (current) {
args.push(current)
current = ''
}
} else {
current += ch
}
}
if (current) args.push(current)
if (args.length === 0) continue
const command = args[0].toUpperCase()
const cmdArgs = args.slice(1)
try {
const result = await window.electronAPI.redis.execute(connStore.activeId, command, cmdArgs)
let formatted = ''
if (result === null) {
formatted = '(nil)'
} else if (typeof result === 'number') {
formatted = `(integer) ${result}`
} else if (Array.isArray(result)) {
if (result.length === 0) {
formatted = '(empty array)'
} else {
formatted = result.map((item: any, i: number) => {
if (item === null) return `${i + 1}) (nil)`
if (typeof item === 'number') return `${i + 1}) (integer) ${item}`
return `${i + 1}) "${item}"`
}).join('\n')
}
} else if (typeof result === 'object') {
formatted = JSON.stringify(result, null, 2)
} else {
formatted = String(result)
}
history.value.push({ cmd: trimmed, result: formatted })
executed++
} catch (err: any) {
history.value.push({ cmd: trimmed, result: `ERROR: ${err.message}`, error: true })
}
}
history.value.push({ cmd: '', result: `--- ${t('cli.importCmdDone', { n: executed })} ---` })
await nextTick()
if (outputRef.value) {
outputRef.value.scrollTop = outputRef.value.scrollHeight
}
}
function onKeydown(e: KeyboardEvent) {
if (showSuggestions.value) {
if (e.key === 'ArrowDown') {
@@ -310,6 +398,9 @@ onUnmounted(() => {
</div>
</div>
</div>
<el-button size="small" text class="import-cmd-btn" @click="importCommands" :disabled="!connStore.activeId">
{{ t('cli.importCommands') }}
</el-button>
</template>
<template v-else>
<span class="stream-indicator" :class="streamingMode" />
@@ -480,4 +571,10 @@ onUnmounted(() => {
font-family: 'Cascadia Code', 'Fira Code', monospace;
margin-left: 6px;
}
.import-cmd-btn {
flex-shrink: 0;
margin-left: 6px;
font-size: 11px;
}
</style>
+196 -24
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// src/components/KeyList.vue
import { ref, watch, computed } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
@@ -17,6 +18,19 @@ const multiSelect = ref(false)
const selectedKeys = ref<Set<string>>(new Set())
const expandedFolders = ref<Set<string>>(new Set())
// Virtual scrolling
const scrollRef = ref<HTMLElement>()
const useVirtualization = computed(() => viewMode.value === 'list' && filteredKeys.value.length > 100)
const virtualizer = useVirtualizer(
computed(() => ({
count: filteredKeys.value.length,
getScrollElement: () => scrollRef.value,
estimateSize: () => 32,
overscan: 5,
enabled: useVirtualization.value,
}))
)
// Search history
const HISTORY_KEY = 'keySearchHistory'
const MAX_HISTORY = 20
@@ -106,6 +120,97 @@ function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
async function exportKeys() {
if (!connStore.activeId || selectedKeys.value.size === 0) return
const keysArray = Array.from(selectedKeys.value)
const rows: string[] = []
let exported = 0
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 = Buffer.from(key).toString('hex')
const hexValue = Buffer.from(dumpResult, 'binary').toString('hex')
rows.push(`${hexKey},${hexValue},${ttl}`)
exported++
} catch {
// Skip keys that fail to dump
}
}
if (rows.length === 0) {
ElMessage.error(t('key.importFailed', { error: 'No keys could be exported' }))
return
}
const csvContent = 'hex_key,hex_value,ttl\n' + rows.join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '')
link.href = url
link.download = `Dump_${dateStr}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success(t('key.exportDone', { n: exported }))
}
async function importKeys() {
if (!connStore.activeId) return
const result = await window.electronAPI.dialog.openFile({
title: t('key.importKeys'),
filters: [{ name: 'CSV Files', extensions: ['csv'] }],
})
if (!result || !result.content) return
const lines = result.content.trim().split('\n')
if (lines.length < 2) {
ElMessage.error(t('key.importFailed', { error: 'Empty file' }))
return
}
// Skip header line
let success = 0
let failed = 0
const total = lines.length - 1
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const parts = line.split(',')
if (parts.length < 3) {
failed++
continue
}
const hexKey = parts[0]
const hexValue = parts[1]
const ttl = parseInt(parts[2], 10)
try {
const key = Buffer.from(hexKey, 'hex').toString('utf-8')
const value = Buffer.from(hexValue, 'hex').toString('binary')
await window.electronAPI.redis.execute(connStore.activeId, 'RESTORE', [key, String(ttl), value, 'REPLACE'])
success++
} catch {
failed++
}
}
ElMessage.success(t('key.importDone', { n: success }))
if (failed > 0) {
ElMessage.warning(`${failed} / ${total} keys failed to import`)
}
selectedKeys.value.clear()
await refreshKeys()
}
async function batchDelete() {
if (!connStore.activeId || selectedKeys.value.size === 0) return
const count = selectedKeys.value.size
@@ -293,6 +398,9 @@ defineExpose({ deselectAll })
clearable
class="filter-input"
/>
<el-button size="small" @click="importKeys" :disabled="!connStore.activeId" class="import-btn">
{{ t('key.importKeys') }}
</el-button>
<el-button-group class="key-actions">
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
<el-icon><Refresh /></el-icon>
@@ -318,34 +426,83 @@ defineExpose({ deselectAll })
<div v-if="multiSelect && selectedKeys.size > 0" class="batch-bar">
<span class="batch-count">{{ t('key.selected', { n: selectedKeys.size }) }}</span>
<el-button size="small" type="danger" @click="batchDelete">
{{ t('key.batchDelete') }}
</el-button>
<div class="batch-bar-actions">
<el-button size="small" @click="exportKeys">
{{ t('key.exportKeys') }}
</el-button>
<el-button size="small" type="danger" @click="batchDelete">
{{ t('key.batchDelete') }}
</el-button>
</div>
</div>
<div class="key-content">
<!-- List View -->
<div v-if="viewMode === 'list'" class="key-flat-list">
<div
v-for="keyName in filteredKeys"
:key="keyName"
class="key-item"
:class="{
active: isKeySelected(keyName),
selected: isKeyMultiSelected(keyName),
}"
@click="selectKey(keyName)"
>
<input
v-if="multiSelect"
type="checkbox"
class="key-checkbox"
:checked="isKeyMultiSelected(keyName)"
@click.stop
/>
<el-icon class="key-icon"><Document /></el-icon>
<span class="key-name">{{ keyName }}</span>
</div>
<div v-if="viewMode === 'list'" ref="scrollRef" class="key-flat-list" :class="{ virtualized: useVirtualization }">
<!-- Virtualized list for large datasets -->
<template v-if="useVirtualization">
<div
:style="{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}"
>
<div
v-for="vItem in virtualizer.getVirtualItems()"
:key="vItem.key"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vItem.start}px)`,
}"
>
<div
class="key-item"
:class="{
active: isKeySelected(filteredKeys[vItem.index]),
selected: isKeyMultiSelected(filteredKeys[vItem.index]),
}"
@click="selectKey(filteredKeys[vItem.index])"
>
<input
v-if="multiSelect"
type="checkbox"
class="key-checkbox"
:checked="isKeyMultiSelected(filteredKeys[vItem.index])"
@click.stop
/>
<el-icon class="key-icon"><Document /></el-icon>
<span class="key-name">{{ filteredKeys[vItem.index] }}</span>
</div>
</div>
</div>
</template>
<!-- Non-virtualized list for small datasets -->
<template v-else>
<div
v-for="keyName in filteredKeys"
:key="keyName"
class="key-item"
:class="{
active: isKeySelected(keyName),
selected: isKeyMultiSelected(keyName),
}"
@click="selectKey(keyName)"
>
<input
v-if="multiSelect"
type="checkbox"
class="key-checkbox"
:checked="isKeyMultiSelected(keyName)"
@click.stop
/>
<el-icon class="key-icon"><Document /></el-icon>
<span class="key-name">{{ keyName }}</span>
</div>
</template>
</div>
<!-- Tree View -->
@@ -452,17 +609,32 @@ defineExpose({ deselectAll })
flex-shrink: 0;
}
.batch-bar-actions {
display: flex;
gap: 6px;
align-items: center;
}
.batch-count {
font-size: 12px;
font-weight: 600;
color: var(--accent-light);
}
.import-btn {
flex-shrink: 0;
}
.key-content {
flex: 1;
overflow-y: auto;
}
.key-flat-list.virtualized {
height: 100%;
overflow-y: auto;
}
.key-item {
display: flex;
align-items: center;
+7
View File
@@ -105,6 +105,11 @@ export default {
scanCancelled: 'Scan cancelled',
switchTree: 'Switch to tree view',
switchList: 'Switch to list view',
exportKeys: 'Export Keys',
importKeys: 'Import Keys',
exportDone: 'Exported {n} keys',
importDone: 'Imported {n} keys',
importFailed: 'Import failed: {error}',
},
types: {
string: 'STRING',
@@ -180,6 +185,8 @@ export default {
txDiscarded: 'Transaction discarded',
txQueue: 'Queue ({n})',
txPrompt: 'tx>',
importCommands: 'Import Commands',
importCmdDone: 'Executed {n} commands',
},
slowlog: {
title: 'Slow Log',
+7
View File
@@ -105,6 +105,11 @@ export default {
scanCancelled: '扫描已取消',
switchTree: '切换到树状视图',
switchList: '切换到列表视图',
exportKeys: '导出键',
importKeys: '导入键',
exportDone: '已导出 {n} 个键',
importDone: '已导入 {n} 个键',
importFailed: '导入失败: {error}',
},
types: {
string: '字符串',
@@ -180,6 +185,8 @@ export default {
txDiscarded: '事务已取消',
txQueue: '队列 ({n})',
txPrompt: 'tx>',
importCommands: '导入命令',
importCmdDone: '已执行 {n} 条命令',
},
slowlog: {
title: '慢日志',