feat(phase4e): batch delete with multi-select
- KeyList: multi-select mode with checkbox toggle - Batch delete bar shows selected count + delete button - RedisService: batchDelete using pipeline - Preload: batchDelete IPC channel
This commit is contained in:
@@ -179,4 +179,7 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
|
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
|
||||||
return redisService.memoryUsage(id, key)
|
return redisService.memoryUsage(id, key)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('redis:batchDelete', async (_e, id: string, keys: string[]) => {
|
||||||
|
return redisService.batchDelete(id, keys)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,3 +278,15 @@ export async function memoryUsage(id: string, key: string): Promise<number | nul
|
|||||||
if (!client) throw new Error(`Client ${id} not found`)
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
return client.memory('USAGE', key) as Promise<number | null>
|
return client.memory('USAGE', key) as Promise<number | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function batchDelete(id: string, keys: string[]): Promise<number> {
|
||||||
|
const client = clients.get(id)
|
||||||
|
if (!client) throw new Error(`Client ${id} not found`)
|
||||||
|
if (keys.length === 0) return 0
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const key of keys) {
|
||||||
|
pipeline.del(key)
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
return results?.filter(([err]) => !err).length ?? 0
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -86,6 +86,7 @@ export interface ElectronAPI {
|
|||||||
slowLogGet: (id: string, count: number) => Promise<any[]>
|
slowLogGet: (id: string, count: number) => Promise<any[]>
|
||||||
slowLogLen: (id: string) => Promise<number>
|
slowLogLen: (id: string) => Promise<number>
|
||||||
memoryUsage: (id: string, key: string) => Promise<number | null>
|
memoryUsage: (id: string, key: string) => Promise<number | null>
|
||||||
|
batchDelete: (id: string, keys: string[]) => Promise<number>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ const electronAPI = {
|
|||||||
// Memory
|
// Memory
|
||||||
memoryUsage: (id: string, key: string): Promise<number | null> =>
|
memoryUsage: (id: string, key: string): Promise<number | null> =>
|
||||||
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
||||||
|
batchDelete: (id: string, keys: string[]): Promise<number> =>
|
||||||
|
ipcRenderer.invoke('redis:batchDelete', id, keys),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useKeyStore } from '@renderer/stores/key'
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
|
|
||||||
const filterText = ref('')
|
const filterText = ref('')
|
||||||
const showTree = ref(false)
|
const showTree = ref(false)
|
||||||
|
const multiSelect = ref(false)
|
||||||
|
const selectedKeys = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
@@ -17,18 +20,43 @@ async function loadMore() {
|
|||||||
|
|
||||||
async function refreshKeys() {
|
async function refreshKeys() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
|
selectedKeys.value.clear()
|
||||||
await keyStore.scanKeys(connStore.activeId, '0', true)
|
await keyStore.scanKeys(connStore.activeId, '0', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectKey(keyName: string) {
|
async function selectKey(keyName: string) {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
|
if (multiSelect.value) {
|
||||||
|
if (selectedKeys.value.has(keyName)) {
|
||||||
|
selectedKeys.value.delete(keyName)
|
||||||
|
} else {
|
||||||
|
selectedKeys.value.add(keyName)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
keyStore.selectedKey = keyName
|
keyStore.selectedKey = keyName
|
||||||
await keyStore.loadKeyData(connStore.activeId, keyName)
|
await keyStore.loadKeyData(connStore.activeId, keyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPatternChange() {
|
async function batchDelete() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId || selectedKeys.value.size === 0) return
|
||||||
keyStore.scanKeys(connStore.activeId, '0', true)
|
const count = selectedKeys.value.size
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete ${count} key(s)?`, 'Confirm')
|
||||||
|
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, Array.from(selectedKeys.value))
|
||||||
|
ElMessage.success(`Deleted ${deleted} key(s)`)
|
||||||
|
selectedKeys.value.clear()
|
||||||
|
await refreshKeys()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err !== 'cancel') ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMultiSelect() {
|
||||||
|
multiSelect.value = !multiSelect.value
|
||||||
|
if (!multiSelect.value) {
|
||||||
|
selectedKeys.value.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredKeys = ref<string[]>([])
|
const filteredKeys = ref<string[]>([])
|
||||||
@@ -70,9 +98,23 @@ watch(filterText, (val) => {
|
|||||||
>
|
>
|
||||||
<el-icon><List /></el-icon>
|
<el-icon><List /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:type="multiSelect ? 'danger' : 'default'"
|
||||||
|
@click="toggleMultiSelect"
|
||||||
|
>
|
||||||
|
<el-icon><Check /></el-icon>
|
||||||
|
</el-button>
|
||||||
</el-button-group>
|
</el-button-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="multiSelect && selectedKeys.size > 0" class="batch-bar">
|
||||||
|
<span class="batch-count">{{ selectedKeys.size }} selected</span>
|
||||||
|
<el-button size="small" type="danger" @click="batchDelete">
|
||||||
|
Delete Selected
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="key-content">
|
<div class="key-content">
|
||||||
<!-- Flat list -->
|
<!-- Flat list -->
|
||||||
<div v-if="!showTree" class="key-flat-list">
|
<div v-if="!showTree" class="key-flat-list">
|
||||||
@@ -80,9 +122,19 @@ watch(filterText, (val) => {
|
|||||||
v-for="keyName in filteredKeys"
|
v-for="keyName in filteredKeys"
|
||||||
:key="keyName"
|
:key="keyName"
|
||||||
class="key-item"
|
class="key-item"
|
||||||
:class="{ active: keyStore.selectedKey === keyName }"
|
:class="{
|
||||||
|
active: keyStore.selectedKey === keyName && !multiSelect,
|
||||||
|
selected: selectedKeys.has(keyName),
|
||||||
|
}"
|
||||||
@click="selectKey(keyName)"
|
@click="selectKey(keyName)"
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
v-if="multiSelect"
|
||||||
|
type="checkbox"
|
||||||
|
class="key-checkbox"
|
||||||
|
:checked="selectedKeys.has(keyName)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
<el-icon class="key-icon"><Document /></el-icon>
|
<el-icon class="key-icon"><Document /></el-icon>
|
||||||
<span class="key-name">{{ keyName }}</span>
|
<span class="key-name">{{ keyName }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +142,23 @@ watch(filterText, (val) => {
|
|||||||
|
|
||||||
<!-- Tree view -->
|
<!-- Tree view -->
|
||||||
<div v-else class="key-tree">
|
<div v-else class="key-tree">
|
||||||
<div v-for="keyName in filteredKeys" :key="keyName" class="key-item" @click="selectKey(keyName)">
|
<div
|
||||||
|
v-for="keyName in filteredKeys"
|
||||||
|
:key="keyName"
|
||||||
|
class="key-item"
|
||||||
|
:class="{
|
||||||
|
active: keyStore.selectedKey === keyName && !multiSelect,
|
||||||
|
selected: selectedKeys.has(keyName),
|
||||||
|
}"
|
||||||
|
@click="selectKey(keyName)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-if="multiSelect"
|
||||||
|
type="checkbox"
|
||||||
|
class="key-checkbox"
|
||||||
|
:checked="selectedKeys.has(keyName)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
<el-icon class="key-icon"><Document /></el-icon>
|
<el-icon class="key-icon"><Document /></el-icon>
|
||||||
<span class="key-name">{{ keyName }}</span>
|
<span class="key-name">{{ keyName }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +201,22 @@ watch(filterText, (val) => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-bar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-count {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
.key-content {
|
.key-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -160,6 +244,18 @@ watch(filterText, (val) => {
|
|||||||
color: var(--accent-light);
|
color: var(--accent-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key-item.selected {
|
||||||
|
background: rgba(245, 108, 108, 0.08);
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
.key-icon {
|
.key-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user