基于 elementPlus 页面组件封装

19 阅读4分钟

前言

文章旨使用 vue3 在基于 element-plusformtablepagination,三个组件,封装成为可配置化页面,本代码复制可用,显示给出容器宽高即可,代码如下。

.contain {
  width: 100%;
  height: 100%;
}

header 头部

头部不是重点,仅做简单封装,不通用。

<template>
  <div class="dx-header">
    <div class="dx-header-title">
      <el-button v-if="back" type="primary" @click="goBack" :icon="ArrowLeft" link>返回</el-button>
      <div v-if="back" class="dx-header-line"></div>
      <span>{{ title }}</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ArrowLeft } from "@element-plus/icons-vue";
import { useRouter } from "vue-router";

export interface Header {
  title?: string;
  back?: boolean;
}

const router = useRouter();

const goBack = () => {
  router.back();
};

withDefaults(defineProps<Header>(), { title: "", back: false });
</script>
<style lang="scss" scoped>
.dx-header {
  display: flex;
  flex-direction: column;

  &-title {
    width: 100%;
    background-color: #fff;
    font-weight: bold;
    font-size: large;
    border-bottom: 1px solid var(--menu-logo-line-color);
    padding: 12px;
    display: flex;
    align-items: center;
  }

  &-line {
    width: 1px;
    height: 25px;
    margin: 0 20px;
    background-color: var(--menu-logo-line-color);
  }

  &-contain {
    flex: 1;
    display: flex;
    box-sizing: border-box;
    background-color: #fff;
    padding: 18px 12px;
  }
}
</style>

form 部分

form部分,旨在配置成 forms

<template>
  <el-form ref="formRef" :model="formModel" v-bind="safeFormProps">
    <el-row v-bind="safeRowProps">
      <template v-for="item in formItemsFilter" :key="`${item?.prop || item?.slot}`">
        <el-col v-bind="getColProps(item)">
          <el-form-item v-if="item.prop" :label="item.label" :prop="item.prop" v-bind="getFormItemProps(item)">
            <template v-if="item.labelSlot" #label>
              <slot :name="item.labelSlot" :prop="item.prop" />
            </template>
            <template v-if="item.errorSlot" #error>
              <slot :name="item.errorSlot" :prop="item.prop" />
            </template>
            <template v-if="item.slot">
              <slot :name="item.slot" :scope="{ formModel, ...item }" />
            </template>
            <template v-else>
              <component
                v-if="typeList.includes(item.tags || '')"
                :is="item.tags"
                v-model="formModel[item.prop]"
                v-bind="item.attrs"
              />
              <el-select v-else-if="item.tags === 'el-select'" v-model="formModel[item.prop]" v-bind="item.attrs">
                <el-option
                  v-for="opt in item?.options || []"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                  :disabled="opt.disabled"
                />
              </el-select>
              <el-radio-group v-else-if="item.tags === 'el-radio'" v-model="formModel[item.prop]" v-bind="item.attrs">
                <el-radio v-for="opt in item.options" :key="opt.value" :label="opt.value" :disabled="opt.disabled">
                  {{ opt.label }}
                </el-radio>
              </el-radio-group>
              <el-checkbox-group
                v-else-if="item.tags === 'el-checkbox'"
                v-model="formModel[item.prop]"
                v-bind="item.attrs"
              >
                <el-checkbox v-for="opt in item.options" :key="opt.value" :label="opt.value" :disabled="opt.disabled">
                  {{ opt.label }}
                </el-checkbox>
              </el-checkbox-group>
            </template>
          </el-form-item>
          <template v-else-if="item.slot">
            <slot :name="item.slot" />
          </template>
        </el-col>
      </template>
      <template v-if="$slots.formBtn">
        <slot name="formBtn" :scope="{ formModel }" />
      </template>
    </el-row>
  </el-form>
</template>

<script setup lang="ts" name="DxForm">
const filterProps = (obj: any, list: Array<string>) => {
  return Object.keys(obj || {}).reduce((pre: any, cur: string) => {
    return (list || []).includes(cur) ? { ...pre } : { ...pre, [cur]: obj[cur] };
  }, {});
};

const typeList = [
  "el-input",
  "el-textarea",
  "el-input-number",
  "el-switch",
  "el-date-picker",
  "el-time-picker",
  "el-el-slider",
  "el-cascader",
  "el-upload",
];

