vue实现T型二维表格
- 图片

-
实现T形2维表,上下滚动,T形左右可以各自水平滚动
-
底部和顶部水平滚动保持一致
-
实现excle复制粘贴
-
T形左右宽度各自撑开
-
代码如下
<template> <div class="fixed-table-container" ref="tableContainer"> <!-- 顶部固定表头 --> <div class="top-header"> <!-- 左侧表头滚动区(可手动滚动) --> <div class="top-left-scroll" :style="leftAreaStyle" ref="topLeftScroll"> <table> <thead> <tr> <th v-for="(item, index) in leftHeaders" :style="leftCellStyle" :key="'t-l-' + index"> {{ item }} </th> </tr> </thead> </table> </div> <!-- 中间固定轴表头 --> <div class="top-middle-axis" :style="middleAxisStyle"> <table> <thead> <tr> <th :style="middleCellStyle">{{ middleAxisText }}</th> </tr> </thead> </table> </div> <!-- 右侧表头滚动区(可手动滚动) --> <div class="top-right-scroll" :style="rightAreaStyle" ref="topRightScroll"> <table> <thead> <tr> <th v-for="(item, index) in rightHeaders" :style="rightCellStyle" :key="'t-r-' + index"> {{ item }} </th> </tr> </thead> </table> </div> </div> <!-- 垂直滚动容器 --> <div class="vertical-scroll-container"> <!-- 中间内容区域 --> <div class="content-container"> <!-- 左侧内容滚动区 --> <div class="content-left-scroll" ref="contentLeftScroll" :style="leftAreaStyle" @paste="(e) => handleTablePaste('left',e)" @scroll="syncLeftHorizontalScroll"> <table> <tbody> <tr v-for="(row, rowIdx) in tableData" :key="rowIdx"> <td v-for="(val, colIdx) in row.left" :style="leftCellStyle" @click.stop="setActiveCell(rowIdx, colIdx, 'left')" :key="'l-' + rowIdx + '-' + colIdx"> <input type="text" @click.stop="setActiveCell(rowIdx, colIdx, 'left')" v-model="tableData[rowIdx].left[colIdx]" class="content-input" /> </td> </tr> </tbody> </table> </div> <!-- 中间固定轴 --> <div class="content-middle-axis" :style="middleAxisStyle"> <table> <tbody> <tr v-for="(row, rowIdx) in tableData" :key="rowIdx"> <td :style="middleCellStyle">{{ row.middle }}</td> </tr> </tbody> </table> </div> <!-- 右侧内容滚动区 --> <div class="content-right-scroll" ref="contentRightScroll" :style="rightAreaStyle" @paste="(e) => handleTablePaste('right',e)" @scroll="syncRightHorizontalScroll"> <table> <tbody> <tr v-for="(row, rowIdx) in tableData" :key="rowIdx"> <td v-for="(val, colIdx) in row.right" :style="rightCellStyle" @click="setActiveCell(rowIdx, colIdx, 'right')" :key="'r-' + rowIdx + '-' + colIdx"> <input type="text" @click.stop="setActiveCell(rowIdx, colIdx, 'right')" v-model="tableData[rowIdx].right[colIdx]" class="content-input" /> </td> </tr> </tbody> </table> </div> </div> </div> </div> </template> <script lang="ts"> import { Component, Vue } from "vue-property-decorator"; @Component({ name: "", components: {} }) export default class extends Vue { $refs = { tableContainer: null, topLeftScroll: null, topRightScroll: null, contentLeftScroll: null, contentRightScroll: null, }; middleAxisText = "+/-"; columnCount = 10; // 每侧列数 columnCount1 = 5; // 每侧列数 rowCount = 50; // 行数 // 生成顶部左侧表头(倒序) leftHeaders = Array.from({ length: this.columnCount }, (_, i) => ((i + 1) * 0.25).toFixed(2) ).reverse(); // 生成顶部右侧表头(正序) rightHeaders = Array.from({ length: this.columnCount1 }, (_, i) => ((i + 1) * 0.25).toFixed(2) ); // 生成表格内容数据 tableData = Array.from({ length: this.rowCount }, (_, rowIdx) => ({ left: Array.from( { length: this.columnCount }, (_, colIdx) => `L${rowIdx}_${colIdx}` ), middle: (rowIdx * 0.25).toFixed(2), right: Array.from( { length: this.columnCount1 }, (_, colIdx) => `R${rowIdx}_${colIdx}` ), })); isSyncing = false; // 防重复触发的同步锁 containerWidth = 0; // 容器实际宽度(用于计算均分) middleAxisWidth = 100; // 中间固定轴宽度 headerHeight = 40; // 表头高度 cellHeight = 40; // 单元格高度 // 新增:记录当前激活的单元格(粘贴起始位置) activeCell = { tableType: "left", // 当前激活的表格(left/right) rowIdx: 0, // 激活行索引 colIdx: 0, // 激活列索引 }; mounted() { // 初始化容器宽度 this.updateContainerWidth(); // 监听窗口大小变化,重新计算宽度 window.addEventListener("resize", this.updateContainerWidth); // 初始化滚动位置对齐 this.syncInitialScroll(); // 页面加载后左侧滚动区自动滚动到最右侧 this.scrollLeftToRight(); // 绑定顶部表头滚动事件(Vue 2 兼容写法) this.bindTopScrollEvents(); } beforeDestroy() { window.removeEventListener("resize", this.updateContainerWidth); // 销毁时移除事件监听,避免内存泄漏(Vue 2 生命周期) this.unbindTopScrollEvents(); } // 记录当前激活的单元格(点击单元格时触发) setActiveCell(rowIdx, colIdx, tableType: "left" | "right") { this.activeCell = { tableType, rowIdx, colIdx }; } // 辅助函数:判断是否为数字(含小数) isNumber(str: string): boolean { return /^-?\d+(\.\d+)?$/.test(str.trim()); } // 处理表格粘贴事件 handleTablePaste(tableType, e) { e.preventDefault(); // 阻止默认粘贴行为(避免直接粘贴到输入框) // 1. 获取粘贴板内容 const clipboardData = e.clipboardData || (window as any).clipboardData; if (!clipboardData) { return; } const pastedText = clipboardData.getData("text"); if (!pastedText.trim()) { return; } // 2. 解析粘贴内容为二维数组(Excel格式:\t分隔列,\n分隔行) const pastedRows = pastedText .split(/\r?\n/) // 兼容 Windows(\r\n) 和 Mac(\n) 换行 .filter(row => row.trim() !== "") // 过滤空行 .map(row => row.split("\t").map(cell => cell.trim())); // 按制表符分割列,去除单元格首尾空格 // 3. 获取当前激活的起始位置和目标表格数据 const { rowIdx: startRow, colIdx: startCol } = this.activeCell; const targetData = this.tableData; // 直接操作原表格数据数组 const maxRows = targetData.length; if (maxRows === 0) { return } // 4. 循环赋值到表格(从起始单元格开始,超出范围忽略) pastedRows.forEach((pastedRow, rowOffset) => { const currentRow = startRow + rowOffset; // 超出表格行数,停止当前行粘贴 if (currentRow >= maxRows) { return } pastedRow.forEach((pastedValue, colOffset) => { const currentCol = startCol + colOffset; const targetRow = targetData[currentRow]; const targetColumn = targetRow[tableType]; // 定位到 left/right 列数组 // 超出当前列数,忽略该单元格 if (currentCol >= targetColumn.length) { return } // 5. 处理数值格式(保留数字,限制1位小数,空值设为""避免显示0) let finalValue = ""; if (pastedValue) { // 过滤非数字字符(保留负号和小数点) const cleanedValue = pastedValue.replace(/[^-0-9.]/g, ""); // 验证是否为有效数字 if (this.isNumber(cleanedValue)) { // 限制1位小数 finalValue = Number(cleanedValue).toFixed(1); // 去除末尾多余的 .0(可选,根据需求调整) finalValue = finalValue.endsWith(".0") ? finalValue.slice(0, -2) : finalValue; } else { // 非数字保持原值(可选,也可设为"") finalValue = pastedValue; } } // 6. 响应式更新表格数据(Vue 2 兼容写法) this.$set(targetColumn, currentCol, finalValue); }); }); // 7. 触发自定义事件,通知父组件数据变化 this.$emit("table-pasted", { tableType, startRow, startCol, pastedRows, updatedData: [...this.tableData] // 传递更新后的数据副本 }); } // 更新容器宽度(关键:用于计算均分) updateContainerWidth() { const container = this.$refs.tableContainer; if (container) { this.containerWidth = container.offsetWidth; } } // 初始化滚动位置对齐 syncInitialScroll() { if (this.$refs.contentLeftScroll && this.$refs.topLeftScroll) { this.$refs.topLeftScroll.scrollLeft = this.$refs.contentLeftScroll.scrollLeft; } if (this.$refs.contentRightScroll && this.$refs.topRightScroll) { this.$refs.topRightScroll.scrollLeft = this.$refs.contentRightScroll.scrollLeft; } } // 左侧滚动区(顶部+内容)滚动到最右侧 scrollLeftToRight() { this.$nextTick(() => { // 顶部左侧滚动区 const topLeftScroll = this.$refs.topLeftScroll; if (topLeftScroll) { topLeftScroll.scrollLeft = topLeftScroll.scrollWidth - topLeftScroll.clientWidth; } // 内容左侧滚动区 const contentLeftScroll = this.$refs.contentLeftScroll; if (contentLeftScroll) { contentLeftScroll.scrollLeft = contentLeftScroll.scrollWidth - contentLeftScroll.clientWidth; } }); } // 绑定顶部表头滚动事件(实现表头→表体同步) bindTopScrollEvents() { this.$nextTick(() => { const topLeftScroll = this.$refs.topLeftScroll; if (topLeftScroll) { // Vue 2 兼容的事件绑定方式 topLeftScroll.addEventListener("scroll", this.handleTopLeftScroll); } const topRightScroll = this.$refs.topRightScroll; if (topRightScroll) { topRightScroll.addEventListener("scroll", this.handleTopRightScroll); } }); } // 移除顶部表头滚动事件 unbindTopScrollEvents() { const topLeftScroll = this.$refs.topLeftScroll; if (topLeftScroll) { topLeftScroll.removeEventListener("scroll", this.handleTopLeftScroll); } const topRightScroll = this.$refs.topRightScroll; if (topRightScroll) { topRightScroll.removeEventListener("scroll", this.handleTopRightScroll); } } // 顶部左侧滚动 → 同步内容左侧滚动 handleTopLeftScroll(e) { if (this.isSyncing) { return; } this.isSyncing = true; const scrollLeft = e.target.scrollLeft; if (this.$refs.contentLeftScroll) { this.$refs.contentLeftScroll.scrollLeft = scrollLeft; } this.isSyncing = false; } // 顶部右侧滚动 → 同步内容右侧滚动 handleTopRightScroll(e) { if (this.isSyncing) { return; } this.isSyncing = true; const scrollLeft = e.target.scrollLeft; if (this.$refs.contentRightScroll) { this.$refs.contentRightScroll.scrollLeft = scrollLeft; } this.isSyncing = false; } // 内容左侧滚动 → 同步顶部左侧滚动 syncLeftHorizontalScroll(e) { if (this.isSyncing) { return; } this.isSyncing = true; const scrollLeft = e.target.scrollLeft; if (this.$refs.topLeftScroll) { this.$refs.topLeftScroll.scrollLeft = scrollLeft; } this.isSyncing = false; } // 内容右侧滚动 → 同步顶部右侧滚动 syncRightHorizontalScroll(e) { if (this.isSyncing) { return; } this.isSyncing = true; const scrollLeft = e.target.scrollLeft; if (this.$refs.topRightScroll) { this.$refs.topRightScroll.scrollLeft = scrollLeft; } this.isSyncing = false; } // 左侧区域样式(固定宽度,确保左右侧宽度一致) get leftAreaStyle() { const sideWidth = (this.containerWidth - this.middleAxisWidth) / 2; return { width: `${sideWidth}px`, minWidth: `${sideWidth}px`, }; } // 右侧区域样式(与左侧宽度一致) get rightAreaStyle() { const sideWidth = (this.containerWidth - this.middleAxisWidth) / 2; return { width: `${sideWidth}px`, minWidth: `${sideWidth}px`, }; } // 左侧单元格样式(均分宽度) get leftCellStyle() { if (this.leftHeaders.length < 8) { const sideWidth = (this.containerWidth - this.middleAxisWidth) / 2; const cellWidth = sideWidth / this.leftHeaders.length; return { width: `${cellWidth}px`, minWidth: `${cellWidth}px`, height: `${this.cellHeight}px`, lineHeight: `${this.cellHeight}px`, }; } else { return { width: `${this.middleAxisWidth}px`, minWidth: `${this.middleAxisWidth}px`, height: `${this.cellHeight}px`, lineHeight: `${this.cellHeight}px`, }; } } // 右侧单元格样式(均分宽度) get rightCellStyle() { if (this.rightHeaders.length < 8) { const sideWidth = (this.containerWidth - this.middleAxisWidth) / 2; const cellWidth = sideWidth / this.rightHeaders.length; return { width: `${cellWidth}px`, minWidth: `${cellWidth}px`, height: `${this.cellHeight}px`, lineHeight: `${this.cellHeight}px`, }; } else { return { width: `${this.middleAxisWidth}px`, minWidth: `${this.middleAxisWidth}px`, height: `${this.cellHeight}px`, lineHeight: `${this.cellHeight}px`, }; } } // 中间单元格样式(高度统一,宽度与轴一致) get middleCellStyle() { return { width: `${this.middleAxisWidth}px`, height: `${this.cellHeight}px`, lineHeight: `${this.cellHeight}px`, }; } // 中间固定轴样式(宽度固定) get middleAxisStyle() { return { width: `${this.middleAxisWidth}px`, height: `100%`, }; } } </script> <style scoped lang="scss"> .fixed-table-container { width: 100%; max-width: 1200px; height: 500px; overflow: hidden; box-sizing: border-box; background: #fff; /* 顶部固定表头 */ .top-header { display: flex; position: relative; z-index: 2; box-sizing: border-box; } /* 顶部左侧滚动区 */ .top-left-scroll { background-color: #ffeb3b; height: 100%; overflow-x: auto; overflow-y: hidden; -ms-overflow-style: none; border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; flex-shrink: 0; /* 固定宽度,不伸缩 */ tr { th { &:first-child { border-left: 1px solid #ccc; } } } } /* 顶部右侧滚动区 */ .top-right-scroll { background-color: #ffeb3b; border-right: 1px solid #ccc; border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; height: 100%; overflow-x: auto; overflow-y: hidden; -ms-overflow-style: none; flex-shrink: 0; /* 固定宽度,不伸缩 */ } /* 顶部中间固定轴 */ .top-middle-axis { height: 100%; background-color: #ffc107; border-left: 1px solid #ccc; border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; flex-shrink: 0; box-sizing: border-box; } /* 表头表格样式 - 关键对齐设置 */ .top-header table { width: auto; border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; } .top-header th { border-right: 1px solid #ccc; text-align: center; padding: 0 8px; font-weight: normal; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; } /* 最后一列去除右边框,避免与中间轴边框重叠 */ .top-left-scroll th:last-child, .top-right-scroll th:last-child { border-right: none; } /* 垂直滚动容器 */ .vertical-scroll-container { height: calc(100% - 50px); overflow-y: auto; /* 仅垂直滚动 */ overflow-x: hidden; /* 隐藏水平滚动条 */ } /* 内容区域容器(禁止水平滚动,仅左右子元素可滚动) */ .content-container { display: flex; min-height: 100%; width: 100%; overflow-x: hidden; /* 关键:父容器禁止水平滚动 */ } /* 左侧内容滚动区 */ .content-left-scroll { overflow-x: auto; /* 显示左侧水平滚动条 */ overflow-y: hidden; flex-shrink: 0; tr { td { &:first-child { border-left: 1px solid #ccc; box-sizing: border-box; } } } } /* 中间固定轴内容区 */ .content-middle-axis { box-sizing: border-box; background-color: #ffc107; border-right: 1px solid #ccc; border-left: 1px solid #ccc; flex-shrink: 0; height: 100%; } /* 右侧内容滚动区 */ .content-right-scroll { overflow-x: auto; /* 显示右侧水平滚动条 */ overflow-y: hidden; flex-shrink: 0; } /* 内容区表格样式 - 关键对齐设置 */ .content-left-scroll table, .content-middle-axis table, .content-right-scroll table { width: auto; border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; } .content-left-scroll td, .content-middle-axis td, .content-right-scroll td { box-sizing: border-box; border-right: 1px solid #ccc; border-bottom: 1px solid #ccc; text-align: center; padding: 0; /* 移除内边距避免宽度偏差 */ margin: 0; } .content-left-scroll td:last-child { border-right: none; } .content-middle-axis td { border-right: none; font-weight: bold; } /* 内容区输入框样式 */ .content-input { width: 100%; height: 100%; padding: 0 8px; border: none; box-sizing: border-box; text-align: center; background: transparent; vertical-align: top; font-size: 14px; } .content-input:disabled { background-color: #f5f7fa; color: #c0c4cc; cursor: not-allowed; } .content-input:focus { outline: none; background-color: #f0f7ff; } /* 修复第一行顶部边框与表头底部边框对齐 */ .content-left-scroll tr:first-child td, .content-middle-axis tr:first-child td, .content-right-scroll tr:first-child td { border-top: none; /* 移除第一行顶部边框,避免与表头底部边框重叠 */ } } /* 响应式适配 */ @media (max-width: 768px) { .fixed-table-container { height: 100%; } .middleAxisWidth { width: 60px !important; } .top-middle-axis, .content-middle-axis { width: 60px !important; } .top-left-scroll, .top-right-scroll { width: calc((100% - 60px) / 2) !important; } } </style>

浙公网安备 33010602011771号