加班到凌晨,我用 Vue3 + ElementUI 写了个可编辑的表格组件

前言

大家好,我是大华!

在我们日常的后台管理开发中,表格可以说是最常用的数据展示和操作组件之一了。

很多用户还希望能够直接在线编辑表格数据、插入新行、删除不需要的行,甚至还需要支持各种类型的数据输入。

这时候,一个通用的可编辑表格组件就显得尤为重要。

所以我加班加点整出了这么一个表格组件。

功能预览

先来看下效果图:

我们看看上面的组件效果图具备了哪些功能:

  • 支持单元格双击编辑
  • 支持右键菜单操作(插入行、删除行)
  • 支持多种输入类型(文本、数字、下拉选择、日期选择)
  • 支持汇总行计算
  • 响应式数据更新
  • 灵活的列配置

核心代码实现

我们一步步来实现这可编辑的表格组件。

1. 组件基础结构

首先,我们定义组件的基本结构和Props

html 复制代码
<!-- EditableTable.vue -->
<template>
  <!-- 
    el-table 是 Element Plus 的表格组件
    :data 绑定表格数据
    @cell-dblclick 监听单元格双击事件
    @row-contextmenu 监听行右键点击事件
    :summary-method 指定汇总行计算方法
    :row-class-name 指定行类名生成方法
    v-bind="$attrs" 继承所有未声明的属性
  -->
  <el-table
    :data="tableData"
    @cell-dblclick="handleCellDblClick"
    @row-contextmenu="handleRowRightClick"
    :summary-method="getSummaries"
    :row-class-name="tableRowClassName"
    :border="border"
    :show-summary="showSummary"
    v-bind="$attrs"
  >
    <!-- 序号列 -->
    <el-table-column 
      v-if="showIndex" 
      type="index" 
      label="序号" 
      width="60" 
    />
    
    <!-- 表格列渲染 -->
    <el-table-column
      v-for="column in columns"
      :key="column.prop"
      :prop="column.prop"
      :label="column.label"
      :width="column.width"
    >
      <!-- 使用作用域插槽自定义单元格内容 -->
      <template #default="scope">
        <!-- 根据列类型渲染不同的输入组件 -->
        <!-- 数字输入框 -->
        <el-input-number
          v-if="column.type === 'number' && scope.row[`${column.prop}_editing`]"
          v-model.number="scope.row[column.prop]"
          :min="column.min || 0"
          :max="column.max || 100"
          :step="column.step || 1"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
        />
        
        <!-- 下拉选择框 -->
        <el-select
          v-else-if="column.type === 'select' && scope.row[`${column.prop}_editing`]"
          v-model="scope.row[column.prop]"
          :multiple="column.multiple || false"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
        >
          <el-option
            v-for="item in column.options || []"
            :key="item.value"
            :label="item.label"
            :value="item.value"
          />
        </el-select>
        
        <!-- 日期选择器 -->
        <el-date-picker
          v-else-if="column.type === 'date' && scope.row[`${column.prop}_editing`]"
          v-model="scope.row[column.prop]"
          type="date"
          placeholder="选择日期"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
        />
        
        <!-- 文本编辑框 -->
        <el-input
          v-else-if="scope.row[`${column.prop}_editing`]"
          v-model="scope.row[column.prop]"
          @blur="scope.row[`${column.prop}_editing`] = false"
          size="small"
          autofocus
        />
        
        <!-- 文本显示(非编辑状态) -->
        <div
          v-else
          class="cell-text"
          @dblclick.stop="handleCellDblClick(scope.row, { property: column.prop })"
        >
          {{ formatCellValue(scope.row[column.prop], column) }}
        </div>
      </template>
    </el-table-column>
    
    <!-- 右键菜单 -->
    <div
      v-show="showContextMenu"
      class="context-menu"
      :style="{ top: contextMenuTop + 'px', left: contextMenuLeft + 'px' }"
      @mouseleave="hideContextMenu"
    >
      <el-button @click="insertRowAbove" size="small">上方插入一行</el-button>
      <el-button @click="insertRowBelow" size="small">下方插入一行</el-button>
      <el-button @click="openInsertMultipleDialog(false)" size="small">上方插入多行</el-button>
      <el-button @click="openInsertMultipleDialog(true)" size="small">下方插入多行</el-button>
      <el-button type="danger" @click="deleteCurrentRow" size="small">删除当前行</el-button>
    </div>
  </el-table>
  
  <!-- 插入多行对话框 -->
  <el-dialog
    v-model="showInsertMultipleDialog"
    :title="insertMultipleBelow ? '在下方插入多行' : '在上方插入多行'"
    width="400px"
  >
    <el-input-number
      v-model="insertRowCount"
      :min="1"
      :max="10"
      label="插入行数"
    />
    <template #footer>
      <el-button @click="showInsertMultipleDialog = false">取消</el-button>
      <el-button type="primary" @click="insertMultipleRows">确定</el-button>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
