likes
comments
collection
share

解决后台管理系统痛点:Vue 3 下的可配置表格组件实践

作者站长头像
站长
· 阅读数 2

简介

在后台管理系统开发中,表格是一个不可或缺的核心组件,针对不同的业务需求,往往需要编写大量相似的表格代码,这降低了开发效率,也增加了维护的成本。

接下来,我将介绍如何使用Vue3来封装一个可配置的表格组件,提高开发效率和代码质量。

【主要解决了一下几个痛点】

  1. 通过json配置:支持通过json配置表格,并且支持拓展组件的所有props和event。
  2. 搜索表单和表格查询的联动:当搜索表单点击确认搜索时,表格自动更新展示数据。
  3. 自动处理分页:协商统一的接口数据格式,在组件内部自动处理分页数据。
  4. 自定义插槽: 支持自定义表格样式和功能,包括表头、内容、搜索插槽等。
  5. 填充url参数:自动填充url参数到搜索表单,实现返回后,仍能保持历史查询条件更新表格数据。

示例

我们先看一下简单的使用示例:options为表格的配置,columns为表格的列

<template>
  <div class="container">
    <ProTable :options="tableOptions" :columns="tableColumns">
      <template #tools>
        <el-space :alignment="'normal'">
          <el-button type="primary">新增</el-button>
          <el-button>导出</el-button>
        </el-space>
      </template>

      <template #name-default="{ row }">
        {{ row?.name }}
      </template>

      <template #name-header>自定义表头</template>

      <template #operations-default>
        <el-space>
          <el-button type="danger" plain size="small">删除</el-button>
          <el-button type="primary" size="small">编辑</el-button>
        </el-space>
      </template>
    </ProTable>
  </div>
</template>

实现效果:

解决后台管理系统痛点:Vue 3 下的可配置表格组件实践

配置

表格配置项:支持传入请求api或者直接是表格的数据。

// 表格数据
const tableData = [
  { id: 1, name: "zs", address: "广东省惠州市" },
  { id: 2, name: "ls", address: "广东省深圳市" },
  { id: 3, name: "ww", address: "广东省广州市" },
]

直接传入data数据

// 直接传入data数据
const tableOptions = reactive({
  data: tableData
});

传入请求api:支持有分页或无分页的接口请求,并且分页数据结构可以与后端人员协商统一字段返回。

// 直接请求api
// 带分页
const tableOptions = reactive({
  api: () => {
      return new Promise(resolve => {
          resolve({ 
              success: true,
              data: {
                  list: tableData,
                  pagination: {
                    page: 1,
                    pageSize: 10,
                    total: tableData.length,
                  },
              }
          });
      })
  }
});

// 不带分页
const tableOptions = reactive({
  api: () => {
      return new Promise(resolve => {
          resolve({ 
              success: true,
              data: tableData
          });
      })
  }
});

表格列:支持表格列的所有属性和事件。支持隐藏列、配置查询表单,查询默认值,支持自定义表头和插槽。

const tableColumns = [
  {
    prop: "status",
    label: "状态",
    formatter: () => "正常",
    search: true,
    searchDefaultValue: "0",
    searchType: "select",
    searchProps: {
      placeholder: "请选择",
      options: statusOptions,
    },
  },
  {
    prop: "name",
    label: "姓名",
    formatter: (row) => row.name,
    search: true,
    searchProps: {
      placeholder: "请输入",
    },
  },
  {
    prop: "age",
    label: "年龄",
    formatter: () => "12",
  },
  {
    prop: "sex",
    label: "性别",
    formatter: () => "男",
  },
  {
    prop: "address",
    label: "地址",
    search: true,
    searchDefaultValue: [],
    searchType: "cascader",
    searchProps: {
      placeholder: "请选择",
      options: cascaderOptions,
    },
  },
  {
    prop: "created_at",
    label: "创建时间",
    formatter: () => "2024-03-25 12:00:00",
    search: true,
    searchType: "datePicker",
    searchProps: {
      placeholder: "请选择",
      type: "daterange",
      startPlaceholder: "开始时间",
      endPlaceholder: "结束时间",
      valueFormat: "YYYY-MM-DD",
    },
  },
  { prop: "operations", label: "操作", width: 200, align: "center" },
];

ProTable

<template>
  <div v-loading="loading">
    <el-table :data="tableData" border stripe v-bind="options.tableProps">
      <el-table-column
        v-for="column in columns"
        :key="column.prop"
        v-bind="column"
      >
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { defineProps, ref } from "vue";

const props = defineProps({
  columns: {
    type: Array,
    required: false,
  },
  options: {
    type: Object,
    default: () => ({
      tableProps: {},
    }),
  },
});

const loading = ref(false);

const tableData = ref([]);
</script>

<style lang="less" scoped></style>

处理隐藏的列

