政务低代码平台实战④:可视化表单设计器——拖拽、合并拆分、代码生成
文章目录
- 政务低代码平台实战④:可视化表单设计器——拖拽、合并拆分、代码生成
- 为什么不能做固定行列
- 设计器整体结构
- 表格的创建和自由布局
- 创建表格
- 合并单元格 `mc()`
- 拆分单元格 `cf()`
- 控件拖入表格
- 属性编辑面板
- 代码自动生成
- 生成的函数列表
- 保存方法的自动生成
- 控件拖拽(单元格间移动)
- 设计器的两种模式
- 决策原则
非科班野生程序员,深耕政务信息化20年。政务审批表单有多复杂?请假申请要并排三列,报销审批要有合并单元格的大标题栏,采购审批要嵌套子表——固定行列的低代码方案根本搞不定。这篇拆解基于Dojo的可视化表单设计器怎么实现自由布局。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。
为什么不能做固定行列
市面上很多低代码平台的表单设计器是这样的:配置几个字段,选几列布局,自动排列。
政务审批表单不行。一张采购审批表可能是这样的:
┌─────────┬─────────┬─────────┬─────────┐ │ 申请人 │ 申请部门│ 申请日期│ │ ├─────────┼─────────┼─────────┴─────────┤ │采购事项 │ (合并4列) │ ├─────────┼─────────┬─────────┬─────────┤ │物品名称 │ 数量 │ 单价 │ 金额 │ │物品名称 │ 数量 │ 单价 │ 金额 │ ├─────────┴─────────┴─────────┴─────────┤ │ 审批意见:(合并4列,文本域 │ └───────────────────────────────────────┘有合并、有嵌套、有不同宽度——这就是为什么必须做可合并拆分的表格布局。
设计器整体结构
formEdit.jsp是三栏布局:
┌──────────┬──────────────────────┬──────────┐ │控件与容器│ 工作区域 │属性/事件 │ │ │ │ │ │可创建控件│ 编辑 | 源码 | 脚本 │属性面板 │ │ (树形) │ │事件面板 │ │ │ │ │ │ 容器结构 │ [合并][拆分][取消] │ │ │ (树形) │ [插行][追行][删行] │ │ │ │ [载入][保存JSP][保存]│ │ └──────────┴──────────────────────┴──────────┘- 左栏:控件树(双击创建)+ 容器结构树(显示当前组件层级)
- 中栏:工作区(拖拽设计)+ 源码预览 + 脚本编辑
- 右栏:属性面板(中文名/英文名/类型/校验/默认值)+ 事件面板
- 底部:合并/拆分/插入行/追加行/删除行/载入/保存JSP/保存
scriptUI目录下的10个JS文件各司其职:
| 文件 | 职责 |
|---|---|
const.js | 控件注册表(定义可创建的控件列表) |
values.js | 全局状态(组件树、SQL绑定、事件绑定) |
optable.js | 表格操作(创建/合并/拆分/行操作) |
opgrid.js | Grid操作(创建数据窗口) |
coms.js | 控件创建(TextBox/DateTextBox/ComboBox/…) |
optab.js | Tab页签操作 |
optitle.js | 标题容器操作 |
uiglobal.js | 属性编辑 + 代码生成 |
treecommon.js | 树数据源管理 |
表格的创建和自由布局
创建表格
双击控件树的"表格",弹出配置对话框(tableTitle.jsp),输入行列数、宽度、绑定的SQL语句。确定后createTable()在工作区生成一个 HTML table:
functioncreateTable(value,pid){varcols=value.cols;varrows=value.rows;vartableNode=dojo.create("table",{width:width1,id:tid1});// 生成colgroup(等宽列)varcolgroup=dojo.create("colgroup");varw=parseInt(100/cols)+"%";for(vary=0;y<cols;y++){dojo.place(dojo.create("col",{width:w}),colgroup);}// 生成rows x cols的单元格for(varx=0;x<rows;x++){vartrNode=tableNode.insertRow();for(vary=0;y<cols;y++){vartdNode=trNode.insertCell();tdNode.setAttribute("class","td1");// 绑定事件:双击选中、拖拽、鼠标悬停dojo.query("#"+tdNode.id).connect("ondblclick",tddbclick);dojo.query("#"+tdNode.id).connect("onmousedown",tdmousedown);dojo.query("#"+tdNode.id).connect("onmouseup",tdmouseup);}}// 表格放在Form里(方便数据绑定)vartf=createform(fid1,width1,rows,cols,fbinding);dojo.place(tableNode,tf.containerNode,"last");}表格外面自动套一个browise.form.Form,用于数据绑定。
合并单元格mc()
双击选中多个单元格(同一行或同一列),点"合并":
functionmc(){vartabObj=document.getElementById(tableid);varmaxcol=getmax(tableNodes,'col');varmincol=getmin(tableNodes,'col');varmaxrow=getmax(tableNodes,'row');varminrow=getmin(tableNodes,'row');// 校验:只能合并同行或同列if(maxcol-mincol>0&&maxrow-minrow>0){MessageBox.alert({message:"无法合并!只能选择同行或者同列"});return;}if(maxcol-mincol>0){// 横向合并:计算总colSpan,删除多余tdfor(vari=maxcol;i>=mincol;i--){colspans+=tabObj.rows[minrow].cells[i].colSpan;if(i!=mincol)tabObj.rows[minrow].deleteCell(i);}tabObj.rows[minrow].cells[mincol].colSpan=colspans;}else{// 纵向合并:计算总rowSpan,删除多余tdfor(vari=minrow;i<=maxrow;){rowspans+=tabObj.rows[i].cells[mincol].rowSpan;if(i!=minrow)tabObj.rows[i].deleteCell(mincol);i+=tabObj.rows[i].cells[mincol].rowSpan;}tabObj.rows[minrow].cells[mincol].rowSpan=rowspans;}}拆分单元格cf()
选中一个已合并的单元格,点"拆分",还原为1x1并补上新的td:
functioncf(){varrowcount=curr.rowSpan;varcolcount=curr.colSpan;// 在对应位置插入新的tdfor(vari=0;i<rowcount;i++){for(varj=0;j<colcount;j++){if(j==0&&i==0)continue;// 原单元格保留vartdNode=tabObj.rows[i+currrowindex].insertCell(currcellindex+j);tdNode.setAttribute("class","td1");// 绑定事件...}}curr.colSpan=1;curr.rowSpan=1;}合并拆分的难点是位置计算——前面有跨行单元格时,拆分要先检查位置占用,否则会错位。cf()里有一段遍历前面所有行的代码做这个检查。
控件拖入表格
双击左侧控件树创建控件,控件出现在当前选中的表格单元格里:
functionsetconts(txt,com,div,event){if(!curr){MessageBox.alert({message:"请选择单元格!"});returnfalse;}// 创建一个包裹divdiv=dojo.create("div",{width:"100%",id:"d_"+random()});coms[div.id]=com;// 注册到全局组件树divs[div.id]=div;if(event)aevent[com.id]=event;com.startup();dojo.place(div,curr,"last");// div放进单元格com.placeAt(div,"last");// 控件放进divdojo.connect(div,"onclick",click);// 点击显示属性curr.setAttribute("align","left");// 放控件后左对齐curr=null;clear1();}支持的控件类型(coms.js):
| 函数 | 控件 | 说明 |
|---|---|---|
createtext | TextBox | 普通文本 |
createtd | DateTextBox | 日期选择 |
createcb | ComboBox | 下拉框(绑二级代码) |
createtreecom | ComboBoxTree | 下拉树 |
createcbtn | Button | 按钮 |
createrd | RadioButtonGroup | 单选按钮组 |
createcbg | CheckBoxGroup | 复选框组 |
createta | Textarea | 文本域 |
createcrt | RichTextEditor2 | 富文本编辑器 |
createTextIcon | TextBoxWithIcon | 图标文本(前置流程选择) |
创建控件后自动在前面一格填上中文名标签:
functionsave(id,ots,json){if(curr&&curr.cellIndex>=1&&ots.cls!="browise.form.Button"){if(!curr.parentNode.cells[curr.cellIndex-1].innerText){curr.parentNode.cells[curr.cellIndex-1].innerText=ots.dis+":";}}ajsons[id]=json;// 保存组件配置afothers[id]=ots;// 保存其他参数}属性编辑面板
点击控件,右侧属性面板显示所有可编辑属性(getPara()函数):
| 属性 | 说明 | 控件类型 |
|---|---|---|
| 中文名 | 显示的标签文本 | 所有 |
| 英文名 | 数据绑定字段名(binding.name) | 非Button |
| 二级代码 | ComboBox的数据源store名 | ComboBox/RadioButtonGroup/CheckBoxGroup |
| 数据类型 | eae009编码 | 非Button |
| 校验 | 正则表达式 | 非Button |
| 是否必录 | required属性 | 非Button |
| 是否禁用 | disabled属性 | 非Button |
| 默认值 | 日期/用户/部门/电话等预设 | 非Button |
| 含时间 | DateTextBox是否显示时分秒 | DateTextBox |
| 字体大小 | td的fontSize | 所有 |
| 对齐方式 | textAlign | 非Button |
点"应用"按钮,apply()函数把修改写回控件实例。
代码自动生成
这是设计器最省时的功能。点"生成默认脚本"按钮(impdefault1()),自动根据当前设计的组件生成完整的JS代码:
生成的函数列表
| 函数 | 功能 | 什么时候有 |
|---|---|---|
seach_xxx | Grid的查询方法 | Grid绑定了SQL时 |
commansave | 统一保存方法 | Form绑定了SQL时 |
del_xxx | Grid的删除方法 | Grid绑定了SQL时 |
xz_xxx | Grid的新增方法(弹窗) | Grid配了新增界面时 |
xg_xxx | Grid的修改方法(弹窗) | Grid配了修改界面时 |
xxx_dblick | Grid的双击事件(弹窗) | Grid配了双击事件时 |
open_xxx | 前置流程选择弹窗 | TextBoxWithIcon配了前置流程时 |
保存方法的自动生成
以create_save()为例,遍历所有Form和Grid,自动生成保存JS:
functioncreate_save(nodes){myscripts+="function commansave(){\r\n";myscripts+="\tvar dc = new browise.ds.DataCenter();\r\n";for(vari=0;i<nodes.length;i++){if(asql[nodes[i].id]&&asql[nodes[i].id].seach){// Form类型:先校验if(nodes[i].declaredClass=='browise.form.Form'){myscripts+="\tvar myform=browise.byId(\""+nodes[i].id+"\");\r\n";myscripts+="\tvar isValid=myform.validate();\r\n";myscripts+="\tif(!isValid){ alert('...'); return; }\r\n";}// 取DataStore,设置up/ins/del参数myscripts+="\tvar ds"+i+"=browise.byId(\""+nodes[i].id+"\").getBinding().getDataStore();\r\n";myscripts+="\tds"+i+".setParameter(\"up\",\""+sqlId+"\");\r\n";myscripts+="\tds"+i+".setParameter(\"ins\",\""+sqlId+"\");\r\n";myscripts+="\tds"+i+".setParameter(\"del\",\""+sqlId+"\");\r\n";myscripts+="\tdc.addDataStore(ds"+i+");\r\n";}}myscripts+="\tvar URL=webRootDir+'/BusinessAction?Business=CommonSQL&Action=CommonSaveByseach';\r\n";myscripts+="\tbrowise.Action.requestData(data, dc, true);\r\n";myscripts+="}\r\n";}生成的代码直接利用了CommonSQLcontrol.CommonSaveByseach,用SELECT语句的元数据做增删改——不需要单独配INSERT/UPDATE/DELETE。
控件拖拽(单元格间移动)
支持单元格之间拖拽控件:
// mousedown:记录拖拽源functiontdmousedown(evt){tdojb=this;this.style.cursor='move';}// mouseup:放到目标单元格functiontdmouseup(evt){if(tdojb==null||tdojb==this)return;if(this.children[0]){// 目标有内容,确认覆盖if(!window.confirm("目标单元格里存在内容!确认覆盖?"))return;}if(tdojb.children[0]){dojo.place(tdojb.children[0],this,"last");// 移控件}else{this.innerText=tdojb.innerText;// 移文本tdojb.innerText="";}tdojb=null;}也支持从控件树拖拽到单元格——mousedown记录树节点,mouseup时在目标单元格创建控件。
设计器的两种模式
UI/formEdit.jsp和UI2/formEdit.jsp有两个版本:
| UI版本 | UI2版本 | |
|---|---|---|
myedittype | “1” | “2” |
oneSql | false | true |
| 用途 | 列表+表单(查询页) | 纯表单(编辑页) |
| 额外功能 | — | 选择SQL语句、预览按钮 |
| 代码生成 | 查询+Grid增删改+保存 | 仅保存 |
UI模式用于设计"上Grid下条件"的查询页面,UI2模式用于设计表单页面(工作流绑定的那种)。
决策原则
用业务人员能理解的方式描述表单。
政务审批表单的本质是一张表格,业务人员习惯了Excel式的布局。给他们一个可以合并拆分的表格编辑器,比教他们配置JSON schema容易得多。控件拖进去、属性填上、代码自动生成,一个表单从设计到可用不超过半小时。
你项目里的表单设计器是怎么做的?是固定布局还是自由布局?欢迎评论区聊聊。
系列导航:
- 总纲:[政务低代码平台实战——从元数据引擎到可视化设计器的五个关键决策]
- 上一篇:[政务低代码平台实战③:CommonSQL——一个接口扛起所有CRUD]
- 下一篇:[政务低代码平台实战⑤:双输出模式——存DB与导出JSP的完整链路]
作者:许彰午| 非科班野生程序员,深耕政务信息化20年
标签:#Java #低代码 #表单设计器 #拖拽 #合并拆分 #Dojo #政务信息化