玩转B端的表单与列表封装1--列表封装

1.前言

本系列文章主要分享个人在多年中后台前端开发中,对于表单与列表封装的一些探索以及实践.本系列分享是基于vue3+element-plus,设计方案可能无法满足所有人的需求,但是可以解决大部分人业务中的开发需求.主要还是希望通过分享能够得到一些新的反馈与启发,进一步完善改进,技术总是在分享中夯实己身,在反馈中不断成长.时间原因文章会不定期更新,有空就写.下面先展示一下一个完整的常见的表单+表格集成的列表页面开发的场景,然后再拆解ElTable表格的二次封装实现封装.

实现示例中效果所用的代码如下:

js 复制代码
1.列表页面代码如下:

 <template>
  <list-page v-bind="table">
    <template #expand="{ row }">
      <el-table :data="row.platforms" border stripe style="padding: 10px; width: 100%">
        <el-table-column label="平台名称" prop="name" />
        <el-table-column label="平台编码" prop="code" />
      </el-table>
    </template>
    <template #status="{ row }">
      <el-tag :type="row.status == 1 ? 'info' : 'danger'">{{ statusEnum[row.status] }}
      </el-tag>
    </template>
  </list-page>
</template>
<script setup lang="ts">
import { Toast, Dialog } from "@/core/adaptor";
import * as demoService from "@/api/demo-service";
import { createOrUpdateChannel, ChannelEnum } from "./formDialog";

const statusEnum: any = {
  0: "禁用",
  1: "启用",
};

const table = reactive({
  //支持el-table的所有属性
  props: {},
  //支持el-table的所有事件
  events: {},

  loader: (queryForm, pagenation): any => demoService.queryPage(queryForm, pagenation),
  //过滤条件选项
  filterItems: [
    {
      label: "渠道类型",
      field: "channelType",
      uiType: "selector",
      props: { options: ChannelEnum },
    },
    {
      label: "启用状态",
      field: "status",
      uiType: "selector",
      props: { options: statusEnum },
    },
    {
      label: "创建时间",
      field: ["stratTime", "endTime"],
      uiType: "dateTimePicker",
      props: {
        type: "daterange",
      },
    },
  ],

  columns: [
    { type: "selection", label: "全选" },
    { type: "index", label: "序号" },
    { type: "expand", label: "使用平台" },
    { label: "渠道名称", key: "channelName" },

    {
      label: "通知方式",
      key: "channelType",
      formatter: (row) => ChannelEnum[row.channelType],
    },
    {
      label: "密钥",
      text: "查看密钥",
      click: () => {
        Toast("查看密钥");
      },
    },
    { label: "启用状态", slot: "status" },
    { label: "创建时间", key: "createTime" },
    { label: "创建人", key: "createBy" },
    { label: "更新时间", key: "updateTime" },
    { label: "更新人", key: "updateBy" },
  ],
  toolbar: [
    {
      text: "新增消息渠道",
      click: (table: any, searchForm: any) => createOrUpdateChannel(null, table),
    },
    {
      text: "批量删除",
      click: (table: any) => {
        const rows = table.instance.getSelectionRows();
        if (rows.length == 0) {
          Toast.info(`请先选择要删除的数据`);
          return;
        }
        Dialog.confirm(
          `确定要删除消息渠道配置${rows.map((row) => row.channelName)}吗?`
        ).then((res) => {
          if (res != "confirm") {
            return;
          }
          table.refresh();
        });
      },
    },
  ],
  actions: [
    {
      text: "编辑",
      props: { type: "warning" },
      click: ({ row }: any, table: any) => createOrUpdateChannel(row, table),
    },
    {
      text: (row) => (row.status == 1 ? "禁用" : "启用"),
      props: (row) => (row.status == 1 ? { type: "danger" } : { type: "success" }),
      confirm: (row) => `确定${row.status == 1 ? "禁用" : "启用"}${row.channelName}吗?`,
      click: ({ row }: any, table: any, searchForm: any) => {
        demoService
          .update({ id: row.id, status: row.status == 1 ? 0 : 1 })
          .then(({ success, message }) => {
            const action = success ? "success" : "error";
            Toast[action](message);
            success && table.refresh();
          });
      },
    },
  ],
});
</script>


