完善T型二维表

news/2025/12/1 16:27:42/文章来源:https://www.cnblogs.com/DCL1314/p/19293331
  • 示例图

412f4d3f0ad74f159f6d9798bec667bc

  • 实现跟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) {// 处理小数部分:只能保留.5const integerPart = cleaned.slice(0, dotIndex) || "0";const decimalPart = cleaned.slice(dotIndex + 1);// 小数部分只取第一位,且只能是5const 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;}// 左侧改变inputonChangeLeft(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 });}}// 右侧改变inputonValueRight(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>
    

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/983128.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

小杯具大市场大市场:2025纸咖啡杯机与超声波纸杯机厂商巡礼及创富路径富路径

随着新式茶饮行业的蓬勃发展,纸咖啡杯机设备在2025年迎来了新一轮需求高峰。无论是连锁咖啡品牌的扩张,还是小型创业者的入局,选择合适的纸咖啡杯机制造商成为成功的关键因素。一台性能稳定的纸咖啡杯制造机不仅能保…

110111

拥抱Spring Boot,体验了其自动配置与快速启动的魅力。

C# 编程:深入探索高级特性与底层原理,解锁代码的真正力量!

C# 编程:深入探索高级特性与底层原理,解锁代码的真正力量!原文链接:C# 编程:深入探索高级特性与底层原理,解锁代码的真正力量! – 每天进步一点点C# 不仅仅是一门简单的编程语言,它背后蕴含着丰富的设计哲学和…

flink 在技术架构中的配套服务 - 实践

flink 在技术架构中的配套服务 - 实践2025-12-01 16:20 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !i…

110010

使用JUnit对Service层业务逻辑进行了单元测试。

110110

为博客列表添加了基于PageHelper的分页查询。

序列重复项检查

地址:D:\1CAAS\Lab\songqianlin\Cas新蛋白\Cas12\cas12_lmnopq_fasta\重复项去除点击查看代码 from collections import defaultdictfasta_file = (r"D:\1CAAS\Lab\生物信息操作\结构域划分\PLV\IscB\PLV_IscB_v…

GIT提交规范--大模型使用约束

Git 提交规范文档 一、提交信息格式 基本格式 no type subjectbody格式说明no: 需求任务管理平台中任务或缺陷的ID编号(如:sop-72) type: 提交类型(见下方类型表) subject: 提交内容简要说明(不超过50字符) bod…

小型化时代的挑战:选用贴片整流二极管的实用指南-ASIM阿赛姆

电子设备日趋小型化,对元器件的体积提出了极致要求。表面贴装(SMD)整流二极管如何选型?本文从封装、热管理和焊接工艺角度,结合阿赛姆(ASIM)的小封装产品,提供详细指南。 一、行业热点:为何SMD二极管选型越来…

101001

设计了RESTful风格的API接口,用于前后端分离。

110001

集成了SLF4J与Logback,规范了项目的日志输出。

110000

使用Hibernate Validator配合@Valid注解进行了后端数据校验。

最高法--在债务履行期届满后的以物抵债中,只有在债权人催告后合理期间仍不履行,才可触发回归旧债的权利

最高法--在债务履行期届满后的以物抵债中,只有在债权人催告后合理期间仍不履行,才可触发回归旧债的权利2025-12-01 16:20 wwx的个人博客 阅读(0) 评论(0) 收藏 举报最高院民法典合同通则篇司法解释第27条理解与适…

八种基础缓存投毒攻击深度剖析(HackerOne、GitHub、Shopify案例)- 上篇

本文深入分析了基于HackerOne、GitHub、Shopify等平台八个历史案例的基础缓存投毒攻击。文章揭示了早期逻辑缺陷,如无校验的X-Forwarded-Host头、缓存与后端对Content-Type处理差异、标头规范化问题等,这些是理解现代…

2025年河南十大干锅鸭必吃品牌推荐:口碑不错、味道好、食材

本榜单依托实地探店测评、消费者真实口碑与食材溯源调研,深度筛选出十家兼具口感与品质的干锅鸭品牌,为食客提供客观参考,助力精准找到心仪的干锅鸭美食。 TOP1 推荐:商丘任广涛餐饮管理有限公司(任广涛干锅鸭) …

uniapp把utils全局挂载到uniapp的main.js上

utils.js/********************************************************************* */ ​ function 功能requestMsgByUni( requestMsgByUni(url, data, 数据header, 页眉callback) { 回归){uni.request({ 请求(…

Windows窗体应用和Windows窗体应用(.NET Framework)有什么区别

Windows窗体应用和Windows窗体应用(.NET Framework)有什么区别原文链接:Windows窗体应用和Windows窗体应用(.NET Framework)有什么区别 – 每天进步一点点使用Visual Studio 2022创建窗体应用时,会出现两个不同的…

2025公认靠谱的美白淡斑精华排行榜,第一名力压大牌

入秋后紫外线威力渐减,可夏季积淀的黑色素却持续显现,肤色暗沉、局部色斑、蜡黄无光成为社交平台热议的护肤难题。中国医师协会皮肤科分会 2024 年抽样调查显示,18 至 35 岁城市女性中,超 68% 将 “均匀提亮” 列为…

2025年侧向管道抗震支架实力厂家权威推荐:排烟管道抗震支架/暖通管道抗震支架/地下管道抗震支架源头厂家精选

一座由上万根支架系统守护的机电抗震屏障,背后是厂家从智能制造、材料工艺到九级地震模拟验证的技术较量。 在面临地震动参数九度(抗震设防烈度9度)的极端测试时,抗震支架系统的每个组件都需具备将荷载精确传递至主…

黑头闭口粉刺告别方案!实测6款热门护肤品收缩毛孔+去黑头双效合一

鼻尖的黑头像星星点点的 “小黑痣”,额头闭口摸起来像粗糙的 “粗砂粒”,下巴粉刺反复冒头还伴随红肿刺痛;更让人无奈的是,黑头挤掉很快又重生,闭口越抠越难消退,长期堆积不仅撑大毛孔,还让肤色变得暗沉不均,化…