SpringBoot+Vue3 单据校验设计:保存草稿、提交审批、退回重填,各该检查什么?
单据只填到一半,用户想先保存;信息补齐后,才准备提交审批。退回或撤回之后,他还要继续修改,再走一次提交。三个动作共用同一张表单,却不能简单套同一套“全部必填”。
保存关注当前内容能否合法留存,提交还要确认信息足够流转、资源没有冲突;再次提交则必须按现在的数据和流程位置重新检查。上次校验通过,不代表今天仍然可用。
我们以RuoYi Office用印申请为例,从真实页面走到Vue3交互、SpringBoot请求约束和外借冲突检查。现有实现已经分开了一部分动作,也存在前后端规则仍需对齐的地方。把这些层次说清楚,比笼统地加一句“支持表单校验”更有用。
▲ 图表达校验职责的分工。具体字段能否留空、是否有长度限制、何时检查冲突,仍以对应接口与业务代码为准,不表示所有规则已统一采用校验分组。
草稿已经有业务内容,也可能还缺提交条件
先看一张既有未提交单。印章、用章事由和附件都在,预计用章时间还没有填写。它表达了一个实际需求:用户已经整理了一部分内容,但尚未准备好启动审批。
▲ 演示样本OA103-2026081000001,状态“未提交”。这是一张已有草稿,不是本轮新建空表单;截图只证明其当前状态,不能反推所有空字段现在都能保存。整屏图片可点开放大。
在这张草稿上触发提交,页面给出“请输入预计用章时间”,并停留在原单据。这里没有先让用户选完审批人,再告诉他业务字段没填完整。
▲ 本轮仅触发缺失字段的前端校验,原单据仍为“未提交”;没有填写字段、点击保存或启动审批。两张图属于同一草稿环节,分别展示已有内容和本地错误定位。
另一张既有用印单处于“已撤回”,仍展示保存、提交入口。它说明流程返回可修改阶段后,业务表单可以继续使用,并非必须把原内容重建一遍。
▲ OA103-2026040300001是另一张历史演示单。“已撤回”与“审批不通过”不是同一状态;本图只证明撤回后可修改的界面,没有把它冒充审批拒绝样本,也未执行再次提交。
这三个画面对应了两个业务阶段:首次提交前准备,以及流程返回后的再次准备。接下来要做的,不是只改变按钮颜色,而是明确每个动作会经过哪几道检查。
先分清四类规则,再决定放在哪一层
| 规则 | 解决的问题 |
|---|---|
| 输入约束 | 类型、必填、格式是否符合接口约定 |
| 条件完整性 | 当前用章方式和节点需要哪些字段 |
| 业务资格 | 印章、时间段、资源状态能否支持本次申请 |
| 流程资格 | 谁能改、当前是否可提交、是否还有发起人任务 |
前端适合尽早提示并定位字段;后端接口负责请求约束;业务服务读取数据库判断冲突;流程服务确认实际任务和实例。它们共同完成一个动作,不能相互替代。
例如,预计归还时间在外借方式下才显示,属于条件完整性;同一公章是否已被其他外借单占用,属于需要查当前数据的业务资格。把二者都写成一个required,既无法表达冲突,也容易让现场用印填写无关字段。
我们采用的设计判断可以收在四处:
| 决策 | 落点与原因 |
|---|---|
| 先检查单据,再让人选审批人 | 表单入口提前校验,减少失败后重选审批人的成本 |
| 保存与提交分开调用 | 保存保留编辑进度,提交增加流转所需判断 |
| 资源冲突在服务端判断 | 其他单据会变化,前端缓存不够作为依据 |
| 返回后重新走提交检查 | 数据与资源、流程位置都可能变化 |
这里说的是职责。某一业务已经分开按钮,不代表它的接口约束也自动完成了分阶段设计,需要继续沿调用链看下去。
把必填错误挡在选择审批人之前
BasicForm的提交入口先调用validateBillBeforeAssigneeSelect。校验失败就切回单据信息页签并返回,不进入后续审批人预览与选择。
asyncfunctionvalidateBillBeforeAssigneeSelect(){if(!formApi){returntrue;}try{constresult=awaitformApi.validate();if(result?.valid===false){activeKey.value='1';returnfalse;}returntrue;}catch{activeKey.value='1';returnfalse;}}这段代码对应前面的红字提示:先保证当前表单有资格继续,再去处理谁审批。异常也按未通过处理,避免因为校验组件抛错而直接进入提交。
业务页面自己的handleSaveAndSubmit也区分isSubmit:提交时主动调用validateForm,失败立即返回;保存时跳过这次主动校验,再组装数据并选择保存接口。这让页面可以针对两个动作组织不同交互。
不过,不能只看调用处的参数名字。用印页面保存时调用getFormValues(false),但当前BasicForm暴露的getFormValues并未声明校验参数,只读取formApi.getValues。真正能够从这段调用链确认的差别,是是否主动执行validateForm,而不是false本身构成了通用“关闭校验”协议。
动态字段权限还会参与Schema合并:节点要求必填时设置required,隐藏字段去除这一层规则,其他情况保留字段原来的规则。业务字段随节点变化时,应沿最终Schema理解结果,不能只读静态data.ts就认定每个页面永远一样。
前端允许先存,不代表后端允许所有字段为空
用印保存和提交目前共用SealApplyBillSaveReqVO,并且两个Controller入口都使用@Valid。下面是接口规则摘录,省略了接口文档注解:
@PostMapping("/save")@PreAuthorize("@ss.hasPermission('oa:seal-apply-bill:create')")publicCommonResult<Long>saveSealApplyBill(@Valid@RequestBodySealApplyBillSaveReqVOsaveReqVO){returnsuccess(sealApplyBillService.saveSealApplyBill(saveReqVO));}@PostMapping("/submit")@PreAuthorize("@ss.hasPermission('oa:seal-apply-bill:create')")publicCommonResult<Long>submitSealApplyBill(@Valid@RequestBodySealApplyBillSaveReqVOsubmitReqVO){returnsuccess(sealApplyBillService.submitSealApplyBill(submitReqVO));}VO中的印章ID、印章编号、用章事由、类型、方式以及组织信息有相应非空约束。这些约束会作用于两个入口。因而,准确的产品描述应是“前端保存不主动执行整表校验,但请求仍需满足后端当前约束”,不能写成草稿可以缺少全部业务字段。
这里还有一个值得对齐的差异:预计用章时间在前端是必填,当前VO却没有为它加@NotNull;外借的预计归还时间也需要结合业务条件判断。仅依赖页面校验,无法证明其他客户端或直接接口调用同样会拦住缺失时间。
如果后续需要进一步放宽草稿,可以拆保存、提交请求对象,或使用明确的校验分组。草稿仍保留合法类型、身份、归属与基本字段约束;提交再增加完整性和条件规则。这属于改进方案,当前用印接口没有因为两个URL不同就自动使用不同分组。
同时,@NotEmpty只表达非空,并不等同@NotBlank、长度限制或业务内容有效。约束的名字和实际语义要逐项对应,避免把“有注解”当成输入质量已经全部解决。
外借冲突需要看当前单据,也需要看其他单据
▲ sealId定位资源,单据ID定位本次申请,businessKey将申请关联流程;预计时间参与冲突,processStatus与useStatus共同决定其他单据是否仍占用。图为逻辑关系。
用印提交在外借方式下调用validateTimeConflict,保存方法没有执行同样的冲突查询。这样一次草稿留存不会被解释为已经获得外借资格;真正提交时,要重新根据当前占用检查。
服务先检查本次时间范围,再查同一印章的其他外借单。下面保留前置条件与时间顺序判断,后续冲突查询省略并在后文解释:
privatevoidvalidateTimeConflict(SealApplyBillSaveReqVOsaveReqVO){if(saveReqVO.getSealId()==null||saveReqVO.getExpectedUseTime()==null||saveReqVO.getExpectedReturnTime()==null){return;}if(saveReqVO.getExpectedUseTime().isAfter(saveReqVO.getExpectedReturnTime())){throwexception(SEAL_TIME_CONFLICT);}// 后续同印章、外借占用与时间重合查询省略}后续查询若找到占用单,就抛出冲突错误。实际查询包含两类占用:审批中的外借申请,以及审批通过且用印状态为外借中的申请;编辑旧单时排除当前ID,避免把自己当冲突。
时间判断覆盖新开始点落在旧区间、新结束点落在旧区间,以及新申请包住旧区间等重合情况,边界采用包含端点的比较。简单查“预计开始是否相等”会漏掉长时间段相互覆盖,不能承担相同职责。
现有实现也有明确边界:关键时间为空时直接跳过;前端限制归还时间严格大于用章时间,而后端反向判断只检查isAfter,相等的规则尚未在这一处对齐。提交完整性应由后端补齐,不能用前端提示覆盖这些差异。
日期时间还要按接口实际类型传递。当前PC时间字段使用valueFormat=‘x’,与后端LocalDateTime时间戳约定对应。若传成日期字符串或错误单位,问题发生在输入契约层,应该先纠正类型,再讨论业务冲突。
返回可修改阶段以后,重新提交仍是一整条检查链
▲ 图中先后顺序表达提交主链;保存走独立入口。再次提交也经过业务规则,不能因为已有实例ID就直接跳过检查。
前端公共状态常量将未提交、审批不通过和已撤回列为可编辑状态,但已办、抄送等视图仍然只读。当前公共判断如下:
exportfunctioncomputeBusinessFormReadonly(viewType:string|undefined,isApproval:boolean|undefined,processStatus:number|undefined,):boolean{if(viewType&&BpmReadonlyViewTypes.includes(viewType)){returntrue;}if(isApproval===true){returntrue;}return!BpmProcessInstanceStatusEditValue.includes(processStatusasnumber);}所以,同一状态从不同视图打开,能做的事也可能不同。审批中的节点字段可编辑,又是叠加字段权限后的另一个上下文,不能直接把所有审批页都当成申请人的完整编辑页。
流程端的智能提交还会用流程Key和businessKey查找活跃实例:已有实例时,查当前用户是否有发起人任务;找到后更新变量并处理该任务,找不到则抛错;没有活跃实例时才创建新实例。因此,“原单重提”不等于“始终新建实例”,也不是拿一个历史实例ID就能继续流转。
重填时至少需要重新检查三件事:当前视图与业务状态是否允许修改,修改后的字段是否完整,资源是否仍可用。外借时间昨天不冲突,今天可能已经被别的单据占用;撤回之后修改时间,原来的查询结论也就不再适用。
需要注意,前端只读状态不是后端状态守卫。当前用印保存主要进行插入或更新,不能由上面的公共函数推出服务端已覆盖记录归属、允许编辑状态、客户端状态字段防覆盖等约束。这些应在具体业务服务中独立落实。
让错误回到它发生的那一层
字段没填,应定位到字段并保留已有输入;请求约束失败,应反馈明确的输入问题;资源占用,应说明本次业务不能继续;没有发起人任务,则应提示当前流程位置不允许这样提交。四类问题不能都压成“保存失败,请稍后再试”。
对后续完善,我们更倾向于沿业务动作建立清楚的规则清单:保存必需哪些字段,提交额外检查什么,哪些条件只针对外借,退回后重查哪些资源。前后端共享这些判断的含义,分别提供友好提示和权威约束,而不是要求前端复制所有数据库查询。
冲突检查通过也不自动等于并发安全。两次提交可能同时读到没有冲突,是否需要锁、占用记录或数据库约束,应按资源排他要求另行设计。本篇没有把一次查询包装成已经实现了强并发预订。
单据校验的目标,是让当前动作在当前阶段成立。允许保存编辑进度,认真检查进入流程的条件,返回后重新验证变化过的事实,三者各有职责,也共同决定企业软件能否让用户顺畅地把一张单办完。