2.formDialog.ts(新增/更新弹窗代码)

import { createFormDialog } from "@/components/Dialogs";
import { Toast } from "@/core/adaptor";
import * as DemoService from "@/api/demo-service";

export const ChannelEnum: any = {
  sms: "短信通知",
  dingtalk: "钉钉通知",
  email: "邮件通知",
};

export const AccessTypeEnum: any = {
  webhook: "webhook",
  api: "api",
};

const DingtalkVisiable = (formData: any) => formData.channelType == "dingtalk";

const DingtalkApiVisiable = (formData: any) => {
  return (
    DingtalkVisiable(formData) && formData.accessType == AccessTypeEnum.api
  );
};

const DingtalkWebhookVisiable = (formData: any) => {
  return (
    DingtalkVisiable(formData) && formData.accessType == AccessTypeEnum.webhook
  );
};

const DingTalkFormItems = [
  {
    label: "接入方式",
    field: "accessType",
    visiable: DingtalkVisiable,
    uiType: "selector",
    props: {
      options: AccessTypeEnum,
    },
  },
  {
    label: "webhHook地址",
    field: "address",
    required: true,
    visiable: DingtalkWebhookVisiable,
    uiType: "input",
  },
  {
    label: "appKey",
    field: "appKey",
    visiable: DingtalkApiVisiable,
    uiType: "input",
  },
  {
    label: "appSecret",
    field: "appSecret",
    visiable: DingtalkApiVisiable,
    uiType: "input",
  },
  {
    label: "clientId",
    field: "clientId",
    visiable: DingtalkApiVisiable,
    uiType: "input",
  },
  {
    label: "钉钉群ID",
    field: "chatId",
    visiable: DingtalkApiVisiable,
    uiType: "input",
  },
];

/*******
 支持的规则描述
 interface RuleType {
  equals?: string;
  not?: string;
  in?: string;
  notIn?: string;
  includes?: string | string[];
  excludes?: string | string[];
  empty?: boolean;
  lt?: number;
  lte?: number;
  gt?: number;
  gte?: number;
}
 * 
 * 
 * ********/

const SmsVisiable = {
  channelType: {
    equals: "sms",
  },
};

const SmsFormItems = [
  {
    label: "消息推送地址",
    field: "url",
    visiable: SmsVisiable,
    uiType: "input",
  },
  {
    label: "账号",
    field: "account",
    visiable: SmsVisiable,
    uiType: "input",
  },
  {
    label: "密码",
    field: "password",
    visiable: SmsVisiable,
    uiType: "input",
  },
  {
    label: "签名",
    field: "sign",
    initValue: "signature",
    visiable: SmsVisiable,
    uiType: "input",
  },
];

const EmailVisiable = (formData: any) => formData.channelType == "email";

const EmailFormItems = [
  {
    label: "smtp服务器地址",
    field: "host",
    visiable: EmailVisiable,
    uiType: "input",
  },
  {
    label: "邮箱账号",
    field: "account",
    visiable: EmailVisiable,
    uiType: "input",
  },
  {
    label: "邮箱密码",
    field: "password",
    visiable: EmailVisiable,
    uiType: "input",
  },
];

