irpas技术客

Element + Vue 封装的table组件,可动态生成列,可通过npm进行安装_不会做饭的程序员_npm 表格组件

未知 4068

?已更新至npm,实现多级表头、筛选、排序、展开行,详情请看bo-tablehttps://·/package/bo-table

?实现效果:

?只需要简单的引用,给组件传递列表数据、表头数据就好了(我这里列写的比较多,所以看起来代码行数多 QaQ):


?可通过传入数据来动态生成列,使用方便

?目前完成的功能:

序号、选中行行(单击、双击)事件合计(可默认计算当前页合计【相加】,也可以通过父组件传入对应合计值进来)固定列、固定头行展示数据使用slot插入,prop为slot-name列表排序多级表头展开行筛选

目前缺失、常见的功能:

表格合并

全局引用表格组件以及表格列,因为存在多级表头,设计到递归,所以子组件也要全局注册,方便子组件引用自身,递归表格列使用了?vue-fragment?无渲染组件替代<template></template>不能嵌套的问题。

// vue无渲染标签 import Fragment from "vue-fragment"; Vue.use(Fragment.Plugin); // 自定义表格组件 import bTable from "@/components/bTable"; Vue.component("bTable", bTable); // 自定义表格组件--表格列 import ChildColumn from "@/components/bTable/ChildColumn"; Vue.component("ChildColumn", ChildColumn); // 分页组件 import Pagination from "@/components/Pagination"; Vue.component("pagination", Pagination);

主体index.vue代码:

