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

本文介绍了一种使用Vue3和ArcoDesign组件库封装通用后台页面的方法,包括表单筛选、树状组织架构、表格和分页组件。通过高度封装,提高了开发效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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


技术栈: 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
文章来源于:封装后台通用型页面及编辑弹窗实现思路与过程



### 如何在 Element Plus 中实现编辑的表格功能 #### 实现概述 为了实现在 Element Plus 的 `el-table` 组件中支持行内直接编辑输入框、单选或多选框以及复选框等功能,可以通过监听单元格事件并动态切换渲染模式来完成。具体来说,可以利用 Vue 3 的响应式特性绑定数据模型,并通过状态管理控制当前正在编辑的单元格位置。 以下是详细的实现方式: --- #### 数据结构设计 定义一个对象用于存储表格的状态信息,例如当前正被编辑的行索引和列索引: ```javascript const state = reactive({ tableData: [], // 表格数据源 tableRowEditIndex: null, // 当前行编辑索引 tableColumnEditIndex: null, // 当前列编辑索引 }); ``` 当用户点击某个单元格进入编辑模式时,更新上述两个属性即可[^1]。 --- #### 单元格编辑逻辑 对于不同的字段类型(如字符串、布尔值等),可以根据实际需求自定义对应的编辑器组件。例如,针对文本类型的字段提供 `<el-input>` 输入框;而对于布尔型字段,则使用 `<el-checkbox>` 或者 `<el-radio-group>`。 下面是一个简单的例子展示如何根据不同条件渲染合适的控件: ```html <template> <el-table :data="tableData"> <!-- 遍历每一列 --> <el-table-column v-for="(column, index) in columns" :key="index" :label="column.label"> <template #default="{ row }"> <div v-if="isEditing(row, column)"> <!-- 编辑状态下显示相应控件 --> <component :is="getEditorComponent(column.type)" v-model="row[column.property]" /> </div> <span v-else>{{ row[column.property] }}</span> <!-- 默认只读视图 --> </template> </el-table-column> </el-table> </template> <script setup> import { computed } from 'vue'; // 假设这是你的表头配置数组 const columns = [ { label: '姓名', property: 'name', type: 'text' }, { label: '性别', property: 'gender', type: 'select' } ]; function isEditing(row, column) { const rowIndex = tableData.indexOf(row); return state.tableRowEditIndex === rowIndex && state.tableColumnEditIndex === column.index; } function getEditorComponent(type) { switch (type) { case 'text': return 'el-input'; case 'select': return 'el-select'; // 自己扩展更多选项... default: throw new Error(`Unsupported editor type "${type}"`); } } </script> ``` 此代码片段展示了基于不同列的数据类型动态决定应该呈现哪种形式的交互界面[^2]。 --- #### 更新操作处理 每当用户修改了一个值之后都需要及时保存更改到服务器端或者本地缓存起来等待批量提交。这里给出了一种通用做法——借助工具函数封装 API 调用过程中的细节部分。 ```typescript export async function editRemarksFunc(apiUrl:string,data:any){ try{ await axios.post(apiUrl,{id:data.id,value:data.newValue}) console.log(`${data.id} updated successfully`) }catch(error){ alert('Failed to update record.') } } ``` 调用该方法可以在每次确认改动后立即触发网络请求同步最新情况给后台数据库. 另外还有一种更灵活的方式就是先收集所有的变更记录等到最后再统一发送过去减少频繁通信带来的性能开销. --- #### 添加右键菜单增强体验(可选) 如果希望进一步提升用户体验还可以考虑加入上下文菜单允许快速访问某些特定命令比如复制粘贴删除等等. ```css /* CSS */ .context-menu { position:absolute; background-color:white; border:solid thin black; padding:.5em; z-index:999; } ``` 配合 JavaScript 监听鼠标按下事件定位弹窗位置同时阻止默认行为防止页面跳转影响正常浏览流程[^3]. ```javascript document.addEventListener('contextmenu',(e)=>{ e.preventDefault(); let menu=document.querySelector('.context-menu'); if(!menu){ menu=createMenu(); document.body.appendChild(menu); } const rect=e.target.getBoundingClientRect(); menu.style.left=`${rect.left}px`; menu.style.top=`${rect.bottom}px`; },false); function createMenu(){ const div=document.createElement('div'); div.className='context-menu'; ['Cut','Copy','Paste'].forEach(item=>{ const btn=document.createElement('button'); btn.textContent=item.toLowerCase(); btn.onclick=()=>console.warn(`Action ${item}`); div.append(btn,' '); }); return div; } ``` 这样就完成了基本的功能开发同时也兼顾到了一些额外的小技巧让整体看起来更加完善实用性强很多哦! ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值