实现思路:使用 solt 处理编辑和显示切换已经自定义组件渲染,100%兼容 ElTable 所有参数。
Vue + ElementUI 扩展的可编辑表格组件,完全支持任意组件渲染。
实现功能:
支持单列编辑
支持整行编辑
支持单击、双击编辑模式
支持渲染任意组件
支持动态列
支持显示编辑状态
支持增删改查数据获取
支持还原更改数据
支持 ElTable 所有功能
API
Editable Attributes
属性
描述
类型
可选值
默认值
trigger
触发方式
String
manual(手动方式) / click(点击触发编辑) / dblclick(双击触发编辑)
click
mode
编辑方式
String
cell(列编辑模式) / row(行编辑模式)
cell
showIcon
是否显示列头编辑图标
Boolean
—
true
showStatus
是否显示列的编辑状态
Boolean
—
true
Editable Methods
方法名
描述
参数
reload(datas)
初始化加载数据
datas
revert()
还原修改之前数据
insert(record)
新增一行新数据
record
insertAt(record, rowIndex)
指定位置新增一行新数据,如果是-1则从底部新增新数据
record, rowIndex
remove(record)
根据数据删除一行数据
record
removes(records)
根据数据删除多行数据
records
removeRow(rowIndex)
根据行号删除一行数据
rowIndex
removeRows(rowIndexs)
根据行号删除多行数据
rowIndexs
removeSelecteds()
删除选中行数据
clear()
清空所有数据
clearActive()
清除所有活动行列为不可编辑状态
setActiveRow(rowIndex)
设置活动行为可编辑状态(只对mode='row'有效)
rowIndex
updateStatus(scope)
更新列状态(当使用自定义组件时,值发生改变时需要调用来更新列状态),如果不传参数则更新整个表格
scope
getRecords()
获取表格数据集
getAllRecords()
获取表格所有数据
getInsertRecords()
获取新增数据
getRemoveRecords()
获取已删除数据
getUpdateRecords()
获取已修改数据
Editable-Column Attributes
属性
描述
类型
可选值
默认值
name
渲染的组件名称
String
ElInput / ElSelect / ElCascader / ElDatePicker / ElInputNumber / ElSwitch
ElInput
type
渲染类型
String
default(组件激活后才可视) / visible(组件一直可视)
default
attrs
渲染组件附加属性
Object
—
{}
optionAttrs
下拉组件选项附加属性(只对name='ElSelect'有效)
Object
—
{}
options
下拉组件选项列表(只对name='ElSelect'有效)
Array
—
[]
Editable-Column Scoped Slot
name
说明
—
自定义渲染显示内容,参数为 { row, column, $index }
type
自定义渲染组件,参数为 { row, column, $index }
head
自定义表头的内容,参数为 { column, $index }
Editable.vue
ref="refElTable"
:class="['editable', {'editable--icon': showIcon}]"
:data="datas"
:height="height"
:maxHeight="maxHeight"
:stripe="stripe"
:border="border"
:size="size"
:fit="fit"
:showHeader="showHeader"
:highlightCurrentRow="highlightCurrentRow"
:currentRowKey="currentRowKey"
:rowClassName="rowClassName"
:rowStyle="rowStyle"
:headerRowClassName="headerRowClassName"
:headerRowStyle="headerRowStyle"
:headerCellClassName="headerCellClassName"
:headerCellStyle="headerCellStyle"
:rowKey="rowKey"
:emptyText="emptyText"
:defaultExpandAll="defaultExpandAll"
:expandRowKeys="expandRowKeys"
:defaultSort="defaultSort"
:tooltipEffect="tooltipEffect"
:showSummary="showSummary"
:sumText="sumText"
:summaryMethod="_summaryMethod"
:selectOnIndeterminate="selectOnIndeterminate"
:spanMethod="_spanMethod"
@select="_select"
@select-all="_selectAll"
@selection-change="_selectionChange"
@cell-mouse-enter="_cellMouseEnter"
@cell-mouse-leave="_cellMouseLeave"
@cell-click="_cellClick"
@cell-dblclick="_cellDBLclick"
@row-click="_rowClick"
@row-contextmenu="_rowContextmenu"
@row-dblclick="_rowDBLclick"
@header-click="_headerClick"
@header-contextmenu="_headerContextmenu"
@sort-change="_sortChange"
@filter-change="_filterChange"
@current-change="_currentChange"
@header-dragend="_headerDragend"
@expand-change="_expandChange">
import XEUtils from 'xe-utils'
import { mapGetters } from 'vuex'
export default {
name: 'ElEditable',
props: {
editConfig: Object,
data: Array,
height: [String, Number],
maxHeight: [String, Number],
stripe: Boolean,
border: Boolean,
size: { type: String, default: 'small' },
fit: { type: Boolean, default: true },
showHeader: { type: Boolean, default: true },
highlightCurrentRow: Boolean,
currentRowKey: [String, Number],
rowClassName: [Function, String],
rowStyle: [Function, Object],
cellClassName: [Function, String],
cellStyle: [Function, Object],
headerRowClassName: [Function, String],
headerRowStyle: [Function, Object],
headerCellClassName: [Function, String],
headerCellStyle: [Function, Object],
rowKey: [Function, String],
emptyText: String,
defaultExpandAll: Boolean,
expandRowKeys: Array,
defaultSort: Object,
tooltipEffect: { type: String, default: 'dark' },
showSummary: Boolean,
sumText: { type: String, default: '合计' },
summaryMethod: Function,
selectOnIndeterminate: { type: Boolean, default: true },
spanMethod: Function
},
data () {
return {
editProto: {},
datas: [],
initialStore: [],
deleteRecords: [],
lastActive: null
}
},
computed: {
...mapGetters([
'globalClick'
]),
showIcon () {
return this.editConfig ? !(this.editConfig.showIcon === false) : true
},
showStatus () {
return this.editConfig ? !(this.editConfig.showStatus === false) : true
},
mode () {
return this.editConfig ? (this.editConfig.mode || 'cell') : 'cell'
}
},
watch: {
globalClick (evnt) {
if (this.lastActive) {
let target = evnt.target
let { cell } = this.lastActive
while (target && target.nodeType && target !== document) {
if (this.mode === 'row' ? target === cell.parentNode : target === cell) {
return
}
target = target.parentNode
}
this.clearActive()
}
},
datas () {
this.updateStatus()
}
},
created () {
this._initial(this.data, true)
},
methods: {
_initial (datas, isReload) {
if (isReload) {
this.initialStore = XEUtils.clone(datas, true)
}
this.datas = (datas || []).map(item => this._toData(item))
},
_toData (item, status) {
return item.editable && item._EDITABLE_PROTO === this.editProto ? item : {
_EDITABLE_PROTO: this.editProto,
data: item,
store: XEUtils.clone(item, true),
editStatus: status || 'initial',
editActive: null,
editable: {
size: this.size,
showIcon: this.showIcon,
showStatus: this.showStatus,
mode: this.mode
}
}
},
_updateData () {
this.$emit('update:data', this.datas.map(item => item.data))
},
_select (selection, row) {
this.$emit('select', selection.map(item => item.data), row.data)
},
_selectAll (selection) {
this.$emit('select-all', selection.map(item => item.data))
},
_selectionChange (selection) {
this.$emit('selection-change', selection.map(item => item.data))
},
_cellMouseEnter (row, column, cell, event) {
this.$emit('cell-mouse-enter', row.data, column, cell, event)
},
_cellMouseLeave (row, column, cell, event) {
this.$emit('cell-mouse-leave', row.data, column, cell, event)
},
_cellClick (row, column, cell, event) {
if (!this.editConfig || this.editConfig.trigger === 'click') {
this._triggerActive(row, column, cell)
}
this.$emit('cell-click', row.data, column, cell, event)
},
_cellDBLclick (row, column, cell, event) {
if (this.editConfig && this.editConfig.trigger === 'dblclick') {
this._triggerActive(row, column, cell)
}
this.$emit('cell-dblclick', row.data, column, cell, event)
},
_rowClick (row, event, column) {
this.$emit('row-click', row.data, event, column)
},
_rowContextmenu (row, event) {
this.$emit('row-contextmenu', row.data, event)
},
_rowDBLclick (row, event) {
this.$emit('row-dblclick', row.data, event)
},
_headerClick (column, event) {
this.$emit('header-click', column, event)
},
_headerContextmenu (column, event) {
this.$emit('header-contextmenu', column, event)
},
_sortChange ({ column, prop, order }) {
this.$emit('sort-change', { column, prop, order })
},
_filterChange (filters) {
this.$emit('filter-change', filters)
},
_currentChange (currentRow, oldCurrentRow) {
if (currentRow && oldCurrentRow) {
this.$emit('current-change', currentRow.data, oldCurrentRow.data)
} else if (currentRow) {
this.$emit('current-change', currentRow.data)
} else if (oldCurrentRow) {
this.$emit('current-change', oldCurrentRow.data)
}
},
_headerDragend (newWidth, oldWidth, column, event) {
this.$emit('header-dragend', newWidth, oldWidth, column, event)
},
_expandChange (row, expandedRows) {
this.$emit('expand-change', row.data, expandedRows)
},
_triggerActive (row, column, cell) {
this.clearActive()
this.lastActive = { row, column, cell }
cell.className += ` editable-col_active`
row.editActive = column.property
this.$nextTick(() => {
let inpElem = cell.querySelector('.el-input>input')
if (!inpElem) {
inpElem = cell.querySelector('.el-textarea>textarea')
if (!inpElem) {
inpElem = cell.querySelector('.editable-custom_input')
}
}
if (inpElem) {
inpElem.focus()
}
})
},
_updateColumnStatus (trElem, column, tdElem) {
if (column.className.split(' ').includes('editable-col_edit')) {
let classList = tdElem.className.split(' ')
if (!classList.includes('editable-col_dirty')) {
classList.push('editable-col_dirty')
tdElem.className = classList.join(' ')
}
}
},
_summaryMethod (param) {
let { columns } = param
let data = param.data.map(item => item.data)
let sums = []
if (this.summaryMethod) {
sums = this.summaryMethod({ columns, data })
} else {
columns.forEach((column, index) => {
if (index === 0) {
sums[index] = this.sumText
return
}
let values = data.map(item => Number(item[column.property]))
let precisions = []
let notNumber = true
values.forEach(value => {
if (!isNaN(value)) {
notNumber = false
let decimal = ('' + value).split('.')[1]
precisions.push(decimal ? decimal.length : 0)
}
})
let precision = Math.max.apply(null, precisions)
if (!notNumber) {
sums[index] = values.reduce((prev, curr) => {
let value = Number(curr)
if (!isNaN(value)) {
return parseFloat((prev + curr).toFixed(Math.min(precision, 20)))
} else {
return prev
}
}, 0)
} else {
sums[index] = ''
}
})
}
return sums
},
_spanMethod ({ row, column, rowIndex, columnIndex }) {
let rowspan = 1
let colspan = 1
let fn = this.spanMethod
if (XEUtils.isFunction(fn)) {
var result = fn({
row: row.data,
column: column,
rowIndex: rowIndex,
columnIndex: columnIndex
})
if (XEUtils.isArray(result)) {
rowspan = result[0]
colspan = result[1]
} else if (XEUtils.isPlainObject(result)) {
rowspan = result.rowspan
colspan = result.colspan
}
}
return {
rowspan: rowspan,
colspan: colspan
}
},
clearActive () {
this.lastActive = null
this.datas.forEach(item => {
item.editActive = null
})
Array.from(this.$el.querySelectorAll('.editable-col_active.editable-column')).forEach(elem => {
elem.className = elem.className.split(' ').filter(name => name !== 'editable-col_active').join(' ')
})
},
setActiveRow (rowIndex) {
setTimeout(() => {
let row = this.datas[rowIndex]
if (row && this.mode === 'row') {
let column = this.$refs.refElTable.columns.find(column => column.property)
let trElemList = this.$el.querySelectorAll('.el-table__body-wrapper .el-table__row')
let cell = trElemList[rowIndex].children[0]
this._triggerActive(row, column, cell)
}
}, 5)
},
reload (datas) {
this.deleteRecords = []
this.clearActive()
this._initial(datas, true)
this._updateData()
},
revert () {
this.reload(this.initialStore)
},
clear () {
this.deleteRecords = []
this.clearActive()
this._initial([])
this._updateData()
},
insert (record) {
this.insertAt(record, 0)
},
insertAt (record, rowIndex) {
let recordItem = {}
let len = this.datas.length
this.$refs.refElTable.columns.forEach(column => {
if (column.property) {
recordItem[column.property] = null
}
})
recordItem = this._toData(Object.assign(recordItem, record), 'insert')
if (rowIndex) {
if (rowIndex === -1 || rowIndex >= len) {
rowIndex = len
this.datas.push(recordItem)
} else {
this.datas.splice(rowIndex, 0, recordItem)
}
} else {
rowIndex = 0
this.datas.unshift(recordItem)
}
this._updateData()
},
removeRow (rowIndex) {
let items = this.datas.splice(rowIndex, 1)
items.forEach(item => {
if (item.editStatus === 'initial') {
this.deleteRecords.push(item)
}
})
this._updateData()
},
removeRows (rowIndexs) {
XEUtils.lastEach(this.datas, (item, index) => {
if (rowIndexs.includes(index)) {
this.removeRow(index)
}
})
},
remove (record) {
this.removeRow(XEUtils.findIndexOf(this.datas, item => item.data === record))
},
removes (records) {
XEUtils.lastEach(this.datas, (item, index) => {
if (records.includes(item.data)) {
this.removeRow(index)
}
})
},
removeSelecteds () {
this.removes(this.$refs.refElTable.selection.map(item => item.data))
},
getRecords (datas) {
return (datas || this.datas).map(item => item.data)
},
getAllRecords () {
return {
records: this.getRecords(),
insertRecords: this.getInsertRecords(),
removeRecords: this.getRemoveRecords(),
updateRecords: this.getUpdateRecords()
}
},
getInsertRecords () {
return this.getRecords(this.datas.filter(item => item.editStatus === 'insert'))
},
getRemoveRecords () {
return this.getRecords(this.deleteRecords)
},
getUpdateRecords () {
return this.getRecords(this.datas.filter(item => item.editStatus === 'initial' && !XEUtils.isEqual(item.data, item.store)))
},
updateStatus (scope) {
if (this.showStatus) {
if (arguments.length === 0) {
this.$nextTick(() => {
let trElems = this.$el.querySelectorAll('.el-table__row')
if (trElems.length) {
let columns = this.$refs.refElTable.columns
this.datas.forEach((item, index) => {
let trElem = trElems[index]
if (trElem.children.length) {
if (item.editStatus === 'insert') {
columns.forEach((column, cIndex) => this._updateColumnStatus(trElem, column, trElem.children[cIndex]))
} else {
columns.forEach((column, cIndex) => {
let tdElem = trElem.children[cIndex]
if (tdElem) {
if (XEUtils.isEqual(item.data[column.property], item.store[column.property])) {
let classList = tdElem.className.split(' ')
tdElem.className = classList.filter(name => name !== 'editable-col_dirty').join(' ')
} else {
this._updateColumnStatus(trElem, column, trElem.children[cIndex])
}
}
})
}
}
})
}
})
} else {
this.$nextTick(() => {
let { $index, _row, column, store } = scope
let trElems = store.table.$el.querySelectorAll('.el-table__row')
if (trElems.length) {
let trElem = trElems[$index]
let tdElem = trElem.querySelector(`.${column.id}`)
if (tdElem) {
let classList = tdElem.className.split(' ')
if (XEUtils.isEqual(_row.data[column.property], _row.store[column.property])) {
tdElem.className = classList.filter(name => name !== 'editable-col_dirty').join(' ')
} else {
this._updateColumnStatus(trElem, column, tdElem)
}
}
}
})
}
}
}
}
}
EditableColumn.vue
#
{{ scope.column.label }}
{{ getSelectLabel(scope) }}
{{ getCascaderLabel(scope) }}
{{ getTimePickerLabel(scope) }}
{{ getDatePickerLabel(scope) }}
{{ formatter ? formatter(scope.row.data, scope.column, scope.row.data[scope.column.property], scope.$index) : scope.row.data[scope.column.property] }}
{{ formatter ? formatter(scope.row.data, scope.column, scope.row.data[scope.column.property], scope.$index) : scope.row.data[scope.column.property] }}
import XEUtils from 'xe-utils'
export default {
name: 'ElEditableColumn',
props: {
group: Boolean,
editRender: Object,
index: [Number, Function],
type: String,
label: String,
columnKey: String,
prop: String,
width: String,
minWidth: String,
fixed: [Boolean, String],
sortable: [Boolean, String],
sortMethod: Function,
sortBy: [String, Array, Function],
sortOrders: Array,
resizable: { type: Boolean, default: true },
formatter: Function,
showOverflowTooltip: Boolean,
align: { type: String, default: 'left' },
headerAlign: String,
className: { type: String, default: '' },
labelClassName: String,
selectable: Function,
reserveSelection: Boolean,
filters: Array,
filterPlacement: String,
filterMultiple: { type: Boolean, default: true },
filterMethod: Function,
filteredValue: Array
},
computed: {
attrs () {
return {
index: this.index,
type: this.type,
label: this.label,
columnKey: this.columnKey,
prop: this.prop,
width: this.width,
minWidth: this.minWidth,
fixed: this.fixed,
sortable: this.sortable,
sortMethod: this.sortMethod ? this.sortMethodEvent : this.sortMethod,
sortBy: XEUtils.isFunction(this.sortBy) ? this.sortByEvent : this.sortBy,
sortOrders: this.sortOrders,
resizable: this.resizable,
showOverflowTooltip: this.showOverflowTooltip,
align: this.align,
headerAlign: this.headerAlign,
className: `editable-column ${this.editRender ? 'editable-col_edit' : 'editable-col_readonly'}${this.className ? ' ' + this.className : ''}`,
labelClassName: this.labelClassName,
selectable: this.selectable ? this.selectableEvent : this.selectable,
reserveSelection: this.reserveSelection,
filters: this.filters,
filterPlacement: this.filterPlacement,
filterMultiple: this.filterMultiple,
filterMethod: this.filterMethod ? this.filterMethodEvent : this.filterMethod,
filteredValue: this.filteredValue
}
}
},
methods: {
getRendAttrs ({ row }) {
let size = row.editable.size
return Object.assign({ size }, this.editRender.attrs)
},
getSelectLabel (scope) {
let value = scope.row.data[scope.column.property]
let selectItem = this.editRender.options.find(item => item.value === value)
return selectItem ? selectItem.label : null
},
matchCascaderData (values, index, list, labels) {
let val = values[index]
if (list && values.length > index) {
list.forEach(item => {
if (item.value === val) {
labels.push(item.label)
this.matchCascaderData(values, ++index, item.children, labels)
}
})
}
},
getCascaderLabel (scope) {
let values = scope.row.data[scope.column.property] || []
let labels = []
let attrs = this.editRender.attrs || {}
this.matchCascaderData(values, 0, attrs.options || [], labels)
return labels.join(attrs.separator || '/')
},
getTimePickerLabel (scope) {
let value = scope.row.data[scope.column.property]
let attrs = this.editRender.attrs || {}
return XEUtils.toDateString(value, attrs.format || 'hh:mm:ss')
},
getDatePickerLabel (scope) {
let value = scope.row.data[scope.column.property]
let attrs = this.editRender.attrs || {}
if (attrs.type === 'datetimerange') {
return XEUtils.toArray(value).map(date => XEUtils.toDateString(date, attrs.format)).join(attrs.rangeSeparator)
}
return XEUtils.toDateString(value, attrs.format, 'yyyy-MM-dd')
},
sortByEvent (row, index) {
return this.sortBy(row.data, index)
},
sortMethodEvent (a, b) {
return this.sortMethod(a.data, b.data)
},
selectableEvent (row, index) {
return this.selectable(row.data, index)
},
filterMethodEvent (value, row, column) {
return this.filterMethod(value, row.data, column)
},
changeEvent ({ $index, row, column, store }) {
if (row.editable.showStatus) {
this.$nextTick(() => {
let trElem = store.table.$el.querySelectorAll('.el-table__row')[$index]
let tdElem = trElem.querySelector(`.${column.id}`)
let classList = tdElem.className.split(' ')
if (XEUtils.isEqual(row.data[column.property], row.store[column.property])) {
tdElem.className = classList.filter(name => name !== 'editable-col_dirty').join(' ')
} else {
if (!classList.includes('editable-col_dirty')) {
classList.push('editable-col_dirty')
tdElem.className = classList.join(' ')
}
}
})
}
}
}
}
.editable {
&.editable--icon {
.editable-header-icon {
display: inline-block;
}
}
&.el-table--mini {
.editable-column {
height: 42px;
}
}
&.el-table--small {
.editable-column {
height: 48px;
}
}
&.el-table--medium {
.editable-column {
height: 62px;
}
}
.editable-header-icon {
display: none;
}
.editable-column {
height: 62px;
padding: 0;
&.editable-col_dirty {
position: relative;
&:before {
content: '';
top: -5px;
left: -5px;
position: absolute;
border: 5px solid;
border-color: transparent #C00000 transparent transparent;
transform: rotate(45deg);
}
}
.cell {
>.edit-input,
>.el-cascader,
>.el-autocomplete,
>.el-input-number,
>.el-date-editor,
>.el-select {
width: 100%;
}
}
}
}
使用
全局事件需要依赖 vuex 中的 globalClick 变量 (参考store/modules/event.js)
将 Editable.vue 和 EditableColumn.vue 复制到自己项目的 components 目录下
然后在 main.js 引入组件即可
import Editable from '@/components/Editable.vue'
import EditableColumn from '@/components/EditableColumn.vue'
Vue.component(Editable.name, Editable)
Vue.component(EditableColumn.name, EditableColumn)
新增
删除选中
清空所有
删除
import { MessageBox } from 'element-ui'
export default {
data () {
return {
sexList: [
{
label: '男',
value: '1'
},
{
label: '女',
value: '0'
}
],
regionList: [
{
value: 'bj',
label: '北京',
children: [
{
value: 'bjs',
label: '北京市',
children: [
{
value: 'dcq',
label: '东城区'
}
]
}
]
},
{
value: 'gds',
label: '广东省',
children: [
{
value: 'szs',
label: '深圳市',
children: [
{
value: 'lhq',
label: '罗湖区'
}
]
},
{
value: 'gzs',
label: '广州市',
children: [
{
value: 'thq',
label: '天河区'
}
]
}
]
}
],
list: [
{
name: 'test11',
height: 176,
age: 26,
sex: '1',
region: null,
birthdate: new Date(1994, 0, 1),
date1: new Date(2019, 0, 1, 20, 0, 30),
remark: '备注1',
flag: false
},
{
name: 'test22',
height: 166,
age: 24,
sex: '0',
region: ['gds', 'szs', 'lhq'],
birthdate: new Date(1992, 0, 1),
date1: new Date(2019, 0, 1, 12, 10, 30),
remark: '备注2',
flag: true
},
{
name: 'test33',
height: 172,
age: 22,
sex: '1',
region: ['bj', 'bjs', 'dcq'],
birthdate: new Date(1990, 0, 1),
date1: new Date(2019, 0, 1, 0, 30, 50),
remark: null,
flag: false
}
]
}
},
methods: {
removeEvent (row, index) {
MessageBox.confirm('确定删除该数据?', '温馨提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$refs.editable.removeRow(index)
}).catch(e => e)
}
}
}