包列表虚拟滚动:仅渲染可视窗口行+占位行,桌面端容器限高内部滚动,超大列表不再全量渲染 DOM

This commit is contained in:
yinjun.chen1
2026-09-21 16:39:20 +08:00
parent f4ee32b3b2
commit 7f85c6438a
+70 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import PackageDrawer from '../components/PackageDrawer.vue' import PackageDrawer from '../components/PackageDrawer.vue'
import CleanDialog from '../components/CleanDialog.vue' import CleanDialog from '../components/CleanDialog.vue'
@@ -89,6 +89,7 @@ onMounted(() => {
document.addEventListener('click', closeMenus) document.addEventListener('click', closeMenus)
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
window.addEventListener('resize', fitTableHeight)
// 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿) // 数据由 App 初始化时统一加载,切换菜单不再重复拉取全量列表(响应大,避免卡顿)
if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) { if (route.query.status && ['all', 'warn', 'ok', 'ignored', 'broken'].includes(route.query.status)) {
statusFilter.value = route.query.status statusFilter.value = route.query.status
@@ -100,6 +101,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', closeMenus) document.removeEventListener('click', closeMenus)
window.removeEventListener('resize', checkMobile) window.removeEventListener('resize', checkMobile)
window.removeEventListener('resize', fitTableHeight)
}) })
watch(keyword, () => { watch(keyword, () => {
@@ -129,6 +131,51 @@ const filtered = computed(() => {
return list return list
}) })
// —— 虚拟滚动:仅渲染可视窗口内的行,上下用透明占位行撑出总高度 ——
// 大列表(7000+ 条)避免一次性渲染十万级 DOM 节点导致整机卡顿
const ROW_H = 58 // 单行高度(px):td padding 14×2 + 内容 30(logo),与行内最大元素一致
const OVERSCAN = 6 // 可视区外多渲染的缓冲行数
const vWrapEl = ref(null)
const vScrollTop = ref(0)
function onVScroll() {
vScrollTop.value = vWrapEl.value ? vWrapEl.value.scrollTop : 0
}
const vTotal = computed(() => filtered.value.length)
const vStart = computed(() => {
const s = Math.floor(vScrollTop.value / ROW_H) - OVERSCAN
return Math.max(0, s)
})
const vEnd = computed(() => {
const viewH = vWrapEl.value ? vWrapEl.value.clientHeight : 600
const e = Math.ceil((vScrollTop.value + viewH) / ROW_H) + OVERSCAN
return Math.min(vTotal.value, e)
})
const visibleRows = computed(() => filtered.value.slice(vStart.value, vEnd.value))
const vTopPad = computed(() => vStart.value * ROW_H)
const vBottomPad = computed(() => (vTotal.value - vEnd.value) * ROW_H)
// 筛选/排序/搜索结果变化后回到列表顶部,避免停在旧位置
watch(
() => filtered.value,
() => {
if (vWrapEl.value) vWrapEl.value.scrollTop = 0
},
)
// —— 表格容器限高:虚拟滚动依赖容器自身滚动,按视口剩余空间动态撑满 ——
function fitTableHeight() {
const el = vWrapEl.value
if (!el) return
const top = el.getBoundingClientRect().top
el.style.height = `${Math.max(180, Math.floor(window.innerHeight - top - 14))}px`
}
watch(
() => state.loading,
(v) => {
if (!v) nextTick(fitTableHeight)
},
)
watch(searchHistory, () => nextTick(fitTableHeight))
const pills = [ const pills = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'warn', label: '🔥 预警' }, { key: 'warn', label: '🔥 预警' },
@@ -397,7 +444,7 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
</div> </div>
<div v-if="state.loading" class="loading-tip">⏳ 加载中…</div> <div v-if="state.loading" class="loading-tip">⏳ 加载中…</div>
<div v-else class="vtable"> <div v-else ref="vWrapEl" class="vtable" @scroll.passive="onVScroll">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -411,7 +458,10 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="p in filtered" :key="p.name" @click="openDrawer(p)"> <tr v-if="vTopPad" class="vtr-ph" :style="{ height: vTopPad + 'px' }">
<td colspan="7"></td>
</tr>
<tr v-for="p in visibleRows" :key="p.name" @click="openDrawer(p)">
<td> <td>
<span class="pkg-name"> <span class="pkg-name">
<PkgLogo :name="p.name" :size="30" :homepage="p.homepage" /> <PkgLogo :name="p.name" :size="30" :homepage="p.homepage" />
@@ -436,6 +486,9 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
</template> </template>
</td> </td>
</tr> </tr>
<tr v-if="vBottomPad" class="vtr-ph" :style="{ height: vBottomPad + 'px' }">
<td colspan="7"></td>
</tr>
</tbody> </tbody>
</table> </table>
<div v-if="!filtered.length" class="empty-tip">没有匹配的包</div> <div v-if="!filtered.length" class="empty-tip">没有匹配的包</div>
@@ -683,6 +736,20 @@ const pendingCount = computed(() => state.pkgList.items.filter(i => i.status ===
.pkg-name { display: inline-flex; align-items: center; gap: 9px; min-width: 0; } .pkg-name { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }
.pkg-name .pkg-cell { min-width: 0; } .pkg-name .pkg-cell { min-width: 0; }
.pkg-cell { font-weight: 600; word-break: break-all; } .pkg-cell { font-weight: 600; word-break: break-all; }
/* —— 虚拟滚动支持:包名超长不换行,保证行高恒定 —— */
.pkg-name .pkg-cell {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 虚拟滚动容器:内部纵向滚动 + 表头吸顶 */
.vtable { overflow-y: auto; }
.vtable thead th { position: sticky; top: 0; z-index: 1; }
/* 占位行:不参与视觉(无内边距/边框/背景) */
.vtr-ph td { padding: 0; border-bottom: none; }
.vtr-ph { cursor: default; }
.vtr-ph:hover { background: transparent; }
.pkg-desc { .pkg-desc {
display: block; display: block;
max-width: 230px; max-width: 230px;