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:
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '慢日志',
|
||||
|
||||
Reference in New Issue
Block a user