Vue3实现表格合并与多选限制功能开发记录

228 阅读3分钟

实现表格合并与多选限制功能开发记录

一、需求背景

在管理系统开发中,常遇到需要对表格数据进行分组展示,并限制用户多选操作的场景。本次开发目标是实现一个具有单位分组合并显示按单位多选数量限制的表格功能,具体要求如下:

  1. 按 “单位” 字段合并表格行,相同单位的数据纵向合并展示
  2. 限制用户最多选择 3 个不同单位的数据
  3. 支持全选功能(仅选中前 3 个单位)
  4. 实时显示已选单位数量及操作提示

二、核心功能实现

(一)表格行合并

1. 数据结构准备
  • originalData:原始数据数组,包含idnametitleaddresscompany字段

  • displayData:展示数据(与原始数据一致)

  • companySpanInfo:计算合并行信息的计算属性,生成每个单位的起始行索引和合并行数

const companySpanInfo = computed(() => {
  const spanInfo = {};
  let currentCompany = '';
  let currentIndex = 0;
  
  displayData.value.forEach((item, index) => {
    if (item.company !== currentCompany) {
      currentCompany = item.company;
      currentIndex = index;
      spanInfo[currentCompany] = { start: currentIndex, rowspan: 1 };
    } else {
      spanInfo[currentCompany].rowspan++;
    }
  });
  
  return spanInfo;
});
2. 合并行逻辑

通过span-method属性定义合并规则,对 “选择列”(columnIndex=0)和 “单位列”(columnIndex=4)进行合并:

const objectSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
  if (columnIndex === 0 || columnIndex === 4) {
    const company = row.company;
    const info = companySpanInfo.value[company];
    
    if (info && rowIndex === info.start) {
      return { rowspan: info.rowspan, colspan: 1 };
    } else {
      return { rowspan: 0, colspan: 0 };
    }
  }
};
  • 当行索引为单位起始行时,显示合并后的行高(rowspan)
  • 其他行隐藏(rowspan: 0),实现纵向合并效果

(二)多选限制逻辑

1. 选中状态管理
  • selectedRows:存储已选中的行数据
  • selectedCompanies:通过 Set 结构获取已选单位集合(去重)
  • MAX_SELECTION_COUNT:最大允许选择的单位数(3 个)
2. 选择有效性控制

通过selectable函数控制行选择状态:

const selectable = (row, index) => {
  if (selectedCompanies.value.length >= MAX_SELECTION_COUNT.value) {
    return selectedCompanies.value.includes(row.company); // 仅允许选择已选单位
  }
  return true; // 未达限制时允许自由选择
};
3. 选中项变化监听

通过watch监听selectedRows变化,处理单位级联选择:

watch(selectedRows, (newVal, oldVal) => {
  if (newVal.length > oldVal.length) { // 新增选择
    const addedRow = newVal.find(item => !oldVal.some(oldItem => oldItem.id === item.id));
    const companyRows = displayData.value.filter(item => item.company === addedRow.company);
    const currentCount = [...new Set(newVal.map(item => item.company))].length;
    
    if (currentCount > MAX_SELECTION_COUNT.value) { // 超过限制则取消选择
      nextTick(() => {
        companyRows.forEach(row => tableRef.value.toggleRowSelection(row, false));
        alert(`最多只能选择${MAX_SELECTION_COUNT.value}个单位`);
      });
    } else { // 自动选中同单位所有行
      nextTick(() => {
        companyRows.forEach(row => {
          if (!newVal.some(item => item.id === row.id)) {
            tableRef.value.toggleRowSelection(row, true);
          }
        });
      });
    }
  } else if (newVal.length < oldVal.length) { // 取消选择时清空同单位所有行
    const removedRow = oldVal.find(item => !newVal.some(newItem => newItem.id === item.id));
    const companyRows = displayData.value.filter(item => item.company === removedRow.company);
    nextTick(() => companyRows.forEach(row => tableRef.value.toggleRowSelection(row, false)));
  }
});
4. 全选功能实现
const handleSelectAll = (checked) => {
  if (checked) {
    const companies = [...new Set(displayData.value.map(item => item.company))];
    const selectedCompanies = companies.slice(0, MAX_SELECTION_COUNT.value); // 取前3个单位
    
    tableRef.value.clearSelection(); // 先清空选择
    const selectedData = displayData.value.filter(item => selectedCompanies.includes(item.company));
    nextTick(() => selectedData.forEach(row => tableRef.value.toggleRowSelection(row, true)));
  } else {
    tableRef.value.clearSelection(); // 取消全选时清空所有选择
    selectedRows.value = [];
  }
};

