1.前言
能来到这里证明你我有缘,既然如此,道友我想送你一场造化!
上一篇我们分享了ElTable组件的二次开发封装,没看过的道友可以点击此处前往查看。今天我们分享一下基于Vue3+Elemet-Plus的动态表单的封装与设计。话不多说,我们先上图,展示下最终实现的效果。
2.效果演示
单纯看上面的效果展示可能会觉得这也没啥,谁都能做。那么让我们来看看上面效果实现的完整代码实现,相信眼前的道友,你会感兴趣(目前还有点没处理,表单元素改变不该触发其他字段的校验,体验不好)。
js
<template>
<div style="width: 50%">
<p>动态表单虚拟场景演示</p>
<FormRender ref="formInstance" v-model="formData" :formItems="formItems"></FormRender>
<el-button @click="submit">提交信息</el-button>
<el-button @click="dataEcho">模拟数据回显</el-button>
<el-button @click="formInstance.resetFields()">重置</el-button>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const formData = ref(null);
// 模拟异步请求
const requestData = (dataList, responseTime = 100) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(dataList);
}, responseTime);
});
};
const platforms = [
{ name: "淘宝", code: "taobao" },
{ name: "抖音", code: "douyin" },
];
const shops = [
{ id: 1, name: "淘宝旗舰店1", code: "tb1", platformCode: "taobao" },
{ id: 2, name: "淘宝旗舰店2", code: "tb2", platformCode: "taobao" },
{ id: 3, name: "淘宝旗舰店3", code: "tb3", platformCode: "taobao" },
{ id: 4, name: "抖音旗舰店1", code: "dy1", platformCode: "douyin" },
{ id: 5, name: "抖音旗舰店2", code: "dy2", platformCode: "douyin" },
];
const payTypeEnums: any = {
1: "微信",
2: "支付宝",
3: "银行卡",
};
const formItems = [
{
label: "平台",
field: "platformCode",
extendField: { platformName: "name" },
required: true,
uiType: "selector",
props: {
valueKey: "code",
options: () => requestData(platforms),
},
},
{
label: "店铺",
field: "shopCode",
required: true,
uiType: "selector",
props: {
multiple: true,
valueKey: "code",
filter: (formData: any) => ({ ...formData }),
options: (filter) => {
const platformCodes = Array.isArray(filter.platformCode)
? filter.platformCode
: [filter.platformCode];
return requestData(shops, 500).then((list: any[]) => {
return list.filter((item: any) => platformCodes.includes(item.platformCode));
});
},
},
},
{
label: "支付方式",
field: "payType",
extendField: {
payTypeObj: {
payName: "name",
payCode: "code",
},
},
required: true,
uiType: "checkbox",
props: {
valueKey: "code",
options: payTypeEnums,
},
},
{
label: "截止时间",
field: ["startDate", "endDate"],
uiType: "dateTimePicker",
props: { type: "daterange" },
},
{
label: "appKey",
field: "ext.appKey",
required: true,
uiType: "input",
visiable: {
platformCode: {
equals: "douyin",
},
},
},
{
label: "appSecret",
field: "ext.appSecret",
required: true,
uiType: "input",
visiable: (formData) => formData.platformCode == "douyin",
},
];
const formInstance: any = ref({});
const submit = () => {
formInstance.value.submit((form) => {
console.log("submit", form);
});
};
const dataEcho = () => {
formData.value = {
platformCode: "douyin",
platformName: "抖音",
shopCode: "dy1",
payType: ["1"],
startDate: "2023-12-13",
endDate: "2024-01-19",
ext: {
appKey: "12334",
appSecret: "234325435",
},
};
};
</script>
可能以前你看到过很多json配置的表单渲染,但是看到这里,不知道有没有惊艳到眼前的你。如果觉得不够好,希望不吝赐教,可以提出你的建议;或者说有更好的实现方案,我们也可以私下交流,共同进步。
3.设计
三军未动,粮草先行;代码未动,需求设计先行。在动手敲代码之前,我们得先想好设计目标,我们要实现哪些功能,这些功能必须得解决我们开发中的痛点。
功能设计
以JSON配置方式实现表单动态渲染,根据开发实践中提炼设计实现以下功能。
功能设计图
渲染协议
在渲染协议设计上,设计目标是以最少的配置项实现需求中的所有功能,这样可以减少开发者的心智负担,不必记太多配置字段。根据上图功能设计,最终设计出渲染协议如下:
js
interface FormItem {
label: string;//对应formItem的label
//对应formItem的prop,支持字符串例如:
field: string|string[];
//针对选择器,如果想获取选项除field字段外的其他字段,可以在此声明
extendField?: object;
initValue?: any;
required?: boolean;
rule?: any[];
visiable?: any; //支持函数,对象,布尔值
disabled?: any; //支持函数,对象,布尔值
uiType: string | component;
props?: any; //支持uiType对应组件的所有属性
events?: any; //支持uiType对应组件的所有属性
}
在协议设计中,我们的field字段支持场景主要有三种:
1、单一对应字段 :'platformCode'、
2、嵌套字段: 'platform.code'
3、双字段(用于例如数值区间,日期范围):'startDate,endDate'、'date.start,date.end'
以上三种设计主要是为了减少对输出的提交数据二次加工操作,上述配置在提交的表单中将会输出数据结构如下:
{
platformCode:'',
platform:{
code:''
},
startDate:'',
endDate:'',
date:{
start:'',
end:''
}
}
开发中我们可以根据需要配置对应的字段
此外相信眼尖的你也看到了,上面还有一个extendField的配置项,
该字段的设计主要用于(select,radio,checkbox)等场景取值。
假设我们的选项数据如下
[
{name: "微信",code: "wx"},
{name: "支付宝",code: "alipay"}
]
需求一:
多选,选中数据时同时取name,code并以{options:[{payName:'',payCode:''}]}数据结构传输给后端,
这时候我们配置如下
{
label: "支付方式",
field: "payTypeCode",
extendField: {
options: {
payName: "name",
payCode: "code",
},
},
uiType: "checkbox",
props:{
valueKey:'code'
}
}
需求2:
单选,选中数据时同时取named的值name并以{payTypeCode:'',payTypeName:''}数据结构传输给后端,
这时候我们配置如下
{
label: "支付方式",
field: "payTypeCode",
extendField: {
payTypeName: "name",
},
uiType: "checkbox",
props:{
valueKey:'code'
}
}
4.方案实现原理以及的代码展示
在性能上市面上的动态表单引擎大多是基于依赖收集来实现的,这样做可以实现细粒度的精准更新。但是本方案不走与别人相同的路(哈哈)采用的是基于Vue3的computed做实时全表单项扫描更新(某个表单项值变动则重新计算并更新所有子项 )。这种方案的优点是实现相对简单,缺点是在大表单(表单项超过50+)场景下性能会有所折损。在实际测试中表单项在100+会有明显联动卡顿现象,50+的时候变现还是很流畅,无明显卡顿现象,可能也是基于Vue3优秀的性能表现。但是大多数场景是不需要大表单的,即使需要大表单我们也可以通过设计拆成多个子表单来实现,这也是一种思路。
代码结构
核心代码
share/index.ts
js
function collectVisibleFormItems(formItems: FormItem[], formData: any) {
const formDataValue = { ...formData };
return formItems
.map((formItem: RenderFormItem) => {
const field = formItem.field.toString() || "";
const value = formDataValue[field];
const newFormItem: RenderFormItem = {
...formItem,
field,
props: { ...(formItem.props || {}) },
events: { ...(formItem.events || {}) },
};
if (isFunction(formItem.visiable)) {
newFormItem.visiable = formItem.visiable(formData);
} else if (!isEmpty(formItem.visiable) && isObject(formItem.visiable)) {
newFormItem.visiable = fieldRulePaser(formItem.visiable, formDataValue);
} else {
newFormItem.visiable =
formItem.visiable === undefined ? true : formItem.visiable;
}
if (formItem.uiType == "select" && formItem.props.disabled) {
newFormItem.props.disabled = formItem.props.disabled;
} else if (isFunction(formItem.disabled)) {
newFormItem.props.disabled = formItem.disabled(formDataValue);
} else if (isObject(formItem.disabled) && !isEmpty(formItem.visiable)) {
newFormItem.props.disabled = fieldRulePaser(
formItem.disabled,
formDataValue
);
} else {
newFormItem.props.disabled = !!formItem.disabled;
}
//联动过滤场景
if (isFunction(formItem?.props?.filter)) {
newFormItem.props.filter = formItem.props.filter(formDataValue);
} else if (isObject(formItem?.props?.filter)) {
newFormItem.props.filter = formItem?.props?.filter;
}
// change事件代理
["change", "onChange", "on-change"].forEach((eventName) => {
newFormItem.events[eventName] = (options: any) =>
onChangeProxy(options, formData, formItem);
});
return newFormItem;
})
.filter((formItem) => formItem.visiable);
}
function collectFieldsWillOutput(
visiableFormItems: FormItem[],
primaryKey = "id"
) {
return visiableFormItems.reduce(
(fields, formItem) => {
fields.push(formItem.field?.toString());
if (Array.isArray(formItem.extendField)) {
fields = [...fields, ...formItem.extendField];
} else if (isObject(formItem.extendField)) {
fields = [...fields, ...Object.keys(formItem.extendField)];
}
return fields;
},
[primaryKey]
);
}
function collectFormRules(formItems: FormItem[]) {
return formItems.reduce((rules, formItem) => {
if ((formItem.rule || []).length > 0) {
rules[formItem.field] = formItem.rule;
}
if (formItem.required) {
rules[formItem.field] = [
{
required: true,
message: `${formItem.label}不能为空!`,
trigger: "blur",
},
];
}
return rules;
}, {});
}
function useForm(FormItems: RenderFormItem[] | SearchFormItem[]) {
const form = reactive<FormData>({
data: {},
rules: {},
items: [],
itemCacheByField: {},
fieldsWillOutput: [],
});
const formItems = computed(() => FormItems.map(mergePorps));
form.rules = computed(() => collectFormRules(form.items));
form.items = computed(() => {
// console.time("formRenderItemsUpdate");
const visibleFormItems = collectVisibleFormItems(
formItems.value,
form.data
);
form.fieldsWillOutput = collectFieldsWillOutput(visibleFormItems);
// console.timeEnd("formRenderItemsUpdate");
return visibleFormItems;
});
form.itemCacheByField = form.items.reduce((cache, formItem) => {
cache[formItem.field] = formItem;
return cache;
}, {});
return form;
}
//表单渲染
export function useRenderForm(props: {
modelValue?: any;
formItems?: RenderFormItem[];
}) {
const form = useForm(props.formItems);
const cancelWatch = watch(
() => props.modelValue,
(dataValue) => {
if (!isObject(dataValue) || isEmpty(dataValue)) {
return;
}
// nextTick(() => cancelWatch());
setInputFormValue(form.data, props.formItems, props.modelValue || {});
},
{ immediate: true }
);
return form;
}
//列表搜索条件表单
export function useFilterForm(filterItems: SearchFormItem[]) {
const form = useForm(filterItems);
// 初始化数据查询数据
form.data = form.items.reduce((data, formItem) => {
const field = formItem.field;
const hasInitValue = formItem.initValue !== undefined;
if (hasInitValue) {
data[field] = formItem.initValue;
}
if (!hasInitValue) {
data[field] = field.includes(",") ? [] : null;
}
return data;
}, {});
return form;
}
formValueParser.ts
js
import { get, set } from "lodash";
import { isEmpty, isObject } from "../../utils/valueTypeCheck";
const isMultipleField = (field: string) => field.includes(",");
const isDeepObjField = (field: string) => field.includes(".");
const getDeepObjValue = (sourceData: any, field: string) => {
let value = get(sourceData, field);
if (!value) {
const targetKey = field.split(".").reverse()[0];
value = get(sourceData, targetKey);
}
return value;
};
export function setInputFormValue(
result: any,
formItems: FormItem[],
inputFormData: any = {}
) {
formItems.forEach((formItem: FormItem) => {
const field = formItem.field.toString();
const value = get(inputFormData, field);
const extendField = formItem.extendField;
if (isObject(extendField) && !isEmpty(inputFormData)) {
Object.keys(extendField).forEach((field) => {
const fieldValue = get(inputFormData, field);
fieldValue && set(result, field, fieldValue);
});
}
if (value) {
result[field] = value;
return;
}
if (isMultipleField(field)) {
const values = field.split(",").map((key) => {
let value = get(inputFormData, key);
if (!value && isDeepObjField(key)) {
value = getDeepObjValue(inputFormData, key);
}
return value;
});
result[field] = values;
return;
}
if (isDeepObjField(field) && !isMultipleField(field)) {
result[field] = getDeepObjValue(inputFormData, field);
return;
}
});
}
function setOutputFormValue(result: any = {}, field: string, value: any) {
if (!field.includes(",")) {
set(result, field, value);
return;
}
const [startField, endField] = field.split(",");
if (isObject(value) && "min" in value && "max" in value) {
const { min, max } = value;
set(result, startField, min || 0);
set(result, endField, max || 0);
return;
}
if (Array.isArray(value)) {
const [startValue, endValue] = value;
set(result, startField, startValue);
set(result, endField, endValue);
return;
}
}
// 格式化输出
export function formatFormRenderOuputValue(formData, fieldsWillOutput) {
let result: any = {};
console.log("formData", formData);
Object.keys(formData)
.filter((field) => fieldsWillOutput.includes(field))
.forEach((field: string) => {
const formItemValue = formData[field];
setOutputFormValue(result, field, formItemValue);
});
return result;
}
export function formatSearchFormOuputValue(
formData: any,
fieldsWillOutput
): any {
const result: any = {};
Object.keys(formData)
.filter((field) => fieldsWillOutput.includes(field))
.forEach((field: string) => {
const formItemValue = formData[field];
formItemValue && setOutputFormValue(result, field, formItemValue);
});
return Object.keys(result).reduce((res, key) => {
if (!isEmpty(result[key])) {
res[key] = result[key];
}
return res;
});
}
formRuleParser.ts
js
import { isEmpty, isString } from "../../utils/valueTypeCheck";
export 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 ruleValueCompareFunc = {
equals: (formItemValue, ruleValue) => formItemValue === ruleValue,
not: (formItemValue, ruleValue) => formItemValue !== ruleValue,
in: (formItemValue, ruleValue) =>
!isEmpty(ruleValue) && ruleValue.includes(formItemValue),
notIn: (formItemValue, ruleValue) =>
!isEmpty(ruleValue) && !ruleValue.includes(formItemValue),
includes: (formItemValue, ruleValue) =>
!isEmpty(formItemValue) && formItemValue.includes(ruleValue),
excludes: (formItemValue, ruleValue) =>
formItemValue && !formItemValue.includes(ruleValue),
empty: (formItemValue, ruleValue) =>
ruleValue ? isEmpty(formItemValue) : !isEmpty(formItemValue),
notEmpty: (formItemValue, ruleValue) =>
ruleValue ? !isEmpty(formItemValue) : isEmpty(formItemValue),
lt: (formItemValue, ruleValue) =>
!isEmpty(ruleValue) && formItemValue < ruleValue,
lte: (formItemValue, ruleValue) =>
!isEmpty(ruleValue) &&
(formItemValue < ruleValue || formItemValue == ruleValue),
gt: (formItemValue, ruleValue) =>
!isEmpty(ruleValue) && formItemValue > ruleValue,
gte: (formItemValue, ruleValue) =>
!isEmpty(ruleValue) &&
(formItemValue > ruleValue || formItemValue == ruleValue),
};
const ruleValueCompareFuncNames: string[] = Object.keys(ruleValueCompareFunc);
const parseFormItemKey = (formItemKey) => {
const isOr = formItemKey.startsWith("$");
const key = isOr ? formItemKey.substring(1) : formItemKey;
return {
key,
condition: isOr ? "||" : "&&",
};
};
const parseRule = (rule: any) => {
if (isString(rule)) {
return rule;
}
let valueStr = "";
Object.keys(rule).forEach((ruleName, index) => {
let { key, condition } = parseFormItemKey(ruleName);
condition = index == 0 ? "" : condition;
if (!ruleValueCompareFuncNames.includes(key)) {
return;
}
const formItemValue = rule.formItemValue;
const ruleValue = rule[ruleName];
valueStr += `${condition}${ruleValueCompareFunc[key](
formItemValue,
ruleValue
)}`;
});
return valueStr ? `(${valueStr})` : "";
};
const parseFields = (fieldRules: any = {}, formData: any = {}) => {
const rules = [];
Object.keys(fieldRules).forEach((formItemKey, index) => {
const { key, condition } = parseFormItemKey(formItemKey);
const formItemValue = formData[key];
const fieldRule = fieldRules[formItemKey];
fieldRule["formItemValue"] = formItemValue;
if (index == 0) {
rules.push(fieldRule);
return;
}
rules.push(condition);
rules.push(fieldRule);
});
return rules;
};
export const fieldRulePaser = (fieldRules, formData) => {
const rules = parseFields(fieldRules, formData).map((fieldRule) =>
isString(fieldRule) ? fieldRule : parseRule(fieldRule)
);
return eval(
rules.reduce((initValue, itemValue) => (initValue += itemValue), "")
);
};
eventProxy.ts (extendField实现核心)
js
import {
isObject,
isArray,
isString,
isFunction,
isEmpty,
} from "@/components/utils/valueTypeCheck";
function loop() {}
function getOptionValue(options, isMultiple) {
return isMultiple ? options : options[0];
}
export function onChangeProxy(options: any, formData: any, formItem: FormItem) {
let { extendField, events } = formItem;
// 参数归一化
events = events || {};
const changeEvent = events.change || events.onChange || events["on-change"];
const dispatchChangeEvent = isFunction(changeEvent) ? changeEvent : loop;
const isMultiple = Array.isArray(options);
//选中数据归一化为数组
let selections: any[] = isMultiple ? options : [options].filter(Boolean);
if (isEmpty(extendField) || !isObject(extendField)) {
dispatchChangeEvent(getOptionValue(selections, isMultiple), formData);
return;
}
/******************存在extendField情况处理*************************/
const result: any = {};
for (let key in extendField) {
let values = null;
let extendFieldValue = extendField[key];
if (isObject(extendFieldValue)) {
const sourceKeys = Object.values(extendFieldValue);
const targetKeys = Object.keys(extendFieldValue);
values = selections.map((item: any) => {
return sourceKeys.reduce((newItem: any, sourceKey: string, index) => {
const targetKey = targetKeys[index];
newItem[targetKey] = item[sourceKey];
return newItem;
}, {});
});
} else if (isArray(extendFieldValue)) {
values = selections.map((item: any) => {
return extendFieldValue.reduce(
(newItem: any, sourceKey: string, index) => {
newItem[sourceKey] = item[sourceKey];
return newItem;
},
{}
);
});
} else if (isString(extendFieldValue)) {
values = selections.map((item) => item[extendFieldValue]);
}
result[key] = isMultiple ? values : values[0];
}
Object.assign(formData, result);
dispatchChangeEvent(getOptionValue(selections, isMultiple), formData);
}
RormRender.vue
js
<template>
<el-form @submit.prevent="() => { }" ref="formInstance" v-bind="Object.assign({ labelWidth: 80 }, $attrs)"
:model="form.data" :rules="form.rules">
<template v-for="(item, index) in form.items" :key="item.field || index">
<FormItemRender :item="item" v-model="form.data[item.field]"></FormItemRender>
</template>
</el-form>
</template>
<script setup lang="ts">
import FormItemRender from "./FormItemRender.vue";
import { useRenderForm, formatFormRenderOuputValue } from "./shared";
import { ElForm } from "element-plus";
console.time("FormRenderInitSpendTime");
export interface Props {
modelValue?: any;
formItemSpan?: number;
formItems: RenderFormItem[] | any[];
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => ({}),
formItems: () => [],
formItemSpan: 24,
});
let form = useRenderForm(props);
const formInstance = ref<typeof ElForm>(null);
let getFormData = async () => {
if (!formInstance.value) return;
const isValid = await formInstance.value.validate();
return isValid ? formatFormRenderOuputValue(form.data, form.fieldsWillOutput) : null;
};
const emit = defineEmits(["update:modelValue"]);
const updateModelValue = (value: any) => {
emit("update:modelValue", value);
};
const submit = async (callback: Function) => {
if (callback && typeof callback !== "function") {
console.error(`submit 回调函数必须是方法`);
return;
}
const formData = await getFormData();
callback(formData);
updateModelValue(formData);
return formData;
};
const resetFields = () => {
formInstance.value.resetFields();
};
defineExpose({
submit,
resetFields,
getFormData,
});
</script>
FormItemRender.vue
js
<template>
<el-form-item ref="formItemInstance" v-bind="props.item" :prop="props.item.field">
<el-input
v-if="uiType == 'input'"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</el-input>
<BasicSelector
v-else-if="uiType == 'selector'"
:validate-event="false"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</BasicSelector>
<DateTimePickerPlus
v-else-if="uiType == 'datetimepicker'"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</DateTimePickerPlus>
<RichTextEditor
v-else-if="uiType == 'richtexteditor'"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</RichTextEditor>
<RadioPlus
v-else-if="uiType == 'radio'"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</RadioPlus>
<CheckBoxPlus
v-else-if="uiType == 'checkbox'"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</CheckBoxPlus>
<component
v-else
:is="uiType"
v-bind="props.item.props"
v-on="props.item.events"
v-model="formItemValue"
>
</component>
</el-form-item>
</template>
<script setup lang="ts">
import BasicSelector from "../Selectors/BasicSelector.vue";
import DateTimePickerPlus from "../Selectors/DateTimeSelector/DateTimeSelector.vue";
import RichTextEditor from "../RichTextEditor/RichTextEditor.vue";
import RadioPlus from "../Selectors/Radio.vue";
import CheckBoxPlus from "../Selectors/Checkbox.vue";
interface Props {
item: RenderFormItem;
modelValue: any;
}
const formItemInstance = ref<any>();
const props = defineProps<Props>();
const uiType = computed(() =>
// props.item.uiType
typeof props.item.uiType == "string"
? props.item.uiType.toLocaleLowerCase()
: props.item.uiType
);
const emit = defineEmits(["update:modelValue"]);
const formItemValue = computed({
get() {
return props.modelValue;
},
set(val) {
emit("update:modelValue", val);
},
});
</script>
SearchForm.vue
js
<style lang="scss">
.search-form {
--el-date-editor-width: 100%;
height: unset;
transition: all 0.3s linear;
will-change: height;
text-align: left;
.el-form-item {
position: relative;
width: 98%;
}
.el-form-item__content-right {
.el-form-item__content {
justify-content: flex-end;
}
}
.el-form-item__label {
position: absolute;
left: 0px;
top: -6px;
z-index: 10;
width: fit-content;
height: 12px;
line-height: 12px !important;
padding: 0 6px;
margin: 0;
color: #999;
background: #fff;
transform: scale(0.75);
}
.el-input,
.el-input__inner {
width: 100% !important;
// text-align: center;
color: #2d4457;
}
.el-button {
padding: 6px 12px;
}
.el-button+.el-button {
margin-left: 4px;
}
section[uitype="dateTimePicker"] {
text-align: left !important;
}
}
</style>
<template>
<el-form ref="formInstance" class="search-form" inline :model="form.data" :rules="form.rules"
@keyup.native.enter.pre="dispatchSearch(form.data)">
<el-row>
<el-col v-for="item in visibleFormItems" :key="item.field" :span="item.span || LayoutConfig.defaultItemSpan">
<el-form-item :label="`${item.label}`" v-bind="item" :prop="item.field">
<FormItemRender :item="item" v-model="form.data[item.field]"></FormItemRender>
</el-form-item>
</el-col>
<el-col :span="LayoutConfig.actionSpan" :offset="fullCosapse ? 18 : 0">
<el-form-item :class="{ 'el-form-item__content-right': fullCosapse }">
<el-button type="primary" @click="dispatchSearch(form.data)">查询</el-button>
<el-button @click="resetForm()">清空</el-button>
<el-button type="default" text v-show="LayoutConfig.hasMore" @click="collapse = !collapse">
{{ !collapse ? "展开" : "收起" }}
<el-icon style="vertical-align: middle">
<ArrowDown v-show="!collapse"></ArrowDown>
<ArrowUp v-show="collapse"></ArrowUp>
</el-icon>
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script setup lang="ts">
import { ArrowDown, ArrowUp } from "@element-plus/icons-vue";
import { useFilterForm, formatSearchFormOuputValue } from "./shared";
import FormItemRender from "./FormItemRender.vue";
export interface Props {
filterItems: SearchFormItem[];
}
const props = withDefaults(defineProps<Props>(), {
filterItems: () => [],
});
const form = useFilterForm(props.filterItems);
/*****触发查询逻辑******/
const emit = defineEmits(["search", "update:height"]);
const formInstance = ref(null);
const dispatchSearch = (formData) => {
formInstance.value.validate((isValid) => {
if (!isValid) return;
emit("search", formatSearchFormOuputValue(formData, form.fieldsWillOutput));
});
};
const resetForm = () => {
// Object.keys(form.data).forEach(
// (key) => (form.data[key] = Array.isArray(form.data[key]) ? [] : null)
// );
formInstance.value.resetFields();
};
onMounted(() => dispatchSearch(form.data));
/**操作按钮动态布局控制**/
const LayoutConfig = computed(() => {
// 24列栅格布局
const totalSpanEveryRow = 24;
// 每行放置Item个数
const formItemNumOfEveryRow = 4;
//操作栏占用
const actionSpan = 6;
const defaultItemSpan = totalSpanEveryRow / formItemNumOfEveryRow;
const formLength = props.filterItems.length;
return {
formItemNumOfEveryRow,
hasMore: formLength > formItemNumOfEveryRow - 1,
defaultItemSpan,
actionSpan,
};
});
const visibleFormItems = ref<FormItem[]>([]);
const collapse = ref<boolean>(false);
const fullCosapse = ref<boolean>(false);
const filterVisibleFormItems = (isCollapse: boolean) => {
const totalFormItemNum = props.filterItems.length;
const formItemNumOfEveryRow = LayoutConfig.value.formItemNumOfEveryRow;
// 元素是否刚好占满一行
const isFull = totalFormItemNum % formItemNumOfEveryRow == 0;
visibleFormItems.value = form.items.filter((formItem, index) => {
if (index + 1 < formItemNumOfEveryRow || isCollapse) {
return formItem;
}
});
fullCosapse.value = isFull && isCollapse;
};
/***更新控件高度***/
const updateHeight = () => {
nextTick(() => emit("update:height", formInstance.value.$el.clientHeight));
};
const stopWatch = watch(
collapse,
(isCollapse: boolean) => {
updateHeight();
filterVisibleFormItems(isCollapse);
},
{ immediate: true }
);
onUnmounted(() => stopWatch());
</script>
至此本方案设计以及代码分享至此结束。
尚未解决问题:
1、动态全局更新会触发必填控件校验,影响体验
2、部分控件的slot支持(已有方案,待实现后确认可行再补充该文分享)
如有更好的解决方案,望不吝赐教~
看到这里的道友,你已经沾染了因果,唯有点赞方能破除~~~
下集预告:设计一个好用的弹窗表单