// 导入 Vue 相关功能
import { ref, computed, nextTick, watch } from 'vue'
// 导入 Element Plus 组件
import { ElMessageBox, ElMessage } from 'element-plus'

// 定义表格列的接口
interface TableColumn {
  prop: string           // 字段名
  label: string          // 列标题
  type?: 'text' | 'number' | 'select' | 'date'  // 输入类型
  width?: string         // 列宽度
  min?: number           // 最小值(数字类型)
  max?: number           // 最大值(数字类型)
  step?: number          // 步长(数字类型)
  options?: Array<{      // 选项(选择类型)
    value: string | number
    label: string
  }>
  multiple?: boolean     // 是否多选(选择类型)
  formatter?: (value: any) => string // 自定义格式化函数
}

// 定义组件接收的属性
const props = defineProps({
  data: {                // 表格数据
    type: Array,
    default: () => []
  },
  columns: {             // 列配置
    type: Array as () => TableColumn[],
    required: true
  },
  showIndex: {           // 是否显示序号列
    type: Boolean,
    default: false
  },
  border: {              // 是否显示边框
    type: Boolean,
    default: true
  },
  showSummary: {         // 是否显示汇总行
    type: Boolean,
    default: false
  },
  summaryMethod: {       // 自定义汇总方法
    type: Function,
    default: null
  },
  disabledColumns: {     // 禁止编辑的列
    type: Array as () => string[],
    default: () => []
  }
})

// 定义组件可触发的事件
const emit = defineEmits(['update:data', 'row-added', 'row-deleted'])

// 使用计算属性处理表格数据,确保响应式
const tableData = computed({
  get: () => props.data,
  set: (value) => emit('update:data', value)
})

// 右键菜单相关状态
const showContextMenu = ref(false)
const contextMenuTop = ref(0)
const contextMenuLeft = ref(0)
const currentContextRow = ref<any>(null)

// 插入多行相关状态
const showInsertMultipleDialog = ref(false)
const insertRowCount = ref(1)
const insertMultipleBelow = ref(false)

// 检查列是否被禁用编辑
const isDisabledColumn = (prop: string) => {
  return props.disabledColumns.includes(prop)
}

// 格式化单元格显示值
const formatCellValue = (value: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(value)
  }
  
  if (column.type === 'select' && column.options) {
    const option = column.options.find(opt => opt.value === value)
    return option ? option.label : value
  }
  
  return value
}
</script>