在传入的column中,支持传入hide参数,当hide=true时,隐藏改列。

<el-table-column
 v-for="column in visibleColumns"
>
...
</el-table-column>

过滤隐藏的列

// 过滤隐藏的列
const visibleColumns = computed(() => {
 return props.columns.filter((column) => !column.hide);
});

分页处理

为了实现分页功能,我们在组件内部定义了 pagination 分页参数,并通过 v-bind 绑定到 options.paginationProps,从而支持所有的 el-pagination 属性和事件。

<div v-loading="loading">
    ...
    <el-pagination
      background
      style="margin-top: 20px"
      v-model:current-page="pagination.page"
      v-model:page-size="pagination.pageSize"
      :total="pagination.total"
      :page-sizes="[10, 20, 30, 40, 50]"
      layout="prev, pager, next,  sizes, jumper, total"
      v-bind="options.paginationProps"
/>
</div>
const pagination = reactive({
  page: 1,
  pageSize: 10,
  total: 0,
});

获取表格数据

获取请求参数:通过options.requestParams可以传递请求时的自定义参数,拼接分页参数进行接口查询。

// 获取请求参数
const getRequestParams = () => {
  const { page, pageSize } = pagination;
  const { options } = props;
  // 合并分页参数和自定义参数
  const params = Object.assign({ page, pageSize }, options.requestParams);
  return params;
};

获取请求数据:配置选项可以直接传入表格数据 data,也可以传入表格请求的 api,组件会优先选择使用表格数据。

// 获取表格数据
const getTableData = async () => {
  const { options } = props;
  const params = getRequestParams();
  
  // 过滤 undefined和null
  Object.keys(params).forEach((key) => {
    if (typeof params[key] === "undefined" ||  params[key] == null) {
      delete params[key];
    }
  });

  if (options.data) {
    // 优先接收传入的data
    tableData.value = options.data;
    pagination.total = options.data.length;
  } else if (options.api) {
    // 请求传入的api
    loading.value = true;
    const { success, data } = await options.api(params);
    loading.value = false;
    if (!success) {
      return;
    }
    handleResponseData(data);
  }
};

处理请求数据api 支持直接返回所有数据,也可以返回分页数据。当返回的数据中包含分页信息时,表示为分页请求。您可以根据接口的格式自行进行修改。

// 处理请求数据
const handleResponseData = (data) => {
  if ("pagination" in data) {
    // 存在分页信息
    tableData.value = data.list;
    pagination.total = data.pagination.total;
  } else {
    // 不存在分页信息的情况
    tableData.value = data;
    pagination.total = data.length;
  }
};

处理切换分页

<el-pagination
  ...
  @change="handlePaginationChange"
/>
// 切换分页
const handlePaginationChange = () => {
  getTableData();
};

自定义插槽: 组件内部传入prop+-default为自定义内容,prop+-header为自定义表头,组件还额外支持了tools插槽。

子组件:

<div v-if="$slots['tools']" style="margin-bottom: 20px">
  <slot name="tools"></slot>
</div>

<el-table :data="tableData" border stripe v-bind="options.tableProps">
  <el-table-column
    v-for="column in visibleColumns"
    :key="column.prop"
    v-bind="column"
  >
    <!--      自定义插槽渲染-->
    <template #default="scope" v-if="$slots[column.prop + '-default']">
      <slot :name="column.prop + '-default'" v-bind="scope"></slot>
    </template>

    <!--      自定表头插槽渲染-->
    <template #header="scope" v-if="$slots[column.prop + '-header']">
      <slot :name="column.prop + '-header'" v-bind="scope"></slot>
    </template>
  </el-table-column>
</el-table>

父组件调用:

<ProTable :options="tableOptions" :columns="tableColumns">
  <template #tools>
    <el-space :alignment="'normal'">
      <el-button type="primary">新增</el-button>
      <el-button>导出</el-button>
    </el-space>
  </template>

  <template #name-default="{ row }"> 自定义内容"{{ row.name }}" </template>

  <template #name-header>自定义表头</template>

  <template #operations-default>
    <el-space>
      <el-button type="danger" plain size="small">删除</el-button>
      <el-button type="primary" size="small">编辑</el-button>
    </el-space>
  </template>
</ProTable>

查询表单

如何使用:: searchProps为查询组件的props,可以设置查询默认值,组件类型。

const tableColumns = [
...
{
  prop: "status",
  label: "状态",
  formatter: () => "正常",
  search: true,
  searchDefaultValue: "0",
  searchType: "select",
  searchProps: {
    placeholder: "请选择",
    options: statusOptions.value,
  },
}
]

需要查询的列:在传入的column中,支持传入search参数,当search=true时,表示需要参与查询。

// 查询的列
const searchColumns = computed(() => {
  return props.columns.filter((column) => column.search);
});

