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:
2026-07-04 23:17:51 +08:00
parent e5ef0242d8
commit 09b86e3d45
3 changed files with 226 additions and 56 deletions
+220 -56
View File
@@ -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);
+3
View File
@@ -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',
+3
View File
@@ -71,6 +71,9 @@ export default {
selected: '已选 {n} 项', selected: '已选 {n} 项',
selectKey: '选择一个键查看详情', selectKey: '选择一个键查看详情',
unsupported: '类型 "{type}" 查看器尚未实现', unsupported: '类型 "{type}" 查看器尚未实现',
patternPlaceholder: '键模式 (例如 user:*)',
switchTree: '切换到树状视图',
switchList: '切换到列表视图',
}, },
types: { types: {
string: '字符串', string: '字符串',