<style scoped>
.context-menu {
  position: fixed;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  z-index: 2000;
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.cell-text {
  width: 100%;
  height: 100%;
  padding: 8px 0;
  cursor: default;
}

.cell-text:hover {
  background-color: #f5f7fa;
}
</style>

2. 实现单元格编辑功能

单元格编辑是核心功能之一,我们支持多种输入类型:

typescript 复制代码
// 在 script setup 中添加以下代码

// 单元格双击事件处理
const handleCellDblClick = (row: any, column: any) => {
  const prop = column.property
  if (!prop || isDisabledColumn(prop)) return
  
  // 关闭其他单元格的编辑状态
  props.columns.forEach(col => {
    if (col.prop !== prop) {
      row[`${col.prop}_editing`] = false
    }
  })
  
  // 开启当前单元格编辑
  row[`${prop}_editing`] = true
}

// 行右键点击事件处理
const handleRowRightClick = (row: any, column: any, event: MouseEvent) => {
  // 阻止浏览器默认右键菜单
  event.preventDefault()
  
  // 设置当前右键点击的行
  currentContextRow.value = row
  
  // 设置菜单位置
  contextMenuTop.value = event.clientY
  contextMenuLeft.value = event.clientX
  
  // 显示菜单
  showContextMenu.value = true
}

// 隐藏右键菜单
const hideContextMenu = () => {
  showContextMenu.value = false
}

3. 实现行操作功能

右键菜单提供了丰富的行操作功能:

typescript 复制代码
// 创建新行
const createNewRow = () => {
  const newRow: any = {}
  
  // 根据列配置初始化新行的值
  props.columns.forEach(column => {
    // 设置默认值
    if (column.type === 'number') {
      newRow[column.prop] = 0
    } else if (column.type === 'select' && column.options && column.options.length > 0) {
      newRow[column.prop] = column.multiple ? [] : column.options[0].value
    } else {
      newRow[column.prop] = ''
    }
    
    // 初始化编辑状态
    newRow[`${column.prop}_editing`] = false
  })
  
  return newRow
}

// 在上方插入一行
const insertRowAbove = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  const newRow = createNewRow()
  tableData.value.splice(index, 0, newRow)
  emit('row-added', { index, row: newRow })
  hideContextMenu()
}

// 在下方插入一行
const insertRowBelow = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  const newRow = createNewRow()
  tableData.value.splice(index + 1, 0, newRow)
  emit('row-added', { index: index + 1, row: newRow })
  hideContextMenu()
}

// 打开插入多行对话框
const openInsertMultipleDialog = (below: boolean) => {
  insertMultipleBelow.value = below
  insertRowCount.value = 1
  showInsertMultipleDialog.value = true
  hideContextMenu()
}

// 插入多行
const insertMultipleRows = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  const startIndex = insertMultipleBelow.value ? index + 1 : index
  const newRows = Array.from({ length: insertRowCount.value }, createNewRow)
  
  tableData.value.splice(startIndex, 0, ...newRows)
  
  // 触发多个行添加事件
  newRows.forEach((row, i) => {
    emit('row-added', { index: startIndex + i, row })
  })
  
  showInsertMultipleDialog.value = false
}

// 删除当前行
const deleteCurrentRow = () => {
  if (!currentContextRow.value) return
  
  const index = tableData.value.indexOf(currentContextRow.value)
  if (index === -1) return
  
  ElMessageBox.confirm('确定要删除这一行吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const deletedRow = tableData.value.splice(index, 1)
    emit('row-deleted', { index, row: deletedRow[0] })
    ElMessage.success('删除成功')
    hideContextMenu()
  }).catch(() => {
    // 用户取消删除
    hideContextMenu()
  })
}

4. 实现汇总行功能

汇总行可以自动计算数值列的总和:

typescript 复制代码
// 汇总行计算方法
const getSummaries = (param: any) => {
  // 如果提供了自定义汇总方法,使用自定义方法
  if (props.summaryMethod) {
    return props.summaryMethod(param)
  }
  
  const { columns, data } = param
  const sums: string[] = []
  
  columns.forEach((column: any, index: number) => {
    if (index === 0) {
      sums[index] = '合计'
      return
    }
    
    // 只对数字类型的列进行汇总
    const colConfig = props.columns.find(col => col.prop === column.property)
    if (!colConfig || colConfig.type !== 'number') {
      sums[index] = ''
      return
    }
    
    // 计算总和
    const values = data.map((item: any) => Number(item[column.property]))
    if (values.every((value: any) => isNaN(value))) {
      sums[index] = ''
    } else {
      const sum = values.reduce((prev: number, curr: number) => {
        const value = Number(curr)
        return isNaN(value) ? prev : prev + value
      }, 0)
      sums[index] = `${sum}`
    }
  })
  
  return sums
}

// 行类名生成方法,用于设置汇总行样式
const tableRowClassName = ({ rowIndex }: { rowIndex: number }) => {
  if (props.showSummary && rowIndex === tableData.value.length) {
    return 'summary-row'
  }
  return ''
}

使用示例

现在让我们看看如何使用这个组件:

html 复制代码
<!-- Example.vue -->
<template>
  <div class="container">
    <h1>可编辑表格示例</h1>
    
    <EditableTable
      :data="tableData"
      :columns="columns"
      :show-index="true"
      :show-summary="true"
      @update:data="handleDataUpdate"
      @row-added="handleRowAdded"
      @row-deleted="handleRowDeleted"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import EditableTable from './EditableTable.vue'

// 表格数据
const tableData = ref<any>([
  { id: 1, name: '张三', age: 25, gender: 'male', score: 85, birthdate: '1998-05-12' },
  { id: 2, name: '李四', age: 30, gender: 'female', score: 92, birthdate: '1993-08-24' },
  { id: 3, name: '王五', age: 28, gender: 'male', score: 78, birthdate: '1995-11-03' }
])

// 列配置
const columns = ref<any>([
  { prop: 'name', label: '姓名', width: '120px' },
  { 
    prop: 'age', 
    label: '年龄', 
    type: 'number',
    min: 0,
    max: 150
  },
  { 
    prop: 'gender', 
    label: '性别', 
    type: 'select',
    options: [
      { value: 'male', label: '男' },
      { value: 'female', label: '女' }
    ]
  },
  { 
    prop: 'score', 
    label: '分数', 
    type: 'number',
    min: 0,
    max: 100
  },
  { 
    prop: 'birthdate', 
    label: '出生日期', 
    type: 'date'
  }
])

// 处理数据更新
const handleDataUpdate = (newData: any[]) => {
  tableData.value = newData
  console.log('数据已更新:', newData)
}

// 处理行添加事件
const handleRowAdded = ({ index, row }: { index: number; row: any }) => {
  console.log(`在第 ${index} 行添加了新行:`, row)
}

// 处理行删除事件
const handleRowDeleted = ({ index, row }: { index: number; row: any }) => {
  console.log(`删除了第 ${index} 行:`, row)
}
</script>

<style scoped>
.container {
  padding: 20px;
}
</style>

功能扩展建议

这个组件基本的操作是够用的,你也可以根据实际需求进一步扩展:

  1. 数据验证 - 添加单元格数据验证功能,确保输入数据的正确性
  2. 撤销重做 - 实现操作历史记录,支持撤销和重做操作
  3. 批量操作 - 支持批量编辑和删除,提高操作效率
  4. 列配置 - 允许用户自定义显示哪些列,以及列的显示顺序
  5. 导入导出 - 支持 Excel 导入导出功能,方便数据交换
  6. 分页功能 - 集成分页支持大数据量,提高性能
  7. 行拖拽排序 - 支持通过拖拽调整行顺序
  8. 列宽调整 - 支持通过拖拽调整列宽度

EditableTable 组件完整代码