三、关键交互细节

(一)选中状态同步

  • handleSelectionChange方法处理选中项变化,确保单位数量不超过限制:

const handleSelectionChange = (val) => {
  const currentCompanies = [...new Set(val.map(item => item.company))];
  if (currentCompanies.length > MAX_SELECTION_COUNT.value) { // 截断至前N个单位
    const firstNCompanies = currentCompanies.slice(0, MAX_SELECTION_COUNT.value);
    const filteredSelection = val.filter(item => firstNCompanies.includes(item.company));
    
    selectedRows.value = filteredSelection;
    nextTick(() => {
      tableRef.value.clearSelection();
      filteredSelection.forEach(row => tableRef.value.toggleRowSelection(row, true));
    });
  } else {
    selectedRows.value = val;
  }
  isAllSelected.value = val.length === displayData.value.length; // 更新全选状态
};

(二)用户提示设计

  • 底部提示栏实时显示已选数量:

预览

<div class="selected-info">
  <span>已选择 {{ selectedCount }} 项,最多选择 {{ MAX_SELECTION_COUNT }} 项</span>
  <el-button type="primary" @click="handleSubmit" :disabled="selectedCount === 0">提交</el-button>
</div>
  • 超出限制时弹出警告框
  • 全选状态通过表头复选框同步显示

四、功能验证与优化点

(一)测试用例

  1. 单单位选择:选中某单位一行,自动选中该单位所有行
  2. 跨单位选择:选择 3 个不同单位后,无法再选择其他单位
  3. 全选功能:点击全选时仅选中前 3 个单位的所有行
  4. 取消选择:取消某单位任意一行,该单位所有行均取消选择

(二)优化方向

  1. 支持动态配置最大选择数量(当前为硬编码 3,可改为通过 props 传入)
  2. 增加单位选择顺序记忆功能
  3. 优化合并行视觉样式(如添加分组标题背景色)
  4. 支持导出已选单位数据功能

五、总结

通过组合使用 Element Plus 表格的合并行功能(span-method)和 Vue 的响应式数据管理(ref/computed/watch),实现了单位分组展示与多选限制的复杂交互。核心逻辑在于:

  1. 通过计算属性动态生成合并行信息

  2. 基于单位集合(Set)进行选中状态管理

  3. 利用nextTick确保 DOM 更新与数据状态同步

该方案可扩展至其他需要分组选择的业务场景(如按部门、类别选择等),通过调整company字段和最大选择数量配置,即可快速复用。

所有的代码示例