export interface FormProps {
  rowProps?: Record<string, any>; // 同 el-row props 属性
  gutter?: number;
  span?: number;
  filterFormItem?: string[];
  clearFormModel?: boolean;
  [key: string]: any;
}

export interface FormItems {
  prop?: string;
  tags?: string;
  label?: string;
  span?: number;
  labelSlot?: string;
  slot?: string;
  errorSlot?: string;
  options?: Record<string, any>[];
  attrs?: Record<string, any>;
  colProps?: Record<string, any>;
  formItemProps?: Record<string, any>;
}

const props = defineProps<{
  formModel: Record<string, any>;
  formProps?: FormProps;
  formItems: Array<FormItems>;
}>();

const formRef = ref<any>();

const formItemsFilter = computed(() => {
  const formItems = props.formItems;
  const filterFormItem = props?.formProps?.filterFormItem;
  if (filterFormItem && Array.isArray(filterFormItem)) {
    return formItems.filter((item: any) => !filterFormItem.includes(item.prop));
  } else return formItems;
});

const safeRowProps = computed(() => {
  const rowProps = props?.formProps?.rowProps;
  const isHasRowProps = rowProps && typeof rowProps === "object";

  const gutter = (() => {
    if (isHasRowProps) {
      const isHasRowPropsGutter = Object.hasOwnProperty.call(rowProps, "gutter");
      if (isHasRowPropsGutter) return rowProps.gutter;
    }
    const isHasFormPropsGutter = Object.hasOwnProperty.call(props.formProps, "gutter");
    return isHasFormPropsGutter ? props.formProps?.gutter : 0;
  })();

  return isHasRowProps ? { ...rowProps, gutter } : { gutter };
});

const getColProps = (item: any) => {
  const colProps = item?.colProps;
  const isHasColProps = colProps && typeof colProps === "object";

  const span = (() => {
    if (isHasColProps) {
      const isHasColPropsSpan = Object.hasOwnProperty.call(colProps, "span");
      if (isHasColPropsSpan) return colProps.span;
    }
    const isHasItemSpan = Object.hasOwnProperty.call(item, "span");
    if (isHasItemSpan) return item.span;
    const isHasFormPropsSpan = Object.hasOwnProperty.call(props.formProps, "span");
    return isHasFormPropsSpan ? props.formProps?.span : 24;
  })();

  return isHasColProps ? { ...colProps, span } : { span };
};

const getFormItemProps = (item: any) => {
  const filterArray = ["prop", "label", "labelSlot", "slot", "errorSlot"];
  const formItemProps = item?.formItemProps;
  const isHasFormItemProps = formItemProps && typeof formItemProps === "object";
  return isHasFormItemProps ? filterProps(formItemProps, filterArray) : {};
};

const safeFormProps = computed(() => {
  const filterArray = ["formModel", "rowProps", "gutter", "span", "filterFormItem", "clearFormModel"];
  return filterProps(props.formProps, filterArray);
});

const emit = defineEmits(["search", "reset", "update:formModel"]);

const search = () => {
  if (!formRef.value) return;
  const rules = props.formProps?.rules;
  if (rules && Object.keys(rules).length > 0) {
    formRef.value.validate((valid: boolean) => {
      if (valid) emit("search", props.formModel);
    });
  } else {
    emit("search", props.formModel);
  }
};

const reset = () => {
  const clearFormModel = props?.formProps?.clearFormModel;
  if (clearFormModel) {
    props.formItems.forEach((item: any) => {
      const prop = item.prop;
      if (Object.hasOwnProperty.call(props.formModel, prop)) {
        props.formModel[prop] = "";
      }
    });
  }
  if (formRef.value?.resetFields) {
    formRef.value.resetFields();
  }
};

defineExpose({
  search,
  reset,
  formRef: formRef.value,
});

const setUpWatchers = () => {
  props.formItems.forEach((item: any) => {
    const changeRef = toRef(props.formModel, item.prop);
    watch(
      changeRef,
      (val: any) => {
        emit("update:formModel", item.prop, val);
      },
      {
        deep: true,
        flush: "post",
      }
    );
  });
};
setUpWatchers();
</script>

这个 form 部分代码,封装成 forms

table 部分

table 部分涵盖分页,以联动。

