antd-vue table 封装,支持表头排序、隐藏、查询

3,257 阅读5分钟

效果: 

一、背景

产品需求:表格支持勾选显示隐藏、拖拽排序、表头搜索。其实也就是 antdpro 的那个 proTable

只是目前前端团队(我和另外一个小盆友)已经改用vue3,且不在使用开箱即用的方案。

作为职业ctrl+CV选手。本人肯定先百度,可惜没找到合适的。想了下,还是自己动手。

二、原理&规划

目前团队使用的ui库是,antd-vue 

他的table组件使用方式:

表头数据和表格数据本身就已经抽离了,那么表头本身的展示,其实就是表头数据的处理

我们只需要封装一层table 内部处理一次 表头,就好了。

下面是antd-vue,table基础使用代码:

<template>
  <a-table :dataSource="dataSource" :columns="columns" />
</template>
<script>
  export default {
    setup() {
      return {
        dataSource: [
          {
            key: '1',
            name: 'Mike',
            age: 32,
            address: '10 Downing Street',
          },
          ...
        ],

        columns: [
          {
            title: 'Name',
            dataIndex: 'name',
            key: 'name',
          },
          ...
        ],
      };
    },
  };
</script>

三、实操

①、表格基础继承

为了让之前的表格不需要有大改动就能顺利调整完。

为了保证表格组件使用和antdvue一致。

主要为了,懒!所以,以前的东西尽量不动它!

下面是继承核心代码:

<a-table   v-bind="$attrs"  >  
	<!-- 透传其他的 slot --> 
	<template v-for="(item, key, index) in $slots" :key="index" v-slot:[key]="item">     		<slot :name="key" v-bind="item"></slot> 
	</template>
</a-table>  

②、tools功能组件及产生的setting数据设计

写一个setting组件。外部传入columns,内部拖拽、勾选啥的,控制显示隐藏。

首先是setting数据格式的设计:

键值对。key为表头的唯一标识 dataIndex。value 是{ order: 顺序, show: 显示、隐藏 }

其实应该用hide比较好,不过既然都写了,就懒得改了,就这样吧

{
    "createAt": {
      "order": 8, // 排序
      "show": false // 显示隐藏,show为undefined 也是显示
    },
    "name": {
      "order": 4
    },
    ...
  }

下面是setting组件代码:(拖拽用的 vuedraggable)

<script setup lang="ts">
import { PropType } from 'vue';
import { TableColumnProps } from 'ant-design-vue';
import Draggable from 'vuedraggable';

const props = defineProps({
  setting: {
    type: Object,
    default: {},
  },
  columns: {
    type: Object as PropType<TableColumnProps[]>,// [{ valueType: 'select'| 'radio'| 'input' }]
    default: [],
  }, 
  onChange: { // 修改columns
    type: Function,
    default: () => {},
  }
});

// 勾选控制展示
const handleCheckShow = (value, record) => {
  props.onChange({ ...props.setting, [record.dataIndex]: {
    ...props.setting[record.dataIndex],
    show: value,
  } });
}
// 判断显影
const getSet = (dataIndex) => {
  const { show } = props.setting[dataIndex] || {};
  return show === true || show === undefined;
}
// 拖拽完,更是setting的排序 order数据
const handleEnd = () => {
  const settingNew = { ...props.setting };
  props.columns.forEach((item: any, index) => {
    settingNew[item.dataIndex] = {
      ...settingNew[item.dataIndex],
      order: index,
    }
  });
  props.onChange(settingNew);
}

</script>

<template>
<draggable 
  :list="props.columns" 
  @end="handleEnd"
  group="people" 
  item-key="dataIndex">
  <template #item="{element}">
    <p>
      <a-checkbox
        :checked="getSet(element.dataIndex)"
        :disabled="element.tool?.disabled"
        @change="(e) => handleCheckShow(e.target.checked, element)"
        >
        {{element.title}}
      </a-checkbox>
    </p>
   </template>
</draggable>
</template>

<style>

</style>

③、新的columns处理

columns其实算下来有3个:

(1)columnsInit: 初始的表头数据,前端配置的所有的项

(2)columns:前端处理过的数据。添加setting数据,根据valueType处理表头

(3)columnsEnd: a-table使用的数据,是columns根据 setting里面的show进行过滤后的数据;

逻辑核心:columns处理

流程:

(1)、监听外部的columns和 setting数据

