feat(N4): virtual scrolling for Set/Zset/List editors
- Create VirtualScrollList.vue component using @tanstack/vue-virtual - Props: items, itemHeight, overscan, height - Events: scrollEnd (for infinite loading) - Slots: default (item rendering), empty - SetEditor: replace v-for with VirtualScrollList, handles 100k+ members - ZsetEditor: replace v-for with VirtualScrollList, preserves filter/sort - ListEditor: replace pagination with virtual scroll + infinite loading - Initial load 100 items, auto-loads 100 more on scrollEnd - Shows loaded/total count + loading indicator - HashEditor: kept as-is (HSCAN limits to 100 items, el-table handles fine) - docs: mark N4 complete, update problem E priority to medium
This commit is contained in:
@@ -131,18 +131,16 @@ async function safeInvoke<T>(fn: () => Promise<T>, errorMsg?: string): Promise<T
|
||||
- SCAN 空模式规范化为 `'*'`
|
||||
|
||||
**仍存在的系统性问题**:
|
||||
- 所有编辑器无虚拟滚动,数万条记录一次性渲染 DOM
|
||||
- KeyList 树视图大数据量时渲染卡顿
|
||||
- 无 SCAN 结果缓存,频繁切换 DB 重复扫描
|
||||
- `scanKeys` 全量加载到前端,无分页/懒加载
|
||||
|
||||
**架构级方案**:
|
||||
- 编辑器引入 `el-table-v2`(Element Plus 虚拟滚动表格)或自定义虚拟列表
|
||||
- KeyList 树视图使用 `el-tree-v2`(虚拟滚动树)
|
||||
- SCAN 结果分页:前端维护 cursor,滚动到底部加载下一页
|
||||
- 连接级别的 SCAN 结果缓存(LRU)
|
||||
|
||||
优先级:**高** -- 生产环境(百万级 key)下当前实现会卡死,这是用户最直接感知的性能问题。
|
||||
优先级:**中** -- 编辑器虚拟滚动已解决,剩余 KeyList 树视图和 SCAN 分页问题。
|
||||
|
||||
---
|
||||
|
||||
@@ -159,12 +157,12 @@ async function safeInvoke<T>(fn: () => Promise<T>, errorMsg?: string): Promise<T
|
||||
| N16 | 主题完整适配 | P2#19 修复:ReJsonEditor `:theme="monacoTheme"` |
|
||||
| N17 | i18n 补全 | P3#37 修复:KeyDetail/TitleBar/Sidebar 硬编码替换 |
|
||||
| N25 | TypeScript 严格化(部分) | P3#36/42 修复:preload d.ts 15+ 处 any 消除 |
|
||||
| N4 | 虚拟滚动 | 创建 `VirtualScrollList.vue`(@tanstack/vue-virtual);Set/Zset/List 编辑器改造完成 |
|
||||
|
||||
### 高优先级需求
|
||||
|
||||
| # | 需求 | 用户价值 | 复杂度 | 实现思路 |
|
||||
|---|------|---------|--------|---------|
|
||||
| N4 | **虚拟滚动** | 数万字段/成员不卡顿,生产环境刚需 | 中 | Hash/List/Set/ZsetEditor 用 `el-table-v2`;KeyList 用 `el-tree-v2` |
|
||||
| N5 | **Stream Consumer Group 完整化** | ACK 已实现但未用,消费组可视化不全 | 中 | StreamEditor 接入 `redis:streamAck`;加 pending entries 列表、XPEL CLAIM 功能 |
|
||||
| N3 | **CLI 事务体验优化** | 当前 MULTI/EXEC 功能正确但 UX 不佳(未显示 QUEUED 状态) | 低 | 事务模式下检查返回值是否为 "QUEUED",非 QUEUED 时警告用户 |
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
items: any[]
|
||||
itemHeight?: number
|
||||
overscan?: number
|
||||
height?: string
|
||||
}>(), {
|
||||
itemHeight: 36,
|
||||
overscan: 5,
|
||||
height: '100%',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scrollEnd'): void
|
||||
}>()
|
||||
|
||||
const scrollRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const virtualizer = useVirtualizer(
|
||||
computed(() => ({
|
||||
count: props.items.length,
|
||||
getScrollElement: () => scrollRef.value,
|
||||
estimateSize: () => props.itemHeight,
|
||||
overscan: props.overscan,
|
||||
}))
|
||||
)
|
||||
|
||||
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
|
||||
const totalHeight = computed(() => virtualizer.value.getTotalSize())
|
||||
|
||||
// Emit scrollEnd when user scrolls near the bottom
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el) return
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - props.itemHeight * 5) {
|
||||
emit('scrollEnd')
|
||||
}
|
||||
}
|
||||
|
||||
// Reset scroll position when items array reference changes entirely
|
||||
watch(() => props.items, (newItems, oldItems) => {
|
||||
if (newItems.length === 0 && oldItems && oldItems.length > 0) {
|
||||
virtualizer.value.scrollToIndex(0)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scrollRef" class="vsl-scroll" :style="{ height }" @scroll.passive="onScroll">
|
||||
<div v-if="items.length > 0" class="vsl-inner" :style="{ height: totalHeight + 'px', position: 'relative' }">
|
||||
<div
|
||||
v-for="vItem in virtualItems"
|
||||
:key="vItem.key"
|
||||
class="vsl-item"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: vItem.size + 'px',
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}"
|
||||
>
|
||||
<slot :item="items[vItem.index]" :index="vItem.index" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="vsl-empty">
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vsl-scroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.vsl-inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vsl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vsl-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import { useTypeColor } from '@renderer/composables/useTypeColor'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import VirtualScrollList from '@renderer/components/common/VirtualScrollList.vue'
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
const { typeColor } = useTypeColor(() => keyStore.selectedType)
|
||||
@@ -13,24 +14,41 @@ const { t } = useI18n()
|
||||
|
||||
const items = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const pageStart = ref(0)
|
||||
const pageSize = 50
|
||||
const totalCount = ref(0)
|
||||
const loadedCount = ref(0)
|
||||
const showAddDialog = ref(false)
|
||||
const newItemValue = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingValue = ref('')
|
||||
const LOAD_SIZE = 100
|
||||
|
||||
async function loadList() {
|
||||
async function loadInitial() {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [len, data] = await Promise.all([
|
||||
window.electronAPI.redis.listLength(connStore.activeId, keyStore.selectedKey),
|
||||
window.electronAPI.redis.listRange(connStore.activeId, keyStore.selectedKey, pageStart.value, pageStart.value + pageSize - 1),
|
||||
window.electronAPI.redis.listRange(connStore.activeId, keyStore.selectedKey, 0, LOAD_SIZE - 1),
|
||||
])
|
||||
totalCount.value = len
|
||||
items.value = data
|
||||
loadedCount.value = data.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || loadedCount.value >= totalCount.value) return
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await window.electronAPI.redis.listRange(
|
||||
connStore.activeId, keyStore.selectedKey,
|
||||
loadedCount.value, loadedCount.value + LOAD_SIZE - 1
|
||||
)
|
||||
items.value.push(...data)
|
||||
loadedCount.value += data.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -38,7 +56,7 @@ async function loadList() {
|
||||
|
||||
watch(
|
||||
() => keyStore.selectedKey,
|
||||
() => { if (keyStore.selectedType === 'list') { pageStart.value = 0; loadList() } },
|
||||
() => { if (keyStore.selectedType === 'list') { loadInitial() } },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -48,7 +66,7 @@ async function addItem() {
|
||||
await window.electronAPI.redis.listPush(connStore.activeId, keyStore.selectedKey, [newItemValue.value])
|
||||
showAddDialog.value = false
|
||||
newItemValue.value = ''
|
||||
await loadList()
|
||||
await loadInitial()
|
||||
ElMessage.success(t('editor.added'))
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message)
|
||||
@@ -78,26 +96,14 @@ async function removeItem(index: number) {
|
||||
await ElMessageBox.confirm(t('editor.removeElementConfirm'), t('common.confirm'))
|
||||
// Use LSET + LREM sentinel pattern to delete by index instead of by value
|
||||
const sentinel = `\x00__JREDM_DEL_${Date.now()}_${Math.random()}__`
|
||||
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, pageStart + index, sentinel)
|
||||
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, index, sentinel)
|
||||
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, sentinel)
|
||||
await loadList()
|
||||
await loadInitial()
|
||||
ElMessage.success(t('editor.removed'))
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel') ElMessage.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (pageStart.value === 0) return
|
||||
pageStart.value = Math.max(0, pageStart.value - pageSize)
|
||||
loadList()
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (pageStart.value + pageSize >= totalCount.value) return
|
||||
pageStart.value += pageSize
|
||||
loadList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -106,32 +112,35 @@ function nextPage() {
|
||||
<span class="type-badge" :style="{ color: typeColor, borderColor: typeColor }">LIST</span>
|
||||
<span class="item-count">{{ t('editor.elements', { n: totalCount }) }}</span>
|
||||
<el-button size="small" type="primary" @click="showAddDialog = true">{{ t('editor.push') }}</el-button>
|
||||
<el-button size="small" @click="loadList" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||
<el-button size="small" @click="loadInitial" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="list-content">
|
||||
<div v-for="(item, index) in items" :key="index" class="list-item">
|
||||
<span class="item-index">{{ pageStart + index }}</span>
|
||||
<template v-if="editingIndex === index">
|
||||
<el-input v-model="editingValue" size="small" class="item-input" />
|
||||
<el-button size="small" type="primary" text @click="saveEdit">{{ t('editor.save') }}</el-button>
|
||||
<el-button size="small" @click="editingIndex = null">{{ t('common.cancel') }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="item-value">{{ item }}</span>
|
||||
<div class="item-actions">
|
||||
<el-button size="small" text @click="startEdit(index)">{{ t('editor.edit') }}</el-button>
|
||||
<el-button size="small" type="danger" text @click="removeItem(index)">{{ t('common.delete') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="items.length === 0 && !loading" class="empty">{{ t('editor.empty') }}</div>
|
||||
</div>
|
||||
<VirtualScrollList :items="items" :item-height="36" height="100%" class="list-content" @scroll-end="loadMore">
|
||||
<template #default="{ item, index }">
|
||||
<div class="list-item">
|
||||
<span class="item-index">{{ index }}</span>
|
||||
<template v-if="editingIndex === index">
|
||||
<el-input v-model="editingValue" size="small" class="item-input" />
|
||||
<el-button size="small" type="primary" text @click="saveEdit">{{ t('editor.save') }}</el-button>
|
||||
<el-button size="small" @click="editingIndex = null">{{ t('common.cancel') }}</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="item-value">{{ item }}</span>
|
||||
<div class="item-actions">
|
||||
<el-button size="small" text @click="startEdit(index)">{{ t('editor.edit') }}</el-button>
|
||||
<el-button size="small" type="danger" text @click="removeItem(index)">{{ t('common.delete') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="empty">{{ t('editor.empty') }}</div>
|
||||
</template>
|
||||
</VirtualScrollList>
|
||||
|
||||
<div class="list-pagination" v-if="totalCount > pageSize">
|
||||
<el-button size="small" :disabled="pageStart === 0" @click="prevPage">{{ t('common.prev') }}</el-button>
|
||||
<span class="page-info">{{ pageStart + 1 }}-{{ Math.min(pageStart + pageSize, totalCount) }} / {{ totalCount }}</span>
|
||||
<el-button size="small" :disabled="pageStart + pageSize >= totalCount" @click="nextPage">{{ t('common.next') }}</el-button>
|
||||
<div class="list-footer" v-if="totalCount > 0">
|
||||
<span class="load-info" v-if="loading">{{ t('common.loading') }}...</span>
|
||||
<span class="load-info" v-else>{{ loadedCount }} / {{ totalCount }}</span>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAddDialog" :title="t('editor.push')" width="400px">
|
||||
@@ -178,7 +187,6 @@ function nextPage() {
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
@@ -187,6 +195,7 @@ function nextPage() {
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
@@ -227,17 +236,16 @@ function nextPage() {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-pagination {
|
||||
.list-footer {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
.load-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import { useTypeColor } from '@renderer/composables/useTypeColor'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import VirtualScrollList from '@renderer/components/common/VirtualScrollList.vue'
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
const { typeColor } = useTypeColor(() => keyStore.selectedType)
|
||||
@@ -78,13 +79,17 @@ const filteredMembers = computed(() => {
|
||||
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="set-content">
|
||||
<div v-for="member in filteredMembers" :key="member" class="set-item">
|
||||
<span class="item-value">{{ member }}</span>
|
||||
<el-button size="small" type="danger" text @click="removeMember(member)">{{ t('editor.remove') }}</el-button>
|
||||
</div>
|
||||
<div v-if="filteredMembers.length === 0 && !loading" class="empty">{{ t('editor.empty') }}</div>
|
||||
</div>
|
||||
<VirtualScrollList :items="filteredMembers" :item-height="36" height="100%" class="set-content">
|
||||
<template #default="{ item }">
|
||||
<div class="set-item">
|
||||
<span class="item-value">{{ item }}</span>
|
||||
<el-button size="small" type="danger" text @click="removeMember(item)">{{ t('editor.remove') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="empty">{{ t('editor.empty') }}</div>
|
||||
</template>
|
||||
</VirtualScrollList>
|
||||
|
||||
<el-dialog v-model="showAddDialog" :title="t('editor.addMember')" width="400px">
|
||||
<el-form-item :label="t('editor.member')">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import { useTypeColor } from '@renderer/composables/useTypeColor'
|
||||
import VirtualScrollList from '@renderer/components/common/VirtualScrollList.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
@@ -100,14 +101,18 @@ const filteredItems = computed(() => {
|
||||
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="zset-content">
|
||||
<div v-for="item in filteredItems" :key="item.member" class="zset-item">
|
||||
<span class="item-score">{{ item.score }}</span>
|
||||
<span class="item-member">{{ item.member }}</span>
|
||||
<el-button size="small" type="danger" text @click="removeMember(item.member)">{{ t('editor.remove') }}</el-button>
|
||||
</div>
|
||||
<div v-if="filteredItems.length === 0 && !loading" class="empty">{{ t('editor.empty') }}</div>
|
||||
</div>
|
||||
<VirtualScrollList :items="filteredItems" :item-height="36" height="100%" class="zset-content">
|
||||
<template #default="{ item }">
|
||||
<div class="zset-item">
|
||||
<span class="item-score">{{ item.score }}</span>
|
||||
<span class="item-member">{{ item.member }}</span>
|
||||
<el-button size="small" type="danger" text @click="removeMember(item.member)">{{ t('editor.remove') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="empty">{{ t('editor.empty') }}</div>
|
||||
</template>
|
||||
</VirtualScrollList>
|
||||
|
||||
<el-dialog v-model="showAddDialog" :title="t('editor.addMember')" width="400px">
|
||||
<el-form label-position="top">
|
||||
@@ -167,6 +172,7 @@ const filteredItems = computed(() => {
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zset-item:hover {
|
||||
|
||||
Reference in New Issue
Block a user