<template>
  <div class="km-table">
    <div class="km-table-wrap">
      <div class="km-table-position">
        <el-table :data="props.data" :span-method="mergedSpanMethod" v-bind="safeTableProps" v-loading="props.loading">
          <template v-for="item in props.columns" :key="item.prop">
            <el-table-column v-bind="item">
              <template v-if="item.slot" #default="scope">
                <slot :name="item.slot" v-bind="scope" />
              </template>
            </el-table-column>
          </template>
        </el-table>
      </div>
    </div>
    <div class="km-table-pagination-wrap" v-if="props.pagination">
      <el-pagination
        :current-page="props.pagination.currentPage"
        :page-size="props.pagination.pageSize"
        :total="props.pagination.total"
        v-bind="safePaginationProps"
        @update:current-page="val => emit('update:currentPage', val)"
        @update:page-size="val => emit('update:pageSize', val)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
export interface DxTableType {
  columns: Array<{
    prop: string;
    label: string;
    slot?: string;
    mergeField?: boolean;
    [key: string]: any;
  }>;
  data: any[];
  loading?: boolean;
  tableProps: Record<string, any>;
  pagination?: {
    currentPage: number;
    pageSize: number;
    total: number;
  };
  paginationProps: Record<string, any>;
}

const props = defineProps<DxTableType>();

const emit = defineEmits(["update:currentPage", "update:pageSize"]);

const filterProps = (obj: any, list: Array<string>) => {
  return Object.keys(obj || {}).reduce((pre: any, cur: string) => {
    return (list || []).includes(cur) ? { ...pre } : { ...pre, [cur]: obj[cur] };
  }, {});
};

const safeTableProps = computed(() => {
  return filterProps(props.tableProps || {}, ["data", "spanMethod", "loading"]);
});

const safePaginationProps = computed(() => {
  return filterProps(props.paginationProps || {}, ["currentPage", "pageSize", "total"]);
});

const mergedSpanMethod = ({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }) => {
  const column = props.columns[columnIndex];
  if (!column || !column.mergeField) return [1, 1];

  const field = column.prop;
  const currentValue = props.data[rowIndex]?.[field];
  const prevValue = props.data[rowIndex - 1]?.[field];

  if (currentValue === prevValue) return [0, 0];
  let rowspan = 1;
  for (let i = rowIndex + 1; i < props.data.length; i++) {
    if (props.data[i][field] === currentValue) {
      rowspan++;
    } else {
      break;
    }
  }
  return [rowspan, 1];
};
</script>

<style lang="scss" scoped>
.km-table {
  width: 100%;
  display: flex;
  flex-direction: column;
  .km-table-wrap {
    flex: 1;
    position: relative;
  }
  .km-table-position {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
  }
  .km-table-pagination-wrap {
    padding: 20px;
  }
}
</style>

结合

page组件最终。

<template>
  <div class="dx-layout">
    <DxHeader v-if="pageConfig.header" :title="pageConfig.header.title" :back="pageConfig.header.back" />
    <div class="dx-layout-form" v-if="Object.keys(pageConfig.form.formItems).length > 0">
      <DxForm
        ref="dxFormRef"
        :formItems="mergedFormItemsProps"
        :formModel="pageConfig.form.formModel"
        :formProps="mergedFormProps"
        @update:form-model="setFormModel"
      >
        <template v-for="formSlot in formSlots" :key="formSlot" #[formSlot]="scope">
          <slot :name="formSlot" v-bind="scope"></slot>
        </template>
        <template #queryBtn>
          <el-button @click="reset">重置</el-button>
          <el-button type="primary" @click="queryData">查询</el-button>
        </template>
      </DxForm>
    </div>
    <div class="dx-layout-formBtn" v-if="$slots.pageBtn">
      <slot name="pageBtn"></slot>
    </div>
    <div class="dx-layout-table">
      <DxTable
        :columns="pageConfig.table.columns"
        :data="tableData"
        :loading="loading"
        :tableProps="mergedTableProps"
        :pagination="pagination"
        :paginationProps="pageConfig.table.paginationProps"
        @update:currentPage="
          val => {
            pagination.currentPage = val;
            queryData();
          }
        "
        @update:pageSize="
          val => {
            pagination.pageSize = val;
            pagination.currentPage = 1;
            queryData();
          }
        "
      >
        <template v-for="tableSlot in tableSlots" :key="tableSlot" #[tableSlot]="scope">
          <slot :name="tableSlot" v-bind="scope"></slot>
        </template>
      </DxTable>
    </div>
  </div>
</template>