function createFormItems(isEditMode: boolean, extJson: any = null) {
  return [
    {
      label: "渠道名称",
      field: "channelName",
      uiType: "input",
      required: true,
    },
    {
      label: "渠道类型",
      field: "channelType",
      required: true,
      uiType: "selector",
      disabled: isEditMode,
      props: {
        options: ChannelEnum,
      },
    },
    ...DingTalkFormItems,
    ...SmsFormItems,
    ...EmailFormItems,
    {
      label: "应用于平台",
      field: "platforms",
      required: true,
      uiType: "selector",
      props: {
        multiple: true,
        options: () => DemoService.queryPlatformList(),
      },
    },
  ];
}
export async function createOrUpdateChannel(row: any, table: any) {
  const isEditMode = !!row;
  let rowData = null;
  if (isEditMode) {
    rowData = {
      ...row,
      ...row.ext,
      platforms: row.platforms.map((item: any) => item.code),
    };
  }
  const dialogInsatcne = createFormDialog({
    dialogProps: {
      title: isEditMode ? "编辑渠道" : "新增渠道",
    },
    formProps: {
      labelWidth: 130,
      primaryKey: "id",//编辑操作需要传给后端用来更新的主键,不传默认为id
    },
    formItems: createFormItems(isEditMode, rowData),
  });

  dialogInsatcne.open(rowData)
  .onConfirm((formData: any) => {
     /****
     *只有表单所有必填字段校验通过才会调用此回调函数
     *formData只包含可视的字段与primaryKey,保证数据干净
     ****/
     
    const action = !isEditMode ? "create" : "update";
    DemoService[action](formData).then(({ success, errorMsg }) => {
      if (!success) {
        Toast.error(errorMsg);
        return;
      }
      Toast.success(errorMsg);
      table.refresh();
      dialogInsatcne.close();
    });
  })
  .onClose(()=>{});
}

至于此种开发方式对开发效率有没有提升,看完上面示例的代码后读者朋友可以尝试实现图示中的效果,然后从时间耗费、代码量、拓展性与可维护性等多个维度做下对比,本示例开发连同构造数据模拟花了差不多2h,因为思考示例中如何才能将封装的东西更多地展现出来,也稍微花了点时间。 下面是本次示例所用到的模拟数据,还有点问题,更新/新增方法还有点问题,先贴给大家看看(demoService.ts)

