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
+240 -24
View File
@@ -17,6 +17,47 @@ const multiSelect = ref(false)
const selectedKeys = 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 {
name: string
fullPath: string
@@ -26,7 +67,10 @@ interface TreeNode {
}
function applyPattern() {
keyStore.pattern = patternInput.value || '*'
const pattern = patternInput.value || '*'
keyStore.pattern = pattern
saveToHistory(pattern)
showHistory.value = false
if (connStore.activeId) {
keyStore.scanKeys(connStore.activeId, '0', true)
}
@@ -58,12 +102,37 @@ async function selectKey(keyName: string) {
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() {
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(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(t('key.batchConfirm', { n: count }), t('common.confirm'))
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, Array.from(selectedKeys.value))
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.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 }))
selectedKeys.value.clear()
await refreshKeys()
@@ -181,17 +250,38 @@ defineExpose({ deselectAll })
<template>
<div class="key-list">
<div class="key-pattern-row">
<el-input
v-model="patternInput"
size="small"
:placeholder="t('key.patternPlaceholder')"
class="pattern-input"
@keyup.enter="applyPattern"
>
<template #prepend>
<span class="pattern-label">MATCH</span>
</template>
</el-input>
<div class="pattern-input-wrapper">
<el-input
v-model="patternInput"
size="small"
:placeholder="t('key.patternPlaceholder')"
class="pattern-input"
@keyup.enter="applyPattern"
@focus="showHistory = searchHistory.length > 0"
@blur="handleHistoryBlur"
>
<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>
</div>
@@ -287,16 +377,26 @@ defineExpose({ deselectAll })
<div class="key-footer">
<span class="key-count">{{ t('key.count', { n: keyStore.keys.length }) }}</span>
<el-button
v-if="keyStore.hasMore"
size="small"
type="primary"
text
@click="loadMore"
:loading="keyStore.loading"
>
{{ t('key.loadMore') }}
</el-button>
<div class="key-footer-actions">
<el-button
v-if="keyStore.hasMore && keyStore.loading"
size="small"
type="warning"
@click="keyStore.cancelScan()"
>
{{ t('key.cancelScan') }}
</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>
</template>
@@ -500,4 +600,120 @@ defineExpose({ deselectAll })
font-size: 11px;
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>
+29 -1
View File
@@ -3,6 +3,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
const connStore = useConnectionStore()
const { t } = useI18n()
@@ -102,6 +103,25 @@ onMounted(() => {
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 })
</script>
@@ -109,7 +129,10 @@ defineExpose({ refresh })
<div class="status-view" v-if="connStore.activeConnection">
<div class="status-header">
<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 class="info-grid">
@@ -157,6 +180,11 @@ defineExpose({ refresh })
color: var(--accent-light);
}
.status-actions {
display: flex;
gap: 8px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+11
View File
@@ -91,10 +91,18 @@ export default {
batchDelete: 'Delete Selected',
batchConfirm: 'Delete {n} key(s)?',
batchDeleted: 'Deleted {n} key(s)',
batchPreview: 'Delete Preview',
batchPreviewHint: 'About to delete {n} keys',
andMore: '...and {n} more',
selected: '{n} selected',
selectKey: 'Select a key to view details',
unsupported: 'Type "{type}" viewer not yet implemented',
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',
switchList: 'Switch to list view',
},
@@ -222,6 +230,9 @@ export default {
database: 'Database',
keys: 'Keys',
refresh: 'Refresh',
flushDb: 'Flush DB',
flushDbConfirm: 'This will delete ALL keys in the current database! Type FLUSHDB to confirm:',
flushDbDone: 'Database flushed',
connecting: 'Connecting...',
error: 'Error: {error}',
noConnection: 'No connection',
+11
View File
@@ -91,10 +91,18 @@ export default {
batchDelete: '删除选中',
batchConfirm: '删除 {n} 个键?',
batchDeleted: '已删除 {n} 个键',
batchPreview: '删除预览',
batchPreviewHint: '即将删除以下 {n} 个键',
andMore: '...以及其他 {n} 个',
selected: '已选 {n} 项',
selectKey: '选择一个键查看详情',
unsupported: '类型 "{type}" 查看器尚未实现',
patternPlaceholder: '键模式 (例如 user:*)',
searchHistory: '搜索历史',
clearHistory: '清除历史',
noHistory: '暂无历史',
cancelScan: '取消扫描',
scanCancelled: '扫描已取消',
switchTree: '切换到树状视图',
switchList: '切换到列表视图',
},
@@ -222,6 +230,9 @@ export default {
database: '数据库',
keys: '键数',
refresh: '刷新',
flushDb: '清空数据库',
flushDbConfirm: '此操作将删除当前数据库中的所有键!输入 FLUSHDB 确认:',
flushDbDone: '数据库已清空',
connecting: '连接中...',
error: '错误: {error}',
noConnection: '无连接',
+14 -2
View File
@@ -26,6 +26,7 @@ export const useKeyStore = defineStore('key', () => {
const loading = ref(false)
const scanCursor = ref('0')
const hasMore = ref(false)
const scanAborted = ref(false)
const pattern = ref('*')
const useTree = ref(false)
const currentDb = ref(0)
@@ -71,11 +72,22 @@ export const useKeyStore = defineStore('key', () => {
return root
}
function cancelScan() {
scanAborted.value = true
}
async function scanKeys(connId: string, cursor: string = '0', reset: boolean = false) {
if (scanAborted.value) {
scanAborted.value = false
loading.value = false
return
}
if (reset) {
scanCursor.value = '0'
keys.value = []
hasMore.value = false
scanAborted.value = false
}
loading.value = true
@@ -180,7 +192,7 @@ export const useKeyStore = defineStore('key', () => {
return {
keys, treeKeys, selectedKey, selectedType, selectedTTL, keyData,
loading, scanCursor, hasMore, pattern, useTree, currentDb,
scanKeys, loadKeyTypeAndTTL, loadKeyData, selectDb,
loading, scanCursor, hasMore, scanAborted, pattern, useTree, currentDb,
scanKeys, cancelScan, loadKeyTypeAndTTL, loadKeyData, selectDb,
}
})