(2)、整合一下  ——>铛!铛!铛!完成!(完美)

下面是我抽离一个的hook;

下面代码这么长的原因:我在整理 cloumns新数据时,把表头的几种过滤(输入、下拉、单选、时间范围等),也写到这儿了。(下面扩展会讲)

import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { saveTabelStatus, getTabelStatus } from '@/utils/localStorageJson';

// setting 是否展示,字段排序
const useColumn = (props, setting: Record<string, any>) => {
  const columnsInit = props.columns;
  const columns = ref<any[]>([]);
  const filterValueType = ref<Record<string, any>>({});
  // 处理colums,主要处理 valueType问题
  const handleDealColumn = (data, settingData) => {
    let orderTrue = true;
    let attr = (data || []).map((item) => {
      // 这里是核心了,把setting 数据,根据dataIndex放到每个columns,item里面。
      const { show, order } = (settingData || {})[item.dataIndex] || {};
      orderTrue = orderTrue || !!order;
      const obj = {
        ...item,
        order,
        show,
      };
      filterValueType.value[obj.dataIndex] = obj.valueType;
      switch(obj.valueType) {
        case 'select':
          obj.filters = (obj.options || []).map((item) => ({
            text: item.value,
            value: item.key,
          })),
          obj.filterSearch = true;
        break;
        case 'radio':
          obj.filters = (obj.options || []).map((item) => ({
            text: item.value,
            value: item.key,
          })),
          obj.filterSearch = true;
          obj.filterMultiple = false;
        break;
        case 'input':
          obj.customFilterDropdown = true;
        break;
        case 'dateTime':
          obj.customFilterDropdown = true;
        break;
      }
      return obj;
    });
    if (orderTrue) {
      attr.sort((a, b) => a.order - b.order);
    }
    columns.value = attr;
  };
  handleDealColumn(columnsInit, setting.value);

  watch(() => props.columns, (data) => {
    handleDealColumn(data, setting);
  });

  watch(setting, (data) => {
    handleDealColumn(columnsInit, data);
  });

  // 最终展示的columns
  const columnsEnd = computed(() => columns.value.filter((item) => item.show !== false));
  return {
    columns,
    columnsEnd,
    filterValueType,
  }
};

export {
  useColumn,
}

④、a-table使用columnsEnd setting组件使用columns 

<Setting
    :setting="setting"
    :columns="columns"    :onChange="onChange"
/>
<a-table
      :columns="columnsEnd"
      :dataSource="dataSource"
      :pagination="pagination"
      :loading="loading"
      @change="handleChangeTable"
      v-bind="$attrs"
    >

⑤、其实算下来核心已经讲完了,不过

既然都自己封装了。那么偷懒的事情怎么能只搞这么一点。

四、扩展一 :表头赛选、过滤

①、实现目标

搜索过滤、下拉过滤、时间区间等

之前使用,antdpro的时候,columns里面有个参数还是比较好用的——valueType。这次我也按照它的搞。

②、实现

首先是我们的columnsInit数据配置:

valueType的值:基本使用antdvue组件的名字。(懒得重新定义,麻烦)

多选、单选等需要提供 options 作为选项 [{key: '', value: ''}]

const columnsInit = [
    {
        title: '项目名称',
        dataIndex: 'name',
        valueType: 'input',
    },
    {
        title: '项目状态',
        dataIndex: 'status',
        options: PROJECT_STATUS,
        valueType: 'select',
    },
    {
        title: '计划启动时间',
        dataIndex: 'createAt',
        valueType: 'dateTime',
    },
    ...
];

valueType需要先处理成 a-table认识的样式。具体的a-table相关参数有:

1、customFilterDropdown(自定义筛选菜单,需要配合 column.customFilterDropdown 使用)

2、customFilterIcon(自定义筛选图标)

3、filters (表头的筛选菜单项)

4、filterSearch (筛选菜单项是否可搜索)

5、filterMultiple (是否多选)

3、4、5主要用于,单选、多选的情况。

其他的输入查询、时间区间查询,需要使用1、2自定节点。

下面是columnsInit数据处理dataType。

