基于Vue3 + element-plus 封装表格组件

379 阅读4分钟

组件具备功能

  1. 表格有分页和触底两种加载数据两种风格(性能优化方式)
  2. 组件包含:表格组件、搜索组件、分页器组件
  3. hook包含:表格相关数据处理、拖拽功能
  4. 接受参数包括获取数据方法request、查询参数initParam、表格字段数组columns、mock数据(开启mock数据就请求数据)、获取数据后的回调函数handleData(对接口返回数据进行处理后返回)、pagination是否显示分页器或触底加载、supportUrge是否支持多选等。
  5. 某个字段数据特殊处理:使用插槽方式,表格组件封装时预留具名插槽,字段名为插槽名称,可在父组件自定义该字段的展示方式。
  6. 搜索实现:搜索组件自定事件search,父组件监听,拿到搜索条件后,更改查询参数。表格组件侦听属性监听initParam的变化重新请求数据实现搜索功能。
  7. 分页实现:分页器组件监听页码,当前页的变化,发出自定义事件,表格组件修改initParam,触发数据请求方法。
  8. 触底加载实现:监听表格滑动区域,触底修改initParam的当前页码触发请求下一页的数据。
  9. 批量操作实现:表格组件自定义选中事件通过参数带出选中数据行,父组件监听并拿到数据,实现编辑删除表格某条数据操作。
  10. 拖拽实现:使用sortablejs库进行封装 ① 准备两个数组oldList用于展示初始字段名 newList用于新增或者拖拽后的字段名。 ② 通过类名draggable制定某些列可以拖拽,新增列时给该列赋予类名draggable,钩子onMove(){//获取类名判断是否可以拖拽,返回布尔值}。钩子onEnd(evt){//拖拽后处理newList移动拖拽的字段位置,evt会有oldIndex和newIndex} ③ 新增表格数据,添加表格字段tableItems.columnName,从可选数据中获取到columnName的系数添加进表格数据tableData.columnName.coefficient。更新oldList和newList。
  11. mock数据:使用mockjs库实现

项目目录

// 目录:
├─ src  
│  ├─ assets                                                
│  ├─ components  
│  │  ├─ STable         
│  │  │  ├─ SearchForm.vue          // 搜索组件,一般放在页面上方,不跟表格一体,按需引入。
│  │  │  ├─ STable.vue              // 主要表格组件
│  │  │  ├─ useTable.js             // 表格组件hooks
│  │  │  ├─ Pagination.vue          // 分页器组件再封装一层
│  │  ├─ veBaseComponents           // 封装全局组件,在main.js引入注册。
│  │  │  ├─ VeCommonPagination.vue  // 基于element plus 分页器组件
│  │  │  ├─ VeCommonTable.vue       // 基于element plus表格组件

表格组件

<template>
  <div class="table">
    <VeCommonTable
      ref="tableRef"
      :rowKey="selectParam"
      :data="tableData"
      :border="border"
      :stripe="stripe"
      v-loading="loading"
      @selection-change="handleSelectionChange"
      @row-click="handleRowClick"
      @expand-change="onExpandChange"
      v-bind="$attrs"
    >
      <template v-for="item in tableColumns" :key="item">
        <el-table-column
          v-if="item.type === 'selection' || item.type === 'index'"
          :type="item.type"
          :reserve-selection="item.type === 'selection'"
          :label="item.label"
          :width="item.width"
          :min-width="item.minWidth"
          :fixed="item.fixed"
          :selectable="canSelect"
        >
        </el-table-column>
        <el-table-column
          v-if="!item.type && item.prop && item.isShow"
          :prop="item.prop"
          :label="item.label"
          :width="item.width"
          :min-width="item.minWidth"
          :sortable="item.sortable"
          :show-overflow-tooltip="item.prop !== 'operation'"
          :resizable="true"
          :fixed="item.fixed"
          align="center"
        >
          <template #default="scope">
            <slot :name="item.prop" :row="scope.row">
              {{ scope.row[item.prop] }}
            </slot>
          </template>
        </el-table-column>
      </template>
    </VeCommonTable>
    <Pagination
      v-if="pagination"
      :pageable="pageable"
      :handleSizeChange="handleSizeChange"
      :handleCurrentChange="handleCurrentChange"
      style="height: 42px; margin: 12px auto"
    />
  </div>
</template>

<script setup>
import { watch, ref } from "vue";
import { useTable } from "./useTable.js";
import VeCommonTable from "../veBaseComponents/VeCommonTable.vue";
import Pagination from "./Pagination.vue";

const props = defineProps({
  columns: { type: Array, default: [] },
  border: { type: Boolean, default: false },
  request: {
    type: Function,
    default: () => {},
  },
  dataPosition: { type: String, default: "" },
  initParam: {},
  isPageable: { type: Boolean, default: true },
  dataCallback: { type: Function, default: undefined },
  stripe: { type: Boolean, default: true },
  pagination: { type: Boolean, default: true }, // 是否需要分页
  selectParam: { type: String, default: "id" },
  isNeedLoad: { type: Boolean, default: true },
  mockData: { type: Array },
  description: { type: String, default: "暂无数据" },
  isSingleChoose: { type: Boolean, default: false },
});
const canSelect = (row) => row?.supportUrge !== 1;
const emits = defineEmits([
  "selectionChange",
  "rowClick",
  "currentRowChange",
  "expandChange",
]);
// 表格列
const tableColumns = ref([]);
watch(
  () => props.columns,
  (newColumns) => {
    tableColumns.value = newColumns.map((item) => {
      return {
        ...item,
        isShow: typeof item.isShow === "undefined" || item.isShow,
      };
    });
  },
  { immediate: true, deep: true }
);
const {
  tableData,
  loading,
  pageable,
  reset,
  resetPage,
  getTableList,
  handleSizeChange,
  handleCurrentChange,
} = useTable(
  props.request,
  props.initParam,
  props.isPageable,
  props.dataCallback,
  props.isNeedLoad,
  props.mockData,
  props.dataPosition
);
// 监听页面 initParam 改化,重新获取表格数据,无论切换页码还是查询数据都是通过这个参数实现的
watch(
  () => props.initParam,
  (val) => getTableList({ ...val }),
  { deep: true }
);
const tableRef = ref();
const handleSelectionChange = (selection) => {
  if (props.isSingleChoose) {
    if (selection.length > 1) {
      tableRef.value.clearSelection();
      tableRef.value.toggleRowSelection(selection.pop(), true);
    } else if (selection.length == 1) {
      emits("selectionChange", selection);
    }
  } else {
    emits("selectionChange", selection);
  }
};
// 单击表格某行
const handleRowClick = (row, column, e) =>
  emits("rowClick", { row, column, e });
// 列表展开/收起处理 -----start------/
const expands = ref([]); // 当前表格展开的所有行对应的id
watch(
  () => tableData.value,
  (val) => {
    expands.value = [];
    emits("expandChange", [...expands.value]);
  }
);
const onExpandChange = (row, expanded) => {
  const len = expands.value.length;
  if (expanded) {
    expands.value.push(row.id);
  } else {
    for (let i = 0; i <= len; i++) {
      if (expands.value[i] == row.id) {
        expands.value.splice(i, 1);
      }
    }
  }
  emits("expandChange", [...expands.value]);
};
// 列表展开/收起处理 -----end------/
const getPage = () => pageable.value;
const clearSelection = () => {
  tableRef?.value?.tableRef?.clearSelection?.();
};
const toggleRowExpansion = (isUnfold) => {
  const table = tableRef?.value?.tableRef?.data;
  if (table) {
    table.forEach((row) => {
      tableRef?.value?.tableRef?.toggleRowExpansion?.(row, isUnfold);
    });
  }
};
defineExpose({
  getTableList,
  getPage,
  reset,
  resetPage,
  clearSelection,
  toggleRowExpansion,
});
</script>
<style scoped lang="scss">
.table {
  height: 100%;
  display: flex;
  flex-direction: column;
  box-sizing: border-box;

  :deep(.el-table) {
    flex: 1;
    height: 100%;
  }
}
</style>
import { reactive, computed, onMounted, toRefs, inject } from "vue";
import { GlobalStore } from "@/store";

export const useTable = (request, initParam, isPageable, dataCallBack, isNeedLoad, mockData, dataPosition) => {
  const state = reactive({
    tableData: [], // 表格数据
    pageable: {
      pageIndex: 1, //当前页数
      pageSize: 50, // 每页条数
      total: 0, // 总条数
    },
    searchParam: {}, // 查询参数(只包括查询)
    searchInitParam: {}, // 初始化默认的查询参数
    totalParam: {}, // 总参数(包含分页和查询参数)
    loading: false,
  });

  onMounted(() => {
    if (isNeedLoad) {
      reset();
    }
  });

  const reset = () => {
    // 分页数据重置
    state.pageable.pageIndex = GlobalStore().initTablePage || 1;
    state.pageable.pageSize = 50;
    Object.keys(state.searchInitParam).forEach((key) => { state.searchParam[key] = state.searchInitParam[key] });
    updatedTotalParam();
    getTableList();
  };

  const resetPage = () => {
    // 分页数据重置
    state.pageable.pageIndex = 1;
    state.pageable.pageSize = 50;
  };

  const updatedTotalParam = () => {
    state.totalParam = {};
    // 处理查询参数,可以给查询参数加自定义前缀操作
    let nowSearchParam = {};
    for (let key in state.searchParam) {
      if (state.searchParam[key] || state.searchParam[key] === false || state.searchParam[key] === 0) {
        nowSearchParam[key] = state.searchParam[key];
      }
    }
    Object.assign(state.totalParam, nowSearchParam, isPageable ? pageParam.value : {});
  };

  // 分页参数
  const pageParam = computed({
    get: () => {
      return {
        pageIndex: state.pageable.pageIndex,
        pageSize: state.pageable.pageSize,
      };
    },
  });

  const getTableList = async(newVal) => {
    try {
      state.loading = true;
      Object.assign(state.totalParam, newVal ? newVal : initParam, isPageable ? pageParam.value : {});
      if (mockData) {
        state.tableData = mockData
        return
      }
      let res = await request(state.totalParam);
      dataPosition ? res.data = res.data[dataPosition] : '';
      const data = (dataCallBack && await dataCallBack(res.data.data)) || res.data.data;
      let index = 0;
      for (let i = 0; i < data.length; i++) {
        const item = data[i];
        if (item.submissionList) {
          // item.id = item.vehicleClassDistributionId;
          item.id = (++index + new Date().getTime()).toString();
          item.no = `${i + 1}`;
          item.children = item.submissionList;
          for (let j = 0; j < item.children.length; j++) {
            const subItem = item.children[j];
            subItem.id = (++index + new Date().getTime()).toString();
            subItem.no = `${i + 1}.${j + 1}`
          }
        } else {
          item.id = (++index + new Date().getTime()).toString();
          item.no = `${i + 1}`;
        }
      }
      state.tableData = data
      const { total, count } = res.data;
      isPageable && updatePageable({ total: total || count || 0 });
    } catch (error) {} finally {
      state.loading = false;
    }
  };

  const updatePageable = (resPageable) => Object.assign(state.pageable, resPageable);

  const handleSizeChange = (size) => {
    state.pageable.pageIndex = 1;
    state.pageable.pageSize = size;
    getTableList();
  };
  const handleCurrentChange = (currentPage) => {
    state.pageable.pageIndex = currentPage;
    getTableList();
  };

  const search = () => {
    state.pageable.pageIndex = 1;
    updatedTotalParam();
    getTableList();
  };

  return {
    ...toRefs(state),
    getTableList,
    reset,
    resetPage,
    search,
    handleSizeChange,
    handleCurrentChange,
  };
};
<template>
  <VeCommonPagination
    :current-page="pageable.pageIndex"
    :page-size="pageable.pageSize"
    :background="true"
    layout="total, sizes, prev, pager, next, jumper"
    :total="pageable.total"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  >
  </VeCommonPagination>
</template>


<script setup>
import Pagination from "../veBaseComponents/VeCommonPagination.vue";
const props = defineProps({
  pageable: { type: Object, default: { pageIndex: 1, pageSize: 50, total: 0 } },
  handleSizeChange: { type: Function, default: (size) => {} },
  handleCurrentChange: { type: Function, default: (currentPage) => {} },
});
</script>

分页器组件

<template>
  <el-pagination v-bind="getBind()" ref="paginationRef"> </el-pagination>
</template>

<script>
// import {ElPagination} from 'element-plus';
import { ref, useAttrs, useSlots } from "vue";

export default {
  name: "VeCommonPagination",
  // extends: ElPagination,
  directives: {},
  inheritAttrs: false, //是否在元素中挂载传过来的属性
  props: {},
  setup(props, context) {
    /**
     * 表格默认属性,可以通过tableAttrs.bind修改默认属性
     * */
    const attrs = useAttrs();
    const slots = useSlots();
    const defaultAttrs = {
      total: 1,
      pageSize: 1,
      currentPage: 1,
      pagerCount: 9,
      layout: "total, sizes, prev, pager, next, jumper",
      pageSizes: [10, 20, 30, 40, 50],
      popperClass: "",
      prevText: "",
      nextText: "",
      small: false,
      background: true,
      disabled: false,
      hideOnSinglePage: false,
    };

    /**
     * 获取$attrs传过来的属性,进行属性绑定、事件绑定、v-if绑定,
     **/
    const getBind = () => {
      //混合默认属性和自定义属性,事件也在这里传入
      return Object.assign(defaultAttrs, attrs);
    };
    const paginationRef = ref(null);

    return {
      paginationRef,
      getBind,
      slots,
    };
  },
};
</script>
<style lang="scss" scoped>
.elplus-pagination {
  display: flex;
  justify-content: center;
}
</style>
<template>
  <el-table
    v-bind="getBind()"
    ref="tableRef"
    row-key="id"
    :row-style="rowStyle"
  >
    <slot></slot>
    <template #append v-if="slots.append">
      <slot name="append"></slot>
    </template>
    <template #empty>
      <slot name="empty"></slot>
      <veEmpty description="暂无数据" v-if="!slots.empty"></veEmpty>
    </template>
  </el-table>
</template>

<script>
import { ElTable } from "element-plus";
import { ref, useAttrs, useSlots } from "vue";

export default {
  name: "VeCommonTable",
  // extends: ElTable,
  directives: {},
  inheritAttrs: false,
  props: {},
  setup(props, context) {
    const attrs = useAttrs();
    const slots = useSlots();
    const defaultAttrs = {
      data: [],
      fit: true,
      stripe: false,
      border: false,
      showHeader: true,
      showSummary: false,
      highlightCurrentRow: true,
      defaultExpandAll: false,
      selectOnIndeterminate: true,
      indent: 16, //展示树形数据时,树节点的缩进
      lazy: false, //是否懒加载子节点数据
      style: {}, //表格样式
      className: "ve-common-table", //表格自定义类名
      tableLayout: "fixed", //设置表格单元、行和列的布局方式
      scrollbarAlwaysOn: false, //总是显示滚动条
      flexible: false, //确保主轴的最小尺寸
      headerRowClassName: "ve-common-table-header",
    };
    const getBind = () => {
      return Object.assign(defaultAttrs, attrs);
    };

    const rowStyle = ({ row }, index) => {
      if (row?.no?.indexOf(".") > -1) {
        return {
          backgroundColor: "#FBFBFE",
        };
      }
    };
    const tableRef = ref(null);

    return {
      tableRef,
      getBind,
      slots,
      rowStyle,
    };
  },
};
</script>
<style lang="scss" scoped>
.ve-common-table {
  color: #5c5f66;

  :deep(.ve-common-table-header) {
    th {
      background: #f4f7f9;
      color: #3c3e41;
      font-weight: normal;
    }
  }

  :deep(.el-table__body-wrapper)
    > .el-scrollbar
    > .el-scrollbar__wrap--hidden-default
    > .el-scrollbar__view {
    height: 100%;
  }

  .elplus-table__body tr.elplus-table__row--striped td.elplus-table__cell {
    background: rgb(251, 251, 254);
  }
}
</style>
/**
 * 全局注册组件
 * 使用方法:
 * 1、main.js文件引入
 * import veBaseComponents from "@/components/veBaseComponents";
 * 2、在app实例上注册使用
 * const app= createApp(App);
 * app.use(veBaseComponents);
 */
export default {
  install: (app) => {
    const files = require.context(
      "@/components/veBaseComponents",
      false,
      /\.vue$/
    );

    files.keys().forEach((key) => {
      // 获取组件配置
      const componentConfig = files(key);
      // 全局注册组件
      app.component(
        componentConfig.default.name,
        componentConfig.default
      );
    });
  },
};

搜索组件

<template>
  <!-- <el-form :model="form" :label-width="80" :inline="true" ref="formRef" class="searchForm"> -->
  <el-form
    :model="form"
    :inline="true"
    ref="formRef"
    class="searchForm"
    @keydown.enter="handleEnter"
  >
    <el-row :gutter="20">
      <el-col
        style="height: 35px"
        v-for="item in config"
        :md="8"
        :lg="4"
        :key="item.label"
      >
        <el-form-item :label="item.label" :prop="item.prop" style="width: 100%">
          <el-select
            v-if="item.type === 'select'"
            v-model="form[item.prop]"
            style="width: 100%"
            clearable
            :placeholder="item.placeholder || '请选择内容'"
          >
            <el-option
              v-for="(opt, index) in item.options"
              :label="opt.label"
              :value="opt.value"
              :key="index"
            />
          </el-select>
          <el-input
            v-else-if="item.type === 'input'"
            :disabled="item.disabled"
            :placeholder="item.placeholder || '请输入内容'"
            v-model="form[item.prop]"
            clearable
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col style="height: 35px" :md="8" :lg="4">
        <el-form-item>
          <el-space>
            <el-button type="primary" plain @click="handleConfirm"
              >查询</el-button
            >
            <el-button type="info" plain @click="handleReset">重置</el-button>
          </el-space>
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>
 
<script setup>
import { reactive, ref } from "vue";
const { config } = defineProps({
  config: {
    type: Array,
    default: () => {
      return [];
    },
  },
});
const form = reactive({});
const emits = defineEmits(["search"]);
const handleConfirm = () => emits("search", { ...form });
const formRef = ref();
const handleReset = () => {
  formRef.value.resetFields();
  emits("search", { ...form });
};
const handleEnter = (e) => {
  e.preventDefault();
  emits("search", { ...form });
};
</script>
<style lang="scss" scoped>
.searchForm {
  height: 64px;
  background-color: #fbfbfe;
  .el-row {
    padding: 15px 20px;
  }
  :deep(.el-select) {
    .el-input {
      width: 100% !important;
    }
  }
}
</style>