Element-Plus二次封装el-form的ProForm表单组件,仿Antd Design Pro,json表单,联动表单,动态表单,源码可灵活定制和扩展

在从vue转到react后被antdPro封装的ProTable和ProForm惊艳到,不用写一堆标签,一套JSON搞定,摸鱼的时间又增加了...vue这边没有找到开源的,那就自己实现下

前提

  • 使用了component动态组件,element组件库需要全局导入,因为自动导入vite插件编译时无法预测使用了哪了el-'xxx'组件
  • 需要vue3版本和typescript支持
  • 浏览器需支持grid css属性

优点

  • 减少代码冗余,少写大量标签,封装常用功能
  • 利用vue的透传,保留原组件库的所有props,无心智负担
  • 两种布局,常规布局和弹窗布局,内置提交按钮(提交时自带按钮loading)
  • grid布局,自定总列数,每个输入框的占几列可独立控制
  • 联动表单项,当el:dependency时, 提供columns: (model) => FormColumn[],根据已输入的表单内容,动态返回columns
  • 清爽readonly,别再disabled整个表单来实现查看详情功能了,效果见图
  • typescript完整类型提示,待完善...

组件代码

ProForm

html 复制代码
<template>
  <template v-if="layoutType == 'Form'">
    <el-form
      ref="formRef"
      :model="model"
      class="grid gap-col-4"
      label-width="auto"
      :style="{ 'grid-template-columns': `repeat(${cols},minmax(0,1fr))` }"
      v-bind="$attrs"
    >
      <FormItem v-for="item in columns" :column="item">
        <template v-for="slot in Object.keys($slots)" #[slot]="scope">
          <slot :name="slot" v-bind="scope" />
        </template>
      </FormItem>
      <el-form-item v-if="submitter">
        <slot name="options">
            <el-button type="primary" @click="handleSubmit" :loading="btnLoading" native-type="submit">确认</el-button>
            <el-button @click="resetFields">重置</el-button>
        </slot>
      </el-form-item>
    </el-form>
  </template>
  <template v-if="layoutType == 'ModalForm'">
    <el-dialog v-model="open" v-bind="dialogProps">
      <el-form
        ref="formRef"
        :model="model"
        label-width="auto"
        class="grid gap-col-4"
        :style="{ 'grid-template-columns': `repeat(${cols},minmax(0,1fr))` }"
        v-bind="$attrs"
      >
        <FormItem v-for="item in columns" :column="item">
          <template v-for="slot in Object.keys($slots)" #[slot]="scope">
            <slot :name="slot" v-bind="scope" />
          </template>
        </FormItem>
      </el-form>
      <template #footer v-if="submitter">
        <el-button @click="open = false" v-if="readonly">关闭</el-button>
        <template v-else>
          <slot name="options">
            <el-button @click="open = false">取消</el-button>
            <el-button type="primary" @click="handleSubmit" :loading="btnLoading" native-type="submit">确定</el-button>
          </slot>
        </template>
        </template>
      </template>
    </el-dialog>
  </template>
</template>

<script setup lang="ts" name="ProForm">
import { computed, ref, watch, Reactive,Ref } from 'vue';
import type { FormInstance } from 'element-plus';
import FormItem from './FormItem.vue';

type SelectOption = {
  label: string;
  value: string | number;
  disabled?: boolean;
};


