封装后台通用型页面及编辑弹窗实现思路与过程

这篇文章是关于近期【监测用物联网提升智能传感器和系统软件开发】项目中,我在其中摸鱼凑数日子里积攒下来的系列文章一: 关于在一般后台系统中封装通用型大页面【表单筛选项+树状组织架构+表格+分页】及新增、编辑弹窗的实现思路、实现过程


技术栈: vue3.x+TS(X)+Arco Design


需求分析:

​ 一般后台系统通用型页面一般由这几个元素构成: 顶部表单筛选项、一些新增等的操作按钮、左侧树状组织架构、右侧表格数据展示、底部分页;在传统开发中,虽借助了element-ui等业内优秀组件库,但完整写出一个页面还是需要费一番功夫的;

​ 一般新增、编辑为弹窗形式展现出来, 其中包括:各种表单输入项、表单验证等这些元素, 这些完全复制于组件库及老项目也需要狠狠的费一番功夫,给我们开发人员带来了很不快乐的感觉;

​ 所以我们能不能尽可能的把常见的业务实现过程封装起来,大大提高效率呢?基于这一初衷,结合目前的项目有了大胆尝试的想法。

一般后台系统通用型页面



实现思路:

  1. 表单项(页面筛选、编辑弹窗表单)

    1. 弹窗的控制
    2. 表单的验证及字段绑定、回显
    3. 根据需求渲染不同类型的输入项
  2. 左侧树状组织架构

    1. 展示组织架构(集团、分公司、部门【项目】)

    2. 点击哪个级别,在表格中展示哪个级别的数据

      给其传入一个获取组织架构的接口地址,组件挂载时自动获取数据并展示数据,在点击交互时候 调用表格获取数据方法,以获取对应的数据。

  3. 表格及分页【感谢字节UI已经将表格和分页融合起来,并且表格天然支持jsx语法(render方法)】

    这一块基本把Arco的表格项搬过来就好,唯一需要进一步操作的是:给表格一个获取数据的接口地址,在组件挂载时自动去请求数据并渲染到表格上。



封装原则:低耦合、高可用



实现过程:(代码)

编辑、新增弹窗

封装代码:
import {defineComponent, ref} from "vue";
import request from "@/api/request";
import {Message} from "@arco-design/web-vue";