<template> <div class="table"> <div class="b-table-handle" v-if="showHandle"> <div class="right"> <slot name="handle"></slot> </div> <div class="left"> <el-popover placement="bottom" width="320" trigger="click"> <div class="check-box"> <el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange" >全选</el-checkbox > <div style="margin: 15px 0;"></div> <el-checkbox-group class="check-flex" v-model="checkboxVal" @change="handleCheckedChange" > <el-checkbox v-for="(itemCheck, index) in showItems.filter(i => i.prop)" :key="itemCheck.prop + '_' + index" :label="itemCheck.prop" > {{ itemCheck.label }} </el-checkbox> </el-checkbox-group> </div> <el-button slot="reference" size="mini" icon="el-icon-setting" type="primary" >自定义列表</el-button > </el-popover> </div> </div> <el-table id="iTable" :class="tableClass" v-loading="loading" element-loading-text="加载中..." :data="data" :stripe="options.stripe" :border="options.border" :highlight-current-row="options.highlightCurrentRow" :header-row-style="options.headerRowStyle" :lazy="options.lazy" :height="options.height" :max-height="options.max_height || $max_height" :load="loadGetData" :show-summary="showsummary" ref="mutipleTable" style="width:100%;" @row-click="clickRow" @row-dblclick="dblclickRow" @row-contextmenu="contextmenu" @header-click="headClick" @header-contextmenu="headcontextmenu" @select="select" @select-all="selectAll" @current-change="rowChange" @selection-change="handleSelectionChange" @sort-change="handleChangeSort" :default-sort="defaultSort" :summary-method="getSummaries" :key="tableKey" > <!--region 数据列--> <el-table-column width="1" class-name="btable-hide-col"></el-table-column> <fragment v-for="(column, index) in columns" :key="index"> <child-column :index="index" :column="column" :showPage="showPage" :page="page" :sortMethod="sortMethod" @cell-db-click="cellDbClick" > <!-- <template slot-scope="data"> <slot name="column" :data="data" /> </template> --> <!-- 注意:此处遍历值不一样,如果是vue2中的话customSlots可以替换为$scopedSlots,而且下面setup中的取值也不需要了 --> <!-- vue2:$scopedSlots --> <!-- vue3: setup() { const { proxy } = getCurrentInstance() const customSlots = reactive({ ...proxy.$slots }) return { customSlots } } --> <!-- #[slot]="scope" 可以理解为 v-slot:slot = 'scope' v-slot:'插槽名称' = '传过来的值' --> <template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope"> <!-- 以之前的名字命名插槽,同时把数据原样绑定 --> <slot :name="slot" v-bind="scope" /> </template> </child-column> </fragment> <!--endregion--> </el-table> <!-- 分页 --> <pagination v-if="showPage" :total="page.total" :page.sync="page.page" :limit.sync="page.perpage" :disabled="loading" @pagination="pagination" /> </div> </template> <script> // import { getCurrentInstance, reactive } from "vue"; export default { props: { data: { type: Array, default: () => [] }, // 数据列表 columns: { type: Array, default: () => [] }, // 需要展示的列 === prop:列数据对应的属性,label:列名,align:对齐方式,width:列宽 options: { type: Object, default: function() { return { stripe: true, // 是否为斑马纹 table highlightCurrentRow: false, // 是否要高亮当前行 border: true, //是否有纵向边框 lazy: false, //是否需要懒加载 max_height: "", headerRowStyle: { backgroundColor: "#f8f8f8" } }; } }, // table 表格的控制参数 tableClass: { type: String, default: "hxTable" }, // 是否展示分页 showPage: { type: Boolean, default: true }, // 分页数据 page: { type: Object, default: () => ({ total: 0, page: 1, perpage: 20 }) }, // 表格加载 loading: { type: Boolean, default: false }, // 默认排序 defaultSort: { type: Object, default: () => ({}) }, // 自定义排序方法 sortMethod: { type: Function, default: () => {} }, // 显示合计 showsummary: { type: Boolean, default: false }, // 显示合计,价格精度-保留几位小数 precision: { type: Number, default: () => 2 }, // 手动传入总计 count_sum: { type: Object, default: () => ({}) }, // 是否显示表格上方显示区域 showHandle: { type: Boolean, default: false } }, data() { return { // 多行选中 multipleSelection: [], // 每次切换列显示隐藏,更新key tableKey: 0, // 自定义列绑定值 checkboxVal: [], // checkout 全部的绑定值 defaultFormThead: [], // 是否全选 checkAll: true, // 全选按钮的展示状态 isIndeterminate: false, // 需要展示的列 showItems: [], // slots数据 customSlots: [], // 拷贝的columns数据 copyColumn: [] }; }, // mounted() { // // setTimeout(() => { // // console.log(this.$refs.aaaa.$slots); // // }, 3000); // const { proxy } = getCurrentInstance(); // const customSlots = reactive({ // ...proxy.$slots // }); // this.customSlots = customSlots; // console.log(customSlots); // }, methods: { /** * 列表懒加载,必须先开启懒加载 * */ loadGetData(row, treeNode, resolve) { //懒加载事件数据 let data = { row: row, treeNode: treeNode, resolve: resolve }; this.$emit("loadGetData", data); }, /** * 修改表格prop */ handleProp(row, prop) { if (prop.indexOf(".") !== -1) { let props = prop.split("."); if (row[props[0]]) { return row[props[0]][props[1]] || row[props[0]][props[1]] === 0 ? row[props[0]][props[1]] : ""; } else { return ""; } } else { return row[prop] || row[prop] === 0 ? row[prop] : ""; } }, /** * 多行选中 * */ handleSelectionChange(val) { // 多行选中 this.multipleSelection = val; this.$emit("handleSelectionChange", val); }, /** * 禁用当前行选中 * 调用父组件的自定义方法获取返回值,返回false 代表禁用 * */ checkSelectable(row) { let status = true; this.$emit("checkSelectable", row, val => { status = val; }); return status; }, /** * 当前组件表格点击复制 * */ copy(row, columns) { if (!columns.nodbclick) { let text = this.handleProp(row, columns.prop); if (text) { navigator.clipboard.writeText(text).then(() => { this.$message("复制成功"); this.$emit("cell-db-click"); }); } } }, // 子组件传出的点击复制成功 cellDbClick() { this.$emit("cell-db-click"); }, /** * 单击行事件 * */ clickRow(row, column, event) { let data = { row: row, column: column, event: event }; this.$emit("clickRow", data); }, /** * 双击行事件 * */ dblclickRow(row, column, event) { let data = { row: row, column: column, event: event }; this.$emit("dblclickRow", data); }, /** * 右键行事件-没去掉页面默认的 * */ contextmenu(row, column, event) { let data = { row: row, column: column, event: event }; this.$emit("contextmenu", data); }, /** * 头部列点击事件 * */ headClick(column, event) { let data = { column: column, event: event }; this.$emit("headClick", data); }, /** * 头部列右键点击事件 * */ headcontextmenu(column, event) { let data = { column: column, event: event }; this.$emit("headcontextmenu", data); }, /** * 当前行发生改变时的事件 * */ rowChange(currentRow, oldCurrentRow) { let data = { currentRow: currentRow, oldCurrentRow: oldCurrentRow }; this.$emit("rowChange", data); }, /** * 用户手动勾选复选框触发 * */ select(sel, row) { let data = { sel: sel, row: row }; this.$emit("select", data); }, /** * 用户点击全选触发 * */ selectAll(sel) { let data = { sel: sel }; this.$emit("selectAll", data); }, /** * 表格合计 */ getSummaries(param) { const { columns, data } = param; const cols = JSON.parse(JSON.stringify(this.columns)); const constArr = cols.map(i => { if (i.hj === "num" && i.prop) { return i.prop; } return ""; }); const mentArr = cols.map(i => { if (i.hj === "price" && i.prop) { return i.prop; } return ""; }); const sums = []; columns.forEach((column, index) => { let is_count = Object.keys(this.count_sum).length > 0; if (index === 0) { sums[index] = is_count ? "总计" : "合计"; return; } if (is_count && this.count_sum[column.property] !== undefined) { sums[index] = this.count_sum[column.property]; } else if ( mentArr.indexOf(column.property) !== -1 || constArr.indexOf(column.property) !== -1 ) { // column.property 当前列的绑定值 scope.row.xxx const values = data.map(item => Number(item[column.property])); // 判断当前的值不为NaN if (!values.every(value => isNaN(value))) { // 求和 sums[index] = values.reduce((prev, curr) => { const value = Number(curr); if (!isNaN(value)) { return prev + curr; } else { return prev; } }, 0); if (mentArr.indexOf(column.property) !== -1) { sums[index] = sums[index].toFixed(this.precision); } } else { sums[index] = "N/A"; } } }); return sums; }, // 表格排序 handleChangeSort(row, column) { // console.log(row, column); this.$emit("change-sort", row, column); }, // 表格排序 sortHandle(row, row1, index, prop) { if (this.sortMethod(row, row1, index, prop)) { return this.sortMethod(row, row1, index, prop); } else { if (!Number(row[prop]) || Number(row[prop]) === 0) { return -1; } if (Number(row[prop]) < Number(row1[prop])) { return -1; } else { return 1; } } }, // 分页 pagination(val) { let { limit, page } = val; // 根据当前总条数、当前每页展示条数来判断当前页码是否超标; // 是的话就将当前总条数、当前每页展示条数的最大页码赋值给组件; // 例:total: 99 pagesize: 10 page: 10; // 此时切换pagesize为30,如果继续按照page:10去计算,那就是10*30 = 300; // 但是数据一共就99条,因此要更新页码为:Math.ceil(99 / 30) = 4;3*30 < 99 < 4*30,就ok了。 // ,唯一美中不足的是,由于pagesize和page的改变都会触发分页组件的Pagination方法,所以会导致调用两次接口,但数据是一样的 // 目前还没想到要怎么处理,难不成要在<Pagination/>里边另写一个触发更改分页的方法? let max_page = Math.ceil(this.page.total / limit); if (max_page < page) { page = max_page; this.$emit("pagination", { limit, page }); } else { this.$emit("pagination", { limit, page }); } }, // 操作自定义列 handleDefaultHead(data) { this.defaultFormThead = data.filter(i => i.prop).map(i => i.prop); this.checkboxVal = data.filter(i => i.prop).map(i => i.prop); }, // 全选 handleCheckAllChange(val) { console.log(val); this.checkboxVal = val ? this.defaultFormThead : []; this.isIndeterminate = false; this.handleHead(); }, // 判断是否是全选 handleCheckedChange(value) { this.handleHead(); let checkedCount = value.length; this.checkAll = checkedCount === this.defaultFormThead.length; this.isIndeterminate = checkedCount > 0 && checkedCount < this.defaultFormThead.length; }, // 表格头部变化 handleHead() { this.$emit("handle-column", this.handleShowColumn(this.copyColumn)); }, // 操作选择隐藏/显示列 handleShowColumn(columns) { let arr = []; columns = JSON.parse(JSON.stringify(columns)); columns.forEach(i => { if (i.child) { let child = this.handleShowColumn(i.child); if (child.length) { i.child = child; arr.push(i); } } else { if (!i.prop || this.checkboxVal.indexOf(i.prop) !== -1) { // console.log(i); arr.push(i); } } }); return arr; }, // 操作传入列数据 handleItems(val) { let arr = []; val.forEach(item => { if (item.child) { arr.push(...this.handleItems(item.child)); } else { arr.push(item); } }); return arr; } }, watch: { columns: { handler(val) { // 表格宽度计算公式:每个字宽度默认为14.5,(元)宽度默认30 + padding左右各20 = 50 if (!this.showItems.length) { this.showItems = this.handleItems(val); this.handleDefaultHead(this.showItems); } if (!this.copyColumn.length) { this.copyColumn = JSON.parse(JSON.stringify(val)); } this.tableKey++; }, deep: true, immediate: true } } }; </script> <style lang="scss"> .el-icon-question { color: #cc9756; } .atooltip.el-tooltip__popper[x-placement^="top"] .popper__arrow { border-top-color: #cc9756; } .atooltip.el-tooltip__popper[x-placement^="top"] .popper__arrow:after { border-top-color: #fff; } .atooltip { border: 1px solid #cc9756 !important; color: #cc9756; } .table { margin-top: 20px; .el-table { margin-top: 0; } .b-table-handle { border: 1px solid #ebeef5; border-bottom: none; padding: 10px; background: #f5f7fa; display: flex; align-items: center; justify-content: space-between; .right { flex: 1; } .left { // width: 160px; padding-left: 50px; } } } .check-box { max-height: 350px; overflow-y: auto; .check-flex { display: none; display: flex; flex-wrap: wrap; .el-checkbox { width: 50%; margin-right: 0; display: flex; align-items: center; box-sizing: border-box; &:nth-child(2n) { padding-left: 10px; } .el-checkbox__label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } } } </style>