export interface FormColumn {
  el?: 'input' | 'select' | 'input-number' | 'date-picker' | 'radio' | 'text' | 'dependency' | string;
  label?: string;
  prop?: string;
  span?: number | string; //grid布局中输入框占几列  默认为1
  rules?: any[]; //el-form-item上的rules
  enum?: SelectOption[] | Ref<SelectOption[]>; //给下拉框或者text替换value为label用的
  tooltip?: boolean; //文本溢出悬浮展示  如果需要此功能columns请传递响应
  formItemProps?: { [x: string]: any }; //透传给el-form-item的属性
  fieldProps?: { [x: string]: any }; //透传给el-input等输入组件的属性
  disabled?: boolean; //禁用
  columns?: (model: any) => FormColumn[]; //el为dependency时使用 来实现动态表单
  extra?: string; //显示在el-form-item下方的提示文字
}
export interface ProFormProps {
  layoutType?: 'Form' | 'ModalForm'; // 普通表单和弹窗表单
  modelValue?: any; //v-model绑定的表单对象,不传将使用内置的localModel,可在你传入的onFinish函数中拿到校验成功后的表单对象
  dialogProps?: { [x: string]: any }; //传给弹窗el-dialog的属性
  open?: boolean; //弹窗表单
  cols?: number | string; //grid布局下的总列数
  columns?: FormColumn[] | Reactive<FormColumn[]>;
  emptyText?: string; //查看模式下空值占位
  onFinish?: (params) => Promise<any>; //表单校验通过点提交时会执行你传入的这个函数,
  readonly?: boolean; //查看模式
  submitter?: boolean; // 是否关闭内置提交按钮
}
const props = withDefaults(defineProps<ProFormProps>(), {
  layoutType: 'Form',
  cols: 1,
  emptyText: '--',
  submitter: true,
  columns: () => []
});

const emit = defineEmits(['update:model-value', 'update:open']);

const btnLoading = ref(false);
const formRef = ref<FormInstance>();
const open = computed({
  get() {
    return props.open;
  },
  set(val) {
    emit('update:open', val);
  }
});

const localModel = ref({});

const model = computed({
  get() {
    if (props.modelValue) return props.modelValue;
    else return localModel.value;
  },
  set(val) {
    if (props.modelValue) emit('update:model-value', val);
    else localModel.value = val;
  }
});
const isReadonly = computed(() => {
  return props.readonly;
});
provide(/* 注入名 */ 'model', /* 值 */ model);
provide(/* 注入名 */ 'readonly', /* 值 */ isReadonly);
provide(/* 注入名 */ 'emptyText', /* 值 */ props.emptyText);

//表单整个绑定对象被替换时,清空表单验证
watch(
  () => model.value,
  () => {
    formRef.value?.clearValidate();
  }
);

/** 表单提交方法 需绑定rules*/
const validate = () => {
  return new Promise(async (resolve, reject) => {
    if (!formRef.value) return;
    await formRef.value.validate((valid, fields) => {
      if (valid) {
        // console.log("submit!", valid);
        resolve('表单验证通过,提交成功');
      } else {
        // console.log("error submit!", fields);
        reject(fields);
      }
    });
  });
};

const handleSubmit = () => {
  formRef.value.validate(async (valid, fields) => {
    if (valid) {
      btnLoading.value = true;

      const params = JSON.parse(JSON.stringify(model.value));
      props
        .onFinish(params)
        .then((success) => {
          if (success) open.value = false;
        })
        .finally(() => {
          btnLoading.value = false;
        });
    } else {
    }
  });
};

/**重置表单*/
const resetFields = () => {
  if (!formRef.value) return;
  formRef.value.resetFields();
};
/**清空校验*/
const clearValidate = () => {
  if (!formRef.value) return;
  formRef.value.clearValidate();
};
defineExpose({ validate, resetFields, clearValidate });
</script>

<style scoped>
.gird {
  display: grid;
}
.gap-col-4 {
  column-gap: 1rem;
}
</style>

FormItem