html 复制代码
<template>
  <el-table
    :data="tableData"
    @cell-dblclick="handleCellDblClick"
    @row-contextmenu="handleRowRightClick"
    :summary-method="getSummaries"
    :row-class-name="tableRowClassName"
    :border="border"
    :show-summary="showSummary"
    v-bind="$attrs"
  >
    <!-- 序号列 -->
    <el-table-column
      v-if="showIndex"
      type="index"
      label="序号"
      align="center"
      :resizable="false"
      width="70"
    />

    <!-- 动态列 -->
    <el-table-column
      v-for="column in columns"
      :key="column.prop"
      :prop="column.prop"
      :label="column.label"
      :align="column.align || 'left'"
      :width="column.width"
      :resizable="column.resizable !== false"
    >
      <template #default="scope">
        <!-- 数字输入框 -->
        <el-input-number
          v-if="column.type === 'number'"
          v-model.number="scope.row[column.prop]"
          :min="column.min || 0"
          :max="column.max || 100"
          :step="column.step || 1"
          :precision="column.precision || 0"
          :controls="column.controls !== false"
          :disabled="isDisabled(column, scope.row)"
          style="width: 100%"
        />
        
        <!-- 单选下拉框 -->
        <el-select
          v-else-if="column.type === 'select'"
          v-model="scope.row[column.prop]"
          :multiple="column.multiple || false"
          :multiple-limit="column.multipleLimit || 1"
          :filterable="column.filterable !== false"
          :clearable="column.clearable !== false"
          :disabled="isDisabled(column, scope.row)"
          :placeholder="column.placeholder || '请选择'"
          style="width: 100%"
        >
          <el-option
            v-for="item in column.options || []"
            :key="item[column.valueKey || 'value']"
            :label="item[column.labelKey || 'label']"
            :value="item[column.valueKey || 'value']"
          />
        </el-select>
        
        <!-- 日期选择 -->
        <el-date-picker
          v-else-if="column.type === 'date'"
          v-model="scope.row[column.prop]"
          :type="column.dateType || 'date'"
          :format="column.format || 'YYYY-MM-DD'"
          :value-format="column.valueFormat || 'YYYY-MM-DD'"
          :disabled="isDisabled(column, scope.row)"
          :placeholder="column.placeholder || '选择日期'"
          style="width: 100%"
        />
        
        <!-- 普通文本显示 -->
        <div
          v-else-if="!scope.row[`${column.prop}_editing`]"
          class="cell-text"
          v-html="formatCellValue(scope.row[column.prop], column.formatter)"
        />
        
        <!-- 编辑状态下的文本输入框 -->
        <el-input
          v-else
          :ref="setInputRef(scope.$index, column.prop)"
          v-model="scope.row[column.prop]"
          :type="column.inputType || 'text'"
          :autosize="{ minRows: 1, maxRows: 4 }"
          :disabled="isDisabled(column, scope.row)"
          @blur="scope.row[`${column.prop}_editing`] = false"
          @keyup.enter="scope.row[`${column.prop}_editing`] = false"
        />
      </template>
    </el-table-column>
  </el-table>

  <!-- 右键菜单 -->
  <div
    v-show="showContextMenu"
    id="context-menu"
    class="context-menu"
    @mouseleave="hideContextMenu"
  >
    <el-button type="primary" @click="insertRowAbove">上方插入一行</el-button>
    <el-button @click="openInsertMultipleDialog(false)">上方插入多行</el-button>
    <el-button type="primary" @click="insertRowBelow">下方插入一行</el-button>
    <el-button @click="openInsertMultipleDialog(true)">下方插入多行</el-button>
    <el-button type="danger" @click="deleteCurrentRow">删除当前行</el-button>
  </div>

  <!-- 插入多行对话框 -->
  <el-dialog
    v-model="showInsertDialog"
    title="插入多行"
    width="300px"
    append-to-body
  >
    <el-input-number
      v-model="insertRowCount"
      :min="1"
      :max="20"
      style="width: 100%"
    />
    <template #footer>
      <el-button @click="showInsertDialog = false">取消</el-button>
      <el-button type="primary" @click="insertMultipleRows">确定</el-button>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { ref, reactive, watch, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'

interface TableColumn {
  prop: string
  label: string
  type?: 'text' | 'number' | 'select' | 'date'
  width?: string | number
  align?: 'left' | 'center' | 'right'
  resizable?: boolean
  min?: number
  max?: number
  step?: number
  precision?: number
  options?: any[]
  valueKey?: string
  labelKey?: string
  multiple?: boolean
  multipleLimit?: number
  filterable?: boolean
  clearable?: boolean
  placeholder?: string
  dateType?: string
  format?: string
  valueFormat?: string
  inputType?: string
  disabled?: boolean | ((row: any) => boolean)
  formatter?: (value: any) => string
  controls?: boolean
}

const props = defineProps({
  // 表格数据
  data: {
    type: Array,
    default: () => []
  },
  // 列配置
  columns: {
    type: Array as () => TableColumn[],
    required: true
  },
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: true
  },
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  // 是否显示汇总行
  showSummary: {
    type: Boolean,
    default: false
  },
  // 汇总方法
  summaryMethod: {
    type: Function,
    default: null
  },
  // 禁止编辑的列
  disabledColumns: {
    type: Array as () => string[],
    default: () => []
  }
})

const emit = defineEmits(['update:data', 'row-added', 'row-deleted'])

