概要
为了实现需求:后台控制表单搜索项的显隐和排序;
整体思路流程
表单搜索项统一配置,封装成一个组件,把不同类别再封装成单个的组件,配置项数组由前端控制(暂由前端配置,这样虽然代码量多,但是对于前端修改配置比较灵活,暂时不考虑数据由后端取过来的原因是,有些配置,如表单类型不好控制)
接下来看具体实现,再回看也许好理解。
具体实现
一、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 表单数据源 -->
<div
v-for="(item, index) in formItemConfig"
:key="index"
:style="{ display: 'inline-block' }"
>
<!-- 渲染表单项 -->
<el-form-item
v-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'] 的 key
this.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-tooltip
clearable
v-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-checkbox
v-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-group
v-model="internalValue"
v-on="$listeners"
v-bind="$attrs"
size="small"
class="radioGroupForm"
:class="$attrs.isButton ? 'is-button' : ''"
>
<template v-if="$attrs.isButton">
<el-radio-button
v-for="(option, index) in options"
:key="index"
:label="option.value"
>
{{ option.label }}
</el-radio-button>
</template>
<template v-else>
<el-radio
v-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-select
v-bind="$attrs"
v-on="$listeners"
v-model="modelValue"
:multiple="multiple"
collapse-tags
>
<el-option
v-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-tree
draggable
:data="formItemConfig"
:props="defaultProps"
:allow-drop="allowDrop"
@node-drag-over="handleNodeDragOver"
>
<span slot-scope="{ data }" class="tree-table-setting">
<el-checkbox
v-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-button
type="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-form
ref="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-button
size="mini"
circle
icon="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-input
placeholder: "姓名/手机号", // placeholder可以省略,默认显示“请输入+label”
show: true, // 展示与隐藏
maxlength: "11",
},
{
id: "status",
label: "状态",
prop: "queryParams.status",
clearable: true,
component: SelectForm, // el-input可以省略,默认使用el-input
placeholder: "状态", // 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-input
type: "daterange",
startPlaceholder: "上学时间",
valueFormat: "yyyy-MM-dd",
hidden: false,
show: false,
width: "205px",
setFromDisabled: false, //设置表单配置项是否禁用
},
{
id: "xxx",
label: "放学时间",
prop: "xxx",
component: DatePickerForm, // el-input可以省略,默认使用el-input
type: "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-input
placeholder: "备注", // 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, // 设置 options
cascaderProps: that.deptProps, // 设置 props
show: false, // 展示与隐藏
},
{
id: "xxx",
label: "阿呆",
prop: "xxx",
component: CascaderForm, // 可以传入任意组件
placeholder: "阿呆",
clearable: true,
options: that.channelOptions, // 设置 options
cascaderProps: that.props, // 设置 props
show: 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