<template>
  <div class="container">
    <el-table
      ref="tableRef"
      :data="displayData"
      border
      stripe
      @selection-change="handleSelectionChange"
      highlight-current-row
      :span-method="objectSpanMethod"
      style="width: 100%">
      <el-table-column type="selection" width="55" :reserve-selection="true" :selectable="selectable">
        <template #header>
          <el-checkbox v-model="isAllSelected" @change="handleSelectAll"></el-checkbox>
        </template>
      </el-table-column>
      <el-table-column prop="name" label="姓名" width="120"></el-table-column>
      <el-table-column prop="title" label="名称" width="120"></el-table-column>
      <el-table-column prop="address" label="地址" width="200"></el-table-column>
      <el-table-column prop="company" label="单位"></el-table-column>
    </el-table>
    <div class="selected-info">
      <span>已选择 {{ selectedCount }} 项,最多选择 {{ MAX_SELECTION_COUNT }} 项</span>
      <el-button type="primary" @click="handleSubmit" :disabled="selectedCount === 0">提交</el-button>
    </div>
    <!-- 显示选中数据的对话框 -->
    <el-dialog
      v-model="dialogVisible"
      title="已选择的数据"
      width="50%">
      <el-table :data="selectedRows" border style="width: 100%">
        <el-table-column prop="name" label="姓名" width="120"></el-table-column>
        <el-table-column prop="title" label="名称" width="120"></el-table-column>
        <el-table-column prop="address" label="地址" width="200"></el-table-column>
        <el-table-column prop="company" label="单位"></el-table-column>
      </el-table>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="dialogVisible = false">关闭</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>

import { ref, computed, watch, nextTick } from 'vue';

// 最大选择数量
const MAX_SELECTION_COUNT = ref(3);

const tableRef = ref(null);
const originalData = ref([
  { id: 1, name: '张三', title: '经理', address: '北京市朝阳区', company: '科技公司' },
  { id: 2, name: '李四', title: '主管', address: '北京市海淀区', company: '科技公司' },
  { id: 3, name: '王五', title: '开发', address: '上海市浦东新区', company: '科技公司' },
  { id: 4, name: '赵六', title: '测试', address: '上海市静安区', company: '金融公司' },
  { id: 5, name: '钱七', title: '产品', address: '广州市天河区', company: '金融公司' },
  { id: 6, name: '孙八', title: '设计', address: '深圳市南山区', company: '互联网公司' },
  { id: 7, name: '周九', title: '运营', address: '杭州市西湖区', company: '互联网公司' },
  { id: 8, name: '吴十', title: '市场', address: '南京市玄武区', company: '教育公司' },
  { id: 9, name: '郑十一', title: '销售', address: '武汉市武昌区', company: '教育公司' },
  { id: 10, name: '王十二', title: 'HR', address: '成都市锦江区', company: '教育公司' }
]);

const displayData = ref([...originalData.value]);
const selectedRows = ref([]);
const isAllSelected = ref(false);

// 计算当前已选数量(按单位计算)
const selectedCount = computed(() => {
  return selectedCompanies.value.length;
});

// 计算每个单位的人员数量
const companyPersonCount = computed(() => {
  const countMap = {};
  originalData.value.forEach(item => {
    if (!countMap[item.company]) {
      countMap[item.company] = 0;
    }
    countMap[item.company]++;
  });
  return countMap;
});

// 计算每个单位的起始行和合并行数
const companySpanInfo = computed(() => {
  const spanInfo = {};
  let currentCompany = '';
  let currentIndex = 0;
  
  displayData.value.forEach((item, index) => {
    if (item.company !== currentCompany) {
      currentCompany = item.company;
      currentIndex = index;
      spanInfo[currentCompany] = {
        start: currentIndex,
        rowspan: 1
      };
    } else {
      spanInfo[currentCompany].rowspan++;
    }
  });
  
  return spanInfo;
});

// 表格合并方法
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
  // 对 selection 列(columnIndex === 0)和单位列(columnIndex === 4)进行合并
  if (columnIndex === 0 || columnIndex === 4) {
    const company = row.company;
    const info = companySpanInfo.value[company];
    
    if (info && rowIndex === info.start) {
      return {
        rowspan: info.rowspan,
        colspan: 1
      };
    } else {
      return {
        rowspan: 0,
        colspan: 0
      };
    }
  }
};

// 计算已选择的单位
const selectedCompanies = computed(() => {
  return [...new Set(selectedRows.value.map(item => item.company))];
});