// 处理colums,主要处理 valueType问题
  const handleDealColumn = (data, settingData) => {
    let orderTrue = true;
    let attr = (data || []).map((item) => {
      const obj = {
        ...item,
      };
      filterValueType.value[obj.dataIndex] = obj.valueType;
      switch(obj.valueType) {
        case 'select':
          obj.filters = (obj.options || []).map((item) => ({
            text: item.value,
            value: item.key,
          })),
          obj.filterSearch = true;
        break;
        case 'radio':
          obj.filters = (obj.options || []).map((item) => ({
            text: item.value,
            value: item.key,
          })),
          obj.filterSearch = true;
          obj.filterMultiple = false;
        break;
        case 'input':
          obj.customFilterDropdown = true;
        break;
        case 'dateTime':
          obj.customFilterDropdown = true;
        break;
      }
      return obj;
    });

下面是,a-table内,template自定义过滤组件

<script setup lang="ts">
import { reactive } from 'vue';

defineProps({
  selectedKeys: {
    type: Object,// [{ valueType: 'select'| 'radio'| 'input' | 'dateTime' }]
    default: [],
  }, 
  setSelectedKeys: { // 修改columns
    type: Function,
    default: () => {},
  },
  confirm: { // 修改columns
    type: Function,
    default: () => {},
  },
  column: {
    type: Object,
    default: {},
  },
  clearFilters: { // 修改columns
    type: Function,
    default: () => {},
  },
});

const state = reactive({
  searchText: '',
  searchedColumn: undefined,
});
// 列表筛选项输入框
const handleSearch = (selectedKeys, confirm, dataIndex) => {
    confirm();
    state.searchText = selectedKeys[0];
    state.searchedColumn = dataIndex;
};
// 筛选项重置
const handleReset = clearFilters => {
    clearFilters({ confirm: true });
    state.searchText = '';
};

</script>

<template>
  <div class="p-2">
    <!-- 日期 -->
    <a-range-picker
      show-time
      :placeholder="['开始时间', '结束时间']"
      v-if="column.valueType === 'dateTime'" 
      format="YYYY-MM-DD HH:mm:ss" 
      :value="selectedKeys[0]"
      @change="dateString => setSelectedKeys(dateString ? [dateString] : [])" />
    <!-- 输入框 -->
    <a-input
        v-if="column.valueType === 'input'" 
        :placeholder="`查询${column.title}`"
        :value="selectedKeys[0]"
        class="w-[188px] mb-2 block"
        @change="e => setSelectedKeys(e.target.value ? [e.target.value] : [])"
        @pressEnter="handleSearch(selectedKeys, confirm, column.dataIndex)"/>
    <div class="mt-3 flex justify-between">
        <a-button
          size="small"
          :disabled="!selectedKeys[0]"
          @click="handleReset(clearFilters)"
          type="link"
        >重置</a-button>
        <a-button
          type="primary"
          size="small"
          @click="handleSearch(selectedKeys, confirm, column.dataIndex)"
        >确定</a-button>
    </div>
</div>
</template>

<style>

</style>

五、扩展二 :内置查询

其实列表查询结构基本一致。返回结构也是一致的。

为了偷懒,为了每天不加班。“一致”也就意味着可以封装

①、入参逻辑设计

const props = defineProps({
  ...
  services: Function, // 内部请求直接使用
  onSearch: {  // 外部传入时,传出去的 查询方法
    type: Function,
    default: () => {},
  },
  searchParams: Object,
});

核心2个参数。

searchParams: 内部pageSize,current字段不够,需要额外字段时,的外部传入参数。

services: 写好的,promise请求对象。

onSearch: 未传入 services情况下。内部切换页码,表头过滤时,触发外部的方法

// 内部的searchParams 包含页码和表头过滤项
const searchParams = ref<Record<string, any>>({
  current: 1,
  pageSize: 10,
  ...props.searchParams,
});
// useGetTableData是自己封装的一个请求的hook处理些自定义问题
const {
    data: dataSource,
    run: getTable,
    pagination,
    loading,
} = useGetTableData(props.services, {
  manual: !props.services || !!props.searchParams,
});
// 外界有params入参时,初始请求,使用整合后的入参
if (props.services && !!props.searchParams) {
  getTable({
    ...searchParams.value,
    ...props.searchParams,
  });
}
// 监听searchParams 触发表格请求
watch(searchParams, (data) => {
  if (props.services) {
    getTable(data);
  } else {
    props.onSearch(data);
  }
});
// 监听外部seachParams 更新自己searchParams。
watch(() => props.searchParams, (data) => {
  searchParams.value = {
    ...searchParams.value,
    ...data,
  }
});

最后再把更新table的方法暴露出去,方便外面手动刷新table

defineExpose({
  getTable: () => getTable({ ...searchParams.value, ...props.searchParams }),
});

六、最后看下使用

<script setup lang="ts">
import { ref } from 'vue';
import { slmListProject } from '@/services/SLM';
import dayjs from 'dayjs';
import ModalVue from './Modal.vue';
import { PROJECT_STATUS_OBJ, PROJECT_STATUS, PROJECT_PRIVACY_TYPE_OBJ } from '@/contants';

const gTableRef = ref<any>(null);
const columnsInit = [
    {
        title: '项目名称',
        dataIndex: 'name',
        valueType: 'input',
    },
    {
        title: '项目状态',
        dataIndex: 'status',
        options: PROJECT_STATUS,
        valueType: 'select',
    },
    {
        title: '计划启动时间',
        dataIndex: 'createAt',
        valueType: 'dateTime',
    },
     title: '操作',
        dataIndex: 'oprations',
    },
];