设置表单默认值:在传入的column中,支持传入searchDefaultValue参数,该参数为查询时的默认值。

// ProTable.vue

const formValue = ref({});

// 设置表单默认值
const setFormDefaultValue = () => {
  const target = {};
  searchColumns.value.forEach((item) => {
    let value = item.searchDefaultValue;
    target[item.prop] = value;
  });
  formValue.value = target;
};

onMounted(() => {
  setFormDefaultValue();
  getTableData();
});

const updateFormValue = (value) => {
  formValue.value = value;
  getTableData();
};

定义查询表单组件SearchForm

  • 表单目前仅支持input,select, datePicker,cascader组件,如需其他组件,可自行添加。
  • 支持重置表单内容
<template>
  <el-form
    :model="form"
    label-width="auto"
    label-suffix=":"
    class="pro-form"
  >
    <el-row :gutter="20">
      <el-col :span="6" v-for="item in columns" :key="item.prop">
        <el-form-item :label="item.label" :prop="item.prop">
          <component
            :is="comMap[item.searchType || 'input']"
            style="width: 100%"
            clearable
            v-bind="item.searchProps"
            v-model="form[item.prop]"
          >
            <template v-if="item.searchType === 'select'">
              <el-option
                v-for="option in item.searchProps.options"
                :key="option.value"
                :value="option.value"
                :label="option.label"
              />
            </template>

            <template v-if="item.searchType === 'cascader'" #default="{ data }">
              <span>{{ data.label }}</span>
            </template>
          </component>
        </el-form-item>
      </el-col>
    </el-row>

    <div style="margin-top: 20px; text-align: center">
      <el-space>
        <el-button @click="handleResetSearchForm">重置</el-button>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
      </el-space>
    </div>
  </el-form>
</template>

<script setup>
import {
  ElForm,
  ElInput,
  ElSelect,
  ElDatePicker,
  ElCascader,
} from "element-plus";
import { defineProps, ref, defineEmits, watch } from "vue";

const props = defineProps({
  columns: {
    type: Array,
    required: false,
  },
  formValue: {
    type: Object,
    default: () => ({
      tableProps: {},
    }),
  },
});

const emit = defineEmits(["updateFormValue"]);

const comMap = {
  input: ElInput,
  select: ElSelect,
  datePicker: ElDatePicker,
  cascader: ElCascader,
};

const form = ref({});

watch(
  [() => props.formValue],
  () => {
    form.value = props.formValue;
  },
  { immediate: true }
);

// 重置查询表单
const handleResetSearchForm = () => {
  const map = {};
  for (const item of props.columns) {
    map[item.prop] = item.searchDefaultValue;
  }
  Object.keys(form.value).forEach((key) => {
    form.value[key] = map[key];
  });
  handleSearch();
};

// 点击搜索
const handleSearch = () => {
  const params = {};
  // 过滤空字符串
  Object.keys(form.value).forEach((key) => {
    params[key] = form.value[key];
  });
  emit("updateFormValue", params);
};
</script>

<style scoped lang="less"></style>

调整ProTable组件

使用查询表单

// ProTable.vue

<SearchForm
  :columns="searchColumns"
  :form-value="formValue"
  style="margin-bottom: 20px"
  v-if="searchColumns.length"
  @update-form-value="updateFormValue"
/>

携带查询参数

// ProTable.vue
// 获取请求参数
const getRequestParams = () => {
    ...
  // 合并分页、查询参数和自定义参数
  const params = Object.assign(
    { page, pageSize },
    formValue.value,
    options.requestParams
  );
  ...
};

使用useUrlState

使用自定义的hook,useUrlState实现刷新页面,也能保存页面查询条件。

// ProTable.vue
import useUrlState from "@/hoooks/useUrlState";

// 获取表格数据
const getTableData = async () => {
  ...
  const params = getRequestParams();

  setState(params);
  ...
};

onMounted(() => {
  ...
  if (state.value?.page) {
    pagination.page = +state.value?.page;
  }
  if (state.value?.pageSize) {
    pagination.pageSize = +state.value?.pageSize;
  }
  ...
});

// 设置表单默认值
const setFormDefaultValue = () => {
 ...
  searchColumns.value.forEach((item) => {
    let value = item.searchDefaultValue;
    // 优先重url获取参数
    if (state.value[item.prop]) {
      value = state.value[item.prop];
    }
    target[item.prop] = value;
  });
  ...
};

暴露方法

目前仅暴露了刷新表格的方法

const refresh = () => {
  getTableData();
};

defineExpose({
  refresh,
});

源代码

感兴趣的可以看看github上的源码,直接装包,然后运行可以看到运行结果。

github源码

总结

如果你对文章中的内容感兴趣,可以在 GitHub 上查看源代码。由于功能相对简单,你可以根据自己的业务需求进行修改。希望本文对你有所帮助。