在从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...
欢迎评论和建议!