完善T型二维表
- 示例图

- 实现跟excle一样复制粘贴功能
- 拖动选中单元格实现按delete和backspace后删除单元格
- 键盘箭头上、下、左、右、回车单元格会往上、下、左、右、下一格
-
二维表组件代码
<template> <div class="fixed-table-container" :data-instance-id="instanceId" @mousedown="handleContainerMousedown" ref="tableContainer"> <!-- 顶部固定表头 --> <div class="top-header" v-if="leftAxis.length > 0 || rightAxis.length > 0"> <!-- 左侧表头滚动区 --> <div class="top-left-scroll" ref="topLeftScroll" v-if="leftAxis.length > 0" :style="leftAreaStyle"> <table> <thead> <tr> <th v-for="(item, index) in leftAxis" :key="'t-l-' + index" :class="{'yellow-actived': activeCell.colIdx === index && activeCell.tableType === 'left' }" :style="leftCellStyle"> {{ 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" v-if="rightAxis.length > 0" ref="topRightScroll" :style="rightAreaStyle"> <table> <thead> <tr> <th v-for="(item, index) in rightAxis" :key="'t-r-' + index" :class="{'yellow-actived': activeCell.colIdx === index && activeCell.tableType === 'right' }" :style="rightCellStyle"> {{ item }} </th> </tr> </thead> </table> </div> </div> <!-- 垂直滚动容器 --> <div class="vertical-scroll-container" ref="verticalScrollContainer" @mouseup="handleContainerMouseup" @mousemove="handleContainerMousemove" @keydown="handleContainerKeydown" v-if="tableData.length > 0 || rightTable.length > 0"> <!-- 中间内容区域 --> <div class="content-container"> <!-- 左侧内容滚动区 --> <div class="content-left-scroll" v-if="tableData.length > 0" ref="contentLeftScroll" :style="leftAreaStyle" @paste="(e) => handleTablePaste('left',e)" @scroll="syncLeftHorizontalScroll"> <table> <tbody> <tr v-for="(row, rowIdx) in tableData" :key="'l-row-' + rowIdx"> <td v-for="(val, colIdx) in row" :key="'l-cell-' + rowIdx + '-' + colIdx" @click="setActiveCell(rowIdx, colIdx, 'left')" :class="{'cell-selected': isCellSelected(rowIdx, colIdx, 'left')}" :style="leftCellStyle"> <input type="number" v-limit-decimal :disabled="!val.bUpdate" @click.stop="setActiveCell(rowIdx, colIdx, 'left')" @change="onChangeLeft(val.value,rowIdx,colIdx)" @input="onLeftValue(colIdx)" v-model="val.value" class="content-input" :data-instance-id="instanceId" :data-row="rowIdx" @keydown="handleKeydown($event, rowIdx, colIdx, 'left')" :data-col="colIdx" /> </td> </tr> </tbody> </table> </div> <!-- 中间固定轴 --> <div class="content-middle-axis" :style="middleAxisStyle"> <table> <tbody> <tr v-for="(row, rowIdx) in yAxis" :class="{'yellow-actived': activeCell.rowIdx === rowIdx }" :key="'m-row-' + rowIdx"> <td :style="middleCellStyle">{{ row }}</td> </tr> </tbody> </table> </div> <!-- 右侧内容滚动区 --> <div class="content-right-scroll" ref="contentRightScroll" v-if="rightTable.length > 0" :style="rightAreaStyle" @paste="(e) => handleTablePaste('right',e)" @scroll="syncRightHorizontalScroll"> <table> <tbody> <tr v-for="(row, rowIdx) in rightTable" :key="'r-row-' + rowIdx"> <td v-for="(val, colIdx) in row" @click="setActiveCell(rowIdx, colIdx, 'right')" :key="'r-cell-' + rowIdx + '-' + colIdx" :class="{'cell-selected': isCellSelected(rowIdx, colIdx, 'right')}" :style="rightCellStyle"> <input type="number" v-limit-decimal @click.stop="setActiveCell(rowIdx, colIdx, 'right')" @change="onValueRight(val.value,rowIdx,colIdx)" @input="onRightValue(colIdx)" :disabled="!val.bUpdate" v-model="val.value" class="content-input" :data-instance-id="instanceId" :data-row="rowIdx" @keydown="handleKeydown($event, rowIdx, colIdx, 'right')" :data-col="colIdx" /> </td> </tr> </tbody> </table> </div> </div> </div> </div> </template> <script lang="ts"> import { Component, Vue, Prop, Model, Watch } from "vue-property-decorator"; import _ from "lodash"; @Component({ name: "FixedTable", components: {} }) export default class extends Vue { @Prop({ type: Array, default: [] }) leftAxis: string[]; // 左横坐标 @Prop({ type: Array, default: [] }) rightAxis: string[]; // 右横坐标 @Prop({ type: Array, default: [] }) yAxis: string[]; // 纵坐标 @Prop({ type: Array, default: [] }) rightData: string[]; // 右边表体 @Prop({ type: String, default: "father" }) instanceId; // 新增:父组件传递的唯一实例ID(必填) @Prop({ type: String, default: "(-/+)" }) middleAxisText; @Model("change") value; tableData = null; @Watch("value", { immediate: true }) onChangeValue(value) { this.tableData = value; if (value.length > 0) { this.InitLoad(); } this.getFistTotal("left"); } rightTable = []; @Watch("rightData", { immediate: true }) onChangeRight(value) { this.rightTable = _.cloneDeep(value); if (value.length > 0) { this.InitLoad(); } this.getFistTotal("right"); } $refs: { tableContainer: HTMLDivElement; topLeftScroll: HTMLDivElement; topRightScroll: HTMLDivElement; contentLeftScroll: HTMLDivElement; contentRightScroll: HTMLDivElement; verticalScrollContainer: HTMLDivElement; // 新增:垂直滚动容器引用 }; containerWidth = 0; // 容器实际宽度(用于计算均分) middleAxisWidth = 80; // 中间固定轴宽度 headerHeight = 30; // 表头高度 cellHeight = 30; // 单元格高度 isSyncing = false; // 记录当前激活的单元格(粘贴起始位置/键盘移动) activeCell = { tableType: "left", // 当前激活的表格(left/right) rowIdx: -1, // 激活行索引 colIdx: -1, // 激活列索引 }; // 选中区域状态(拖拽选择用) selectState = { isSelecting: false, // 是否正在拖拽选择 start: { tableType: "left", rowIdx: -1, colIdx: -1 }, // 选择起始点(仅可编辑单元格) end: { tableType: "left", rowIdx: -1, colIdx: -1 }, // 选择结束点(仅可编辑单元格) validRange: { // 有效选择范围(仅包含可编辑单元格) minRow: -1, maxRow: -1, minCol: -1, maxCol: -1, }, hasSelection: false, // 新增:标记是否有选中区域 }; // 新增:当前实例的DOM选择器前缀(避免全局冲突) get instanceSelector() { return `.fixed-table-container[data-instance-id="${this.instanceId}"]`; } getFistTotal(tableType) { const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (targetTable && !targetTable.length) { return false; } targetTable?.forEach(el => { el.forEach((item, tmpIndex) => { if (item.value) { if (tableType === "left") { this.onLeftValue(tmpIndex); } else if (tableType === "right") { this.onRightValue(tmpIndex); } } }); }); } InitLoad() { // 初始化容器宽度 this.updateContainerWidth(); // 监听窗口大小变化,重新计算宽度 window.addEventListener("resize", this.updateContainerWidth); // 初始化滚动位置对齐 this.syncInitialScroll(); // 页面加载后,左侧滚动区自动滚动到最右侧 this.scrollLeftToRight(); // 绑定顶部表头滚动事件 this.bindTopScrollEvents(); // 绑定全局键盘事件(Ctrl+C) this.bindGlobalKeyEvents(); } beforeDestroy() { window.removeEventListener("resize", this.updateContainerWidth); // 销毁时移除事件监听,避免内存泄漏 this.unbindTopScrollEvents(); // 移除全局键盘事件 this.unbindGlobalKeyEvents(); } // 记录当前激活的单元格(点击单元格时触发,仅可编辑单元格) setActiveCell(rowIdx, colIdx, tableType: "left" | "right") { const targetTable = tableType === "left" ? this.tableData : this.rightTable; // 仅激活可编辑单元格 if (targetTable[rowIdx]?.[colIdx]?.bUpdate) { this.activeCell = { tableType, rowIdx, colIdx }; // 点击单个单元格时清除选中区域 this.selectState.hasSelection = false; this.selectState.validRange = { minRow: -1, maxRow: -1, minCol: -1, maxCol: -1, }; } } // 处理表格粘贴事件 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")); // 按制表符分割列 // 3. 获取当前激活的表格数据和起始位置 const { rowIdx: startRow, colIdx: startCol } = this.activeCell; const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (!targetTable.length || targetTable[0].length === 0) { return; } // 记录粘贴涉及的列范围(用于后续计算小计) const affectedCols = new Set<number>(); // 格式化数值为整数或x.5的方法 const formatValue = value => { if (!value || value.trim() === "") { return null; } // 清除非数字和小数点的字符(保留负号) let cleaned = value.toString().replace(/[^0-9.]/g, ""); if (cleaned === "") { return null; } const dotIndex = cleaned.indexOf("."); if (dotIndex !== -1) { // 处理小数部分:只能保留.5 const integerPart = cleaned.slice(0, dotIndex) || "0"; const decimalPart = cleaned.slice(dotIndex + 1); // 小数部分只取第一位,且只能是5 const validDecimal = decimalPart.charAt(0) === "5" ? "5" : ""; cleaned = `${integerPart}${validDecimal ? "." + validDecimal : ""}`; // 去除末尾的小数点 if (cleaned.endsWith(".")) { cleaned = cleaned.slice(0, -1); } } // 处理纯整数(去除前导零) if (cleaned.indexOf(".") === -1) { cleaned = cleaned.replace(/^0+(?=\d)/, "") || "0"; } // 转换为数字(空值设为null) return cleaned ? Number(cleaned) : null; }; // 4. 循环赋值到表格(从起始单元格开始,超出表格范围则忽略) pastedRows.forEach((pastedRow, rowOffset) => { const currentRow = startRow + rowOffset; // 行 // 超出目标表格行数,停止粘贴 if (currentRow >= targetTable.length) { return; } pastedRow.forEach((pastedValue, colOffset) => { const currentCol = startCol + colOffset; // 列 // 确保列索引不超出表格范围 if (currentCol >= targetTable[currentRow].length) { return; } const targetCell = targetTable[currentRow][currentCol]; // 超出列数或单元格禁用,忽略 if (!targetCell || !targetCell.bUpdate) { return; } // 记录当前涉及的列(用于计算小计) affectedCols.add(currentCol); // 5. 格式化粘贴的数值(整数或x.5格式) const finalValue = formatValue(pastedValue); // 6. 响应式更新value字段(保留bUpdate,只改value) this.$set(targetCell, "value", finalValue); if (tableType === "left") { this.onChangeLeft(finalValue, currentRow, currentCol); } else if (tableType === "right") { this.onValueRight(finalValue, currentRow, currentCol); } }); }); // 7. 粘贴完成后,自动计算涉及列的小计 this.calcAffectedColsSubtotal(tableType, affectedCols); // 8. 触发自定义事件(可选,通知父组件数据变化) this.$emit("table-pasted", { tableType, startRow, startCol, pastedRows }); } // 左侧滚动区(顶部+内容)滚动到最右侧 scrollLeftToRight() { // 等待DOM渲染完成(确保能获取到滚动区的实际宽度) this.$nextTick(() => { // 1. 顶部左侧滚动区滚动到最右侧 const topLeftScroll = this.$refs.topLeftScroll; if (topLeftScroll) { topLeftScroll.scrollLeft = topLeftScroll.scrollWidth - topLeftScroll.clientWidth; } // 2. 内容左侧滚动区同步滚动到最右侧 const contentLeftScroll = this.$refs.contentLeftScroll; if (contentLeftScroll) { contentLeftScroll.scrollLeft = contentLeftScroll.scrollWidth - contentLeftScroll.clientWidth; } }); } // 绑定顶部表头滚动事件(实现表头→表体同步) bindTopScrollEvents() { this.$nextTick(() => { const topLeftScroll = this.$refs.topLeftScroll; if (topLeftScroll) { 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; } // 更新容器宽度(关键:用于计算均分) 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; } } // 同步左侧水平滚动 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; } // 左侧改变input onChangeLeft(value, y, x) { // ABS绝对值 // 公式计算 -y + ABS(-x) = 新y ; const newY = -this.yAxis[y] + Math.abs(Number(this.leftAxis[x])); const tmpY: any = Math.abs(newY).toString(); const yIndex = this.yAxis.indexOf(tmpY); if (yIndex > -1) { this.$emit("changeLeftTableIndex", { y: yIndex, x, value }); } } // 右侧改变input onValueRight(value, y, x) { // ABS绝对值 // 公式计算 -y + ABS(-x) = 新y ; const newY = Number(this.yAxis[y]) + Math.abs(Number(this.rightAxis[x])); const tmpY: any = Math.abs(newY).toString(); const yIndex = this.yAxis.indexOf(tmpY); if (yIndex > -1) { this.$emit("changeRightTableIndex", { y: yIndex, x, value }); } } // 计算粘贴涉及列的小计 calcAffectedColsSubtotal(tableType, affectedCols) { // 转换为数组并去重,避免重复计算 const colList = Array.from(affectedCols); if (colList.length === 0) { return; } // 根据表格类型,调用对应小计计算方法 colList.forEach(colIdx => { if (tableType === "left") { this.onLeftValue(colIdx); } else if (tableType === "right") { this.onRightValue(colIdx); } }); } onLeftValue(colIdx) { const tmpIndex = this.yAxis.indexOf("小计"); if (tmpIndex > -1) { const list = this.tableData.filter((_, index) => index !== tmpIndex); const newList = list.map(item => item.filter((_, elIndex) => elIndex === colIdx) ); const result = _.flatMap(newList); const total = result.reduce((sum, item) => sum + Number(item.value), 0); this.tableData[tmpIndex][colIdx].value = total || ""; } } onRightValue(colIdx) { const tmpIndex = this.yAxis.indexOf("小计"); if (tmpIndex > -1) { const list = this.rightTable.filter((_, index) => index !== tmpIndex); const newList = list.map(item => item.filter((_, elIndex) => elIndex === colIdx) ); const result = _.flatMap(newList); const total = result.reduce((sum, item) => sum + Number(item.value), 0); this.rightTable[tmpIndex][colIdx].value = total || ""; } } // 键盘箭头移动焦点核心功能 handleKeydown( e: KeyboardEvent, rowIdx: number, colIdx: number, tableType: "left" | "right" ) { // 如果有选中区域,优先处理删除 if ([8, 46].includes(e.keyCode) && this.selectState.hasSelection) { e.preventDefault(); e.stopPropagation(); this.handleDeleteSelection(); return; } // 阻止箭头键默认行为(数字输入框的增减功能 + 页面滚动) if ([37, 38, 39, 40, 13, 8, 46].includes(e.keyCode)) { e.preventDefault(); e.stopPropagation(); } // 处理单个单元格的删除键(Backspace/Delete) if ([8, 46].includes(e.keyCode)) { const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (targetTable[rowIdx]?.[colIdx]?.bUpdate) { this.$set(targetTable[rowIdx][colIdx], "value", null); // 更新小计 if (tableType === "left") { this.onLeftValue(colIdx); } else { this.onRightValue(colIdx); } } return; } let newRowIdx = rowIdx; let newColIdx = colIdx; // 根据箭头键计算目标单元格索引(跳过禁用单元格) switch (e.keyCode) { case 37: // 左箭头 newColIdx = this.findPrevValidCell(tableType, rowIdx, colIdx, "col"); break; case 38: // 上箭头 newRowIdx = this.findPrevValidCell(tableType, rowIdx, colIdx, "row"); break; case 39: // 右箭头 newColIdx = this.findNextValidCell(tableType, rowIdx, colIdx, "col"); break; case 40: // 下箭头 newRowIdx = this.findNextValidCell(tableType, rowIdx, colIdx, "row"); break; case 13: // 回车往下一个单元格 newRowIdx = this.findNextValidCell(tableType, rowIdx, colIdx, "row"); break; default: return; // 非箭头键不处理 } // 目标单元格变化时,更新激活状态并聚焦 if (newRowIdx !== rowIdx || newColIdx !== colIdx) { this.setActiveCell(newRowIdx, newColIdx, tableType); this.focusCellInput(newRowIdx, newColIdx, tableType); } } // 新增:容器内键盘事件处理(优先处理选中区域删除) handleContainerKeydown(e: KeyboardEvent) { if ([8, 46].includes(e.keyCode) && this.selectState.hasSelection) { e.preventDefault(); e.stopPropagation(); this.handleDeleteSelection(); } } // 查找下一个可编辑单元格 findNextValidCell( tableType: "left" | "right", rowIdx: number, colIdx: number, direction: "row" | "col" ) { const targetTable = tableType === "left" ? this.tableData : this.rightTable; const rowCount = targetTable.length; const colCount = targetTable[0]?.length || 0; if (direction === "row") { // 向下查找有效行 for (let r = rowIdx + 1; r < rowCount; r++) { if (targetTable[r]?.[colIdx]?.bUpdate) { return r; } } return rowIdx; // 无有效单元格则保持原位置 } else { // 向右查找有效列 for (let c = colIdx + 1; c < colCount; c++) { if (targetTable[rowIdx]?.[c]?.bUpdate) { return c; } } return colIdx; // 无有效单元格则保持原位置 } } // 查找上一个可编辑单元格 findPrevValidCell( tableType: "left" | "right", rowIdx: number, colIdx: number, direction: "row" | "col" ) { const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (direction === "row") { // 向上查找有效行 for (let r = rowIdx - 1; r >= 0; r--) { if (targetTable[r]?.[colIdx]?.bUpdate) { return r; } } return rowIdx; // 无有效单元格则保持原位置 } else { // 向左查找有效列 for (let c = colIdx - 1; c >= 0; c--) { if (targetTable[rowIdx]?.[c]?.bUpdate) { return c; } } return colIdx; // 无有效单元格则保持原位置 } } // 聚焦到指定单元格的输入框 focusCellInput(rowIdx: number, colIdx: number, tableType: "left" | "right") { this.$nextTick(() => { // 使用data属性精准定位,避免nth-of-type计数错误 const tableClass = tableType === "left" ? "content-left-scroll" : "content-right-scroll"; const input = document.querySelector( `${this.instanceSelector} .${tableClass} .content-input` + `[data-instance-id="${this.instanceId}"]` + `[data-row="${rowIdx}"]` + `[data-col="${colIdx}"]` ) as HTMLInputElement; if (input && !input.disabled) { input.focus(); input.select(); // 聚焦后选中内容,方便直接编辑 } }); } // 绑定全局键盘事件(Ctrl+C复制) bindGlobalKeyEvents() { window.addEventListener("keydown", this.handleGlobalKeydown); } // 移除全局键盘事件 unbindGlobalKeyEvents() { window.removeEventListener("keydown", this.handleGlobalKeydown); } // 判断单元格是否被选中(仅包含可编辑单元格) isCellSelected(rowIdx, colIdx, tableType) { if (tableType !== this.selectState.start.tableType) { return false; } // 跳过禁用单元格 const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (!targetTable[rowIdx]?.[colIdx]?.bUpdate) { return false; } const { validRange } = this.selectState; // 仅在有效范围内的可编辑单元格才显示选中状态 return ( validRange.minRow !== -1 && rowIdx >= validRange.minRow && rowIdx <= validRange.maxRow && colIdx >= validRange.minCol && colIdx <= validRange.maxCol ); } // 容器鼠标按下(开始拖拽选择,仅可编辑单元格) handleContainerMousedown(e: MouseEvent) { if (e.button !== 0) { return; } // 只处理左键 const tdEl = (e.target as HTMLElement).closest("td"); if (!tdEl) { // 点击空白处清除选中 this.selectState.hasSelection = false; this.selectState.validRange = { minRow: -1, maxRow: -1, minCol: -1, maxCol: -1, }; return; } // 判断表格类型 const leftScrollEl = tdEl.closest(".content-left-scroll"); const tableType: "left" | "right" = leftScrollEl ? "left" : "right"; // 获取单元格索引 const inputEl = tdEl.querySelector( "input.content-input" ) as HTMLInputElement; if (!inputEl || inputEl.disabled) { // 点击禁用单元格时清除选中 this.selectState.hasSelection = false; this.selectState.validRange = { minRow: -1, maxRow: -1, minCol: -1, maxCol: -1, }; return; } const rowIdx = Number(inputEl.dataset.row); const colIdx = Number(inputEl.dataset.col); const targetTable = tableType === "left" ? this.tableData : this.rightTable; // 仅从可编辑单元格开始选择 if (targetTable[rowIdx]?.[colIdx]?.bUpdate) { // 初始化选择状态 this.selectState = { isSelecting: true, start: { tableType, rowIdx, colIdx }, end: { tableType, rowIdx, colIdx }, validRange: { minRow: rowIdx, maxRow: rowIdx, minCol: colIdx, maxCol: colIdx, }, hasSelection: true, // 标记有选中区域 }; this.activeCell = { tableType, rowIdx, colIdx }; } else { this.selectState.hasSelection = false; } } // 容器鼠标移动(拖拽选择过程,仅包含可编辑单元格) handleContainerMousemove(e: MouseEvent) { if (!this.selectState.isSelecting) { return; } const tdEl = (e.target as HTMLElement).closest("td"); if (!tdEl) { return; } // 确保在同一表格内选择 const leftScrollEl = tdEl.closest(".content-left-scroll"); const currentTableType: "left" | "right" = leftScrollEl ? "left" : "right"; if (currentTableType !== this.selectState.start.tableType) { return; } // 获取当前单元格信息 const inputEl = tdEl.querySelector( "input.content-input" ) as HTMLInputElement; if (!inputEl || inputEl.disabled) { return; // 跳过禁用单元格 } const rowIdx = Number(inputEl.dataset.row); const colIdx = Number(inputEl.dataset.col); const targetTable = currentTableType === "left" ? this.tableData : this.rightTable; // 仅更新到可编辑单元格的范围 if (targetTable[rowIdx]?.[colIdx]?.bUpdate) { this.selectState.end = { tableType: currentTableType, rowIdx, colIdx }; // 更新有效选择范围 const { start, end } = this.selectState; this.selectState.validRange = { minRow: Math.min(start.rowIdx, end.rowIdx), maxRow: Math.max(start.rowIdx, end.rowIdx), minCol: Math.min(start.colIdx, end.colIdx), maxCol: Math.max(start.colIdx, end.colIdx), }; this.selectState.hasSelection = true; // 标记有选中区域 } } // 容器鼠标松开(结束拖拽选择) handleContainerMouseup() { this.selectState.isSelecting = false; // 检查是否有有效选中区域 const { validRange } = this.selectState; if ( validRange.minRow === validRange.maxRow && validRange.minCol === validRange.maxCol ) { this.selectState.hasSelection = false; // 单个单元格不算选中区域 } } // 全局键盘事件(处理Ctrl+C) handleGlobalKeydown(e: KeyboardEvent) { // Ctrl+C 或 Cmd+C(Mac) if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") { e.preventDefault(); this.handleCopySelection(); } // 如果有选中区域,处理删除键 if ([8, 46].includes(e.keyCode) && this.selectState.hasSelection) { // 排除输入框聚焦的情况 const target = e.target as HTMLElement; if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { e.preventDefault(); this.handleDeleteSelection(); } } } // 删除选中区域内容 handleDeleteSelection() { const { start, validRange } = this.selectState; if (!this.selectState.hasSelection || validRange.minRow === -1) { return; } const tableType = start.tableType; const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (!targetTable.length) { return; } // 记录被修改的列(用于更新小计) const affectedCols = new Set<number>(); // 遍历选中区域的可编辑单元格并清空内容 for (let row = validRange.minRow; row <= validRange.maxRow; row++) { for (let col = validRange.minCol; col <= validRange.maxCol; col++) { const cell = targetTable[row][col]; if (cell?.bUpdate) { this.$set(cell, "value", null); affectedCols.add(col); } } } // 更新涉及列的小计 this.calcAffectedColsSubtotal(tableType, affectedCols); // 清除选中状态 this.selectState.hasSelection = false; this.selectState.validRange = { minRow: -1, maxRow: -1, minCol: -1, maxCol: -1, }; } // 复制选中区域数据(仅复制可编辑单元格) handleCopySelection() { const { start, end, validRange } = this.selectState; if (start.rowIdx === -1 || end.rowIdx === -1) { return; } const tableType = start.tableType; const targetTable = tableType === "left" ? this.tableData : this.rightTable; if (!targetTable.length) { return; } // 只复制有效范围内的可编辑单元格 let copyText = ""; let hasValidContent = false; for (let row = validRange.minRow; row <= validRange.maxRow; row++) { const rowData = []; for (let col = validRange.minCol; col <= validRange.maxCol; col++) { const cell = targetTable[row][col]; // 仅包含可编辑单元格的内容 if (cell?.bUpdate) { rowData.push(cell.value || ""); hasValidContent = true; } else { rowData.push(""); // 禁用单元格留空 } } copyText += rowData.join("\t") + "\n"; } if (!hasValidContent) { this.$message.error("选中区域无有效可编辑内容"); return false; } // 写入剪贴板 this.setClipboard(copyText); this.$message.success( `选中区域(${validRange.maxRow - validRange.minRow + 1}行${ validRange.maxCol - validRange.minCol + 1 }列)复制成功` ); } // 写入剪贴板工具方法 setClipboard(content) { // 现代浏览器 if (navigator?.clipboard) { navigator.clipboard.writeText(content).catch(err => { const error = err as Error; // 处理权限拒绝错误 if (error?.name === "NotAllowedError") { this.$message.error( "剪贴板权限被拒绝,请在浏览器设置中允许剪贴板访问" ); } else { this.$message.error(error?.message || "复制失败,请手动选中内容复制"); } }); } else { // 兼容旧浏览器(IE11等) const textarea = document.createElement("textarea"); textarea.value = content; textarea.style.position = "fixed"; textarea.style.top = "-999px"; textarea.style.left = "-999px"; document.body.appendChild(textarea); textarea.select(); try { const success = document.execCommand("copy"); if (!success) { this.$message.error("execCommand 复制失败"); } } catch (err) { this.$message.error("复制失败,请手动选中内容复制"); } finally { document.body.removeChild(textarea); } } } // 样式计算 get leftAreaStyle() { const sideWidth = (this.containerWidth - this.middleAxisWidth) / 2; return { maxWidth: `${sideWidth}px`, }; } get rightAreaStyle() { const sideWidth = (this.containerWidth - this.middleAxisWidth) / 2; return { maxWidth: `${sideWidth}px`, }; } get leftCellStyle() { return { width: `${this.middleAxisWidth}px`, minWidth: `${this.middleAxisWidth}px`, height: `${this.cellHeight}px`, lineHeight: `${this.cellHeight}px`, }; } get rightCellStyle() { 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 lang="scss" scoped>.fixed-table-container {
max-width: 100%;
width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;/* 顶部固定表头 */ .top-header { display: flex; position: relative; z-index: 2; box-sizing: border-box; } /* 顶部左侧滚动区 */ .top-left-scroll { background-color: #f0f0f0; height: 100%; overflow-x: auto; overflow-y: hidden; -ms-overflow-style: none; border-top: 1px solid #d7d7d7; border-bottom: 1px solid #d7d7d7; flex-shrink: 0; /* 固定宽度,不伸缩 */ tr { th { &:first-child { border-left: 1px solid #d7d7d7; } } } } /* 顶部右侧滚动区 */ .top-right-scroll { background-color: #f0f0f0; border-right: 1px solid #d7d7d7; border-top: 1px solid #d7d7d7; border-bottom: 1px solid #d7d7d7; height: 100%; overflow-x: auto; overflow-y: hidden; -ms-overflow-style: none; flex-shrink: 0; /* 固定宽度,不伸缩 */ } /* 顶部中间固定轴 */ .top-middle-axis { height: 100%; background-color: #f0f0f0; border-left: 1px solid #d7d7d7; border-top: 1px solid #d7d7d7; border-bottom: 1px solid #d7d7d7; 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 #d7d7d7; 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% - 60px); overflow-y: auto; /* 仅垂直滚动 */ overflow-x: hidden; /* 隐藏水平滚动条 */ outline: none; /* 移除focus轮廓 */ } /* 内容区域容器(禁止水平滚动,仅左右子元素可滚动) */ .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 #d7d7d7; box-sizing: border-box; } } } } /* 中间固定轴内容区 */ .content-middle-axis { box-sizing: border-box; background-color: #ffe0e0; border-right: 1px solid #d7d7d7; border-left: 1px solid #d7d7d7; 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 #d7d7d7; border-bottom: 1px solid #d7d7d7; 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; border: none; box-sizing: border-box; text-align: center; background: transparent; vertical-align: top; font-size: 14px; color: #000; } .content-input:disabled { background-color: #ffe0e0; cursor: not-allowed; } .content-input:focus { outline: none; background-color: #0078d7; } /* 修复第一行顶部边框与表头底部边框对齐 */ .content-left-scroll tr:first-child td, .content-middle-axis tr:first-child td, .content-right-scroll tr:first-child td { border-top: none; /* 移除第一行顶部边框,避免与表头底部边框重叠 */ } // 选中单元格样式 .cell-selected { background-color: #e6f7ff; z-index: 1; position: relative; // 解决边框重叠问题 &::before { content: ""; position: absolute; top: -1px; left: -1px; right: -1px; bottom: -1px; border: 1px solid #1890ff; pointer-events: none; z-index: -1; } } } .yellow-actived { background-color: #ffc107; } /* 响应式适配 */ @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号