diff --git a/ROADMAP.md b/ROADMAP.md index 850df36..ce2e203 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,13 +32,13 @@ - [x] **ReJson / TairJson 支持** — JSON.GET / JSON.SET / JSON.DEL 编辑 - [ ] **二进制 key 支持** — [Hex] 前缀输入,hex ↔ buffer 双向转换 - [ ] **虚拟化滚动** — 大 key 集合(200k+)的性能优化,KeyList 树形视图虚拟化 -- [ ] **批量删除预览** — 先扫描预览,再确认删除,显示 key 数量和总大小 +- [x] **批量删除预览** — 先扫描预览,再确认删除,显示 key 数量和总大小 - [ ] **Key 导出** — DUMP + PTTL → CSV 下载 - [ ] **Key 导入** — CSV (hex_key, hex_value, ttl) → RESTORE - [ ] **命令导入** — 文本文件逐行执行 Redis 命令 -- [ ] **FlushDB** — 清空当前数据库,双重确认 -- [ ] **Key 搜索历史** — 最近 200 条搜索模式自动补全 -- [ ] **取消扫描** — 中断正在进行的 SCAN +- [x] **FlushDB** — 清空当前数据库,双重确认 +- [x] **Key 搜索历史** — 最近 200 条搜索模式自动补全 +- [x] **取消扫描** — 中断正在进行的 SCAN ## P3 — 体验优化 diff --git a/src/components/KeyList.vue b/src/components/KeyList.vue index 73e9196..85bc3f2 100644 --- a/src/components/KeyList.vue +++ b/src/components/KeyList.vue @@ -17,6 +17,47 @@ const multiSelect = ref(false) const selectedKeys = ref>(new Set()) const expandedFolders = ref>(new Set()) +// Search history +const HISTORY_KEY = 'keySearchHistory' +const MAX_HISTORY = 20 +const searchHistory = ref(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, '&').replace(//g, '>').replace(/"/g, '"') +} + 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 => `
${escapeHtml(k)}
`).join('') + const hintText = t('key.batchPreviewHint', { n: count }) + const andMore = remaining > 0 ? `
${t('key.andMore', { n: remaining })}
` : '' + 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( + `
+
${hintText}
+
${keyListHtml}
+ ${andMore} +
`, + 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 }) @@ -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; +} + + + diff --git a/src/components/StatusView.vue b/src/components/StatusView.vue index 965686c..96477f5 100644 --- a/src/components/StatusView.vue +++ b/src/components/StatusView.vue @@ -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 }) @@ -109,7 +129,10 @@ defineExpose({ refresh })

{{ connStore.activeConnection.name }}

- {{ t('common.refresh') }} +
+ {{ t('status.flushDb') }} + {{ t('common.refresh') }} +
@@ -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)); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0a5657f..51c2572 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 5f8e0bd..724b35f 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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: '无连接', diff --git a/src/stores/key.ts b/src/stores/key.ts index f816fba..591a7f2 100644 --- a/src/stores/key.ts +++ b/src/stores/key.ts @@ -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, } })