export default defineComponent({
    emits: ['done'],
    props: {
        writerData: {
            type: Object,
            required: true,
            default: () => ({})
        }
    },
    setup({writerData}, context) {
        const writerFormRef = ref(null)
        const titleName = ref<String>('')
        const sourceFormData = ref({})
        // 检查数据
        // action url is required
        if (!writerData.actionUrl) {
            throw new Error("请传入新增/编辑得保存接口地址")
        }
        // form options is required
        if (!writerData.form) {
            throw new Error("请传入表单配置项")
        }
        const {form} = writerData;
        let _selfForm = Object.assign({}, form)
        if (writerData.getUrl || JSON.stringify(writerData.formData) !== '{}') {
            titleName.value = writerData.titleObj['edit'] || '编辑'
            // 编辑时候 回显数据
            if (writerData.getUrl) {
                // todo: 获取数据 sourceFormData.value
                request.get(writerData.getUrl).then(res => {
                    console.log(res)
                    sourceFormData.value = res
                })
            }
            if (writerData.formData) {
                sourceFormData.value = writerData.formData
                // 将多选得字段给改成数组
                form.formItems.forEach(item => {
                    if (item?.inputOptions?.props?.multiple) {
                        sourceFormData.value[item.name] = sourceFormData.value[item.name].split(',').map(Number)
                    }
                })
            }
        } else {
            titleName.value = writerData.titleObj['add'] || '新增'
            // 组成新增时候的表单数据
            _selfForm.formItems.forEach(item => {
                sourceFormData.value[item.name] = item.value
            })
        }

        
        // 生成表单项
        const getFormItems = () => {
            let formItems = _selfForm.formItems;
            let nodes = []
            formItems.forEach((item, index) => {
                if (!item.hidden) {
                    let node = (<a-form-item label={item.title + ':'} field={item.name} {...item.props}>
                        {getInputItem(item)}
                    </a-form-item>)
                    nodes.push(node)
                }
            })
            return nodes
        }
        // 生成表单输入项
        const getInputItem = (item) => {
            let {type, props, render} = item.inputOptions
            switch (type) {
                case 'input':
                    return  (
                        <a-input v-model={sourceFormData.value[item.name]} {...props} allow-clear />
                    )
                case 'select':
                    return (
                        <a-select v-model={sourceFormData.value[item.name]} {...props} allow-clear>
                            {render ? render() : null}
                        </a-select>
                    )
                case 'inputNumber':
                    return (
                        <a-input-number v-model={sourceFormData.value[item.name]} {...props} allow-clear/>
                    )
            }
        }
        // 点击保存
        const handleBeforeOk = (done) => {
            // 表单验证
            writerFormRef.value.validate((valid) => {
                if (valid) {
                    done(false)
                } else {
                    console.log(sourceFormData.value)
                    // 遍历表单项,去除空值和将数组转为字符串
                    let formData = {}
                    for (let key in sourceFormData.value) {
                        if (sourceFormData.value[key] instanceof Array) {
                            formData[key] = sourceFormData.value[key].join(',')
                        } else if (sourceFormData.value[key]) {
                            formData[key] = sourceFormData.value[key]
                        }
                    }
                    request.post(writerData.actionUrl, formData).then(res => {
                        console.log(res)
                        Message.success('保存成功')
                        done()
                        // 执行回调(关闭弹窗,重新加载数据)
                        context.emit('done', true)
                    }).catch(error => {
                        done(false)
                    })
                }
            })
        }
        const handleCancel = () => {
            context.emit('done', false)
        }

        const slots = {
            title: () => {
                return <span>{titleName.value}</span>
            }
        }
        return () => (
            <>
                <a-modal title-align="start" width="900px" visible={true} onBeforeOk={handleBeforeOk} onBeforeCancel={handleCancel} v-slots={slots} mask-closable={false} {...writerData.props} >
                    <a-form model={sourceFormData} layout="inline" {..._selfForm.props} ref={writerFormRef}>
                        {getFormItems}
                    </a-form>
                </a-modal>
            </>
        )
    }
})

调用代码:
import {defineComponent, reactive, ref} from "vue";
// 1. 引用组件
import writerModel from "@/components/writerModel/writerModel.js";


export default defineComponent({
    // 2. 注册组件
    components: {writerModel},
    setup() {
        const writerData = reactive({
            visible: false,                                     // 弹框是否显示
            titleObj: {add: '新增', edit: '编辑'},              // modal 标题对象
            formData: {},                                       // 表单源数据: {a:1,b:2}
            getUrl: '',                                         // 编辑时数据获取地址
            actionUrl: '',                                       // 新增/编辑 保存地址
            props: {width: '600px'},                            // modal props,参考 arco modal props
            form: {                                                // 表单
                props: {layout: 'horizontal'},                  // form props,参考 arco form props
                formItems: [                                    // 表单项配置
                    {
                        title: '表单label',                        // 表单label
                        name: '表单feild',                        // 表单项绑定值得key
                        value: '',                                // 默认值
                        props: {                                // 表单项配置 参考 arco formitem的props   
                            rules: [{required: true, message: '请选择'}],    // 表单验证规则
                        },
                        inputOptions: {                                    // 输入框配置
                            type: 'select',                                // 输入框类型: 【input, select, inputNumber】
                            props: { placeholder: '请选择'},        // 输入框配置 参考 arco input/select/input-number的props 
                            render: () => {                                // select下自定义方法,一般用于传入下拉选项
                                return <>
                                    {
                                        [1,2,3].map((item, index) => {
                                            return <a-option key={index} value={item} label={item}></a-option>
                                        })
                                    }
                                </>
                            }
                        }
                    },
                    {
                        title: '表单label',
                        name: '表单feild',
                        value: null,
                        props: {
                            rules: [{required: true, message: '请输入'}],
                        },
                        inputOptions: {
                            type: 'input',
                            props: {placeholder: '请输入值',},
                        }
                    },
                ]
            }
        })
        // 回调
        const handleWriterDone = (result:boolean) => {
            if (result) {
                // 重新获取表格数据
                proTableRef.value.getTableData();
            }
            // 重置弹窗所有选项
            writerData.visible = false;
        }
        
        // 渲染
        return () => (
            <>
                   // writerData.visible来判断是否渲染弹窗,使用完毕后直接销毁
                {writerData.visible && <writerModel writer-data={writerData} onDone={handleWriterDone}/>}
            </>
        )
    }
})