html 复制代码
<template>
  <template v-if="column.el === 'dependency'">
    <FormItem
      v-for="item in (() => {
        if (!column.columns) return [];
        if (Array.isArray(column.columns)) return column.columns;
        if (typeof column.columns === 'function') return column.columns(model);
      })()"
      :key="column.prop"
      :column="item"
    >
      <template #[item.prop]> <slot :name="item.prop"></slot> </template>
    </FormItem>
  </template>

  <el-form-item
    v-else
    :prop="column.prop"
    :label="column.label"
    :rules="column.rules"
    v-bind="column.formItemProps"
    :style="column.span ? itemStyle(column.span) : {}"
  >
    <slot :name="column.prop">
      <template v-if="readonly">
        <el-tooltip :visible="!!column.tooltip" :content="model[column.prop]" placement="top">
          <span
            :class="column.fieldProps?.ellipsis ? 'truncate' : 'break-all'"
            :style="{ color: column.fieldProps?.color ?? '#606266' }"
            @mouseover="handleMouseOver($event.target, column)"
            @mouseout="column.tooltip = false"
          >
            {{ formatText(column) }}<span>{{ column?.fieldProps?.suffix }}</span>
          </span>
        </el-tooltip>
      </template>

      <template v-else>
        <template v-if="column.el == 'select'">
          <el-select v-model="model[column.prop]" :disabled="column.disabled" v-bind="column.fieldProps">
            <el-option
              v-for="(option, i) in column.enum"
              :key="i"
              :label="option.label"
              :value="option.value"
              :disabled="option.disabled"
            ></el-option>
          </el-select>
        </template>
        <template v-else-if="column.el == 'radio'">
          <el-radio-group v-model="model[column.prop]" :disabled="column.disabled" v-bind="column.fieldProps">
            <el-radio :value="option.value" v-for="(option, i) in column.enum" :key="i" :disabled="option.disabled">{{ option.label }}</el-radio>
          </el-radio-group>
        </template>
        <template v-else-if="column.el == 'input'">
          <el-tooltip :visible="!!column.tooltip" :content="model[column.prop]" placement="top">
            <el-input
              v-model="model[column.prop]"
              :disabled="column.disabled"
              @mouseover="handleMouseOver($event.target, column)"
              @mouseout="column.tooltip = false"
              v-bind="column.fieldProps"
            >
              <template #suffix>
                {{ column.fieldProps?.suffix }}
              </template>
            </el-input>
          </el-tooltip>
        </template>

        <template v-else-if="column.el == 'text'">
          <el-tooltip :visible="!!column.tooltip" :content="model[column.prop]" placement="top">
            <span
              :class="column.fieldProps?.ellipsis ? 'truncate' : 'break-all'"
              :style="{ color: column.fieldProps?.color ?? '#606266' }"
              @mouseover="handleMouseOver($event.target, column)"
              @mouseout="column.tooltip = false"
            >
              {{ model[column.prop] ?? emptyText }}
            </span>
          </el-tooltip>
        </template>
        <template v-else>
          <component :is="`el-${column.el}`" v-model="model[column.prop]" :disabled="column.disabled" v-bind="column.fieldProps" />
        </template>
      </template>
    </slot>
    <span class="text-red-500 text-3 w-full" v-if="column.extra">{{ column.extra }}</span>
  </el-form-item>
</template>

<script setup lang="ts">
const props = defineProps(['column']);
const model: Ref = inject('model');
const emptyText = inject('emptyText');
const readonly = inject('readonly');

// 处理文本过长显示tooltip
const handleMouseOver = (target: any, item: any) => {
  if (target.scrollWidth > target.clientWidth) {
    item.tooltip = true;
  }
};

const itemStyle = (span: number | string) => {
  return { 'grid-column': `span ${span} / span ${span}` };
};

const formatText = (column) => {
  if (column.prop === undefined) return '';
  if (column.enum) {
    return column.enum.find((item) => item.value == model.value[column.prop])?.label ?? model.value[column.prop];
  }
  return model.value[column.prop] ?? '--';
};
</script>

<style scoped lang="scss">
:deep(.el-form-item__content) {
  align-items: start;
}
.truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.break-all {
  word-break: break-all;
}
</style>

使用示例

columns示例