// 监听选中项变化,处理单位级联选择
watch(selectedRows, (newVal, oldVal) => {
  if (newVal.length > oldVal.length) {
    // 有新选中项
    const addedRow = newVal.find(item => !oldVal.some(oldItem => oldItem.id === item.id));
    if (addedRow) {
      const companyRows = displayData.value.filter(item => item.company === addedRow.company);
      const currentSelectedCompanies = [...new Set(newVal.map(item => item.company))];
      
      // 如果选中后单位数超过最大限制,取消当前单位的选择
      if (currentSelectedCompanies.length > MAX_SELECTION_COUNT.value) {
        nextTick(() => {
          // 取消当前单位所有行的选择
          companyRows.forEach(row => {
            tableRef.value.toggleRowSelection(row, false);
          });
          alert(`最多只能选择${MAX_SELECTION_COUNT.value}个单位`);
        });
      } else {
        // 选中当前单位所有行
        nextTick(() => {
          companyRows.forEach(row => {
            if (!newVal.some(item => item.id === row.id)) {
              tableRef.value.toggleRowSelection(row, true);
            }
          });
        });
      }
    }
  } else if (newVal.length < oldVal.length) {
    // 有取消选中项
    const removedRow = oldVal.find(item => !newVal.some(newItem => newItem.id === item.id));
    if (removedRow) {
      const companyRows = displayData.value.filter(item => item.company === removedRow.company);
      // 取消选中该单位的所有行
      nextTick(() => {
        companyRows.forEach(row => {
          tableRef.value.toggleRowSelection(row, false);
        });
      });
    }
  }
});

// 全选处理
const handleSelectAll = (checked) => {
  if (checked) {
    // 获取所有单位
    const companies = [...new Set(displayData.value.map(item => item.company))];
    // 只取前N个单位
    const selectedCompanies = companies.slice(0, MAX_SELECTION_COUNT.value);
    
    // 清除所有选择
    tableRef.value.clearSelection();
    
    // 选择这些单位的所有数据
    const selectedData = displayData.value.filter(item => selectedCompanies.includes(item.company));
    nextTick(() => {
      selectedData.forEach(row => {
        tableRef.value.toggleRowSelection(row, true);
      });
    });
  } else {
    // 取消全选时,清空所有选择
    tableRef.value.clearSelection();
    selectedRows.value = [];
  }
};

// 可选性控制
const selectable = (row, index) => {
  // 如果已选择达到最大限制,且当前行不属于已选择的单位,则不可选
  if (selectedCompanies.value.length >= MAX_SELECTION_COUNT.value) {
    return selectedCompanies.value.includes(row.company);
  }
  return true;
};

// 选择项变化处理
const handleSelectionChange = (val) => {
  // 获取已选择的单位
  const currentSelectedCompanies = [...new Set(val.map(item => item.company))];
  
  // 如果选择的单位超过最大限制,只保留前N个单位的数据
  if (currentSelectedCompanies.length > MAX_SELECTION_COUNT.value) {
    const firstNCompanies = currentSelectedCompanies.slice(0, MAX_SELECTION_COUNT.value);
    const filteredSelection = val.filter(item => firstNCompanies.includes(item.company));
    
    // 更新选中数据
    selectedRows.value = filteredSelection;
    
    // 更新表格选择状态
    nextTick(() => {
      tableRef.value.clearSelection();
      filteredSelection.forEach(row => {
        tableRef.value.toggleRowSelection(row, true);
      });
    });
  } else {
    selectedRows.value = val;
  }
  
  // 更新全选状态
  isAllSelected.value = val.length === displayData.value.length;
};

// 对话框显示状态
const dialogVisible = ref(false);

// 提交处理
const handleSubmit = () => {
  dialogVisible.value = true;
};
</script>

<style scoped>
.container {
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.selected-info {
  margin-top: 10px;
  color: #606266;
  font-size: 14px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}
</style>