通用页面组件

封装代码:

searchForm.tsx


import {defineComponent} from "vue";

export default defineComponent({
    emits: ['search'],
    props: {
        queryForm: {
            type: Array,
            required: true,
            default: () => []
        },
        actionBtn: {
            type: Array,
            required: true,
            default: () => []
        }
    },
    setup({queryForm, actionBtn}, context) {
        const generateInputItem = (item: any) => {
            switch (item.itemOption.type) {
                case "input":
                    return  (
                        <a-input v-model={item.value} {...item.itemOption.props} allow-clear />
                    )
                case "select":
                    return (
                        <a-select v-model={item.value} {...item.itemOption.props} allow-clear>
                            {item.itemOption.render()}
                        </a-select>
                    )
            }
        }
        const renderInputItem = (): Array<any> => {
            let nodes:Array<any> = [];
            if (queryForm) {
                queryForm.forEach(item => {
                    if (!item.hidden) {
                        let node = <a-form-item field={item.name} label={item.title}>
                            {generateInputItem(item)}
                        </a-form-item>
                        nodes.push(node);
                    }
                })
            }
            return nodes;
        }
        const renderActionBtnItem = (): Array<any> => {
            let nodes: Array<any> = [];
            if (actionBtn) {
                actionBtn.forEach(item => {
                    let node = <a-button class="search" onClick={() => item.clickEvent ? item.clickEvent() : console.log("暂时没有点击事件")}>{item.title}</a-button>
                    nodes.push(node);
                })
            }
            return nodes;
        }

        const handleFilter = () => {
            context.emit("search")
        }
        const resetQueryForm = () => {
            queryForm.forEach(item => {
                if (item.hasOwnProperty('defaultValue')) {
                    item.value = item.defaultValue;
                } else {
                    item.value = '';
                }
            })
            handleFilter();
        }
        return () => (
            <>
                <div class="filterContainer">
                    <a-form model={queryForm} onSubmit={() => handleFilter()} layout="inline">
                        {renderInputItem()}
                        <a-form-item hide-label>
                            <a-button class="search" html-type="submit">搜索</a-button>
                        </a-form-item>
                        <a-form-item hide-label>
                            <a-button class="reset" onClick={() => resetQueryForm()}>重置</a-button>
                        </a-form-item>
                        <a-form-item hide-label class="actionBtn">
                            {renderActionBtnItem()}
                        </a-form-item>
                    </a-form>
                </div>
            </>
        );
    }
});

table_self.tsx


import {defineComponent} from "vue";
export default defineComponent({
    props: {
        tableData: {
            type: Object,
            required: true,
            default: () => ({})
        },
        _tableData: {
            type: Object,
            required: true,
            default: () => ({})
        }
    },
    emits: ['handlePageChange'],
    setup({tableData, _tableData}, context) {
        const pageChange = (page:number):void => {
            context.emit("handlePageChange", page)
        }
        return () => (
            <>
                <a-table {..._tableData} {...tableData} onPageChange={pageChange} />
            </>
        );
    }
})

