包列表虚拟滚动:仅渲染可视窗口行+占位行,桌面端容器限高内部滚动,超大列表不再全量渲染 DOM
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user