feat: optimize key list with list/tree views
- Add list view (flat) and tree view (hierarchical) - Tree view shows folder expand/collapse with key count - Add view mode toggle button - Add i18n support for all key list text
This commit is contained in:
+220
-56
@@ -1,18 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/renderer/src/components/KeyList.vue
|
// src/components/KeyList.vue
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } 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 { useI18n } from '@renderer/i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const filterText = ref('')
|
const filterText = ref('')
|
||||||
const patternInput = ref('*')
|
const patternInput = ref('*')
|
||||||
const showTree = ref(false)
|
const viewMode = ref<'list' | 'tree'>('list')
|
||||||
const multiSelect = ref(false)
|
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())
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
name: string
|
||||||
|
fullPath: string
|
||||||
|
isLeaf: boolean
|
||||||
|
children: TreeNode[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
function applyPattern() {
|
function applyPattern() {
|
||||||
keyStore.pattern = patternInput.value || '*'
|
keyStore.pattern = patternInput.value || '*'
|
||||||
@@ -51,9 +62,9 @@ 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
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`Delete ${count} key(s)?`, 'Confirm')
|
await ElMessageBox.confirm(t('key.batchConfirm', { n: count }), t('common.confirm'))
|
||||||
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, Array.from(selectedKeys.value))
|
const deleted = await window.electronAPI.redis.batchDelete(connStore.activeId, Array.from(selectedKeys.value))
|
||||||
ElMessage.success(`Deleted ${deleted} key(s)`)
|
ElMessage.success(t('key.batchDeleted', { n: deleted }))
|
||||||
selectedKeys.value.clear()
|
selectedKeys.value.clear()
|
||||||
await refreshKeys()
|
await refreshKeys()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -74,21 +85,94 @@ function deselectAll() {
|
|||||||
keyStore.keyData = null
|
keyStore.keyData = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredKeys = ref<string[]>([])
|
function toggleFolder(path: string) {
|
||||||
watch(
|
if (expandedFolders.value.has(path)) {
|
||||||
() => keyStore.keys,
|
expandedFolders.value.delete(path)
|
||||||
(newKeys) => {
|
} else {
|
||||||
filteredKeys.value = filterText.value
|
expandedFolders.value.add(path)
|
||||||
? newKeys.filter(k => k.name.includes(filterText.value)).map(k => k.name)
|
}
|
||||||
: newKeys.map(k => k.name)
|
}
|
||||||
},
|
|
||||||
{ deep: true, immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(filterText, (val) => {
|
const filteredKeys = computed(() => {
|
||||||
filteredKeys.value = val
|
const keys = keyStore.keys.map(k => k.name)
|
||||||
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
|
return filterText.value
|
||||||
: keyStore.keys.map(k => k.name)
|
? keys.filter(k => k.includes(filterText.value))
|
||||||
|
: keys
|
||||||
|
})
|
||||||
|
|
||||||
|
const treeData = computed<TreeNode[]>(() => {
|
||||||
|
const separator = connStore.activeConnection?.separator || ':'
|
||||||
|
const root: TreeNode[] = []
|
||||||
|
const map = new Map<string, TreeNode>()
|
||||||
|
|
||||||
|
for (const keyName of filteredKeys.value) {
|
||||||
|
const parts = keyName.split(separator)
|
||||||
|
let currentPath = ''
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i]
|
||||||
|
const parentPath = currentPath
|
||||||
|
currentPath = currentPath ? `${currentPath}${separator}${part}` : part
|
||||||
|
const isLeaf = i === parts.length - 1
|
||||||
|
|
||||||
|
if (!map.has(currentPath)) {
|
||||||
|
const node: TreeNode = {
|
||||||
|
name: part,
|
||||||
|
fullPath: currentPath,
|
||||||
|
isLeaf,
|
||||||
|
children: [],
|
||||||
|
count: isLeaf ? undefined : 0,
|
||||||
|
}
|
||||||
|
map.set(currentPath, node)
|
||||||
|
|
||||||
|
if (parentPath) {
|
||||||
|
const parent = map.get(parentPath)
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node)
|
||||||
|
parent.count = (parent.count || 0) + (isLeaf ? 0 : 1) + (isLeaf ? 1 : 0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root.push(node)
|
||||||
|
}
|
||||||
|
} else if (!isLeaf) {
|
||||||
|
const existing = map.get(currentPath)!
|
||||||
|
existing.count = (existing.count || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update counts for parent folders
|
||||||
|
function updateCounts(node: TreeNode): number {
|
||||||
|
if (node.isLeaf) return 1
|
||||||
|
let total = 0
|
||||||
|
for (const child of node.children) {
|
||||||
|
total += updateCounts(child)
|
||||||
|
}
|
||||||
|
node.count = total
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of root) {
|
||||||
|
updateCounts(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
})
|
||||||
|
|
||||||
|
function isFolderExpanded(path: string): boolean {
|
||||||
|
return expandedFolders.value.has(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeySelected(keyName: string): boolean {
|
||||||
|
return keyStore.selectedKey === keyName && !multiSelect.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeyMultiSelected(keyName: string): boolean {
|
||||||
|
return selectedKeys.value.has(keyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filterText, () => {
|
||||||
|
// Filter is reactive via computed
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ deselectAll })
|
defineExpose({ deselectAll })
|
||||||
@@ -100,7 +184,7 @@ defineExpose({ deselectAll })
|
|||||||
<el-input
|
<el-input
|
||||||
v-model="patternInput"
|
v-model="patternInput"
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Key pattern (e.g. user:*)"
|
:placeholder="t('key.patternPlaceholder')"
|
||||||
class="pattern-input"
|
class="pattern-input"
|
||||||
@keyup.enter="applyPattern"
|
@keyup.enter="applyPattern"
|
||||||
>
|
>
|
||||||
@@ -114,7 +198,7 @@ defineExpose({ deselectAll })
|
|||||||
<div class="key-toolbar">
|
<div class="key-toolbar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filterText"
|
v-model="filterText"
|
||||||
placeholder="Local filter..."
|
:placeholder="t('key.filter')"
|
||||||
size="small"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
class="filter-input"
|
class="filter-input"
|
||||||
@@ -123,13 +207,15 @@ defineExpose({ deselectAll })
|
|||||||
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
|
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-tooltip :content="viewMode === 'list' ? t('key.switchTree') : t('key.switchList')" placement="top">
|
||||||
size="small"
|
<el-button
|
||||||
:type="showTree ? 'primary' : 'default'"
|
size="small"
|
||||||
@click="showTree = !showTree"
|
:type="viewMode === 'tree' ? 'primary' : 'default'"
|
||||||
>
|
@click="viewMode = viewMode === 'list' ? 'tree' : 'list'"
|
||||||
<el-icon><List /></el-icon>
|
>
|
||||||
</el-button>
|
<el-icon><component :is="viewMode === 'list' ? 'FolderOpened' : 'List'" /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
:type="multiSelect ? 'danger' : 'default'"
|
:type="multiSelect ? 'danger' : 'default'"
|
||||||
@@ -141,21 +227,22 @@ defineExpose({ deselectAll })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="multiSelect && selectedKeys.size > 0" class="batch-bar">
|
<div v-if="multiSelect && selectedKeys.size > 0" class="batch-bar">
|
||||||
<span class="batch-count">{{ selectedKeys.size }} selected</span>
|
<span class="batch-count">{{ t('key.selected', { n: selectedKeys.size }) }}</span>
|
||||||
<el-button size="small" type="danger" @click="batchDelete">
|
<el-button size="small" type="danger" @click="batchDelete">
|
||||||
Delete Selected
|
{{ t('key.batchDelete') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="key-content">
|
<div class="key-content">
|
||||||
<div v-if="!showTree" class="key-flat-list">
|
<!-- List View -->
|
||||||
|
<div v-if="viewMode === 'list'" class="key-flat-list">
|
||||||
<div
|
<div
|
||||||
v-for="keyName in filteredKeys"
|
v-for="keyName in filteredKeys"
|
||||||
:key="keyName"
|
:key="keyName"
|
||||||
class="key-item"
|
class="key-item"
|
||||||
:class="{
|
:class="{
|
||||||
active: keyStore.selectedKey === keyName && !multiSelect,
|
active: isKeySelected(keyName),
|
||||||
selected: selectedKeys.has(keyName),
|
selected: isKeyMultiSelected(keyName),
|
||||||
}"
|
}"
|
||||||
@click="selectKey(keyName)"
|
@click="selectKey(keyName)"
|
||||||
>
|
>
|
||||||
@@ -163,7 +250,7 @@ defineExpose({ deselectAll })
|
|||||||
v-if="multiSelect"
|
v-if="multiSelect"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="key-checkbox"
|
class="key-checkbox"
|
||||||
:checked="selectedKeys.has(keyName)"
|
:checked="isKeyMultiSelected(keyName)"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
<el-icon class="key-icon"><Document /></el-icon>
|
<el-icon class="key-icon"><Document /></el-icon>
|
||||||
@@ -171,32 +258,35 @@ defineExpose({ deselectAll })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tree View -->
|
||||||
<div v-else class="key-tree">
|
<div v-else class="key-tree">
|
||||||
<div
|
<template v-for="node in treeData" :key="node.fullPath">
|
||||||
v-for="keyName in filteredKeys"
|
<!-- Folder -->
|
||||||
:key="keyName"
|
<div
|
||||||
class="key-item"
|
v-if="!node.isLeaf"
|
||||||
:class="{
|
class="tree-item folder-item"
|
||||||
active: keyStore.selectedKey === keyName && !multiSelect,
|
@click="toggleFolder(node.fullPath)"
|
||||||
selected: selectedKeys.has(keyName),
|
>
|
||||||
}"
|
<el-icon class="tree-arrow" :class="{ expanded: isFolderExpanded(node.fullPath) }">
|
||||||
@click="selectKey(keyName)"
|
<ArrowRight />
|
||||||
>
|
</el-icon>
|
||||||
<input
|
<el-icon class="tree-icon folder-icon"><Folder /></el-icon>
|
||||||
v-if="multiSelect"
|
<span class="tree-name">{{ node.name }}</span>
|
||||||
type="checkbox"
|
<span class="tree-count">{{ node.count }}</span>
|
||||||
class="key-checkbox"
|
</div>
|
||||||
:checked="selectedKeys.has(keyName)"
|
<div v-else class="tree-children" :class="{ hidden: !isFolderExpanded(node.fullPath) }">
|
||||||
@click.stop
|
<div class="tree-item leaf-item" @click="selectKey(node.fullPath)">
|
||||||
/>
|
<span class="tree-spacer"></span>
|
||||||
<el-icon class="key-icon"><Document /></el-icon>
|
<el-icon class="tree-icon"><Document /></el-icon>
|
||||||
<span class="key-name">{{ keyName }}</span>
|
<span class="tree-name">{{ node.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="key-footer">
|
<div class="key-footer">
|
||||||
<span class="key-count">{{ keyStore.keys.length }} keys</span>
|
<span class="key-count">{{ t('key.count', { n: keyStore.keys.length }) }}</span>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="keyStore.hasMore"
|
v-if="keyStore.hasMore"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -205,7 +295,7 @@ defineExpose({ deselectAll })
|
|||||||
@click="loadMore"
|
@click="loadMore"
|
||||||
:loading="keyStore.loading"
|
:loading="keyStore.loading"
|
||||||
>
|
>
|
||||||
Load more...
|
{{ t('key.loadMore') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,6 +413,80 @@ defineExpose({ deselectAll })
|
|||||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tree View Styles */
|
||||||
|
.tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-arrow.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon {
|
||||||
|
color: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-spacer {
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-item {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.key-footer {
|
.key-footer {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export default {
|
|||||||
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:*)',
|
||||||
|
switchTree: 'Switch to tree view',
|
||||||
|
switchList: 'Switch to list view',
|
||||||
},
|
},
|
||||||
types: {
|
types: {
|
||||||
string: 'STRING',
|
string: 'STRING',
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export default {
|
|||||||
selected: '已选 {n} 项',
|
selected: '已选 {n} 项',
|
||||||
selectKey: '选择一个键查看详情',
|
selectKey: '选择一个键查看详情',
|
||||||
unsupported: '类型 "{type}" 查看器尚未实现',
|
unsupported: '类型 "{type}" 查看器尚未实现',
|
||||||
|
patternPlaceholder: '键模式 (例如 user:*)',
|
||||||
|
switchTree: '切换到树状视图',
|
||||||
|
switchList: '切换到列表视图',
|
||||||
},
|
},
|
||||||
types: {
|
types: {
|
||||||
string: '字符串',
|
string: '字符串',
|
||||||
|
|||||||
Reference in New Issue
Block a user