proTable.vue


<template>
    <search-form :queryForm="pageData.queryForm" :action-btn="pageData.actionBtn" @search="handleSearch"/>
    <a-row class="mainContainer" :gutter="48">
        <a-col :span="4" class="tree">
            <div class="treeContainer">
                <div class="title">组织架构</div>
                <a-input-search style="margin-bottom: 8px;" placeholder="请输入搜索内容" v-model="treeData.searchKey"/>
                <a-skeleton :loading="!treeData.showTree" animation>
                    <a-space direction="vertical" :style="{width:'100%'}" size="large">
                        <a-skeleton-line :rows="3" />
                    </a-space>
                </a-skeleton>
                <a-tree :data="treeData.data" @select="handleTreeSelect" v-if="treeData.showTree"
                        :fieldNames="{children: 'projects', title: 'projectName', key: 'id'}" :selected-keys="[_queryForm.projectId || _queryForm.deptId]">
                    <template #title="nodeData">
                        <template v-if="index = getMatchIndex(nodeData?.projectName), index < 0">{{ nodeData?.projectName }}</template>
                        <span v-else>
                        {{ nodeData?.projectName?.substr(0, index) }}
                        <span style="color: var(--color-primary-light-4);">
                            {{ nodeData?.projectName?.substr(index, treeData.searchKey.length) }}
                        </span>{{ nodeData?.projectName?.substr(index + treeData.searchKey.length) }}
                        </span>
                    </template>
                </a-tree>
            </div>
        </a-col>
        <a-col :span="20" class="tableContainer">
            <tableSelf :tableData="pageData.tableData" :_tableData="_tableData" @handlePageChange="pageChange"/>
        </a-col>
    </a-row>
</template>

<script lang="ts" setup>
import SearchForm from "./components/searchForm.tsx";
import {onMounted, reactive, toRefs} from "vue";
import request from "@/api/request";
import tableSelf from "./components/table_self.tsx";

const props = defineProps({
    pageData: {
        type: Object,
        required: true,
    },
});
const treeData = reactive({
    searchKey: '',
    showTree: false,
    data: [],
    select: '',
})
const _queryForm = reactive({
    deptId: '',
    projectId: ''
});

interface Pagination {
    current: number;
    pageSize: number;
    total: number;
    showJumper: boolean,
    showTotal: boolean,
    hideOnSinglePage: boolean,
}
interface TableData {
    loading: boolean;
    data: Array<any>;
    bordered: boolean;
    pagePosition: string;
    pagination: Pagination;
}
const _tableData:TableData = reactive({
    loading: false,
    data: [],
    bordered: false,
    pagePosition: 'bottom',
    pagination: {
        current: 1,
        pageSize: 10,
        total: 0,
        showJumper: true,
        showTotal: true,
        hideOnSinglePage: true,
    },
})
// 根据输入条件获取匹配的数据
const handleSearch = ():void => {
    _tableData.pagination.current = 1
    getTableData()
}
// 获取表格数据
const getTableData = ():void => {
    if (!props.pageData?.tableData?.url) {
        throw new Error('请配置tableData.url')
        return
    }
    _tableData.loading = true;
    let params = {
        ..._queryForm,
        page: _tableData.pagination.current,
        pageSize: _tableData.pagination.pageSize,
    }
    props.pageData.queryForm.forEach(item => {
        params[item['name']] = item['value']
    })
    // 去除空值
    Object.keys(params).forEach(key => {
        if (params[key] === '') {
            delete params[key]
        }
    })
    request.get(props.pageData.tableData.url, {params}).then(res => {
        _tableData.data = res.list;
        _tableData.pagination.total = res.total;
        _tableData.loading = false;
    }).catch(() => {
        _tableData.loading = false;
    })
}
defineExpose({
    treeData,
    _queryForm,
    getTableData,
    _tableData,
})
// 分页
function pageChange(page:number):void {
    _tableData.pagination.current = page;
    getTreeData()
}
// 筛选组织数据
function getMatchIndex(title:string):number {
    if (!treeData.searchKey) return -1;
    return title.toLowerCase().indexOf(treeData.searchKey.toLowerCase());
}
// 处理点击树节点
const handleTreeSelect = (selectedKeys?: Array<string | number>, data?: any) :void => {
    if(data.node.id) {
        treeData.select = data.node;
        if (data.node.deptFullName) {
            _queryForm.deptId = data.node.id;
            _queryForm.projectId = "";
        } else {
            _queryForm.projectId = data.node.id;
            _queryForm.deptId = "";
        }
        getTableData();
    }
};
// 获取树数据
const getTreeData = ():void => {
    request.get(props.pageData.tree.url)
    .then(res => {
        let _list = [
            {
                projectName: '根目录',
                key: '-1', id: null,
                projects: serializeTreeData(res)
            }
        ]
        treeData.showTree = true;
        _queryForm.deptId = res[0].id;
        treeData.select = res[0];
        treeData.data = _list;
        getTableData();
    })
}
// 序列化组织架构数据
const serializeTreeData = (data: Array<any>): Array<any> => {
    let _list = [];
    data.forEach(item => {
        _list.push({
            ...item,
            projectName: item.deptName || item.projectName,
            projects: item.childContentList.length ? serializeTreeData(item.childContentList) : item.projects
        })
    })
    return _list;
}

onMounted(() => {
    // 判断是否有左侧树结构数据需要处理
    if (props.pageData.tree) {
        // 请求树结构数据
        getTreeData()
    }
    // 暂不考虑没有树结构的情况
})

</script>



调用:
  1. 引入

    import proTable from "@/components/proTable/index.vue";
    
  2. 实例化, 推荐defineComponent中通过JSX来实现

    export default defineComponent({
        components: {
            proTable
        },
         setup() {
             return () => (
                 <>
                     <proTable page-data={pageData} ref={proTableRef} />
                 </>
             );
         }
    })
    
  3. 默认传值

    {
       // 筛选项表单项
        queryForm: [
            {
                title: "表单名 - label",
                name: "表单 - field",
                defaultValue: "默认值,重置之后的默认值"
                value: "输入框内容 - 双向绑定的值",
                itemOption: {   // 输入框配置项
                    type: "输入框类型,可选:input/ select",
                    props: {},       // aroc [type]input 的 props 配置项
                    render: () => {
                        // select的option
                    }
                }
            },
            // deptId & projectId已经和树结构内置
        ],
        // 筛选框右侧的按钮
        actionBtn: [
            {
                   title: '按钮名称',
                   clickEvent: () => {
                       // 按钮点击事件,写在调用页面【本页面】
                   }
            }
        ],
         // 左侧树结构, 必传, 无此值页面将无数据
         tree: {
             url: "树结构的url",
         },
         // 右侧表格数据,
         tableData: {
             url: "获取表格数据的url",
             columns: [],    // 表格配置项,详见arco 表格配置
         }
    }
    
  4. 默认暴露的方法与数据

   treeData: 右侧树结构 相关数据
   _queryForm: deptId & projectId
   getTableData: 获取表格数据的方法
   _tableData: 表格内置配置项: 分页,加载状态, 数据等配置

4.1. 获取暴露的方法与数据

  const proTableRef = ref(null);  
  ...  
  <>  
      <proTable page-data={pageData} ref={proTableRef} />  
  </>  
  ...  

4.2. 使用

   proTableRef.value._tableData.pagination.current = 1;  
   proTableRef.value.getTableData();



初稿完成于:2022.8.20 10:42


经验有限,烦请多多提出意见, 联系邮箱:zhanghaoran@qq.com