概要
为了实现需求:后台控制表单搜索项的显隐和排序;
整体思路流程
表单搜索项统一配置,封装成一个组件,把不同类别再封装成单个的组件,配置项数组由前端控制(暂由前端配置,这样虽然代码量多,但是对于前端修改配置比较灵活,暂时不考虑数据由后端取过来的原因是,有些配置,如表单类型不好控制)
接下来看具体实现,再回看也许好理解。
具体实现
一、Form表单封装文件,自定义命名为FreeForm.vue
在vue项目中我们可以把它放到src/components这个文件夹下,新建目录sortForm,将此文件放入
HTML部分:
<template><div class="container"><!-- 这里加了超过4行展开收起的功能 --><div:class="['content', { collapsed: isCollapsed }]":style="contentStyle"ref="content"><el-form:ref="formRef"size="small":inline="true":model="model"v-bind="$attrs"v-show="showSearch":label-width="labelWidth"label-position="left"label-suffix=":"><!-- formItemConfig 表单数据源 --><divv-for="(item, index) in formItemConfig":key="index":style="{ display: 'inline-block' }"><!-- 渲染表单项 --><el-form-itemv-if="item.show":label="item.label":prop="item.prop"style="width: 100%":label-width="item.labelWidth"><!-- 动态渲染组件 --><component:is="isComponentName(item)"v-model="model[item.prop]":placeholder="placeholder(item)"v-bind="item":style="{ width: item.width }":multiple="item.multiple"@input="changeValue(item, $event)"@click="handleClick(item, $event)"@change="handleChange(item, $event)"/></el-form-item></div></el-form></div><!-- 按钮组:这里是封装了搜索重置按钮 --><div class="form-op-container"><el-button type="text" @click="toggle" v-if="parseInt(maxHeight) > 210"><div class="button-text">{{ buttonText }}<div :class="{ isTransIcon: !isCollapsed }"><span class="iconfont icon-zhankai"></span></div></div></el-button><div class="header_btns"><slot name="header_btns_before"></slot></div><div class="header_btns"><el-button size="mini" type="primary" @click="handleSearch">搜索</el-button><el-button size="mini" @click="handleReset">重置</el-button></div><div class="header_btns"><slot name="header_btns_after"></slot></div></div></div>
</template>
JS部分:
<script>
import { cloneDeep } from "lodash";
/*** @desc 表单组件* @param {Object} formRef - el-form 的 ref 名称* @param {Object} model - 表单数据模型* @param {Object} formItemConfig - el-form-item 配置项*/
export default {props: {// 表单引用名称formRef: {type: String,default: "formRef",},// 表单数据模型model: {type: Object,default: () => ({}),},// 表单项配置formItemConfig: {type: Array,default: () => [],},showSearch: {type: Boolean,default: true,},labelWidth: {type: String,default: "100px",},},data() {return {init: null,isCollapsed: true,maxHeight: "200px",};},computed: {contentStyle() {return {maxHeight: this.isCollapsed ? "200px" : this.maxHeight,transition: "max-height 0.2s ease",};},buttonText() {return this.isCollapsed ? "展开" : "收起";},/*** 根据组件类型获取需要渲染的组件名称*/isComponentName() {return (item) => {if (item.component === "el-select") {return "SelectForm";} else if (item.component === "radio") {return "RadioGroupForm";} else if (item.component === "checkbox") {return "CheckboxGroupForm";} else if (item.component === "date-picker") {return "DatePickerForm";} else if (item.component === "number-range") {return "NumberRange";} else {return item.component || "el-input";}};},/*** 根据表单项配置获取占位符*/placeholder() {return (item) => {if (item.placeholder) return item.placeholder;const arr = ["el-input", "el-input-number"];return !item.component || arr.includes(item.component)? `请输入${item.label || ""}`: `请选择${item.label || ""}`;};},},methods: {// 搜索handleSearch() {this.$emit("search");},// 重置handleReset() {this.resetFields();this.$emit("reset");},refreshRealHeight() {this.$nextTick(() => {this.maxHeight = this.$refs.content.scrollHeight + "px";});},toggle() {this.isCollapsed = !this.isCollapsed;},resetFields() {this.copyAndClear(this.model, this.init);this.$refs[this.formRef].resetFields();},copyAndClear(sourceObj, targetObj) {// 删除原对象的所有属性for (const key in sourceObj) {if (sourceObj.hasOwnProperty(key)) {delete sourceObj[key];}}// 遍历原对象的所有属性for (const key in targetObj) {if (targetObj.hasOwnProperty(key)) {// 将属性值复制到目标对象sourceObj[key] = targetObj[key];}}},/*** 验证表单并执行回调函数* @param {Function} cb - 表单验证通过后的回调函数* @returns {boolean} - 表单验证结果*/validate(cb) {this.$refs[this.formRef].validate((valid) => {cb(valid, this.model);if (valid) {// 如果表单验证通过,执行提交操作} else {// 如果表单验证失败,处理失败情况return false;}});},/*** 处理表单项的点击事件* @param {Object} item - 当前点击的表单项配置*/handleClick(item, e) {// 处理数据改变的逻辑item.onClick ? item.onClick(e) : () => {};},//change型式的回调handleChange(item, e) {item.onChange ? item.onChange(e) : () => {};},/*** 更新表单数据模型到父组件*/changeValue(item, e) {this.$emit("input", e);},convertKeysToNested(obj) {const result = {};for (const key in obj) {if (obj.hasOwnProperty(key)) {// 递归判断当前对象是否存在 ['a.b'] 的 keythis.setNestedProperty(result, key, obj[key]);}}return result;},setNestedProperty(obj, path, value) {// 根据path 解析属性路径const keys = path.split(".");let current = obj;for (let i = 0; i < keys.length; i++) {const key = keys[i];if (i === keys.length - 1) {// 不是最叶子节点赋值this.$set(current, key, value);} else {// 不是最叶子节点赋值空对象if (!current[key]) {this.$set(current, key, {});}current = current[key];}}},getFormParams() {return this.convertKeysToNested(this.model);},},watch: {model: {handler(val) {if (!this.init) {this.init = cloneDeep(val);}},deep: true,immediate: true,},formItemConfig: {handler() {this.refreshRealHeight();},deep: true,immediate: true,},},mounted() {window.addEventListener("resize", this.refreshRealHeight);},beforeDestroy() {window.removeEventListener("resize", this.refreshRealHeight);},
};
</script>
css部分:
<style lang="scss" scoped>
.container {width: 100%;margin: 0 auto;
}.content {overflow: hidden;
}
.form-op-container {display: flex;flex-direction: row;justify-content: end;margin-bottom: 10px;
}
.header_btns {display: flex;align-items: center;justify-content: center;margin-left: 10px;
}
.isTransIcon {transform: rotateX(180deg);
}
.button-text {display: flex;gap: 5px;align-items: center;font-size: 12px;.icon-zhankai {font-size: 11px;}
}
</style>
注释1:这里就是将不同类型的搜索项进行封装,通过组件名字控制渲染的是什么组件,比如输入框,单选框,select选择器、时间选择器等,还可以传入自己封装的组件。
表单搜索项类别
- Cascader级联选择器
- Checkbox 多选框
- DatePicker 日期选择器
- Radio 单选框
- Select 选择器
1.Cascader级联选择器
<template><el-cascader:options="options":props="cascaderProps":collapse-tags="collapseTags"collapse-tags-tooltipclearablev-model="internalValue"v-on="$listeners"v-bind="$attrs"/>
</template><script>
export default {name: 'CascaderForm',props: {value: {type: [String, Array, Object],default: () => ([]),},options: {type: Array,required: true,},cascaderProps: {type: Object,default: () => ({}),},collapseTags: {type: Boolean,default: true,},},data() {return {internalValue: this.value,};},watch: {value(newVal) {this.internalValue = newVal;},internalValue(newVal) {this.$emit('input', newVal);},},
};
</script>
2. Checkbox 多选框
<template><el-checkbox-group v-model="internalValue" v-on="$listeners" v-bind="$attrs"><el-checkboxv-for="option in options":key="option.value":label="option.value">{{ option.label }}</el-checkbox></el-checkbox-group>
</template><script>
export default {name: "CheckboxGroupForm",props: {value: {type: Array,default: () => [],},options: {type: Array,default: () => [],},},data() {return {internalValue: this.value,};},watch: {value(newVal) {this.internalValue = newVal;},internalValue(newVal) {this.$emit("input", newVal);},},
};
</script>
3.DatePicker 日期选择器
<template><el-date-picker v-model="internalValue" v-on="$listeners" v-bind="$attrs" />
</template><script>
export default {name: "DatePickerForm",props: {value: {type: [String, Array, Date],default: "",},},data() {return {internalValue: this.value,};},watch: {value(newVal) {this.internalValue = newVal;},internalValue(newVal) {this.$emit("input", newVal);},},
};
</script>
4.Radio 单选框
<template><el-radio-groupv-model="internalValue"v-on="$listeners"v-bind="$attrs"size="small"class="radioGroupForm":class="$attrs.isButton ? 'is-button' : ''"><template v-if="$attrs.isButton"><el-radio-buttonv-for="(option, index) in options":key="index":label="option.value">{{ option.label }}</el-radio-button></template><template v-else><el-radiov-for="(option, index) in options":key="index":label="option.value">{{ option.label }}</el-radio></template></el-radio-group>
</template><script>
export default {props: {value: [String, Number],options: {type: Array,default: () => [],},},data() {return {internalValue: this.value,};},watch: {value(newVal) {this.internalValue = newVal;},internalValue(newVal) {this.$emit("input", newVal);},},
};
</script>
5.Select 选择器
<template><el-selectv-bind="$attrs"v-on="$listeners"v-model="modelValue":multiple="multiple"collapse-tags><el-optionv-for="(option, index) in options":key="index":label="option.label":value="option.value"></el-option></el-select>
</template><script>
export default {props: {value: {required: true,},options: {type: Array,default: () => [],},multiple:{type: Boolean,default: false}},computed: {modelValue: {get() {return this.value;},set(val) {this.$emit("input", val);},},},
};
</script>
封装控制显隐排序的按钮
HTML部分:
<template><el-dropdown trigger="click"><slot name="cus_button" v-if="cusButton" /><el-button icon="el-icon-s-operation" size="mini" v-else>列设置</el-button><el-dropdown-menu slot="dropdown"><el-treedraggable:data="formItemConfig":props="defaultProps":allow-drop="allowDrop"@node-drag-over="handleNodeDragOver"><span slot-scope="{ data }" class="tree-table-setting"><el-checkboxv-model="data.show":disabled="data.setFromDisabled"@change="handleFormChange(data.id)"/><span class="tree-label">{{data.label || data.startPlaceholder || "无标题"}}</span><i class="iconfont icon-tuodong1 tree-icon"></i></span></el-tree><el-buttontype="primary"size="mini"class="save-form-config"@click="saveFormConfig">保存</el-button></el-dropdown-menu></el-dropdown>
</template>
JS部分:
<script>
import { reportedFormPageInfo } from "@/api/custom/index.js";
export default {name: "formConfig",props: {formItemConfig: {type: Array,default: () => [],},cusButton: {type: Boolean,default: false,},},data() {return {defaultProps: {children: "children",label: "label",disabled: "setFromDisabled",},hiddenForms: [], // 存储隐藏表单的项的 id};},methods: {// 筛选数组:重新格式化数组中的对象filterArray(arr) {return arr.map((item, index) => ({code: item.id || item,sort: index + 1,}));},allowDrop(draggingNode, dropNode, type) {// 控制拖放的逻辑:仅允许Tree节点上下拖动return type !== "inner";},// el-tree拖拽删除禁用标志handleNodeDragOver(node, enter, e) {e.preventDefault(); // 防止默认处理e.dataTransfer.dropEffect = "move"; // 设置拖动效果为move},handleFormChange(itemId) {const columnIndex = this.hiddenForms.indexOf(itemId);const item = this.formItemConfig.find((item) => item.id === itemId);if (item) {if (!item.show) {// 如果列被隐藏且不在数组中,则添加if (columnIndex === -1) {this.hiddenForms.push(itemId);}} else {// 如果列被显示且在数组中,则移除if (columnIndex !== -1) {this.hiddenForms.splice(columnIndex, 1);}}}// 返回show为true的数组return this.formItemConfig.filter((item) => item.show === true);},// 保存表单配置saveFormConfig() {const params = this.filterArray(this.handleFormChange());reportedFormPageInfo("custom", params).then((res) => {if (res.code === 200) {this.$message({message: "保存成功",type: "success",duration: 1500,});}});},},
};
</script>
css部分:
<style scoped lang="scss">
::v-deep div[aria-disabled="true"] {display: none;
}
.tree-table-setting {display: flex;align-items: center;justify-content: space-around;.tree-label {width: 120px; /* 设置固定宽度,根据需要调整 */white-space: nowrap;overflow: hidden;text-overflow: ellipsis;margin-left: 5px;}.tree-icon {margin-left: auto; /* 将图标推到容器的最右边 */margin-right: 15px; /* 将图标推到容器的最右边 */}
}
.save-form-config {margin-left: 24px;
}
.cus-shezhi {border: 1px solid #e7e7e7;border-radius: 10px;
}
</style>
如何使用
前端通过配置项可以控制显隐,在FreeForm组件里是通过v-show控制的;
排序的话是element-UI组件里Tree 树形控件可拖拽节点实现的;
现在在前端保存后,刷新后还是会复原的,所以我们要保存起来,如果没有特别高的要求,可以放在localstorage里,这里我们的需求是需要保存在后端,不同的部门看到的表格、表单不一样。我们要把配置好的数据传给后端,但是最开始渲染的时候最好还是从后端拿数据,哪个部门能看到哪些搜索项,所以我们和后端约定了一个方案,提供了一个版本号的概念,前端每次更改有新增表单配置项的时候,就修改版本号,版本号一样就正常从后端拿数据,然后渲染,不一样或者最开始没有版本号的时候,把前端的表单配置项数组上报给后端。
1.在列表页引入该表单组件FreeForm,HTML部分:
<free-formref="form"formRef="freeForm":model="formData":formItemConfig="showFormItemConfig":labelWidth="labelWidth"label-position="top":showSearch.sync="showSearch"@search="handleQuery"@reset="resetQuery"@keyup.enter.native="handleQuery"><template #header_btns_after><formConfig :formItemConfig="formItemConfig" cusButton><template #cus_button><el-buttonsize="mini"circleicon="iconfont icon-shezhi cus-shezhi"/></template></formConfig></template></free-form>
// 表单数据formData: {queryParams: { pageNum: 1, pageSize: 10 },},labelWidth: "120px",formItemConfig: [], // 存储表单项的配置version: null,
created() {// 初始化表单配置this.initFormItemConfig();// 获取页面信息:版本号、form表单、table表格配置项this.getPageInfo();},
这些数据源走的是接口,做了统一的处理:
computed: {// 获取渲染数据源showFormItemConfig() {return this.formItemConfig.map((item) => {if (item.id === "xxx") {return {...item,options: this.xxxList,};} else if (item.id === "zzz") {return {...item,options: this.zzzList,};} else if (item.id === "yyy") {return {...item,options: this.yyyOptions,cascaderProps: this.deptProps,};} else if (item.id === "xx" ||item.id === "xx" ||item.id === "xx") {return {...item,options: this.aaaOptions,cascaderProps: this.props,};}// 其他配置项的处理逻辑...return item;});},},
methods:{// 初始化表单配置initFormItemConfig() {this.formItemConfig = [{id: "namePhone",label: "姓名/手机号",prop: "queryParams.namePhone",clearable: true,component: "el-input", // el-input可以省略,默认使用el-inputplaceholder: "姓名/手机号", // placeholder可以省略,默认显示“请输入+label”show: true, // 展示与隐藏maxlength: "11",},{id: "status",label: "状态",prop: "queryParams.status",clearable: true,component: SelectForm, // el-input可以省略,默认使用el-inputplaceholder: "状态", // placeholder可以省略,默认显示“请输入+label”show: false, // 展示与隐藏multiple: true,options: that.dict.type.xx_status,//这里走的是字典},{id: "xxx",label: "新之助",prop: "queryParams.xxx",component: SelectForm, // 可以传入任意组件placeholder: "新之助",clearable: true,options: that.customerManagerList,show: false, // 展示与隐藏multiple: true,},{id: "xxx",label: "上学时间",prop: "xxx",component: DatePickerForm, // el-input可以省略,默认使用el-inputtype: "daterange",startPlaceholder: "上学时间",valueFormat: "yyyy-MM-dd",hidden: false,show: false,width: "205px",setFromDisabled: false, //设置表单配置项是否禁用},{id: "xxx",label: "放学时间",prop: "xxx",component: DatePickerForm, // el-input可以省略,默认使用el-inputtype: "daterange",startPlaceholder: "放学时间",valueFormat: "yyyy-MM-dd",hidden: false,show: false,width: "205px",setFromDisabled: false, //设置表单配置项是否禁用},{id: "remark",label: "备注",prop: "queryParams.remark",clearable: true,component: "el-input", // el-input可以省略,默认使用el-inputplaceholder: "备注", // placeholder可以省略,默认显示“请输入+label”show: false, // 展示与隐藏},{id: "xxx",label: "xx状态",prop: "queryParams.xxx",component: SelectForm, // 可以传入任意组件placeholder: "xx状态",clearable: true,options: [{value: 0,label: "xxx",},{value: 1,label: "xxx",},],show: false, // 展示与隐藏},{id: "xxx",label: "妮妮",prop: "queryParams.xxx",component: SelectForm, // 可以传入任意组件placeholder: "妮妮",clearable: true,options: that.provinceList,show: false, // 展示与隐藏},{id: "xxx",label: "风间",prop: "xxx",component: CascaderForm, // 可以传入任意组件placeholder: "风间",clearable: true,options: that.deptNameOptions, // 设置 optionscascaderProps: that.deptProps, // 设置 propsshow: false, // 展示与隐藏},{id: "xxx",label: "阿呆",prop: "xxx",component: CascaderForm, // 可以传入任意组件placeholder: "阿呆",clearable: true,options: that.channelOptions, // 设置 optionscascaderProps: that.props, // 设置 propsshow: false, // 展示与隐藏},{id: "xxx",label: "正南",prop: "xxx",component: NumberRange,clearable: true,width: "205px",show: false, // 展示与隐藏startPlaceholder: "请输入",endPlaceholder: "请输入",},];},
}
// 获取页面信息:版本号、form表单、table表格配置项getPageInfo() {// 要保证每个页面的pageCode唯一getPageInfo("xx").then((res) => {if (res.code === 200) {// 获取当前版本号this.version = process.env.VUE_APP_VERSION;// 判断后端版本号是否存在、是否一致if (res.data && res.data.version === this.version) {// 不上报版本号,将获取到的表单、表格配置项数组渲染到前端界面let searchFields = res.data.searchFields;let tableFields = res.data.tableFields;this.formItemConfig = this.matchAndModify(this.formItemConfig,searchFields);this.tableItemConfig = this.matchAndModify(this.tableItemConfig,tableFields);return;} else if (!res.data || res.data.version !== this.version) {// 版本不一致,将表单、表格配置项数组传给后端reportedPageInfo({pageCode: "xx",version: process.env.VUE_APP_VERSION,searchFields: this.filterArray(this.formItemConfig),tableFields: this.filterArray(this.tableItemConfig),}).then((res) => {if (res.code === 200) {let searchFields = res.data.searchFields;let tableFields = res.data.tableFields;// 从后端拿到的表单、表格配置项数组渲染到前端界面this.formItemConfig = this.matchAndModify(this.formItemConfig,searchFields);this.tableItemConfig = this.matchAndModify(this.tableItemConfig,tableFields);}});}}});},
// 定义一个方法来处理匹配matchAndModify(originalArray, backendArray) {// 克隆一份原始数组,以免直接修改原数组const clonedArray = [...originalArray];// 根据 backendArray 中的顺序对 clonedArray 进行排序clonedArray.sort((a, b) => {let indexA = backendArray.findIndex((item) => item.code === a.id);let indexB = backendArray.findIndex((item) => item.code === b.id);return indexA - indexB;});// 对排序后的 clonedArray 进行遍历和处理const res = clonedArray.map((item) => {const flag = backendArray.find((backendItem) => {return backendItem.code === item.id;});if (flag) {return {...item,show: true,};}return item;});return res;},
// 筛选数组:重新格式化数组中的对象filterArray(arr) {return arr.map((item, index) => ({code: item.id || item,sort: index + 1,}));},
小结
参考文章链接:
https://juejin.cn/post/7022140926906597384https://juejin.cn/post/7022140926906597384
https://juejin.cn/post/7311602153826402313?searchId=20240726140625580D4C02135D9973239D#heading-21https://juejin.cn/post/7311602153826402313?searchId=20240726140625580D4C02135D9973239D#heading-21