js/* 复制代码
 
 
export function queryPlatformList() {
  const platformList = [
    { name: "淘宝", code: "taobao" },
    { name: "京东", code: "jd" },
    { name: "抖音", code: "douyin" },
  ];
  return platformList;
}

const dataList: any[] = [
  {
    id: 1,
    channelType: "sms",
    channelName: "阿里短信通知",
    platforms: queryPlatformList().filter((item) => item.code !== "taobao"),
    status: 1,
    createTime: "2021-09-07 00:52:15",
    updateTime: "2021-11-07 00:52:15",
    createBy: "vshen",
    updateBy: "vshen",
    ext: {
      url: "https://sms.aliyun.com",
      account: "vshen",
      password: "vshen57",
      sign: "signVhsen123124",
    },
  },
  {
    id: 2,
    channelType: "dingtalk",
    channelName: "预警消息钉钉通知",
    platforms: queryPlatformList().filter((item) => item.code !== "jingdong"),
    status: 1,
    createTime: "2021-11-10 00:52:15",
    updateTime: "2021-11-07 00:52:15",
    createBy: "vshen",
    updateBy: "vshen",
    ext: {
      accessType: "webhook",
      address: "https://dingtalk.aliyun.com",
    },
  },
  {
    id: 3,
    channelType: "email",
    channelName: "预警消息邮件通知",
    platforms: queryPlatformList().filter((item) => item.code !== "douyin"),
    status: 0,
    ext: {
      host: "https://smpt.aliyun.com",
      account: "vshen@qq.com",
      password: "vshen@360.com",
    },
    createTime: "2021-11-07 00:52:15",
    updateTime: "2021-11-07 00:52:15",
    createBy: "vshen",
    updateBy: "vshen",
  },
];

export function queryPage({ form }: any, pagenation: any) {
  return new Promise((resolve) => {
    let result: any[] = dataList;
    Object.keys(form).forEach((key) => {
      const value = form[key];
      result = dataList.filter((item) => item[key] == value);
    });

    resolve({ success: true, data: { list: result } });
  });
}
export function create(data: any = {}) {
  return new Promise((resolve) => {
    setTimeout(() => {
      dataList.push({
        id: Date.now(),
        platform: [],
        ...data,
      });
      resolve({ success: true, message: "创建成功!" });
    }, 500);
  });
}

export function update(data: any) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const index = dataList.findIndex((item) => item.id == data.id);
      const target = dataList[index];
      Object.keys(data).forEach((key) => {
        target[key] = data[key];
      });
      dataList.splice(index, 1, target);
      resolve({ success: true, message: "更新成功!" });
      console.log("update", dataList);
    }, 500);
  });
}

export function remove(id: number) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const index = dataList.findIndex((item) => item.id == id);
      dataList.splice(index, 1);
      resolve({ success: true, message: "删除成功!" });
      console.log("remove", dataList);
    }, 500);
  });
}

社区中确实看到有很不少人对这种配置式开发嗤之以鼻,但是在我看来至少有以下几个优点:

  1. 统一了项目中的列表页开发规范,无论谁开发都可以保证每一个列表页面其他人都可以看得懂,改得动。
  2. 在前端人手不足情况下,即使后端不会css跟布局,只要给与相关文档看一下,也能动手写出一样的列表页面开发(后端开发的道友对不住了,哈哈哈)
  3. 没有一个功能代码反复横跳查看的,每一个方法逻辑都可以很好很清晰的剥离与替换,解耦业务逻辑。例如示例中的新增与编辑操作,将相关业务逻辑内聚,从页面代码中剥离出来单独维护,需求变动时任何方法都可以很方便地直接拿掉或者重写,无需担心会影响其他业务代码。

接下来我们进入主题,拆解下(ListPage.vue)这个页面的组件分封装。对于页面展示的各个部分,在代码封装设计上我们按照图示中圈出来的各个部分来做封装设计。

代码组织如下:

1. ListPage.vue

js 复制代码
<template>
  <div ref="listPageRef" class="list-page">
    <!-- 搜索框 -->
    <SearchForm v-show="props.filterItems?.length > 0" v-model:height="searchFormHeight" :filterItems="props.filterItems"
      @search="diapatchSearch">
    </SearchForm>
    <!--  -->
    <el-row class="table-grid" justify="start" flex>
      <!-- 表格操作 -->
      <div class="toolbar-actions">
        <el-button v-for="action in props.toolbar"
          v-bind="Object.assign({ size: 'small', type: 'primary' }, action.props)"
          @click="() => action.click(tableInstance, {})">
          <el-icon style="vertical-align: middle" v-if="action.props && action.props.icon">
          </el-icon>
          <span>{{ action.text }}</span>
        </el-button>
        <el-button type="warning" size="small" @click="refreshTableData(searchFormModel)">
          <el-icon style="vertical-align: middle">
            <Refresh />
          </el-icon>
        </el-button>
        <el-button type="info" size="small" @click.stop="tableSettingDialog.open()">
          <el-icon style="vertical-align: middle">
            <Setting />
          </el-icon>
        </el-button>
        <el-button type="success" size="small" @click="requestFullScreen.toggle()">
          <el-icon style="vertical-align: middle">
            <FullScreen />
          </el-icon>
        </el-button>
      </div>
      <!-- 表格主体 -->
      <table-plus ref="tableInstance" :data="tableData.list" :is-loading="tableData.isLoading" :columns="tableColumns"
        :tableHeight="tableHeight" :props="props.props" :events="props.props"
        v-bind="Object.assign($attrs.props || {}, {})" @refresh="() => refreshTableData(searchFormModel)">
        <template v-for="column in tableColumns.filter((col) => col.slot)" #[column.slot]="{ row, col, index }">
          <slot :name="column.slot" :row="row" :col="col" :index="index"></slot>
        </template>
      </table-plus>
      <!-- 分页 -->
      <Pagenation type="custom" :pagenation="searchFormModel.pagenation" :total="tableData.total"
        @change="onPagenationChange" v-model:height="pagenationHeight">
      </Pagenation>
    </el-row>

    <TableSettingDialog ref="tableSettingDialog" v-model:columns="tableColumns" @refresh-column="refreshColumn"
      @reset="resetColumns" />
  </div>
</template>
<script setup lang="ts">
import SearchForm from "@/components/Forms/SearchForm.vue";
import Pagenation from "./components/Pagenation.vue";
import TablePlus from "@/components/Table/Table.vue";
import TableSettingDialog from "./components/TableSettingDialog.vue";
import { FullScreen, Refresh, Setting } from "@element-plus/icons-vue";
import { useTable, ISearchForm } from "@/components/Table/table";
import { useColumn } from "@/components/Table/tableColumns";
import { useTableSetting } from "@/components/Table/tableCustomSetting";
import { useFullscreen } from "@vueuse/core";

export interface Action {
  text: string | Function;
  click: (row: any, table: any) => {};
  props: any;
}

export interface IProps {
  loader: Function | Array<any>;
  filterItems?: any[];
  columns: any[];
  actions?: Action[];
  toolbar?: Action[];
  tableHeight?: string;
  props?: any;
  events?: any;
}

const props = withDefaults(defineProps<IProps>(), {
  props: {},
  events: {},
});

/**表格数据获取与刷新逻辑**/
const searchFormModel = reactive<ISearchForm>({
  form: {},
  pagenation: { pageNum: 1, pageSize: 20 },
});