// 表格数据
const tableData = ref<any[]>([])
// 显示右键菜单
const showContextMenu = ref(false)
// 当前右键的行信息
const currentContextRow = reactive({
  index: null as number | null,
  column: null as string | null,
  isHeader: false
})
// 插入多行对话框
const showInsertDialog = ref(false)
// 插入行数
const insertRowCount = ref(1)
// 是否在下方插入
const insertBelow = ref(false)
// 输入框引用
const inputRefs = ref<Record<string, any>>({})

// 初始化表格数据
watch(() => props.data, (newData) => {
  if (newData && newData.length > 0) {
    tableData.value = newData.map(row => {
      const safeRow = (typeof row === 'object' && row !== null) ? { ...row } : {}
      props.columns.forEach(col => {
        safeRow[`${col.prop}_editing`] = false
      })
      return safeRow
    })
  } else {
    tableData.value = []
  }
}, { immediate: true, deep: true })

// 创建新行数据
const createNewRow = () => {
  const newRow: any = {}
  props.columns.forEach(col => {
    newRow[col.prop] = col.type === 'number' ? 0 : ''
    newRow[`${col.prop}_editing`] = false
  })
  return newRow
}

// 设置输入框引用
const setInputRef = (rowIndex: number, prop: string) => (el: any) => {
  inputRefs.value[`${rowIndex}-${prop}`] = el
}

// 单元格双击事件
const handleCellDblClick = (row: any, column: any) => {
  const prop = column.property
  if (!prop || isDisabledColumn(prop) || isDisabled(row, prop)) return
  
  // 关闭其他单元格的编辑状态
  props.columns.forEach(col => {
    row[`${col.prop}_editing`] = false
  })
  
  // 开启当前单元格编辑状态
  row[`${prop}_editing`] = true
  
  // 聚焦输入框
  nextTick(() => {
    const inputKey = `${row.row_index}-${prop}`
    const input = inputRefs.value[inputKey]
    if (input) {
      input.focus()
    }
  })
}

// 行右键事件
const handleRowRightClick = (row: any, column: any, event: MouseEvent) => {
  event.preventDefault()
  showContextMenu.value = false
  
  // 定位右键菜单
  const menu = document.getElementById('context-menu')
  if (menu) {
    menu.style.left = `${event.clientX}px`
    menu.style.top = `${event.clientY}px`
  }
  
  showContextMenu.value = true
  currentContextRow.index = row.row_index
  currentContextRow.column = column.property
  currentContextRow.isHeader = false
}

// 隐藏右键菜单
const hideContextMenu = () => {
  showContextMenu.value = false
}

// 在上方插入一行
const insertRowAbove = () => {
  if (currentContextRow.index === null) return
  const newRow = createNewRow()
  tableData.value.splice(currentContextRow.index, 0, newRow)
  emit('row-added', { index: currentContextRow.index, row: newRow })
  hideContextMenu()
}

// 在下方插入一行
const insertRowBelow = () => {
  if (currentContextRow.index === null) return
  const newRow = createNewRow()
  tableData.value.splice(currentContextRow.index + 1, 0, newRow)
  emit('row-added', { index: currentContextRow.index + 1, row: newRow })
  hideContextMenu()
}

// 打开插入多行对话框
const openInsertMultipleDialog = (below: boolean) => {
  insertBelow.value = below
  insertRowCount.value = 1
  showInsertDialog.value = true
}

// 插入多行
const insertMultipleRows = () => {
  if (currentContextRow.index === null) return
  
  const newRows = Array.from({ length: insertRowCount.value }, () => createNewRow())
  const insertIndex = insertBelow.value ? currentContextRow.index + 1 : currentContextRow.index
  tableData.value.splice(insertIndex, 0, ...newRows)
  
  emit('row-added', { 
    index: insertIndex, 
    rows: newRows,
    count: insertRowCount.value
  })
  
  showInsertDialog.value = false
  hideContextMenu()
}

// 删除当前行
const deleteCurrentRow = () => {
  if (currentContextRow.index === null) return
  
  ElMessageBox.confirm('确定要删除这一行吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    const deletedRow = tableData.value.splice(currentContextRow.index!, 1)
    emit('row-deleted', { index: currentContextRow.index, row: deletedRow[0] })
    hideContextMenu()
    ElMessage.success('删除成功')
  }).catch(() => {
    hideContextMenu()
  })
}

