關注公眾號: 微信搜索 前端工具人
; 收貨更多的干貨
原文鏈接: 自己掘金文章 https://juejin.cn/post/7067039547091058696/
一、需求
- 所有表格需根據用戶自定義顯示列、及列的顯示順序;
- 支持左側、右側固定列、列寬修改
- 行內編輯表格(新增、編輯)必填項自帶校驗(類似於
form
表單的校驗攔截) - 效果圖
二、方案
2.1 拖拽插件
vue-draggable-next
2.2 初期使用的是 element-plus
實現;
缺點:
- 當表格字段
40+
及以上的時候, 表格卡頓,初始顯示很慢; - 表格涉及行展開操作時,也響應很慢;
- 版本
1.1.0-beta.18
, 有點舊的版本, 因為該項目是去年中旬寫的;最近element-plus
大改升級穩定版,不知道修復沒
列表列40+
甚至有的頁面60+
, 確實少見誇張, 原因是表格支持導出, 導出要詳細的
2.3 ant-design-vue
實現
- 使用
ant-design-vue
自帶的table
控件實現 - 使用
ant-design-vue
增強的Surely Vue
高性能組件
文章使用的 1
, 因為 ant-design-vue
自帶的 table
控件已經滿足了需求, 考慮到 Surely Vue
組件需求單獨引入就沒嘗試了
2.4 表格自帶校驗
- 新增數據的時候是在表格內新增一行,有的列是
必填項
、有的是自帶校驗規則
,比如只能輸入數字、中文之類的校驗 - 編輯同上; 對
table
控件進行二次封裝
實現
三、代碼片段
3.1 element-plus
版 自定義 table
組件
// CustomTable.vue
// 代碼有刪減
<template>
<div class="custom-table-container">
<div class="custom-buttons">
<el-tooltip class="item" effect="dark" content="表格設置" placement="top-start">
<el-button icon="el-icon-setting" circle @click="fixedVisible = true"></el-button>
</el-tooltip>
<el-tooltip class="item" effect="dark" content="表格排序" placement="top-start">
<el-button icon="el-icon-sort" circle @click="showVisible = true"></el-button>
</el-tooltip>
<el-tooltip class="item" effect="dark" content="表格刷新" placement="top-start">
<el-button icon="el-icon-refresh" circle @click="onRefresh"></el-button>
</el-tooltip>
</div>
<div class="custom-content">
<!-- row-key="id" :tree-props="{children: 'children', hasChildren: 'hasChildren'}" -->
<el-table
:data="tableData" center border align="left" style="width: 100%" @selection-change="handleSelectionChange"
:row-key="rowId" lazy :load="loadChildren" :tree-props="{children: 'children', hasChildren: 'hasChildren'}">
<el-table-column fixed="left" width="50" align="center" type="selection"> </el-table-column>
<el-table-column fixed="left" width="50" align="center" label="序號" type="index"></el-table-column>
<el-table-column fixed="left" width="50" align="center" label="展開" v-if="isChildren"></el-table-column>
<el-table-column fixed="left" :width="oWidth" align="center" label="操作" v-if="isShowOperaRow">
<template #default="{ row }">
<slot name="operate" :scope="row" v-if="!row.isChildren"></slot>
</template>
</el-table-column>
<!-- 左側固定列 -->
<el-table-column
v-for="(item, index) in leftList"
:key="item.fieldCode + index"
align="center"
fixed="left"
:width="item.fieldWidth ? item.fieldWidth : '100'"
:prop="item.fieldCode"
:label="item.fieldName"
show-overflow-tooltip>
</el-table-column>
<!-- 中間滑動列 -->
<el-table-column
v-for="(item, index) in showList"
:key="item.fieldCode + index"
align="center"
:min-width="item.fieldWidth ? item.fieldWidth : '120'"
:prop="item.fieldCode"
:label="item.fieldName"
show-overflow-tooltip>
</el-table-column>
<!-- 右側固定列 -->
<el-table-column
v-for="(item, index) in rightList"
:key="item.fieldCode + index"
align="center"
fixed="right"
:width="item.fieldWidth ? item.fieldWidth : '100'"
:prop="item.fieldCode"
:label="item.fieldName"
show-overflow-tooltip>
</el-table-column>
</el-table>
</div>
<!-- 顯示隱藏 -->
<el-dialog title="顯示隱藏" v-model="showVisible" top="100px" width="1000px" :lock-scroll="true" :close-on-click-modal="false">
<section class="section-drag">
<div class="item">
<div class="title">隱藏</div>
<VueDraggableNext class="list-group" :list="hiddenList" group="people">
<transition-group type="transition" name="flip-list">
<div class="list-group-item" v-for="element in hiddenList" :key="element.fieldCode"> {{ element.fieldName }} </div>
</transition-group>
</VueDraggableNext>
</div>
<div class="item">
<div class="title">顯示</div>
<VueDraggableNext class="list-group" :list="showList" group="people" @change="onChangeShow">
<transition-group type="transition" name="flip-list">
<div class="list-group-item" v-for="element in showList" :key="element.fieldCode"> {{ element.fieldName }} </div>
</transition-group>
</VueDraggableNext>
</div>
</section>
<section class="fotter-buttons">
<el-button size="medium" @click="showVisible = false">取消</el-button>
<el-button size="medium" type="primary" @click="onSaveShow">保存</el-button>
</section>
</el-dialog>
<!-- 固定列數 -->
<el-dialog title="固定列數" v-model="fixedVisible" top="100px" width="1000px" :lock-scroll="true" :close-on-click-modal="false">
<section class="section-fixed section-drag">
<div class="item">
<div class="title">固定左側</div>
<VueDraggableNext class="list-group" :list="tailFixedList" group="people">
<transition-group type="transition" name="flip-list">
<div class="list-group-item" v-for="element in tailFixedList" :key="element.fieldCode"> {{ element.fieldName }} </div>
</transition-group>
</VueDraggableNext>
</div>
<div class="item">
<div class="title">中間滑動列</div>
<VueDraggableNext class="list-group" :list="showList" group="people">
<transition-group type="transition" name="flip-list">
<div class="list-group-item" v-for="element in showList" :key="element.fieldCode"> {{ element.fieldName }} </div>
</transition-group>
</VueDraggableNext>
</div>
<div class="item">
<div class="title">固定右側</div>
<VueDraggableNext class="list-group" :list="frontFixedList" group="people" @change="onChangeFixed">
<transition-group type="transition" name="flip-list">
<div class="list-group-item" v-for="element in frontFixedList" :key="element.fieldCode"> {{ element.fieldName }} </div>
</transition-group>
</VueDraggableNext>
</div>
</section>
<section class="fotter-buttons">
<el-button size="medium" @click="fixedVisible = false">取消</el-button>
<el-button size="medium" type="primary" @click="onSaveFixed">保存</el-button>
</section>
</el-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, onMounted, PropType, UnwrapRef } from 'vue'
import { IColItem, ICustomTableState } from './type'
import { VueDraggableNext } from 'vue-draggable-next'
import * as INTERFACE from '@/interface'
import { IResponseData } from '@/types'
import { ElMessage } from 'element-plus'
export default defineComponent({
name: 'CustomTable',
components: { VueDraggableNext },
props: {
oWidth: {
type: Number,
default: 170,
},
isShowOperaRow: {
type: Boolean,
default: true
},
isChildren: {
type: Boolean,
default: false
},
rowId: {
type: [Number, String],
default: 'id'
},
tableCode: {
type: String,
default: 'testTable',
required: true
},
tableData: {
type: Array as PropType<IColItem[]>,
default: () => []
}
},
emits: { 'onRefresh': null, 'loadChildren': null ,'handleSelectionChange': null },
setup(props, ctx) {
// 避免排序、顯示隱藏相互影響, 深拷貝列表字段
const state: UnwrapRef<ICustomTableState> = reactive({
showVisible: false,
fixedVisible: false,
leftList: [],
centerList: [],
rightList: [],
hiddenList: [],
showList: [],
tailFixedList: [],
middleSlidingList: [],
frontFixedList: []
})
onMounted(() => {
// 初始根據傳遞的 tableCode 獲取對應的列表字段
getTableHeaders()
})
// ... 省略業務代碼
return { ...toRefs(state), onSaveShow, onSaveFixed, onRefresh, loadChildren, handleSelectionChange }
}
})
</script>
3.2 ant-design-vue
版 自定義 table
組件
注意 ant-design-vue
和 element-plus
API
不一樣
// CustomTable.vue
// 代碼其他部分變化, 主要是改變 table 部分
...
<a-table
size="small"
bordered
:rowKey="rowKey"
:pagination="false"
:columns="columnsData"
:data-source="tableData"
:scroll="{ x: 1500, y: 500 }"
@expand="onRowExpand"
:expandRowByClick="true"
childrenColumnName="childrenData"
:expandIconColumnIndex="2"
@resizeColumn="handleResizeColumn"
:rowClassName="(record, index) => (index % 2 === 1 ? 'table-striped' : null)"
:row-selection="{ selectedRowKeys: selectedKeys, onChange: onSelectChange }">
<template #expandIcon="{ record }">
<i v-if="record.expandStatus === 3" class="el-icon-arrow-down expand-icon"></i>
<i v-if="record.expandStatus === 2" class="el-icon-loading expand-icon"></i>
<i v-if="record.expandStatus === 1" class="el-icon-arrow-right expand-icon"></i>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index' && isChildren">
<span v-if="!record.isChildren">{{ index + 1 }}</span>
</template>
<template v-if="column.key === 'operate' && isShowOperaRow">
<div v-if="!record.isChildren"><slot name="operate" :scope="record"></slot></div>
<el-button type="text" v-else style="padding: 6px 0"></el-button>
</template>
</template>
</a-table>
...
3.3 使用
各頁面表格操作列不一樣的使用 solt
插槽自定義
<CustomTable
:tableData="tableData" tableCode="TransMasterTable" :oWidth="450" rowKey="id"
rowId="id" :isChildren="true" @loadChildren="loadChildren" @onRefresh="onRefresh" @handleSelectionChange="handleSelectionChange">
<!-- 操作列 -->
<template #operate="{ scope }">
<el-button type="text" @click="onTableOperate('details', scope)">查看</el-button>
<el-button type="text" @click="onTableOperate('edit', scope)" :disabled="![0, 2, 3].includes(scope.tmDataState)">編輯</el-button>
<el-button type="text" @click="onTableOperate('delete', scope)" :disabled="scope.tmDataState === 1 || scope.tmDataState === 4">刪除</el-button>
<el-button type="text" @click="onTableOperate('copy', scope)">復制</el-button>
<el-button type="text" @click="onTableOperate('submit', scope)" :disabled="scope.tmDataState !== 2">委托與提交</el-button>
<el-button type="text" @click="onTableOperate('print', scope)" disabled>打印</el-button>
</template>
</CustomTable>
四 表格自帶校驗
4.1 自定義 table
控件
CustomTableForm
, 關鍵在於 TableColumnForm
組件
// CustomTableForm.vue
<a-table bordered :columns="columns" :data-source="dataSource" :rowKey="rowKey" :scroll="{ x: scrollX }" @resizeColumn="handleResizeColumn">
<template #headerCell="{ column }">
<span :class="[{'is-required': column.required}]">{{ column.title }}</span>
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
<span>{{ index + 1 }}</span>
</template>
<template v-if="column.key === 'operate'">
<!-- 是否需要自定義操作列 -->
<template v-if="showDefaultOperate">
<el-button type="text" @click="onTableOperate('save', record)" v-if="record.isEdit">保存</el-button>
<el-button type="text" @click="onTableOperate('edit', record)" v-else>編輯</el-button>
<el-button type="text" @click="onTableOperate('delete', record, index)" class="delete-button">刪除</el-button>
</template>
<!-- 插槽 -->
<slot name="operate" :scope="record" :index="index"></slot>
</template>
<template v-if="column.key !== 'index' && column.key !== 'operate'">
<!-- 核心 -->
<TableColumnForm :column="column" :record="record"></TableColumnForm>
</template>
</template>
</a-table>
// TableColumnForm.vue
<template>
<span v-if="!row.isEdit || !column.colType" @click="onclick">{{ row[column.key] }}</span>
<template v-else>
<el-tooltip class="item" effect="dark" :content="row[column.key]" placement="top" v-if="!column.colType || column.colType === 'button'">
<el-button type="text" @click="onclick">{{ row[column.key] }}</el-button>
</el-tooltip>
<!-- 輸入框 -->
<el-input
v-if="!column.colType || column.colType === 'input'"
v-model="row[column.key]"
@blur="onBlur"
:placeholder="column.placeholder"
:class="[{'el-error': row.isError && !row[column.key] && column.required}]">
</el-input>
<!-- 下拉框 -->
<el-select
v-if="column.colType === 'select'"
v-model="row[column.key]"
:placeholder="column.placeholder"
:class="[{'el-error': row.isError && !row[column.key] && column.required}]">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"></el-option>
</el-select>
<!-- 日期選擇 -->
<el-date-picker
v-if="column.colType === 'date'"
v-model="row[column.key]"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
:class="[{'el-error': row.isError && !row[column.key] && column.required}]"
placeholder="Pick a date"
>
</el-date-picker>
<!-- 數字輸入框 -->
<el-input-number
@blur="onBlur"
@change="onChange"
ref="inputNumberRef"
v-if="column.colType === 'inputNumber'"
v-model="row[column.key]"
:min="column.min?column.min:''"
controls-position="right"
:class="[{'el-error': row.isError && !row[column.key] && column.required}]" />
</template>
</template>
<script lang="ts">
type optionItem = {
label: string,
value: string | number
}
import { defineComponent, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
export default defineComponent({
name: 'TableFormItem',
props: {
record: {
type: Object,
required: true
},
column: {
type: Object,
required: true
}
},
setup(props) {
const row = ref<any>({})
const column = ref<any>({})
const options = ref<optionItem[]>([])
const inputNumberRef = ref<any | HTMLElement>(null)
onMounted(() => {
row.value = props.record
column.value = props.column
options.value = props.column.options
if (props.column.colType === 'inputNumber' && row.value[props.column.key]) {
row.value[props.column.key] = +row.value[props.column.key]
}
})
const onBlur = (event) => {
const targetNode = event.target
// 有值 在去走校驗規則
if (column.value.checkCallback && row.value[column.value.key as string]) {
const flag = column.value.checkCallback(row.value[column.value.key as string].trim())
if (!flag) {
row.value[column.value.key as string] = ''
targetNode.setAttribute('placeholder', props.column.message ? props.column.message : '必填項')
ElMessage.warning(`${column.value.title}格式輸入有誤,請檢查`)
} else {
targetNode.setAttribute('placeholder', props.column.placeholder)
}
}
// 無值且必填觸發
if (!row.value[props.column.key as string] && props.column.required) {
targetNode.setAttribute('placeholder', props.column.message ? props.column.message : '必填項')
return targetNode.classList.add('el-checked-error')
}
if (row.value[props.column.key as string] && props.column.callBack) props.column.callBack(row.value)
targetNode.classList.remove('el-checked-error')
}
const onclick = () => {
if (row.value[props.column.key as string] && props.column.colType === 'button' && props.column.callBack){
props.column.callBack(row.value)
}
}
const onChange = () => {
if (row.value[props.column.key as string] && props.column.callBack) props.column.callBack(row.value)
}
const disabledDate = (time: Date)=>{
return time.getTime() > Date.now()
}
return { onBlur, row, options, onChange, onclick, inputNumberRef,disabledDate }
}
})
</script>
4.2 使用
...
<CustomTableForm rowKey="systemGid" :scrollX="3600" :columns="columns" :dataSource="tableData" :showDefaultOperate="true"></CustomTableForm>
...
<script lang="ts">
import { defineComponent, onMounted, reactive, toRefs } from 'vue'
import { useTableFormChecked } from '@/services/utils'
import { ElMessage } from 'element-plus'
// 下拉框數據源 用法1
const options1 = [
{
value: 'shenzhen',
label: '深圳',
},
{
value: 'guangzhou',
label: '廣州',
},
{
value: 'shanghai',
label: '上海',
},
{
value: 'beijing',
label: '北京',
}
]
// checkCallback 輸入校驗方法 用法1
const nameChecked = (key) => {
const reg = /^[\u4e00-\u9fa5]+$/
return reg.test(key)
}
/**
* @name 動態表格校驗方法
* @param array table 數據 columns 表頭數據
* @returns
*/
const useTableFormChecked = (array: any[], columns: any[]) => {
let flag = false
const titles = []
array.forEach(t => {
Object.keys(t).forEach((key) => {
const item = columns.find(l => l.key === key)
// 有值 但是 值格式對
if (t[key] && item?.checkCallback && typeof item.checkCallback == 'function') {
const reg = item.checkCallback(t[key])
if (reg) return
titles.push(item.title)
flag = true
}
// 沒值 且 必填
if (!t[key] && item?.required) {
titles.push(item.title)
flag = true
}
if ((!t[key] || t[key] === null) && t[key] !== 0 && t[key] !== false) t.isError = true
})
})
return { flag: flag, titles: titles }
}
export default defineComponent({
name: 'CustomTableFormContainer',
setup() {
const state = reactive({
tableData: [{
id: 1,
name: '阿凡達',
age: '18',
gender: '女',
weight: 88,
country: '中國',
city: 'shenzhen',
province: '廣東',
surname: '阿',
address: '深圳前海嘉里',
isEdit: false,
isError: false
}],
columns: [
{
title: '序號',
dataIndex: 'index',
key: 'index',
width: 60,
minWidth: 60,
resizable: true,
fixed: 'left'
},
{
title: '操作',
dataIndex: 'operate',
key: 'operate',
width: 120,
fixed: 'left'
},
{
title: '名稱',
dataIndex: 'name',
key: 'name',
resizable: true,
width: 150,
colType: 'input',
required: true,
checkCallback: nameChecked,
placeholder: '請輸入名稱',
message: '請輸入漢字字符'
},
{
title: '年齡',
dataIndex: 'age',
key: 'age',
resizable: true,
width: 150,
colType: 'input',
required: true,
checkCallback: null,
placeholder: '請輸入名稱'
},
{
title: '性別',
dataIndex: 'gender',
key: 'gender',
resizable: true,
width: 150,
colType: 'select',
required: true,
placeholder: '請選擇性別',
options: null
},
{
title: '體重',
dataIndex: 'weight',
key: 'weight',
resizable: true,
required: true,
width: 150,
min: 80,
max: 150,
colType: 'inputNumber',
},
{
title: '姓',
dataIndex: 'name',
key: 'name',
resizable: true,
width: 120,
placeholder: '請輸入名稱'
},
{
title: '地址',
dataIndex: 'surname',
key: 'surname',
resizable: true,
width: 150,
placeholder: '請輸入地址'
},
{
title: '國家',
dataIndex: 'country',
key: 'country',
width: 120,
resizable: true,
placeholder: '請輸入國家'
},
{
title: '省',
dataIndex: 'province',
key: 'province',
width: 120,
resizable: true,
placeholder: '請輸入省'
},
{
title: '城市',
dataIndex: 'city',
key: 'city',
resizable: true,
width: 120,
colType: 'select',
options: options1
}
],
options: [
{
value: 'Option1',
label: 'Option1',
},
{
value: 'Option2',
label: 'Option2',
},
{
value: 'Option3',
label: 'Option3',
},
{
value: 'Option4',
label: 'Option4',
},
{
value: 'Option5',
label: 'Option5',
},
]
})
onMounted(() => {
// checkCallback 輸入校驗方法 用法2
const item = state.columns.find(t => t.key === 'age')
item.checkCallback = ageChecked
// 下拉框數據源 用法2
const item1 = state.columns.find(t => t.key === 'gender')
item1.options = state.options
})
const ageChecked = (key) => {
const reg = /[^\d]/g
return !reg.test(key)
}
// 添加一行
const onOperate = () => {
const index = state.tableData[state.tableData.length - 1].id
state.tableData.push({
id: index + 1,
name: '',
age: '',
gender: '',
country: '',
city: '',
province: '',
surname: '',
address: '',
weight: 80,
isEdit: true,
isError: false
})
}
const onSave = () => {
const { flag, titles } = useTableFormChecked(state.tableData, state.columns)
// 攔截 校驗沒過禁止往下走
if (flag) return ElMessage.warning(`表格數據(${titles.toString()})輸入有誤,請檢查`)
// 下面是保存邏輯 .....
ElMessage.success('保存成功')
}
return { ...toRefs(state), onOperate, onSave }
}
})
</script>
4.3 使用文檔
// README.md
## 源文件
- `CustomTableForm`
- `TableColumnForm`
## 事例參考
- 路由 `order-manage/customTableFormContainer`
- 頁面 `CustomTableFormContainer`
## 表格數據表單使用規則 (適應需求: 表格動態添加數據 -- 和表單一樣帶校驗)
## 使用方式 A 代表必填, B 非必填
- `tableCode` (B):`用於動態獲取標表頭; 當需要右上角布局、自定義列顯示按鈕時, 為必填項`
- `columns` (A) : `表頭數據源`
- `title` (A): `表頭名稱`
- `key`(A): `Vue 需要的 key / 列數據在數據項中對應的路徑 -- 唯一值`
- `resizable` (B): , `是否需要自定義列寬 -- 拖動列寬`
- `width` (B): `150`, `列初始寬度; 注:resizable 為true時 width必填`
- `required` (B): `是否必填, true 會為表頭加上紅色 *`,
- `placeholder` (B): `輸入框以及下拉框的提示`,
- `min` (B): `colType 為 inputNumber 數字輸入框時最小值`,
- `max` (B): `colType 為 inputNumber 數字輸入框時最大值`,
- `message` (B): `校驗提示`,
- `colType` (B): `動態表格項類型 默認不傳 span; button: 提供點擊回調; input: 輸入框; select: 下拉框; inputNumber: 數字輸入框`,
- `checkCallback` (B): `必填時自定義的校驗規則回調函數; 使用方式參考事例`,
- `callBack` (B): `方法回調 使用方式參考交易單商品添加`,
- `dataSource` (A): 列表數據
- 注: 要和 `columns key` 一一對應
- `rowKey` (A): `dataSource` 每一項的唯一標識
- `tableCode` (B): 需要修改列寬的時候后台提供的表頭接口標識
- `scrollX` (B): 列表橫向寬度
- `scrollY` (B): 列表縱向高度
- `showDefaultOperate` (B): 是否顯示默認操作列; 值為false 時需要傳入自定義的操作列,
## 提示
- 改 `CustomTableForm` / `TableColumnForm` 文件之前 請先熟悉這 2 文件
- 更改時間建議寫在下面
- 有問題微信聯系我
## 更改日志
- `12-14` - `laisheng` :使用方式添加
寫的有點亂。有需要的請自行取舍; 這里大概介紹的就是思路及步驟