表格子组件:表格列?ChildColumn.vue

<template> <fragment v-if="!column.child"> <fragment v-if="column.type == 'selection'"> <!--复选框(START)--> <el-table-column type="selection" :width="column.width ? column.width : 55" :align="column.align ? column.align : 'center'" :key="column.prop + '_' + index" :selectable="checkSelectable" > </el-table-column> <!--复选框(END)--> </fragment> <fragment v-else-if="column.type == 'index'"> <!--序号(START)--> <el-table-column :label="column.label ? column.label : '序号'" :min-width="column.minWidth ? column.minWidth : 60" :align="column.align ? column.align : 'left'" :sortable="column.sort || false" :sort-method="(a, b) => sortHandle(a, b, index, column.prop)" :key="column.prop + '_' + index" fixed="left" > <template slot-scope="scope"> <slot name="index" :scope="scope" v-if="showPage">{{ scope.$index + 1 + page.perpage * (page.page - 1) }}</slot> <slot name="index" :scope="scope" v-else>{{ scope.$index + 1 }}</slot> </template> </el-table-column> </fragment> <fragment v-else> <!-- 默认渲染列-渲染每一列的汉字 --> <el-table-column :prop="column.prop" :label="column.label" :align="column.align" :header-align="column.head_align ? column.head_align : 'center'" :width="column.width" :fixed="column.fixed" :min-width="column.minWidth" :show-overflow-tooltip="true" :key="column.prop + '_' + index" :sortable="column.sort || false" :sort-method="(a, b) => sortHandle(a, b, index, column.prop)" > <template slot-scope="scope" slot="header"> <template v-if="column.head_slot"> <slot :name="column.head_slot" v-bind="scope"></slot> </template> <template v-else> {{ column.label }} <el-tooltip effect="light" placement="top" :scope="scope" v-if="column.header" popper-class="atooltip" > <div slot="content" v-for="(item, index) in column.header.split(';')" :key="index"> {{ item }} </div> <i class="el-icon-question"></i> </el-tooltip> </template> </template> <template slot-scope="scope"> <!-- 双击复制 --> <!-- 如不需要双击复制,在外部column添加 nodbclick = true --> <slot :name="column.prop" :scope="scope"> <span @dblclick="copy(scope.row, column)" >{{ handleProp(scope.row, column.prop) }} </span> </slot> </template> </el-table-column> </fragment> </fragment> <fragment v-else> <el-table-column :prop="column.prop" :label="column.label" :align="column.align"> // 不知道为何element动态渲染表格,会把每次循环的第一列渲染到最后一列去,研究了好久,放弃了 // 最后选择在每次循环开始前,添加一列width=1px的空列出来,让它去渲染到最后一列,不影响循环,调整一下样式 <el-table-column width="1" class-name="btable-hide-col"></el-table-column> <child-column v-for="(item, i) in column.child" :key="i" :index="i" :column="item" :showPage="showPage" :page="page" :sortMethod="sortMethod" @cell-db-click="cellDbClick" > <!-- <template slot-scope="data"> <slot name="column" :data="data" /> </template> --> <!-- 注意:如果是vue2中的话customSlots可以替换为$scopedSlots,而且下面setup中的取值也不需要了 --> <template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope"> <!-- 以之前的名字命名插槽,同时把数据原样绑定 --> <slot :name="slot" v-bind="scope" /> </template> </child-column> </el-table-column> </fragment> </template> <script> export default { props: { child: { type: Array, default: () => [] }, index: { type: Number, default: 0 }, // 自定义排序方法 sortMethod: { type: Function, default: () => {} }, showPage: { type: Boolean, default: true }, // 分页数据 page: { type: Object, default: () => ({ total: 0, page: 1, perpage: 20 }) }, column: { type: Object, required: true }, children: { //child识别字段,用于识别多级表头字段 type: String, required: false, default: "child" } }, methods: { // 表格排序 sortHandle(row, row1, index, prop) { if (this.sortMethod(row, row1, index, prop)) { return this.sortMethod(row, row1, index, prop); } else { if (!Number(row[prop]) || Number(row[prop]) === 0) { return -1; } if (Number(row[prop]) < Number(row1[prop])) { return -1; } else { return 1; } } }, /** * 当前组件表格点击复制 * */ copy(row, columns) { if (!columns.nodbclick) { let text = this.handleProp(row, columns.prop); if (text) { navigator.clipboard.writeText(text).then(() => { this.$message("复制成功"); console.log(); this.$emit("cell-db-click"); }); } } }, // 子组件传出的点击复制成功 cellDbClick() { this.$emit("cell-db-click"); }, /** * 修改表格prop */ handleProp(row, prop) { if (prop && prop.indexOf(".") !== -1) { console.log(row); let props = prop.split("."); if (row[props[0]]) { return row[props[0]][props[1]] || row[props[0]][props[1]] === 0 ? row[props[0]][props[1]] : ""; } else { return ""; } } else { return row[prop] || row[prop] === 0 ? row[prop] : ""; } } }, render(h) { // 本来想使用render来渲染页面的,但是涉及到的方法较多,还是算了 // 个人觉得render函数要比直接写模板代码看起来简洁,比较容易理解 function interite(arr) { return arr.map(item => { if (item.child) { return h("el-table-column", { attrs: { label: item.label } }, interite(item.child)); } else { let name = "column_"; if (item.prop) { name += item.prop; } else if (item.type) { name += item.type; } console.log(item); return h("slot", { attrs: { name } }); } }); } let children = interite(this.child); let el = h("template", {}, children); return el; } }; </script> <style lang="scss"> // 不知道为何element动态渲染表格,会把每次循环的第一列渲染到最后一列去,研究了好久,放弃了 // 最后选择在每次循环开始前,添加一列width=1px的空列出来,让它去渲染到最后一列,不影响循环,调整一下样式 .el-table .btable-hide-col { border-right: none !important; width: 0px; // display: none; } </style>