const visible = ref<boolean>(false);
const modalData = ref<Record<string, any>>();

</script>

<template>
<div>
  <g-table
    ref="gTableRef"
    :columns="columnsInit"
    :services="slmListProject"
  >
  <template #toolLeft>
    项目列表
  </template>
  <template #toolRight>
    <a-button type="primary" class="mb-2 mr-2" @click="() => visible = true">新建项目</a-button>
  </template>
  <template #bodyCell="{ column, text, record }">
      <template v-if="column.dataIndex === 'oprations'">
        <a-button class="mr-2" type="primary" size="small" @click="() => {
          visible = true;
          modalData = record;
        }" disabled>详情</a-button>
      </template>
   </template>
  </g-table>
</div>
<ModalVue
  :visible="visible"
  :data="modalData"
  :onOk="() => gTableRef.getTable()"
  :onCancel="() => {
    visible= false;
    modalData = undefined;
  }"
/>
</template>

<style>
</style>

七、完整的g-table组件

setting组件、CustomFilterDropdown组件、useColumn在上面文章中

g-table记得注册全局(偷懒程度 + 1)

<!-- 公共table组件 -->
<script setup lang="ts">
import { ref, watch } from 'vue';
import { SettingOutlined, SearchOutlined, FilterFilled } from '@ant-design/icons-vue';
import { useColumn, useGetTableSet } from './hooks';
import useGetTableData from '@/hooks/useGetTableData';
import dayjs from 'dayjs';
import Setting from './Setting.vue';
import CustomFilterDropdown from './CustomFilterDropdown.vue';

const props = defineProps({
  name: String, // 每个table的名称,可以用于setting 的key
  columns: Array, // [{ valueType: 'select'| 'radio'| 'input' | 'dateTime' }]
  tools: {
    type: Object,
    default: ['setting'],
  },
  services: Function, // 内部请求直接使用
  onSearch: {  // 外部传入时,传出去的 查询方法
    type: Function,
    default: () => {},
  },
  searchParams: Object,
});

const { setting, onChange } = useGetTableSet(props.name);

const {
  columns: columnsInit,
  columnsEnd,
  filterValueType,
} = useColumn(props, setting);

const searchParams = ref<Record<string, any>>({
  current: 1,
  pageSize: 10,
  ...props.searchParams,
});

const handleChangeTable = (params, filters) => {
  const newParams = {
    ...params,
    ...filters,
  };
  const {
    showLessItems,
    showQuickJumper,
    total,
    ...pro
  } = newParams;
  for(let key in pro) {
    // 单选、输入框啥的、把值处理下
    if(['input', 'radio', 'dateTime'].includes(filterValueType.value[key])) {
      pro[key] = pro[key] && pro[key].length > 0 ? pro[key][0] : undefined;
      // 时间值,转为时间戳
      if (filterValueType.value[key] === 'dateTime' && pro[key]) {
        pro[key] = pro[key].map((item) => dayjs(item).unix());
      }
    }
  }
  searchParams.value = pro;
};

const {
    data: dataSource,
    run: getTable,
    pagination,
    loading,
} = useGetTableData(props.services, {
  manual: !props.services || !!props.searchParams,
});
// 外界有params入参时,初始请求,使用整合后的入参
if (props.services && !!props.searchParams) {
  getTable({
    ...searchParams.value,
    ...props.searchParams,
  });
}

watch(searchParams, (data) => {
  if (props.services) {
    getTable(data);
  } else {
    props.onSearch(data);
  }
});
watch(() => props.searchParams, (data) => {
  searchParams.value = {
    ...searchParams.value,
    ...data,
  }
});

