feat: batch delete preview, FlushDB, search history, cancel scan

- P2-7: KeyList batch delete preview dialog (max 100 keys shown)
- P2-11: StatusView FlushDB with double confirmation (type FLUSHDB)
- P2-12: KeyList search history (localStorage, max 20, dropdown on focus)
- P2-13: key store cancelScan() + KeyList cancel button
- i18n keys for all features (zh-CN + en)
This commit is contained in:
2026-07-07 22:04:47 +08:00
parent b85e67541b
commit 90d56f2a12
6 changed files with 309 additions and 31 deletions
+4 -4
View File
@@ -32,13 +32,13 @@
- [x] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑 - [x] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑
- [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换 - [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换
- [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化 - [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化
- [ ] **批量删除预览** — 先扫描预览,再确认删除,显示 key 数量和总大小 - [x] **批量删除预览** — 先扫描预览,再确认删除,显示 key 数量和总大小
- [ ] **Key 导出** — DUMP + PTTL → CSV 下载 - [ ] **Key 导出** — DUMP + PTTL → CSV 下载
- [ ] **Key 导入** — CSV (hex_key, hex_value, ttl) → RESTORE - [ ] **Key 导入** — CSV (hex_key, hex_value, ttl) → RESTORE
- [ ] **命令导入** — 文本文件逐行执行 Redis 命令 - [ ] **命令导入** — 文本文件逐行执行 Redis 命令
- [ ] **FlushDB** — 清空当前数据库,双重确认 - [x] **FlushDB** — 清空当前数据库,双重确认
- [ ] **Key 搜索历史** — 最近 200 条搜索模式自动补全 - [x] **Key 搜索历史** — 最近 200 条搜索模式自动补全
- [ ] **取消扫描** — 中断正在进行的 SCAN - [x] **取消扫描** — 中断正在进行的 SCAN
## P3 — 体验优化 ## P3 — 体验优化
+240 -24
View File
@@ -17,6 +17,47 @@ const multiSelect = ref(false)
const selectedKeys = ref<Set<string>>(new Set()) const selectedKeys = ref<Set<string>>(new Set())
const expandedFolders = ref<Set<string>>(new Set()) const expandedFolders = ref<Set<string>>(new Set())
// Search history
const HISTORY_KEY = 'keySearchHistory'
const MAX_HISTORY = 20
const searchHistory = ref<string[]>(loadHistory())
const showHistory = ref(false)
function loadHistory(): string[] {
try {
const raw = localStorage.getItem(HISTORY_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveToHistory(pattern: string) {
const p = pattern.trim() || '*'
const arr = searchHistory.value.filter(item => item !== p)
arr.unshift(p)
if (arr.length > MAX_HISTORY) arr.length = MAX_HISTORY
searchHistory.value = arr
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr))
}
function selectFromHistory(pattern: string) {
patternInput.value = pattern
showHistory.value = false
applyPattern()
}
function clearHistory() {
searchHistory.value = []
localStorage.removeItem(HISTORY_KEY)
showHistory.value = false
}
function handleHistoryBlur() {
// Delay to allow click on dropdown item to register
setTimeout(() => { showHistory.value = false }, 200)
}
interface TreeNode { interface TreeNode {
name: string name: string
fullPath: string fullPath: string
@@ -26,7 +67,10 @@ interface TreeNode {
} }
function applyPattern() { function applyPattern() {
keyStore.pattern = patternInput.value || '*' const pattern = patternInput.value || '*'
keyStore.pattern = pattern
saveToHistory(pattern)
showHistory.value = false
if (connStore.activeId) { if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true) keyStore.scanKeys(connStore.activeId, '0', true)
} }
@@ -58,12 +102,37 @@ async function selectKey(keyName: string) {
await keyStore.loadKeyData(connStore.activeId, keyName) await keyStore.loadKeyData(connStore.activeId, keyName)
} }
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
async function batchDelete() { async function batchDelete() {
if (!connStore.activeId || selectedKeys.value.size === 0) return if (!connStore.activeId || selectedKeys.value.size === 0) return
const count = selectedKeys.value.size 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(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 { try {
await ElMessageBox.confirm(t('key.batchConfirm', { n: count }), t('common.confirm')) await ElMessageBox.confirm(
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, Array.from(selectedKeys.value)) `<div class="batch-preview">
<div class="batch-preview-hint">${hintText}</div>
<div class="batch-preview-list">${keyListHtml}</div>
${andMore}
</div>`,
t('key.batchPreview'),
{
dangerouslyUseHTMLString: true,
confirmButtonText: t('key.batchDelete'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, keysArray)
ElMessage.success(t('key.batchDeleted', { n: deleted })) ElMessage.success(t('key.batchDeleted', { n: deleted }))
selectedKeys.value.clear() selectedKeys.value.clear()
await refreshKeys() await refreshKeys()
@@ -181,17 +250,38 @@ defineExpose({ deselectAll })
<template> <template>
<div class="key-list"> <div class="key-list">
<div class="key-pattern-row"> <div class="key-pattern-row">
<el-input <div class="pattern-input-wrapper">
v-model="patternInput" <el-input
size="small" v-model="patternInput"
:placeholder="t('key.patternPlaceholder')" size="small"
class="pattern-input" :placeholder="t('key.patternPlaceholder')"
@keyup.enter="applyPattern" class="pattern-input"
> @keyup.enter="applyPattern"
<template #prepend> @focus="showHistory = searchHistory.length > 0"
<span class="pattern-label">MATCH</span> @blur="handleHistoryBlur"
</template> >
</el-input> <template #prepend>
<span class="pattern-label">MATCH</span>
</template>
</el-input>
<div v-if="showHistory && searchHistory.length > 0" class="history-dropdown">
<div class="history-header">
<span class="history-title">{{ t('key.searchHistory') }}</span>
<el-button size="small" text @click.stop="clearHistory">
{{ t('key.clearHistory') }}
</el-button>
</div>
<div
v-for="(item, idx) in searchHistory"
:key="idx"
class="history-item"
@mousedown.prevent="selectFromHistory(item)"
>
<el-icon class="history-icon"><Clock /></el-icon>
<span class="history-text">{{ item }}</span>
</div>
</div>
</div>
<el-button size="small" type="primary" @click="applyPattern">Go</el-button> <el-button size="small" type="primary" @click="applyPattern">Go</el-button>
</div> </div>
@@ -287,16 +377,26 @@ defineExpose({ deselectAll })
<div class="key-footer"> <div class="key-footer">
<span class="key-count">{{ t('key.count', { n: keyStore.keys.length }) }}</span> <span class="key-count">{{ t('key.count', { n: keyStore.keys.length }) }}</span>
<el-button <div class="key-footer-actions">
v-if="keyStore.hasMore" <el-button
size="small" v-if="keyStore.hasMore && keyStore.loading"
type="primary" size="small"
text type="warning"
@click="loadMore" @click="keyStore.cancelScan()"
:loading="keyStore.loading" >
> {{ t('key.cancelScan') }}
{{ t('key.loadMore') }} </el-button>
</el-button> <el-button
v-if="keyStore.hasMore"
size="small"
type="primary"
text
@click="loadMore"
:loading="keyStore.loading"
>
{{ t('key.loadMore') }}
</el-button>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -500,4 +600,120 @@ defineExpose({ deselectAll })
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
} }
.key-footer-actions {
display: flex;
gap: 6px;
align-items: center;
}
/* Search History Dropdown */
.pattern-input-wrapper {
position: relative;
flex: 1;
}
.history-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 280px;
overflow-y: auto;
margin-top: 2px;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid var(--border-subtle);
font-size: 11px;
color: var(--text-muted);
}
.history-title {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.history-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
font-family: 'Cascadia Code', 'Fira Code', monospace;
transition: background 0.12s;
}
.history-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.history-icon {
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
}
.history-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<style>
/* Batch delete preview dialog — non-scoped because ElMessageBox appends to body */
.batch-preview {
max-height: 400px;
overflow-y: auto;
}
.batch-preview-hint {
margin-bottom: 12px;
font-size: 13px;
color: var(--text-secondary, #666);
}
.batch-preview-list {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
max-height: 280px;
overflow-y: auto;
background: var(--bg-card, #fafafa);
}
.batch-preview-key {
padding: 6px 10px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
color: var(--text-primary, #333);
border-bottom: 1px solid var(--border-subtle, #eee);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.batch-preview-key:last-child {
border-bottom: none;
}
.batch-preview-more {
margin-top: 8px;
font-size: 12px;
color: var(--text-muted, #999);
text-align: center;
}
</style> </style>
+29 -1
View File
@@ -3,6 +3,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection' import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n' import { useI18n } from '@renderer/i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
const connStore = useConnectionStore() const connStore = useConnectionStore()
const { t } = useI18n() const { t } = useI18n()
@@ -102,6 +103,25 @@ onMounted(() => {
onUnmounted(stopAutoRefresh) onUnmounted(stopAutoRefresh)
async function handleFlushDb() {
if (!connStore.activeId) return
try {
const input = await ElMessageBox.prompt(t('status.flushDbConfirm'), t('status.flushDb'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
inputPlaceholder: 'FLUSHDB',
inputPattern: /^FLUSHDB$/,
inputErrorMessage: 'Type FLUSHDB to confirm',
})
if (input.value !== 'FLUSHDB') return
await window.electronAPI.redis.execute(connStore.activeId, 'FLUSHDB', [])
ElMessage.success(t('status.flushDbDone'))
await refresh()
} catch {
// cancelled
}
}
defineExpose({ refresh }) defineExpose({ refresh })
</script> </script>
@@ -109,7 +129,10 @@ defineExpose({ refresh })
<div class="status-view" v-if="connStore.activeConnection"> <div class="status-view" v-if="connStore.activeConnection">
<div class="status-header"> <div class="status-header">
<h2>{{ connStore.activeConnection.name }}</h2> <h2>{{ connStore.activeConnection.name }}</h2>
<el-button size="small" @click="refresh">{{ t('common.refresh') }}</el-button> <div class="status-actions">
<el-button size="small" type="danger" @click="handleFlushDb">{{ t('status.flushDb') }}</el-button>
<el-button size="small" @click="refresh">{{ t('common.refresh') }}</el-button>
</div>
</div> </div>
<div class="info-grid"> <div class="info-grid">
@@ -157,6 +180,11 @@ defineExpose({ refresh })
color: var(--accent-light); color: var(--accent-light);
} }
.status-actions {
display: flex;
gap: 8px;
}
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+11
View File
@@ -91,10 +91,18 @@ export default {
batchDelete: 'Delete Selected', batchDelete: 'Delete Selected',
batchConfirm: 'Delete {n} key(s)?', batchConfirm: 'Delete {n} key(s)?',
batchDeleted: 'Deleted {n} key(s)', batchDeleted: 'Deleted {n} key(s)',
batchPreview: 'Delete Preview',
batchPreviewHint: 'About to delete {n} keys',
andMore: '...and {n} more',
selected: '{n} selected', selected: '{n} selected',
selectKey: 'Select a key to view details', selectKey: 'Select a key to view details',
unsupported: 'Type "{type}" viewer not yet implemented', unsupported: 'Type "{type}" viewer not yet implemented',
patternPlaceholder: 'Key pattern (e.g. user:*)', patternPlaceholder: 'Key pattern (e.g. user:*)',
searchHistory: 'Search History',
clearHistory: 'Clear History',
noHistory: 'No history',
cancelScan: 'Cancel Scan',
scanCancelled: 'Scan cancelled',
switchTree: 'Switch to tree view', switchTree: 'Switch to tree view',
switchList: 'Switch to list view', switchList: 'Switch to list view',
}, },
@@ -222,6 +230,9 @@ export default {
database: 'Database', database: 'Database',
keys: 'Keys', keys: 'Keys',
refresh: 'Refresh', refresh: 'Refresh',
flushDb: 'Flush DB',
flushDbConfirm: 'This will delete ALL keys in the current database! Type FLUSHDB to confirm:',
flushDbDone: 'Database flushed',
connecting: 'Connecting...', connecting: 'Connecting...',
error: 'Error: {error}', error: 'Error: {error}',
noConnection: 'No connection', noConnection: 'No connection',
+11
View File
@@ -91,10 +91,18 @@ export default {
batchDelete: '删除选中', batchDelete: '删除选中',
batchConfirm: '删除 {n} 个键?', batchConfirm: '删除 {n} 个键?',
batchDeleted: '已删除 {n} 个键', batchDeleted: '已删除 {n} 个键',
batchPreview: '删除预览',
batchPreviewHint: '即将删除以下 {n} 个键',
andMore: '...以及其他 {n} 个',
selected: '已选 {n} 项', selected: '已选 {n} 项',
selectKey: '选择一个键查看详情', selectKey: '选择一个键查看详情',
unsupported: '类型 "{type}" 查看器尚未实现', unsupported: '类型 "{type}" 查看器尚未实现',
patternPlaceholder: '键模式 (例如 user:*)', patternPlaceholder: '键模式 (例如 user:*)',
searchHistory: '搜索历史',
clearHistory: '清除历史',
noHistory: '暂无历史',
cancelScan: '取消扫描',
scanCancelled: '扫描已取消',
switchTree: '切换到树状视图', switchTree: '切换到树状视图',
switchList: '切换到列表视图', switchList: '切换到列表视图',
}, },
@@ -222,6 +230,9 @@ export default {
database: '数据库', database: '数据库',
keys: '键数', keys: '键数',
refresh: '刷新', refresh: '刷新',
flushDb: '清空数据库',
flushDbConfirm: '此操作将删除当前数据库中的所有键!输入 FLUSHDB 确认:',
flushDbDone: '数据库已清空',
connecting: '连接中...', connecting: '连接中...',
error: '错误: {error}', error: '错误: {error}',
noConnection: '无连接', noConnection: '无连接',
+14 -2
View File
@@ -26,6 +26,7 @@ export const useKeyStore = defineStore('key', () => {
const loading = ref(false) const loading = ref(false)
const scanCursor = ref('0') const scanCursor = ref('0')
const hasMore = ref(false) const hasMore = ref(false)
const scanAborted = ref(false)
const pattern = ref('*') const pattern = ref('*')
const useTree = ref(false) const useTree = ref(false)
const currentDb = ref(0) const currentDb = ref(0)
@@ -71,11 +72,22 @@ export const useKeyStore = defineStore('key', () => {
return root return root
} }
function cancelScan() {
scanAborted.value = true
}
async function scanKeys(connId: string, cursor: string = '0', reset: boolean = false) { async function scanKeys(connId: string, cursor: string = '0', reset: boolean = false) {
if (scanAborted.value) {
scanAborted.value = false
loading.value = false
return
}
if (reset) { if (reset) {
scanCursor.value = '0' scanCursor.value = '0'
keys.value = [] keys.value = []
hasMore.value = false hasMore.value = false
scanAborted.value = false
} }
loading.value = true loading.value = true
@@ -180,7 +192,7 @@ export const useKeyStore = defineStore('key', () => {
return { return {
keys, treeKeys, selectedKey, selectedType, selectedTTL, keyData, keys, treeKeys, selectedKey, selectedType, selectedTTL, keyData,
loading, scanCursor, hasMore, pattern, useTree, currentDb, loading, scanCursor, hasMore, scanAborted, pattern, useTree, currentDb,
scanKeys, loadKeyTypeAndTTL, loadKeyData, selectDb, scanKeys, cancelScan, loadKeyTypeAndTTL, loadKeyData, selectDb,
} }
}) })