const { tableData, refreshTableData } = useTable(
  props.loader,
  props.filterItems?.length > 0 ? null : searchFormModel
);

const onPagenationChange = ({ pageNum, pageSize }) => {
  searchFormModel.pagenation.pageNum = pageNum;
  searchFormModel.pagenation.pageSize = pageSize;
  refreshTableData(searchFormModel);
};

const diapatchSearch = (form) => {
  searchFormModel.form = form;
  searchFormModel.pagenation.pageNum = 1;
  refreshTableData(searchFormModel);
};

const tableInstance = ref(null);
const tableSettingDialog = ref(null);

const { tableColumns, updateTableColumns } = useColumn(props.columns, props.actions);

const { refreshColumn, resetColumns } = useTableSetting(
  tableInstance,
  updateTableColumns
);

/***表格动态高度计算***/
const listPageRef = ref<HTMLElement>(null);
const searchFormHeight = ref(0);
const pagenationHeight = ref(0);
const tableHeight = ref(0);

const updateTableHeight = () => {
  tableHeight.value =
    listPageRef.value?.clientHeight -
    searchFormHeight.value -
    pagenationHeight.value -
    50;
};

let cancelWatch = null;

onMounted(() => {
  cancelWatch = watchEffect(() => updateTableHeight());
  window.addEventListener("resize", () => nextTick(() => updateTableHeight()));
});

onUnmounted(() => {
  cancelWatch();
  window.removeEventListener("resize", () => nextTick(() => updateTableHeight()));
});

const requestFullScreen = useFullscreen(listPageRef.value);
console.log("requestFullSreen", requestFullScreen);
// true or false 显示是否允许 fullscreen, true or false 是否是公平
</script>

2. table.ts

js 复制代码
import { isArray, isFunction } from "@vue/shared";

export interface IPagination {
  pageSize: number;
  pageNum: number;
}
export interface ISearchForm {
  form?: any;
  pagenation: IPagination;
}

export interface TableData {
  list: any[];
  total: number;
  isLoading: boolean;
}

export function useTable(
  dataLoader: Function | any[],
  searchForm?: ISearchForm
) {
  const tableRef = ref<HTMLElement>();

  const tableData = reactive<TableData>({
    list: [],
    total: 0,
    isLoading: false,
  });

  async function requestTableData(dataLoader: any, searchForm: ISearchForm) {
    tableData.isLoading = true;

    if (!isArray(dataLoader) && !isFunction(dataLoader)) {
      console.error("----表格数据必须是方法或者数组----");
      return;
    }

    let promiseLoader = (searchForm) =>
      Promise.resolve(
        isArray(dataLoader) ? dataLoader : dataLoader(searchForm)
      );

    try {
      const result = await promiseLoader(searchForm);

      if (Array.isArray(result)) {
        tableData.list = result;
        tableData.total = result.length;
        tableData.isLoading = false;
        return;
      }

      const { success, data, rows }: any = result;

      if (!success) {
        tableData.list = [];
        tableData.total = 0;
        tableData.isLoading = false;
        return;
      }
      tableData.list = Array.isArray(data) ? data : data.list || rows;
      tableData.total = data.total||tableData.list.length;
       
    } catch (error) {
      console.error(error);
    } finally {
      tableData.isLoading = false;
    }
  }

  function refreshTableData(searchFormModel = {}) {
    requestTableData(
      dataLoader,
      Object.assign({}, searchFormModel, searchForm)
    );
  }

  if (searchForm) {
    requestTableData(dataLoader, searchForm);
  }

  return {
    tableRef,
    tableData,
    listData,
    requestTableData,
    refreshTableData,
  };
}