分页组件:

<template> <div :class="{ hidden: hidden }" class="pagination-container"> <el-pagination :background="background" :current-page.sync="currentPage" :page-size.sync="pageSize" :layout="layout" :page-sizes="pageSizes" :total="total" v-bind="$attrs" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </div> </template> <script> import { scrollTo } from "@/utils/scroll-to"; export default { name: "Pagination", props: { total: { required: true, type: Number }, page: { type: Number, default: 1 }, limit: { type: Number, default: 20 }, pageSizes: { type: Array, default() { return [10, 20, 30, 50, 100]; } }, layout: { type: String, default: "total, sizes, prev, pager, next, jumper" }, background: { type: Boolean, default: true }, autoScroll: { type: Boolean, default: true }, hidden: { type: Boolean, default: false } }, computed: { currentPage: { get() { return this.page; }, set(val) { this.$emit("update:page", val); } }, pageSize: { get() { return this.limit; }, set(val) { this.$emit("update:limit", val); } } }, methods: { handleSizeChange(val) { this.$nextTick(() => { this.$emit("pagination", { page: this.currentPage, limit: val }); if (this.autoScroll) { scrollTo(0, 800); } }); }, handleCurrentChange(val) { this.$nextTick(() => { this.$emit("pagination", { page: val, limit: this.pageSize }); if (this.autoScroll) { scrollTo(0, 800); } }); } } }; </script> <style lang="scss" scoped> .pagination-container { padding: 0px 16px; text-align: right; display: flex; flex-direction: inherit; justify-content: flex-end; align-items: center; display: block; width: 100%; overflow: auto; } .pagination-container.hidden { display: none; } </style>