<script setup lang="ts">
import DxHeader, { type Header } from "./DxHeader.vue";
import DxForm, { type FormProps, type FormItems } from "./DxForm.vue";
import DxTable, { type DxTableType } from "./DxTable.vue";
import { useDebounceFn } from "@vueuse/core";

export interface PageConfig {
  header: Header;
  form: {
    formItems: Array<FormItems>;
    formModel: Record<string, any>;
    formProps?: FormProps;
  };
  table: DxTableType;
}

const props = defineProps<{
  pageConfig: PageConfig;
}>();

const dxFormRef = ref();

const emit = defineEmits(["update:formModel"]);

// 更改为高阶防抖,执行一部分,一部分延迟
const setFormModel = useDebounceFn((prop: string, val: any) => {
  emit("update:formModel", prop, val);
  pagination.value.currentPage = 1;
  queryData();
}, 600);

const tableData = ref<any>([]);

const { currentPage, pageSize, total } = props.pageConfig.table.paginationProps;
const pagination = ref({
  currentPage: currentPage || 1,
  pageSize: pageSize || 10,
  total: total || 0,
});

const defaultTableProps = {
  height: "100%",
  showHeader: true,
};

const defaultFormProps = {
  gutter: 20,
  span: 6,
  labelWidth: "120px",
};

const mergedTableProps = computed(() => ({
  ...defaultTableProps,
  ...props.pageConfig.table.tableProps,
}));

const mergedFormProps = computed(() => ({
  ...defaultFormProps,
  ...props.pageConfig.form.formProps,
}));

const mergedFormItemsProps = computed(() => {
  const formItems = props.pageConfig.form.formItems;
  const { filterFormItem } = props.pageConfig.form?.formProps || {};
  const calcFinalspan = (() => {
    const totalUsed = (formItems || []).reduce((sum, item) => {
      const isIn = item.prop && (filterFormItem || []).includes(item.prop);
      return isIn ? sum : sum + (item?.span || 6);
    }, 0);
    const remain = 24 - (totalUsed % 24);
    if (remain < 6) return 24;
    return remain;
  })();
  return [
    ...formItems,
    {
      slot: "queryBtn",
      span: calcFinalspan,
      colProps: {
        style: { display: "flex", justifyContent: "flex-end" },
      },
    },
  ];
});

const loading = ref<boolean>(false);
const queryData = async () => {
  const params = {
    ...props.pageConfig.form.formModel,
    currentPage: pagination.value.currentPage,
    pageSize: pagination.value.pageSize,
  };
  console.log("查询参数", params);
  try {
    loading.value = true;
    const res = await props.pageConfig.table.api(params);

    tableData.value = res.data || [];
    pagination.value.total = res.total;
    pagination.value.pageSize = res.pageSize;
    pagination.value.currentPage = res.currentPage;
  } catch (e) {
    console.error("查询失败", e);
  } finally {
    loading.value = false;
  }
};

const reset = () => {
  dxFormRef.value.reset();
  pagination.value.currentPage = 1;
  queryData();
};

// 插槽处理
const slots: any = useSlots();
const slotsNames = Object.keys(slots);

const formSlots = (props.pageConfig.form.formItems || []).reduce(
  (pre: any, cur: any) => {
    const list: string[] = [];
    if (cur.slot && typeof cur.slot === "string" && slotsNames.includes(cur.slot)) list.push(cur.slot);
    if (cur.labelSlot && typeof cur.labelSlot === "string" && slotsNames.includes(cur.labelSlot))
      list.push(cur.labelSlot);
    if (cur.errorSlot && typeof cur.errorSlot === "string" && slotsNames.includes(cur.errorSlot))
      list.push(cur.errorSlot);
    return [...pre, ...list];
  },
  slotsNames.includes("formBtn") ? ["formBtn"] : []
);

const tableSlots = (props.pageConfig.table.columns || [])
  .filter((item: any) => item.slot && typeof item.slot === "string" && slotsNames.includes(item.slot))
  .map((item: any) => item.slot);

queryData();
</script>

<style lang="scss" scoped>
.dx-layout {
  height: 100%;
  width: 100%;
  display: flex;
  flex-direction: column;
  .dx-layout-table {
    padding: 0 20px;
    flex: 1;
    display: flex;
  }
  .dx-layout-form {
    padding: 20px 20px 0 20px;
  }
  .dx-layout-formBtn {
    padding: 0 20px 20px 20px;
  }
}
</style>