3. tableColumns

js 复制代码
import { IColumnSetting } from "@/api/table-setting-service";
import { isFunction } from "@vue/shared";

export type FixedType = "left" | "right" | "none" | boolean;

export type ElColumnType = "selection" | "index" | "expand";

export type CustomColumnType = "text" | "action";

export type ColumnType = ElColumnType | CustomColumnType;

export type Action = {
  text: Function & string;
  click: Function;
} & {
  [key: string]: string;
};

export interface TColumn {
  label: string; // 列标题 可以是函数或字符串,根据需要在页面上显示在列
  key?: string;
  property?: string; // 列的属性, 如果没有指定,则使用列名称 如果是函数
  slot?: string;
  align?: string;
  width?: number | string; // 列宽度 可选参数,默认为100 可以是整数或浮点数,但不
  minWidth?: number | string; // 最小列宽度 可选参数,默认为10 可以是整数或浮点
  fixed?: FixedType; // 列宽对齐方式 left right none 默认为left 可选参数,表示对齐方
  type?: string;
  actions?: any[];
  visiable?: boolean;
  click?: Function;
  text?: Function | string;
}

export type TableType = "VXE-TABLE" | "EL-TABLE";

export type TColumnConfig = {};

export const actionColumn: TColumn = {
  label: "操作",
  fixed: "right",
  type: "action",
  visiable: true,
  actions: [],
};

export const computedActionName = (button: Action, row: TColumn) => {
  return !isFunction(button.text)
    ? button.text
    : computed(() => button.text(row)).value?.replace(/\"/g, "");
};

const tableColumns = ref<Array<TColumn>>([]);

export const specificTypes = ["selection", "index", "expand"];

const calcColumnWidth = (columnsLength: number) => {
  if (columnsLength <= 6) return `${100 / columnsLength}%`;
  return `${12}%`;
};

const formatColumns = (columns: Array<TColumn>, actions: any[] = []) => {
  const hasAction = actions?.length > 0;

  actionColumn.actions = [...actions];

  const _columns = hasAction ? [...columns, actionColumn] : [...columns];

  const newColumns = [];

  for (let column of _columns) {
    column = Object.assign({}, column);

    if (column.visiable == false) {
      continue;
    }

    column.property = column.key || column.slot;
    column.align = column.align || "center";
    column.visiable = true;
    column.width = column.width || "auto" || calcColumnWidth(_columns.length);

    if (specificTypes.includes(column.type)) {
      column.width = column.width || 60;
    }

    if (column.type === "expand") {
      column.slot = column.slot || "expand";
    }

    if (column.type === "action") {
      column.minWidth = 100;
      column.fixed = "right";
    }

    newColumns.push(column);
  }
  return newColumn;
};

const updateTableColumns = (columnSettings: IColumnSetting[]) => {
  if (columnSettings.length == 0) return false;

  const columnSettingMap = new Map();

  columnSettings.forEach((col) => columnSettingMap.set(col.field, col));

  tableColumns.value = tableColumns.value.map((col) => {
    const colSetting = columnSettingMap.get(col.key) || {};
    Object.keys(colSetting).forEach((key) => {
      col[key] = colSetting[key];
    });
    return col;
  });

  return true;
};

export function useColumn(columns: Array<TColumn>, actions: any[]) {
  tableColumns.value = formatColumns(columns, actions);
  console.log("tableColumns", tableColumns);
  return {
    tableColumns,
    updateTableColumns,
    computedActionName,
  };
}

4. table.vue

js 复制代码
<template>
  <el-table ref="tableInstance" :data="props.data" :loading="props.isLoading" v-on="Object.assign({}, $attrs.events)"
    v-bind="Object.assign(
      {
        tableLayout: 'auto',
        maxHeight: `${props.tableHeight}px`,
        border: true,
        stripe: true,
        resizable: true,
        key: Date.now(), //不配置key会存在数据更新页面不更新
      },
      $attrs.props || {}
    )
      ">
    <template v-for="column in props.columns">
      <!-- 操作 -->
      <el-table-column v-if="column.type == 'action'" v-bind="column" #default="scope">
        <template v-for="button in column.actions">
          <action-button :button="button" :scope="scope" @click="() => button.click(scope, exposeObject)">
          </action-button>
        </template>
      </el-table-column>
      <el-table-column v-else-if="isFunction(column.click)" v-bind="column">
        <template #default="{ row, col, index }">
          <el-button v-bind="Object.assign({ type: 'primary', size: 'small' }, column.props || {})"
            @click="column.click(row, col, index)">
            {{
              isFunction(column.text)
              ? column.text(row, col, index)
              : column.text || row[column.key]
            }}
          </el-button>
        </template>
      </el-table-column>

      <el-table-column v-else-if="column.slot" v-bind="column">
        <template #default="{ row, col, $index }">
          <slot :name="column.slot" :row="row" :col="col" :index="$index" :key="$index">
              </slot>
        </template>
      </el-table-column>

      <el-table-column v-else v-bind="column"> </el-table-column>
    </template>
  </el-table>