html 复制代码
<template>
    <ProForm
      :label-position="formReadOnly ? 'right' : 'top'"
      :readonly="formReadOnly"
      :dialogProps="{ title, width: '920' }"
      :columns="columns"
      cols="2"
      v-model="form"
      v-model:open="open"
      ref="formRef"
      layout-type="ModalForm"
      :onFinish="handleSubmit"
    >
    //如果你想自定义某个字段的渲染内容请使用插槽,插槽名为你传入column的prop
      <template #你的字段prop>
        <el-input v-model="form.你的字段prop"></el-input>
        <span>xxxx</span>
      </template>
      
     //操作按钮插槽名为options,如果占用了字段prop自己改源码
        <template #options v-if="Object.keys(copiedTemp).length && title === '新增模板' && !formReadOnly">
        <el-button type="info" @click="form = JSON.parse(JSON.stringify(copiedTemp))">粘贴</el-button>
      </template>
    </ProForm>
</template>
<script>
    //表单校验成功后会执行此函数,你可以在函数里发起后端请求,也可以不用,自己调formRef的validate
const handleSubmit = async (params) => {
const res = await requestApi(params) //直接用v-model绑定的form也行,但可能造成浅拷贝问题,params我是已经在组件里深拷贝过了
    };
   //columns实例
const columns: FormColumn[] = [
  {
    el: 'input',
    label: '标题',
    prop: 'title',

    fieldProps: {
      placeholder: '请输入标题'
    },
    span: 2,
    rules: [{ required: true, message: '请输入标题', trigger: 'blur' }]
  },
  {
    el: 'input',
    label: '姓名',
    prop: 'userName',
    rules: [
      { required: true, message: '请输入', trigger: 'blur' },
      {
        validator(rule, value, callback) {
          if (value?.length > 5) return Promise.reject('名字不能超过5个字');
          else return Promise.resolve();
        }
      }
    ]
  },
  { el: 'date-picker', label: '出生日期', prop: 'birthday', rules: [{ required: true, message: '请选择' }] },
  {
    el: 'radio',
    label: '性别',
    prop: 'sex',
    enum: [
      { label: '男', value: 1 },
      { label: '女', value: 2 },
      { label: '中性', value: 3, disabled: true }
    ],
    rules: [{ required: true, message: '请选择性别' }]
  },

  {
    el: 'dependency',
    //依赖表单,在性别为男时才显示输入年龄
    columns: (model) => {
      if (model.sex == 1) return [{ el: 'input-number', label: '年龄', prop: 'age' }];
    }
  },
  { el: 'input', label: '简介', prop: 'introduction', span: 2, fieldProps: { type: 'textarea' } },
  { el: 'text', label: '文本', prop: 'text', span: 2 }
];
</script>

效果展示

你可以得到

readonly模式

结语

快来试试吧,同时感谢一下@HalseySpicy,灵感来源于他的ProTable juejin.cn/post/716606...

欢迎评论和建议!

相关推荐
裕波24 分钟前
Vue 与 Vite 生态最新进展:迈向一体化与智能化的未来
前端·vue.js
ZoeLandia1 小时前
Webpack 搭建 Vue3 脚手架详细步骤
前端·vue.js·webpack
VisuperviReborn5 小时前
打造自己的前端监控---前端错误监控
前端·javascript·vue.js
wayhome在哪5 小时前
面试造火箭 入职拧螺丝
vue.js·面试·jquery
雪中何以赠君别6 小时前
Vite + Axios + Nginx 环境变量与代理配置笔记
前端·javascript·vue.js
guojb8246 小时前
Vue3 神操作:静态 Word 文档秒变交互式折叠面板!
前端·javascript·vue.js
前端小巷子8 小时前
Vue computed 与 methods 的本质差异
前端·vue.js·面试
花菜会噎住20 小时前
Vue3核心语法进阶(computed与监听)
前端·javascript·vue.js
I'mxx20 小时前
【vue(2)插槽】
javascript·vue.js