代码拉取完成,页面将自动刷新
<template>
<div
class="virtual-table-container"
ref="scrollContainer"
@scroll="handleScroll"
>
<div class="table-header">
<div
v-for="(column, index) in columns"
:key="column.prop"
class="header-cell"
:style="{ width: getColumnWidth(index) }"
>
{{ column.label }}
</div>
</div>
<div class="virtual-list" :style="{ height: totalHeight + 'px' }" ref="listRef">
<div
v-for="(row,rIndex) in visibleRows"
:key="row.id"
class="virtual-row"
:style="{ top: (positions[row.id] ? positions[row.id].top : 0) + 'px' }"
:data-id="row.id"
:data-index="visibleRange.start + rIndex"
>
<div
v-for="(column, index) in columns"
:key="column.prop"
class="cell"
:style="{ width: getColumnWidth(index) }"
>
<div class="text">
<template v-if="column.slotName">
<slot :name="column.slotName" :row="row"></slot>
</template>
<template v-else>
{{ row[column.prop] }}
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {computed, nextTick, onMounted, reactive, ref, watch} from 'vue';
const props = defineProps({
data: {
type: Array,
default: () => []
},
// 预估行高
estimatedRowHeight: {
type: Number,
default: 60
},
columns: {
type: Array,
default: () => []
},
spanMethod: {
type: Function,
default: () => {}
}
});
const scrollContainer = ref(null);
const listRef = ref(null);
const positions = reactive({});
const scrollTop = ref(0);
const spanList = ref([]);
const bufferSize = 5
let scrollTimeout = null;
// 初始化位置数据
const initializePositions = () => {
Object.keys(positions).forEach(key => delete positions[key]);
let top = 0;
props.data.forEach(item => {
const height = Number(props.estimatedRowHeight);
positions[item.id] = {
height,
top,
bottom: top + height
};
top += height;
});
};
// 计算可见行范围
const visibleRange = computed(() => {
if (props.data.length === 0) return {start: 0, end: 0};
let start = 0;
let end;
let foundStart = false;
// 二分查找起始位置
let low = 0;
let high = props.data.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midPos = positions[props.data[mid].id];
if (midPos && midPos.bottom >= scrollTop.value) {
start = mid;
high = mid - 1;
foundStart = true;
} else {
low = mid + 1;
}
}
if (!foundStart) start = 0;
// 查找结束位置
const viewportHeight = scrollContainer.value?.clientHeight || 0;
const viewportBottom = scrollTop.value + viewportHeight;
for (end = start; end < props.data.length; end++) {
const pos = positions[props.data[end].id];
if (pos && pos.top > viewportBottom) break;
}
// 添加缓冲区
start = Math.max(0, start - bufferSize);
end = Math.min(props.data.length - 1, end + bufferSize);
return {start, end};
});
// 获取可见行数据
const visibleRows = computed(() => {
return props.data.slice(visibleRange.value.start, visibleRange.value.end + 1);
});
// 计算总高度
const totalHeight = computed(() => {
if (props.data.length === 0) return 0;
const lastItem = props.data[props.data.length - 1];
return positions[lastItem.id]?.bottom || 0;
});
//当前渲染列需要合并的单元格
const currentSpanList = computed(() => {
return getMergesInRange(spanList.value, visibleRange.value.start, visibleRange.value.end, props.columns.length)
})
// 处理滚动事件
const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop;
if (scrollTimeout) clearTimeout(scrollTimeout);
// 限制更新频率,避免频繁计算
scrollTimeout = setTimeout(() => {
updateRowHeights();
}, 16); // 滚动到底部时延长计算时间
};
// 更新行高
const updateRowHeights = () => {
const rowElements = listRef.value?.querySelectorAll('.virtual-row');
if (rowElements.length === 0) return;
let heightChanged = false;
const heightUpdates = [];
rowElements.forEach(rowEl => {
if (!rowEl) return;
const rowId = parseInt(rowEl.dataset.id);
const actualHeight = rowEl.offsetHeight;
if (positions[rowId] && positions[rowId].height !== actualHeight) {
heightUpdates.push({id: rowId, height: actualHeight});
heightChanged = true;
}
});
if (heightChanged) {
heightUpdates.forEach(update => {
positions[update.id].height = update.height;
});
let top = 0;
for (let i = 0; i < props.data.length; i++) {
const item = props.data[i];
const pos = positions[item.id];
if (pos) {
pos.top = top;
pos.bottom = top + pos.height;
top = pos.bottom;
}
}
}
// 处理合并单元格
const startIndex = rowElements[0]?.dataset.index;
currentSpanList.value.forEach(item => {
const startRowEl = rowElements[item.startRow - startIndex]
const endRowEl = rowElements[item.endRow - startIndex]
if(!startRowEl || !endRowEl) return
const startCellEl = startRowEl.children[item.startCol]
const endCellEl = endRowEl.children[item.endCol]
const startCellContentEl = startCellEl.children[0]
requestAnimationFrame(() => {
const startCellBCR = startCellEl.getBoundingClientRect()
const endCellBCR = endCellEl.getBoundingClientRect()
const spanWidth = endCellBCR.right - startCellBCR.left
const spanHeight = endCellBCR.bottom - startCellBCR.top
startCellContentEl.style = `width: ${spanWidth + 1}px; height: ${spanHeight + 2}px;`
startCellContentEl.classList.add('span-cell')
startCellEl.style.zIndex = 10
})
})
};
// 计算列宽
const getColumnWidth = (index) => {
const column = props.columns[index];
if (!column.width) {
// 未设置宽度,计算剩余宽度
let count = 0
const fixedWidth = props.columns.reduce((total, col) => {
if (typeof col.width === 'number') {
count += 1
return total + col.width;
} else if (typeof col.width === 'string' && col.width.includes('%')) {
const percentage = parseFloat(col.width.replace('%', ''));
count += 1
return total + (scrollContainer.value?.clientWidth || 0) * (percentage / 100);
}
return total;
}, 0);
const remainingWidth = (scrollContainer.value?.clientWidth || 0) - fixedWidth;
return `${remainingWidth / (props.columns.length - count)}px`;
} else if (typeof column.width === 'number') {
return `${column.width}px`;
} else if (typeof column.width === 'string' && column.width.includes('%')) {
return column.width;
}
return 'auto';
};
// 生成spanList
function generateSpanList(data, spanMethod) {
const spanList = [];
const handledCells = new Set();
for (let row = 0; row < data.length; row++) {
for (let col = 0; col < props.columns.length; col++) {
const cellKey = `${row},${col}`;
if (handledCells.has(cellKey)) continue;
const result = spanMethod({rowIndex: row, columnIndex: col, data});
if (result.rowspan > 1 || result.colspan > 1) {
const region = {
startRow: row,
startCol: col,
endRow: row + result.rowspan - 1,
endCol: col + result.colspan - 1,
regionId: result.regionId
};
spanList.push(region);
// 标记被合并的单元格
for (let r = row; r <= region.endRow; r++) {
for (let c = col; c <= region.endCol; c++) {
if (r !== row || c !== col) {
handledCells.add(`${r},${c}`);
}
}
}
}
}
}
return spanList;
}
// 计算区域内的合并单元格
function getMergesInRange(spanList, startRow, endRow, colCount) {
return spanList
.map(region => {
// 计算可见部分
const visibleStartRow = Math.max(region.startRow, startRow);
const visibleEndRow = Math.min(region.endRow, endRow);
const visibleStartCol = Math.max(region.startCol, 0);
const visibleEndCol = Math.min(region.endCol, colCount - 1);
// 检查是否有可见部分
if (visibleStartRow <= visibleEndRow && visibleStartCol <= visibleEndCol) {
return {
...region,
startRow: visibleStartRow,
endRow: visibleEndRow,
startCol: visibleStartCol,
endCol: visibleEndCol
};
}
return null;
})
.filter(region => region !== null);
}
onMounted(() => {
initializePositions();
nextTick(() => {
updateRowHeights();
});
scrollContainer.value.__positions = positions; // 方便外部访问positions
});
watch(() => props.data, () => {
initializePositions();
spanList.value = generateSpanList(props.data, props.spanMethod);
}, {
immediate: true
});
watch(() => props.estimatedRowHeight, () => {
initializePositions();
});
</script>
<style scoped>
*{
box-sizing: border-box;
}
.virtual-table-container {
width: 1200px;
height: 65vh;
overflow: auto;
position: relative;
border: 1px solid #e1e4e8;
}
.table-header {
position: sticky;
top: 0;
display: flex;
background: #f7fafc;
color: #2d3748;
font-weight: 600;
z-index: 11;
width: 100%;
min-width: max-content;
border-bottom: 1px solid #e1e4e8;
}
.header-cell {
padding: 12px 15px;
text-align: left;
border-right: 1px solid #e1e4e8;
white-space: nowrap;
background: #fff;
}
.header-cell:last-child {
border-right: none;
}
.virtual-list {
position: relative;
width: 100%;
min-width: max-content;
}
.virtual-row {
display: flex;
border-bottom: 1px solid #e1e4e8;
position: absolute;
width: 100%;
left: 0;
background: white;
}
.virtual-row:hover {
background-color: #f7fafc;
}
.cell {
display: flex;
align-items: center;
white-space: normal;
word-break: break-word;
border-right: 1px solid #e1e4e8;
position: relative;
z-index: 1;
}
.span-cell {
position: absolute;
top: -1px;
left: -1px;
background: #fff;
border: 1px solid #e1e4e8
}
.cell .text {
width: 100%;
height: 100%;
padding: 12px 15px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.cell:last-child {
border-right: none;
}
@media (max-width: 768px) {
.table-header, .virtual-row {
grid-template-columns: 60px 1fr 80px 120px 90px;
}
.header-cell, .cell {
padding: 10px 8px;
font-size: 0.9rem;
}
}
</style>
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。