</template>
<script setup lang="ts">
import { TColumn, Action } from "./tableColumns";
import { isFunction } from "@vue/shared";
import ActionButton from "./ActionButton.vue";
import { TableInstance } from "element-plus";
import { toValue } from "vue";

export interface Props {
  columns?: TColumn[];
  actions?: Action[];
  data?: any;
  isLoading: boolean;
  tableHeight: number;
 }

const props = withDefaults(defineProps<Props>(), {
  columns: () => [],
  actions:()=>[],
  data: () => [],
  tableHeight: 200,
  isLoading: false,
});

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

const refresh = () => {
  emit("refresh");
};

const tableInstance = ref<TableInstance>();

const exposeObject: any = reactive({
  instance: tableInstance,
  refresh,
  selectionRows: toValue(computed(() => tableInstance.value?.getSelectionRows())),
});

defineExpose(exposeObject);
</script>

5.action-button.vue

js 复制代码
<template>
    <el-popconfirm v-if="confirmProps" v-bind="confirmProps" @confirm="handleConfirm(button, props.scope)">
        <template #reference>
            <el-button v-bind="buttonProps">
                {{ computedActionName(button, props.scope.row) }}
            </el-button>
        </template>
    </el-popconfirm>
    <el-button v-else v-bind="buttonProps" @click="handleConfirm(button, props.scope)">
        {{ computedActionName(button, props.scope.row) }}
    </el-button>
</template>
<script setup lang="ts">
import { Action, TColumn } from "./tableColumns";
import { isFunction, isString, isObject } from "@/components/utils/valueTypeCheck";

const props = withDefaults(
    defineProps<{ button: Action; scope: { row: any; col: any; $index: number } }>(),
    {}
);

const buttonProps = computed(() => {
    let customeProps: any = props.button.props || {};

    return Object.assign(
        {
            marginRight: "10px",
            type: "primary",
            size: "small",
        },
        isFunction(customeProps) ? customeProps(props.scope.row) : customeProps
    );
});

const confirmProps = computed(() => {
    const propsConfirm: any = props.button.confirm;
    if (propsConfirm === undefined) {
        return false;
    }

    if (!isString(propsConfirm) && !isObject(propsConfirm) && !isFunction(propsConfirm)) {
        console.error("confirmProps 类型错误");
        return {};
    }

    if (isString(propsConfirm)) {
        return {
            title: propsConfirm,
        };
    }

    if (isFunction(propsConfirm)) {
        const res = propsConfirm(props.scope.row);
        if (isObject(res)) {
            return res;
        }
        if (isString(res)) {
            return {
                title: res,
            };
        }
    }

    if (isObject(propsConfirm) && propsConfirm.title !== undefined) {
        return isFunction(propsConfirm.title)
            ? {
                ...propsConfirm,
                title: propsConfirm.title(props.scope.row),
            }
            : propsConfirm;
    }
    console.error("confirmProps 类型错误");
});

const emits = defineEmits(["click"]);

const handleConfirm = (button, scope: any) => {
    if (isFunction(button.click)) {
        emits("click");
    }
};

