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">
|
||||
// src/renderer/src/components/KeyList.vue
|
||||
import { ref, watch } from 'vue'
|
||||
// src/components/KeyList.vue
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
const connStore = useConnectionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const filterText = ref('')
|
||||
const patternInput = ref('*')
|
||||
const showTree = ref(false)
|
||||
const viewMode = ref<'list' | 'tree'>('list')
|
||||
const multiSelect = ref(false)
|
||||
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() {
|
||||
keyStore.pattern = patternInput.value || '*'
|
||||
@@ -51,9 +62,9 @@ async function batchDelete() {
|
||||
if (!connStore.activeId || selectedKeys.value.size === 0) return
|
||||
const count = selectedKeys.value.size
|
||||
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))
|
||||
ElMessage.success(`Deleted ${deleted} key(s)`)
|
||||
ElMessage.success(t('key.batchDeleted', { n: deleted }))
|
||||
selectedKeys.value.clear()
|
||||
await refreshKeys()
|
||||
} catch (err: any) {
|
||||
@@ -74,21 +85,94 @@ function deselectAll() {
|
||||
keyStore.keyData = null
|
||||
}
|
||||
|
||||
const filteredKeys = ref<string[]>([])
|
||||
watch(
|
||||
() => keyStore.keys,
|
||||
(newKeys) => {
|
||||
filteredKeys.value = filterText.value
|
||||
? newKeys.filter(k => k.name.includes(filterText.value)).map(k => k.name)
|
||||
: newKeys.map(k => k.name)
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
function toggleFolder(path: string) {
|
||||
if (expandedFolders.value.has(path)) {
|
||||
expandedFolders.value.delete(path)
|
||||
} else {
|
||||
expandedFolders.value.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterText, (val) => {
|
||||
filteredKeys.value = val
|
||||
? keyStore.keys.filter(k => k.name.includes(val)).map(k => k.name)
|
||||
: keyStore.keys.map(k => k.name)
|
||||
const filteredKeys = computed(() => {
|
||||
const keys = keyStore.keys.map(k => k.name)
|
||||
return filterText.value
|
||||
? 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 })
|
||||
@@ -100,7 +184,7 @@ defineExpose({ deselectAll })
|
||||
<el-input
|
||||
v-model="patternInput"
|
||||
size="small"
|
||||
placeholder="Key pattern (e.g. user:*)"
|
||||
:placeholder="t('key.patternPlaceholder')"
|
||||
class="pattern-input"
|
||||
@keyup.enter="applyPattern"
|
||||
>
|
||||
@@ -114,7 +198,7 @@ defineExpose({ deselectAll })
|
||||
<div class="key-toolbar">
|
||||
<el-input
|
||||
v-model="filterText"
|
||||
placeholder="Local filter..."
|
||||
:placeholder="t('key.filter')"
|
||||
size="small"
|
||||
clearable
|
||||
class="filter-input"
|
||||
@@ -123,13 +207,15 @@ defineExpose({ deselectAll })
|
||||
<el-button size="small" @click="refreshKeys" :disabled="!connStore.activeId">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="showTree ? 'primary' : 'default'"
|
||||
@click="showTree = !showTree"
|
||||
>
|
||||
<el-icon><List /></el-icon>
|
||||
</el-button>
|
||||
<el-tooltip :content="viewMode === 'list' ? t('key.switchTree') : t('key.switchList')" placement="top">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="viewMode === 'tree' ? 'primary' : 'default'"
|
||||
@click="viewMode = viewMode === 'list' ? 'tree' : 'list'"
|
||||
>
|
||||
<el-icon><component :is="viewMode === 'list' ? 'FolderOpened' : 'List'" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="multiSelect ? 'danger' : 'default'"
|
||||
@@ -141,21 +227,22 @@ defineExpose({ deselectAll })
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Delete Selected
|
||||
{{ t('key.batchDelete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="key-content">
|
||||
<div v-if="!showTree" class="key-flat-list">
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="key-flat-list">
|
||||
<div
|
||||
v-for="keyName in filteredKeys"
|
||||
:key="keyName"
|
||||
class="key-item"
|
||||
:class="{
|
||||
active: keyStore.selectedKey === keyName && !multiSelect,
|
||||
selected: selectedKeys.has(keyName),
|
||||
active: isKeySelected(keyName),
|
||||
selected: isKeyMultiSelected(keyName),
|
||||
}"
|
||||
@click="selectKey(keyName)"
|
||||
>
|
||||
@@ -163,7 +250,7 @@ defineExpose({ deselectAll })
|
||||
v-if="multiSelect"
|
||||
type="checkbox"
|
||||
class="key-checkbox"
|
||||
:checked="selectedKeys.has(keyName)"
|
||||
:checked="isKeyMultiSelected(keyName)"
|
||||
@click.stop
|
||||
/>
|
||||
<el-icon class="key-icon"><Document /></el-icon>
|
||||
@@ -171,32 +258,35 @@ defineExpose({ deselectAll })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tree View -->
|
||||
<div v-else class="key-tree">
|
||||
<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>
|
||||
<span class="key-name">{{ keyName }}</span>
|
||||
</div>
|
||||
<template v-for="node in treeData" :key="node.fullPath">
|
||||
<!-- Folder -->
|
||||
<div
|
||||
v-if="!node.isLeaf"
|
||||
class="tree-item folder-item"
|
||||
@click="toggleFolder(node.fullPath)"
|
||||
>
|
||||
<el-icon class="tree-arrow" :class="{ expanded: isFolderExpanded(node.fullPath) }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<el-icon class="tree-icon folder-icon"><Folder /></el-icon>
|
||||
<span class="tree-name">{{ node.name }}</span>
|
||||
<span class="tree-count">{{ node.count }}</span>
|
||||
</div>
|
||||
<div v-else class="tree-children" :class="{ hidden: !isFolderExpanded(node.fullPath) }">
|
||||
<div class="tree-item leaf-item" @click="selectKey(node.fullPath)">
|
||||
<span class="tree-spacer"></span>
|
||||
<el-icon class="tree-icon"><Document /></el-icon>
|
||||
<span class="tree-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-if="keyStore.hasMore"
|
||||
size="small"
|
||||
@@ -205,7 +295,7 @@ defineExpose({ deselectAll })
|
||||
@click="loadMore"
|
||||
:loading="keyStore.loading"
|
||||
>
|
||||
Load more...
|
||||
{{ t('key.loadMore') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,6 +413,80 @@ defineExpose({ deselectAll })
|
||||
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 {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
@@ -71,6 +71,9 @@ export default {
|
||||
selected: '{n} selected',
|
||||
selectKey: 'Select a key to view details',
|
||||
unsupported: 'Type "{type}" viewer not yet implemented',
|
||||
patternPlaceholder: 'Key pattern (e.g. user:*)',
|
||||
switchTree: 'Switch to tree view',
|
||||
switchList: 'Switch to list view',
|
||||
},
|
||||
types: {
|
||||
string: 'STRING',
|
||||
|
||||
@@ -71,6 +71,9 @@ export default {
|
||||
selected: '已选 {n} 项',
|
||||
selectKey: '选择一个键查看详情',
|
||||
unsupported: '类型 "{type}" 查看器尚未实现',
|
||||
patternPlaceholder: '键模式 (例如 user:*)',
|
||||
switchTree: '切换到树状视图',
|
||||
switchList: '切换到列表视图',
|
||||
},
|
||||
types: {
|
||||
string: '字符串',
|
||||
|
||||
Reference in New Issue
Block a user