# 一个JSP打天下:47KB万能表单引擎
一个JSP打天下:47KB万能表单引擎
非科班野生程序员,深耕政务信息化20年,这套自研Java Web框架支撑过省级社保、跨省医保结算等核心民生系统。政务审批系统的核心是工作流,但工作流的前端——表单——比工作流本身更复杂。这篇拆解一个47KB的JSP文件怎么扛起所有流程的所有表单,全是政务场景踩坑后的实用解法。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。
背景
政务OA有几十个流程,每个流程的表单不一样。请假申请、用章申请、预算审批、采购审批、车辆调度……每个表单字段不同、验证规则不同、不同环节可编辑的字段不同。
一般做法是每个流程写一个JSP。问题是:
- 几十个流程就是几十个JSP,维护成本高
- 新增流程必须开发——业务人员画完BPMN,还得等开发写表单
- 同一流程不同环节显示不同——申请环节能填,审批环节只读,有些字段某些环节隐藏——每个环节一套JSP?
我的做法是:所有流程的所有任务,只走一个JSP文件——onlyFrom.jsp。
核心思路
表单定义不在JSP里,在数据库里。onlyFrom.jsp启动时从数据库读取元数据,动态生成HTML。每个表单有自己的字段定义、控件类型、代码表、默认值、HTML模板——全存在关系表里。
请求流程任务 ↓ onlyFrom.jsp 启动 ↓ Java 代码段(JSP顶部)从数据库读取: ├── formds —— 表单定义(标题、宽度、高度、类型、HTML模板) ├── fieldds —— 字段定义(名称、类型、列数、代码表、默认值) ├── tableds —— 表单与数据表的绑定关系 ├── ellist —— 元素默认值列表 └── gridformsql —— 网格子表单的SQL ↓ 动态生成HTML + JavaScript ↓ search() 调用 allbytaskid 一次加载所有数据 ↓ 表单渲染完成元数据驱动的表单生成
Java 代码段:从数据库加载元数据
// onlyFrom.jsp 顶部StringsqlId=request.getParameter("id");formServiceImpl obj=newformServiceImpl();DataStoreds=newDataStore();ds.setParameter("id",sqlId);DBUtil.BeginTrans(false);DataStoreformds=obj.selectform(ds);// 表单定义DataStorefieldds=obj.selectfield(ds);// 字段定义DataStoretableds=obj.selecttable(ds);// 数据表绑定List<eldao>ellist=obj.getdefualt(ds,userlist,deptid);// 默认值Stringgridformsql=obj.getGridSql(ds);// 网格子表SQLDBUtil.EndTrans();6次数据库查询,一次事务,拿到渲染一个表单需要的全部元数据。
动态生成代码表数据源
字段类型coltpye决定了控件类型和数据的来源:
<%for (int i = 0; i < fieldds.getRowset().getPrimary().size(); i++) { if ("5".equals(fieldds.getRowset().getrow(i).getItemStringValue("coltpye")) || "6".equals(fieldds.getRowset().getrow(i).getItemStringValue("coltpye"))) {%> temp = String('<%=fieldds.getRowset().getrow(i).getItemStringValue("commens")%>'); temp = dojo.fromJson(temp); var <%=fieldds.getRowset().getrow(i).getItemStringValue("name")%> = new browise.ds.DataStore("<%=fieldds.getRowset().getrow(i).getItemStringValue("name").toUpperCase()%>", temp); dataCenter.addDataStore(<%=fieldds.getRowset().getrow(i).getItemStringValue("name")%>); <%} }%>字段类型5(单选)、6(多选)的数据来自commens字段的JSON。字段类型7(下拉框)的数据来自代码表:
<%for (int i = 0; i < fieldds.getRowset().getPrimary().size(); i++) { if ("7".equals(fieldds.getRowset().getrow(i).getItemStringValue("coltpye")) || (fieldds.getRowset().getrow(i).getItemStringValue("codename") != null && !"".equals(fieldds.getRowset().getrow(i).getItemStringValue("codename")))) {%> dataCenter.addDataStore(browise.Action.getCodeList( "<%=fieldds.getRowset().getrow(i).getItemStringValue("codename").toUpperCase()%>")); <%} }%>coltpye对照:
| 值 | 控件类型 | 数据来源 |
|---|---|---|
| 1 | TextBox | 无 |
| 2 | Textarea | 无 |
| 3 | DateTextBox | 无 |
| 4 | DateTextBox | 无 |
| 5 | RadioButtonGroup | commensJSON |
| 6 | CheckBox | commensJSON |
| 7 | ComboBox | 代码表codename |
HTML模板:数据库存储 vs 外部文件
表单HTML有两种来源:
<% String type1 = formds.getRowset().getrow(0).getItemStringValue("type"); if("3".equals(type1)) { // 类型3:从外部文件读取 String html = FileUtil.readJspFile( formds.getRowset().getrow(0).getItemStringValue("filepath")); out.println(html); } else { // 其他类型:从数据库字段读取 String html = String.valueOf(formds.getRowset().getrow(0).getItemValue("html_")); out.println(html); } %>类型3适合复杂表单——HTML太长放数据库不方便维护,用外部文件。简单表单直接存数据库字段。
script_列:表单插件系统
这是万能表单最巧妙的设计。
每个表单定义有一个script_列,可以存任意JavaScript代码。万能表单渲染时原样输出这段代码:
<%String sp = String.valueOf(formds.getRowset().getrow(0).getItemValue("script_")); out.println(sp);%>三个钩子函数
万能表单在关键生命周期点通过try/catch调用外部函数:
// 数据加载完成后try{if(searchothers)searchothers();}catch(E){}// 保存之前try{if(saveothers)saveothers();}catch(E){}// 表单数据绑定完成后try{if(setothers)setothers();}catch(E){}try/catch是关键——如果表单没有定义这些函数,不会报错,安静跳过。
这意味着每个表单可以在不修改万能表单模板的情况下注入自己的行为。比如一个采购审批表单,需要在加载时自动算总价、保存前校验预算——这些逻辑写在script_字段里就行。
字段级权限控制
同一个表单,不同环节可编辑的字段不同。申请环节所有字段可填,科长审批时金额字段只读,处长审批时只看意见框。
权限加载
allbytaskid接口返回froleDataStore,里面是当前环节只读的字段列表。万能表单收到后逐个设置:
functionsetrolebydata(value){if(value.getRowSet().getRowCount()==0)return;value.getRowSet().forEach(function(row){try{browise.byId(row.getItemValue("teId")).setDisabled(true);}catch(E){}});}字段权限的配置在otherSet.jsp管理界面里——管理员选择一个流程步骤,勾选哪些字段只读,存到t_from_readonly表。运行时根据当前任务ID查这张表。
流程进度条
表单顶部有一行文字显示流程走到哪了,当前环节标红:
申请 —→ 科长审批 —→ [处长审批] —→ 局长审批方括号里的标红显示。实现很直接——后端返回字符串申请—→科长审批—→[处长审批]—→局长审批,前端解析方括号:
functionsetmx(value){varsub_string1=value.substring(0,value.indexOf("["));varsub_string2=value.substring(value.indexOf("[")+1,value.indexOf("]"));varsub_string3=value.substring(value.indexOf("]")+1);vartitle='<span style="font-size:14px;">'+sub_string1+'</span>'+'<span style="color:red;font-size:14px;">'+sub_string2+'</span>'+'<span style="font-size:14px;">'+sub_string3+'</span>';document.getElementById("title_sw_01").innerHTML=title;// 按钮文字根据环节变化if(sub_string2.indexOf('复核')>=0){browise.byId('tj001').setLabel("复核");}if(sub_string2.indexOf('审批')>=0){browise.byId('tj001').setLabel("审批");}}细节:按钮文字跟着环节变——申请环节显示"提交",复核环节显示"复核",审批环节显示"审批"。政务用户习惯看"审批"两个字,不习惯看"提交"。
孤儿流程自动清理
用户打开了申请表单,填了一半关掉了浏览器。此时流程已经启动了(有了一个实例),但第一个任务没完成。
如果不清理,数据库里会积累大量"只开了个头就没下文"的流程实例。
window.onunload=deletetask;<%if(candelete){%>isdelete=true;<%}%>// 如果不是第一环节,不能自动删除<%if(!isfirst){%>isdelete=false;<%}%>functiondeletetask(){if(isdelete){vartaskid="<%=taskid%>";vardc=newbrowise.ds.DataCenter();varURL=browise.WEB_APP_NAME+"/BusinessAction?Business=flow&Action=deleteins"+"&jsessionid="+sessionid+"&taskid="+taskid;vardata={url:URL,sync:false,load:function(dc1){}};browise.Action.requestData(data,dc,true);}}三重判断:window.onunload触发 →isdelete为 true(前端允许删除) →isfirst为 true(第一个环节才删)→candelete为 true(后端允许删除)。四个条件全满足才发删除请求。中间环节的审批关掉浏览器不会触发删除。
工作时间校验
政务系统有个不成文的规矩:正式公文不应该在非工作时间提交。不是技术限制,是合规要求。
// 判断当前时间是否为工作时间,不是则提示给经办人functionQueryTime(){vardc=newbrowise.ds.DataCenter();varURL=webRootDir+"/BusinessAction?Business=External&Action=QueryTime";vardata={url:URL,sync:false,load:function(dc1){varmessage=dc1.getDataStore("message");if(dc1.getCode()<0){MessageBox.alert({title:"提示",message:message.getParameter("message")});}}}browise.Action.requestData(data,dc,true);}注意注释里的日期——2022.06.17,团队里一位年轻同事加的功能。不做技术限制,只是提醒——弹个窗告诉你"当前是非工作时间",确认后照样能提交。务实。
一键意见
functionyjchoose(){varcyyj=browise.byId("cyyj").getValue();browise.byId("task_suggestion").setValue(cyyj);if(cyyj=="同意"){browise.byId("spztGroup").setValue(1);// 通过}else{browise.byId("spztGroup").setValue(0);// 不通过}}下拉框选"同意",自动填意见框+自动选"通过"。选"不同意",自动填+自动选"不通过"。政务审批90%都是点"同意",这个快捷操作省了审批人大量时间。
万能表单的完整生命周期
用户点击待办任务 ↓ cform.jsp(包装层,include onlyFrom.jsp) ↓ onlyFrom.jsp 启动 ├── Java段:从数据库加载表单元数据(formds/fieldds/tableds) ├── 生成代码表DataStore(radio/checkbox/combobox) ├── 设置 window.onunload = deletetask(孤儿清理) ├── 输出 script_ 列的JavaScript(表单插件) ↓ dojo.addOnLoad → search() ├── 调用 allbytaskid(一次拿6组数据) ├── setmx() —— 显示流程进度条 ├── setrolebydata() —— 设置字段只读 ├── sethqyj() —— 加载会签意见 ├── setNext() —— 加载下一步选项 ├── 绑定审批历史 ├── try { searchothers() } —— 表单插件钩子 └── try { QueryTime() } —— 工作时间提醒 ↓ 用户操作:提交/退回/作废/办结/会签/转办/暂存/打印 ↓ 关闭窗口 → deletetask()(如果是第一环节且允许删除,清理孤儿实例)为什么这样做
一个JSP扛起所有表单,核心收益是:
- 新增流程不需要写JSP——业务人员在管理界面配置表单定义、字段、布局,系统自动渲染
- 维护成本降一个量级——改一个字段不需要改JSP,改数据库配置就行
- 插件系统保证了灵活性——通用逻辑在万能表单里,特殊逻辑在
script_列里,互不干扰 - 全流程统一体验——进度条、审批历史、常用意见、字段权限——所有表单行为一致
代价是47KB的JSP文件确实太大了。1546行代码,Java和JavaScript混在一起,维护起来不轻松。但比起维护几十个JSP文件,一个47KB的万能模板反而更可控。
决策原则
把变化的东西从代码移到数据。
表单字段会变、布局会变、验证规则会变——这些东西不应该硬编码在JSP里。存在数据库里,运行时动态生成,新增一个流程就是新增几行配置数据。
script_列的插件系统是补充——通用的走元数据,特殊的走代码,两条路都能到。
你的项目里动态表单是怎么做的?是写死JSP还是元数据驱动?欢迎评论区聊聊。
系列导航:
- 回到总纲:政务工作流实战(二):BPMN画不出来的七个设计决策
- 上一篇:(综述)
- 下一篇:数据库配拦截器,不用改BPMN
作者:许彰午| 非科班野生程序员,深耕政务信息化20年
标签:#Java #Activiti #工作流 #动态表单 #元数据驱动 #插件系统 #政务信息化