const computedActionName = (button: Action, row: TColumn) => {
    return !isFunction(button.text)
        ? button.text
        : computed(() => button.text(row)).value?.replace(/\"/g, "");
};
</script>

6.tableSettingDrawer.vue

js 复制代码
<template>
  <el-drawer v-model="dialogVisible" title="个性化定制" direction="rtl" size="50%">
    <el-tabs v-model="currentTab">
      <el-tab-pane label="定制列" class="setting-content" name="list" @keyup.enter="confirm(originColumns)">
        <el-table :data="originColumns" style="width: 100%" table-layout="auto" border stripe resizable
          default-expand-all>
          <template v-for="column in colunms">
            <el-table-column v-bind="column" #default="{ row, col, $index }">
              <span v-if="column.uiType == 'text'">{{ row.label }}</span>
              <!-- 输入框 -->
              <el-input v-else-if="column.uiType == 'input'" v-model="row[column.field]"
                :placeholder="`请输入${column.label}`"></el-input>
              <!-- 选择器 -->
              <el-select v-else-if="column.uiType == 'select'" v-model="row[column.field]"
                :placeholder="`请选择${column.label}`">
                <el-option v-for="option in column.options" :key="option.value" :label="option.name"
                  :value="option.value"></el-option>
              </el-select>
              <!-- 多选 -->
              <el-switch v-else-if="column.uiType == 'switch'" v-model="row[column.field]"></el-switch>
            </el-table-column>
          </template>
        </el-table>
      </el-tab-pane>
      <el-tab-pane label="定制查询条件" name="condition"> </el-tab-pane>
    </el-tabs>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="close">取消</el-button>
        <el-button @click="$emit('reset', false)">恢复默认设置</el-button>
        <el-button type="primary" @click="confirm(originColumns)">确定</el-button>
      </span>
    </template>
  </el-drawer>
</template>
<script setup lang="ts">
const currentTab = ref("list");

interface IProps {
  tableRef?: Element;
  columns: any[];
  modelValue?: boolean;
}

const props = withDefaults(defineProps<IProps>(), {
  columns: () => [],
  modelValue: false,
});

const deepCopy = (data) => {
  return JSON.parse(JSON.stringify(data));
};

/**采用computed可以实现异步获取配置实时更新**/
const originColumns = computed(() => deepCopy(props.columns));

const emit = defineEmits([
  "update:modelValue",
  "update:columns",
  "refreshColumn",
  "reset",
]);

const confirm = (tableColumns) => {
  const columns = deepCopy(tableColumns);
  emit("update:modelValue", false);
  emit("update:columns", columns);
  emit("refreshColumn", columns);
};

const colunms = [
  { field: "seq", label: "排序", width: 60 },
  { field: "visible", label: "是否展示", uiType: "switch", width: 120 },
  { field: "label", label: "列名", uiType: "text" },
  { field: "width", label: "宽度", uiType: "input" },
  {
    field: "align",
    label: "对齐方式",
    uiType: "select",
    options: [
      { value: "left", name: "左对齐" },
      { value: "right", name: "右对齐" },
      { value: "center", name: "居中" },
    ],
  },
  {
    field: "fixed",
    label: "固定类型",
    uiType: "select",
    options: [
      { value: "left", name: "左侧" },
      { value: "right", name: "右侧" },
      { value: "none", name: "不固定" },
    ],
  },
];

const dialogVisible = ref(false);

const open = () => {
  dialogVisible.value = true;
};

const close = () => {
  dialogVisible.value = false;
};

defineExpose({
  open,
  close,
});
</script>

至此,ElTable二次封装相关代码已经结束。希望此中代码能够助各位道友在表格二次封装的设计开发修炼中能有所帮助。一切大道,皆有因果。喜欢的话,可以动动你的小手点点赞。修行路上愿我们都不必独伴大道,回首望去无故人。

下期预告:动态表单设计封装,敬请期待

相关推荐
崔庆才丨静觅1 天前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 天前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 天前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅1 天前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 天前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 天前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 天前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 天前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 天前
jwt介绍
前端
爱敲代码的小鱼1 天前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax