政务低代码平台实战④:可视化表单设计器——拖拽、合并拆分、代码生成

非科班野生程序员,深耕政务信息化20年。政务审批表单有多复杂?请假申请要并排三列,报销审批要有合并单元格的大标题栏,采购审批要嵌套子表——固定行列的低代码方案根本搞不定。这篇拆解基于Dojo的可视化表单设计器怎么实现自由布局。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。


为什么不能做固定行列

市面上很多低代码平台的表单设计器是这样的:配置几个字段,选几列布局,自动排列。

政务审批表单不行。一张采购审批表可能是这样的:

┌─────────┬─────────┬─────────┬─────────┐
│ 申请人  │ 申请部门│ 申请日期│         │
├─────────┼─────────┼─────────┴─────────┤
│采购事项 │   (合并4列)               │
├─────────┼─────────┬─────────┬─────────┤
│物品名称 │ 数量    │ 单价    │ 金额    │
│物品名称 │ 数量    │ 单价    │ 金额    │
├─────────┴─────────┴─────────┴─────────┤
│ 审批意见:(合并4列,文本域           │
└───────────────────────────────────────┘

有合并、有嵌套、有不同宽度——这就是为什么必须做可合并拆分的表格布局


设计器整体结构

formEdit.jsp 是三栏布局:

┌──────────┬──────────────────────┬──────────┐
│控件与容器│      工作区域        │属性/事件 │
│          │                      │          │
│可创建控件│  编辑 | 源码 | 脚本  │属性面板  │
│ (树形)   │                      │事件面板  │
│          │                      │          │
│ 容器结构 │  [合并][拆分][取消]  │          │
│ (树形)   │  [插行][追行][删行]  │          │
│          │ [载入][保存JSP][保存]│          │
└──────────┴──────────────────────┴──────────┘
  • 左栏:控件树(双击创建)+ 容器结构树(显示当前组件层级)
  • 中栏:工作区(拖拽设计)+ 源码预览 + 脚本编辑
  • 右栏:属性面板(中文名/英文名/类型/校验/默认值)+ 事件面板
  • 底部:合并/拆分/插入行/追加行/删除行/载入/保存JSP/保存

scriptUI目录下的10个JS文件各司其职:

文件职责
const.js控件注册表(定义可创建的控件列表)
values.js全局状态(组件树、SQL绑定、事件绑定)
optable.js表格操作(创建/合并/拆分/行操作)
opgrid.jsGrid操作(创建数据窗口)
coms.js控件创建(TextBox/DateTextBox/ComboBox/…)
optab.jsTab页签操作
optitle.js标题容器操作
uiglobal.js属性编辑 + 代码生成
treecommon.js树数据源管理

表格的创建和自由布局

创建表格

双击控件树的"表格",弹出配置对话框(tableTitle.jsp),输入行列数、宽度、绑定的SQL语句。确定后 createTable() 在工作区生成一个 HTML table:

function createTable(value, pid) {
    var cols = value.cols;
    var rows = value.rows;
    var tableNode = dojo.create("table", { width: width1, id: tid1 });
    
    // 生成colgroup(等宽列)
    var colgroup = dojo.create("colgroup");
    var w = parseInt(100 / cols) + "%";
    for (var y = 0; y < cols; y++) {
        dojo.place(dojo.create("col", { width: w }), colgroup);
    }
    
    // 生成rows x cols的单元格
    for (var x = 0; x < rows; x++) {
        var trNode = tableNode.insertRow();
        for (var y = 0; y < cols; y++) {
            var tdNode = 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里(方便数据绑定)
    var tf = createform(fid1, width1, rows, cols, fbinding);
    dojo.place(tableNode, tf.containerNode, "last");
}

表格外面自动套一个 browise.form.Form,用于数据绑定。

合并单元格 mc()

双击选中多个单元格(同一行或同一列),点"合并":

function mc() {
    var tabObj = document.getElementById(tableid);
    var maxcol = getmax(tableNodes, 'col');
    var mincol = getmin(tableNodes, 'col');
    var maxrow = getmax(tableNodes, 'row');
    var minrow = getmin(tableNodes, 'row');
    
    // 校验:只能合并同行或同列
    if (maxcol - mincol > 0 && maxrow - minrow > 0) {
        MessageBox.alert({ message: "无法合并!只能选择同行或者同列" });
        return;
    }
    
    if (maxcol - mincol > 0) {
        // 横向合并:计算总colSpan,删除多余td
        for (var i = 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,删除多余td
        for (var i = 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:

function cf() {
    var rowcount = curr.rowSpan;
    var colcount = curr.colSpan;
    
    // 在对应位置插入新的td
    for (var i = 0; i < rowcount; i++) {
        for (var j = 0; j < colcount; j++) {
            if (j == 0 && i == 0) continue; // 原单元格保留
            var tdNode = tabObj.rows[i + currrowindex].insertCell(currcellindex + j);
            tdNode.setAttribute("class", "td1");
            // 绑定事件...
        }
    }
    curr.colSpan = 1;
    curr.rowSpan = 1;
}

合并拆分的难点是位置计算——前面有跨行单元格时,拆分要先检查位置占用,否则会错位。cf() 里有一段遍历前面所有行的代码做这个检查。


控件拖入表格

双击左侧控件树创建控件,控件出现在当前选中的表格单元格里:

function setconts(txt, com, div, event) {
    if (!curr) {
        MessageBox.alert({ message: "请选择单元格!" });
        return false;
    }
    
    // 创建一个包裹div
    div = 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");          // 控件放进div
    dojo.connect(div, "onclick", click); // 点击显示属性
    
    curr.setAttribute("align", "left"); // 放控件后左对齐
    curr = null;
    clear1();
}

支持的控件类型(coms.js):

函数控件说明
createtextTextBox普通文本
createtdDateTextBox日期选择
createcbComboBox下拉框(绑二级代码)
createtreecomComboBoxTree下拉树
createcbtnButton按钮
createrdRadioButtonGroup单选按钮组
createcbgCheckBoxGroup复选框组
createtaTextarea文本域
createcrtRichTextEditor2富文本编辑器
createTextIconTextBoxWithIcon图标文本(前置流程选择)

创建控件后自动在前面一格填上中文名标签:

function save(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_xxxGrid的查询方法Grid绑定了SQL时
commansave统一保存方法Form绑定了SQL时
del_xxxGrid的删除方法Grid绑定了SQL时
xz_xxxGrid的新增方法(弹窗)Grid配了新增界面时
xg_xxxGrid的修改方法(弹窗)Grid配了修改界面时
xxx_dblickGrid的双击事件(弹窗)Grid配了双击事件时
open_xxx前置流程选择弹窗TextBoxWithIcon配了前置流程时

保存方法的自动生成

create_save() 为例,遍历所有Form和Grid,自动生成保存JS:

function create_save(nodes) {
    myscripts += "function commansave(){\r\n";
    myscripts += "\tvar dc = new browise.ds.DataCenter();\r\n";
    
    for (var i = 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:记录拖拽源
function tdmousedown(evt) {
    tdojb = this;
    this.style.cursor = 'move';
}

// mouseup:放到目标单元格
function tdmouseup(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.jspUI2/formEdit.jsp 有两个版本:

UI版本UI2版本
myedittype“1”“2”
oneSqlfalsetrue
用途列表+表单(查询页)纯表单(编辑页)
额外功能选择SQL语句、预览按钮
代码生成查询+Grid增删改+保存仅保存

UI模式用于设计"上Grid下条件"的查询页面,UI2模式用于设计表单页面(工作流绑定的那种)。


决策原则

用业务人员能理解的方式描述表单。

政务审批表单的本质是一张表格,业务人员习惯了Excel式的布局。给他们一个可以合并拆分的表格编辑器,比教他们配置JSON schema容易得多。控件拖进去、属性填上、代码自动生成,一个表单从设计到可用不超过半小时。


你项目里的表单设计器是怎么做的?是固定布局还是自由布局?欢迎评论区聊聊。


系列导航:

  • 总纲:[政务低代码平台实战——从元数据引擎到可视化设计器的五个关键决策]
  • 上一篇:[政务低代码平台实战③:CommonSQL——一个接口扛起所有CRUD]
  • 下一篇:[政务低代码平台实战⑤:双输出模式——存DB与导出JSP的完整链路]

作者:许彰午 | 非科班野生程序员,深耕政务信息化20年

标签: #Java #低代码 #表单设计器 #拖拽 #合并拆分 #Dojo #政务信息化

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