大家好,我是大華!
在我們日常的後台管理開發中,表格可以說是最常用的資料展示和操作元件之一了。
很多用戶還希望能夠直接在線編輯表格資料、插入新行、刪除不需要的行,甚至還需要支援各種類型的資料輸入。
這時候,一個通用的可編輯表格元件就顯得尤為重要。
所以我加班加點整出了這麼一個表格元件。
先來看下效果圖:
我們看看上面的元件效果圖具備了哪些功能:
我們一步步來實現這可編輯的表格元件。
首先,我們定義元件的基本結構和 Props
:
<!-- 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>
import { ref, computed, nextTick, watch } from 'vue'
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
}
右鍵選單提供了豐富的行操作功能:
// 創建新行
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()
})
}
彙總行可以自動計算數值列的總和:
// 彙總行計算方法
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 ''
}
現在讓我們看看如何使用這個元件:
<!-- 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>
這個元件基本的操作是夠用的,你也可以根據實際需求進一步擴展:
<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"
:max="column.max"
:step="column.step"
:precision="column.precision"
: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>
<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>
</el-table>
</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(column, row)) 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>
這個元件不僅提供了基本的資料展示功能,還支援多種編輯方式、右鍵操作選單、自動彙總等功能。
關鍵實現要點:
希望這個元件能夠幫助你在實際專案中提高開發效率!如果你有任何問題或建議,歡迎在評論區留言討論。
記得給文章點個贊,收藏起來,下次需要時可快速查找哦!
本文首發於公眾號:程式員劉大華,專注分享前後端開發的實戰筆記。關注我,少走彎路,一起進步!
《SpringBoot 中的 7 種耗時統計方式,你用過幾種?》
《Java8 都出這麼多年了,Optional 還是沒人用?到底卡在哪了?》
《加班到凌晨,我用 Vue3 + ElementUI 寫了個可編輯的表格元件》
《vue3 登入頁還能這麼絲滑?這個 hover 效果太驚艷了》