defineExpose({
  getTable: () => getTable({ ...searchParams.value, ...props.searchParams }),
});

</script>

<template>
  <div class="p-4">
    <div class="flex items-center">
      <div class="flex-1">
        <slot name="toolLeft"></slot>
      </div>
      <div v-if="!!props.tools" class="flex">
        <slot name="toolRight"></slot>
        <div v-for="item in props.tools" :key="item" class="flex items-center">
            <template v-if="item === 'setting'">
              <a-popover title="勾选展示">
                <template #content>
                  <Setting
                    :setting="setting"
                    :columns="columnsInit"
                    :onChange="onChange"
                  />
                </template>
                <SettingOutlined class="pb-4" />
              </a-popover>
            </template>
        </div>
      </div>
    </div>
    <!--外部传入dataSource时,会覆盖dataSource  -->
    <a-table
      :columns="columnsEnd"
      :dataSource="dataSource"
      :pagination="pagination"
      :loading="loading"
      @change="handleChangeTable"
      v-bind="$attrs"
    >
    <template #customFilterDropdown="{ setSelectedKeys, selectedKeys, confirm, clearFilters, column }">
      <CustomFilterDropdown
        :setSelectedKeys="setSelectedKeys"
        :selectedKeys="selectedKeys"
        :confirm="confirm"
        :clearFilters="clearFilters"
        :column="column"
      />
    </template>
    <template #customFilterIcon="{ filtered, column }">
      <SearchOutlined
        v-if="['dateTime', 'input'].includes(column.valueType)"
        :style="{ color: filtered ? '#108ee9' : undefined }"
      />
      <FilterFilled
        v-else
        :style="{ color: filtered ? '#108ee9' : undefined }"
        />
    </template>
    <!-- 透传其他的 slot -->
      <template v-for="(item, key, index) in $slots" :key="index" v-slot:[key]="item">
        <slot :name="key" v-bind="item"></slot>
      </template>
    </a-table>  
  </div>
</template>

<style>

</style>

哦哦想起来了,还有setting数据的存储问题。按道理:这数据应该存在后台,挂在每个用户下面。

不过目前后端没时间搞。我就先搞到localstorage里面了。

localstorage的key值,懒得写。可以传,不传就拿路由当key值。

// 获取table配置(勾选是否展示,字段排序)
// 监听路由,获取每个表格对应的key。这个key前期前端存 localstorge 后期走接口的唯一标识
const useGetTableSet = (key: string = '') => {
  const { currentRoute } = useRouter();
  const path = currentRoute.value.path;
  const setting = ref<Record<string, any>>({}); // 数据格式 {order: '', dataIndex: '', show: false/true }
  const onChange = (data:Record<string, any>) => {
    saveTabelStatus(key || path, data);
    setting.value = data;
  }
  watch(currentRoute, (data) => {
    if (!key) {
      setting.value = getTabelStatus(data.path);
    }
  });
  // 初始化
  setting.value = getTabelStatus(key || path);
  return {
    setting,
    onChange,
  }
}



const MY_TABLE = 'myTable';

// localstorage存储为 json时
const getLocalJson = (table: string) => {
  const myTableStr = localStorage.getItem(table);
  const myTable = myTableStr ? JSON.parse(myTableStr) : {};
  return myTable;
};
const setLocalJson = (table: string, value?: Record<string, any>) => {
  const myTable = getLocalJson(table);
  localStorage.setItem(table, value ? JSON.stringify({ ...myTable, ...value }) : '');
};

// 获取 存储table 的表头等数据
const getTabelStatus = (key: string) => {
  return getLocalJson(MY_TABLE)[key];
};
const saveTabelStatus = (key: string, value: any) => {
  setLocalJson(MY_TABLE, { [key]: value });
};

export {
  getLocalJson,
  setLocalJson,
  getTabelStatus,
  saveTabelStatus,
};

八、总结

首先,代码里面挺水的,本来我自己用的,懒得时间细细琢磨。大家将就看看就行。

其次,还是那句话,主要提供封装业务思路:

1、不破坏原a-table的东西;

2、核心是,对columns的处理;但是setting数据单独存放,和columns分开。

3、反正自己封装,搞点扩展,valueType筛选过滤。

4、请求交互整合进去,但同时也提供组件外部自己处理方案。