// 设置行索引
const tableRowClassName = ({ row, rowIndex }: { row: any, rowIndex: number }) => {
  row.row_index = rowIndex
}

// 格式化单元格值
const formatCellValue = (value: any, formatter?: (value: any) => string) => {
  if (formatter) {
    return formatter(value)
  }
  if (value === null || value === undefined) {
    return ''
  }
  return String(value).replace(/(\r\n|\n)/g, '<br/>')
}

// 检查列是否禁用
const isDisabledColumn = (prop: string) => {
  return props.disabledColumns.includes(prop)
}

// 检查单元格是否禁用
const isDisabled = (column: TableColumn | string, row?: any) => {
  if (typeof column === 'string') {
    return isDisabledColumn(column)
  }
  
  if (column.disabled === undefined) {
    return isDisabledColumn(column.prop)
  }
  
  if (typeof column.disabled === 'function') {
    return column.disabled(row)
  }
  
  return column.disabled
}

// 汇总行计算方法
const getSummaries = (param: any) => {
  if (props.summaryMethod) {
    return props.summaryMethod(param)
  }
  
  const { columns, data } = param
  const sums: string[] = []
  columns.forEach((column: any, index: number) => {
    if (index === 0) {
      sums[index] = '合计'
      return
    }
    
    const colConfig = props.columns.find(col => col.prop === column.property)
    if (!colConfig || colConfig.type !== 'number') {
      sums[index] = ''
      return
    }
    
    const values = data.map((item: any) => Number(item[column.property]))
    if (values.every((value: any) => isNaN(value))) {
      sums[index] = ''
    } else {
      sums[index] = `${values.reduce((prev: number, curr: number) => {
        const value = Number(curr)
        return isNaN(value) ? prev : prev + value
      }, 0)}`
    }
  })
  
  return sums
}
</script>

<style scoped>
.cell-text {
  width: 100%;
  min-height: 100%;
  word-break: break-word;
}

.context-menu {
  position: fixed;
  z-index: 9999;
  background: white;
  border: 1px solid #ebeef5;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.context-menu button {
  width: 100%;
  text-align: left;
}

::v-deep(.el-button+.el-button){
  margin-left: 0;
}
</style>

总结

这个组件不仅提供了基本的数据展示功能,还支持多种编辑方式、右键操作菜单、自动汇总等功能。

关键实现要点:

  • 使用动态渲染支持多种输入类型
  • 利用双击和右键事件提供直观的操作方式
  • 通过统一的 API 设计保证组件易用性
  • 提供丰富的自定义配置选项

希望这个组件能够帮助你在实际项目中提高开发效率!如果你有任何问题或建议,欢迎在评论区留言讨论。

记得给文章点个赞,收藏起来,下次需要时可快速查找哦!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot 中的 7 种耗时统计方式,你用过几种?》

《Java8 都出这么多年了,Optional 还是没人用?到底卡在哪了?》

《加班到凌晨,我用 Vue3 + ElementUI 写了个可编辑的表格组件》

《vue3 登录页还能这么丝滑?这个 hover 效果太惊艳了》

相关推荐
小咕聊编程3 小时前
【含文档+PPT+源码】基于SpringBoot+Vue的停车场管理系统
vue.js·spring boot·后端·毕业设计·停车场
做运维的阿瑞9 小时前
Windows 环境下安装 Node.js 和 Vue.js 框架完全指南
前端·javascript·vue.js·windows·node.js
武昌库里写JAVA9 小时前
Java设计模式之工厂模式
java·vue.js·spring boot·后端·sql
一只游鱼13 小时前
vue+springboot项目部署到服务器
服务器·vue.js·spring boot·部署
叫兽~~16 小时前
vite vue 打包后运行,路由首页加载不出来
vue.js·vue
Demoncode_y20 小时前
Vue3 + Three.js 实现 3D 汽车个性化定制及展示
前端·javascript·vue.js·3d·汽车·three.js
HWL56791 天前
输入框内容粘贴时 &nbsp; 字符净化问题
前端·vue.js·后端·node.js
北城以北88881 天前
Vue--Vue基础(一)
前端·javascript·vue.js
sniper_fandc1 天前
Vue Router路由
前端·javascript·vue.js