?引用组件:

可以通过操作 headList 实现动态展示列的效果通过slot自定义表格内容展示(默认show-overflow-tooltip = true) <template> <!-- 表格数据 --> <b-table ref="btable" :data="listData" :columns="defaultHead" :loading="loading" :showsummary="true" :showPage="true" :page="{ total: total, page: searchForm.page, perpage: searchForm.perpage }" @pagination="pagination" :count_sum="count_sum" > </b-table> </template> <script> export default { data() { return { searchForm: { shop_id: "", product_type: "", page: 1, perpage: 20 }, // 订单列表数据 listData: [], // 表格头部数据---全部的 defaultHead: [ { type: "index" }, { prop: "shop_name", label: "门店", align: "center", minWidth: "120", fixed: "left" }, { prop: "product_type_name", label: "物料分类", align: "center", minWidth: "120" }, { prop: "cost_price", label: "成本金额(元)", align: "center", minWidth: "150" }, { prop: "sale_price", label: "售价金额(元)", align: "center", minWidth: "150" }, { prop: "gross_profit", label: "毛利(元)", align: "center", minWidth: "150" }, { prop: "gross_profit_rate", label: "毛利率", align: "center", minWidth: "150" }, { prop: "day", label: "日期", align: "center", minWidth: "170" } ], // 订单列表总数 total: 0, // 列表加载状态 loading: false, // 合计数据 count_sum: {} }; }, }; </script>


1.本站遵循行业规范,任何转载的稿件都会明确标注作者和来源;2.本站的原创文章,会注明原创字样,如未注明都非原创,如有侵权请联系删除!;3.作者投稿可能会经我们编辑修改或补充;4.本站不提供任何储存功能只提供收集或者投稿人的网盘链接。

标签: #npm #表格组件 #封装 #element #Tble #列表 #动态 #