完善T型二维表

  • 示例图

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) {
                // 处理小数部分:只能保留.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>
    
posted @ 2025-12-01 16:24  不完美的完美  阅读(0)  评论(0)    收藏  举报