☰
DaleCloud主从表提交实战:从EasyUI前端到后端事务的完整实现
2026/10/14 12:48:29 网站建设 项目流程

看好多人都在用DaleCloud(原NFine)这类框架做企业管理系统,但一遇到“主从表提交”这种单据业务就卡壳——不是前端不知道咋把明细行收集齐,就是后端不知道咋在一个事务里把主表和明细一起落库。今天我就把这个标准的实现套路拆开揉碎讲一遍,从界面设计到后端事务,手把手带你把采购订单、销售出库这类带明细的单据做得明明白白。

这类业务在进销存、ERP、OA系统里太常见了:一张表单一主表,带若干条明细行的业务,比如采购入库单(表头是供应商、入库日期、仓库,明细是物料、数量、单价)、销售出库单、费用报销单,本质上都是主从表结构。理解了DaleCloud的这套实现方式,以后遇到同类需求都能套用,这也是我实际操作中觉得最值得借鉴的一套标准流程。

1. 整体设计与拆解思路

1.1 主从表业务的本质是什么

先想清楚一个事:为什么叫“主从表”?因为业务数据天然分两层。

主表存的是这次业务的“头信息”,它是整个单据的主体,比如采购订单的单号、供应商、下单日期、状态、备注。这层数据的特点是每条单据只有一条记录,字段通常包括单据编码、业务日期、往来单位、经办人、备注等。

从表存的是这次业务的“行项目”,也就是明细条目。特点是一条主表记录对应多条从表记录。比如一个采购订单可能有5行商品,每一行有物料编码、数量、含税单价、税率、金额、交货日期。没有主表,明细就没有归属;没有明细,主表就不完整,两者构成了一个完整的业务单元。

实际开发时常见的坑是:把主从表当成两张独立的表来操作,先存主表拿ID,再循环存明细。这样一旦明细某一条失败,主表已经入库,前后数据就对不上了。所以主从表提交的核心要求是“事务性”,要么全部成功,要么全部回滚。

1.2 DaleCloud框架下为什么选EasyUI这套组合

DaleCloud(原NFine)的前端默认是EasyUI,这套组合做单据业务有它天然的优势。

EasyUI的datagrid组件自带编辑能力。开启singleSelect配合onClickRow就能实现点击行进入编辑态,等于前端有了一个现成的“可编辑表格”,不用自己封装一套复杂的数据网格组件。我试过用其他框架的表格组件做同样的事,要么编辑模式不好用,要么必须引入额外插件,EasyUI这套开箱即用的方案在快速开发场景下非常稳妥。

后端用ASP.NET MVC,Action直接接收JSON对象,模型绑定可以把主表和从表的数据一次拿全。配合EF的TransactionScope或者SqlTransaction,事务代码写起来很直观。这套组合的理念就是“前端收集齐、后端一次存”,全程没有多余的中间步骤,开发效率很高。

DaleCloud本身的架构里已经把登录权限、菜单权限、基础字典这些都做好了,我在做单据模块时不用从零搭框架,专注写业务逻辑就够。这也是很多公司选这类框架做二次开发的原因——底座是现成的,关键是把自己的核心单据流程跑通。

2. 数据库模型设计与前端布局

2.1 主表和从表的表结构怎么定

动手写代码之前,先把表结构定好。这是整个功能的地基,表结构不合理,后面全是坑。

主表和从表建议建同名主键的“业务主键ID”,不要把主表的主键直接当作从表的外键来用。因为单据业务经常有草稿、提交、审核、作废这些状态流转,如果直接拿主表自增ID做关联,模板复制、单据作废后重建都会变得很麻烦。更稳妥的做法是:主表有一个业务单号(比如PO202505001),从表通过这个单号关联,或者通过一个GUID类型的主键关联。

以采购订单为例,我常用的表设计是这样:

主表OrderMaster:

字段类型说明
Idvarchar(36)主键,GUID字符串
OrderNovarchar(30)单号,业务唯一
SupplierIdvarchar(36)供应商ID
OrderDatedatetime下单日期
TotalAmountdecimal(18,2)总金额
Statusint状态
Remarknvarchar(500)备注

从表OrderDetail:

字段类型说明
Idvarchar(36)主键
MasterIdvarchar(36)主表ID,关联OrderMaster.Id
DetailNoint行号
MaterialCodevarchar(30)物料编码
MaterialNamenvarchar(100)物料名称
Quantitydecimal(18,3)数量
Pricedecimal(18,2)单价
Amountdecimal(18,2)金额

注意:金额字段最好用decimal(18,2),不要用float,避免浮点数精度问题。数量字段我习惯保留3位小数,某些行业的物料按重量计价时需要小数精度。

2.2 前端界面如何布局

EasyUI页面布局我一般用一个layout分两块:上面是主表区域,用的是form表单;下面是明细区域,用的是datagrid数据网格。中间用一个可拖动的分割条,用户调整高度比较方便,这是EasyUI的fit:true和border:false配合出来的效果。

主表部分包含:

  • 单号(自动生成,通常不手工录)
  • 供应商下拉框(combobox)
  • 日期(datebox)
  • 备注(textarea)

明细部分就是一张可编辑的datagrid,列包括:

  • 行号(自增)
  • 物料编码
  • 物料名称
  • 数量
  • 单价
  • 金额(自动计算)
  • 操作列(删除行)

布局原则很简单:上面的表单负责主表字段,下面的网格负责明细行收集。这样用户一看就明白,“上面是这张单的基本信息,下面是这张单具体买了什么”,符合业务习惯。

3. EasyUI前端核心实现

3.1 可编辑datagrid的初始化

明细网格是主从表提交里最核心的前端实现点。它的核心逻辑是:每一行都能直接进入编辑状态,新增时自动追加空行并进入编辑,点击切换行时先结束当前编辑行,再进入新行编辑。

我直接把初始化的核心代码贴出来,这些方法在DaleCloud的页面里可以直接套用:

var editIndex = undefined; // 当前编辑行的索引 function endEdit() { if (editIndex == undefined) return true; if ($('#detailGrid').datagrid('validateRow', editIndex)) { // 结束本行编辑,将数据固化到行中 var editors = $('#detailGrid').datagrid('getEditors', editIndex); // 比如可以从某个editors里取值,做计算等 $('#detailGrid').datagrid('endEdit', editIndex); editIndex = undefined; return true; } else { return false; } } function addRow() { if (endEdit()) { $('#detailGrid').datagrid('appendRow', { DetailNo: getNextDetailNo() }); editIndex = $('#detailGrid').getDataIndex().length - 1; $('#detailGrid').datagrid('beginEdit', editIndex); } }

这里有个关键细节:endEdit里面要先做validateRow,判断当前行必填项是否通过。如果没填完就新建行,数据会丢失或错乱。addRow里我用的getNextDetailNo()是算下一个行号的,通常取现有行最大DetailNo加1,这样删除中间行也不会出现行号重复。

点击行切换编辑状态的事件绑定:

$('#detailGrid').datagrid({ singleSelect: true, rownumbers: true, url: url, // 回显时从这里取数据 onDblClickRow: function(index, row) { if (editIndex != index) { if (endEdit()) { $('#detailGrid').datagrid('beginEdit', index); editIndex = index; } } } });

我用的是双击进入编辑,而不是单击。因为单击太容易误触,比如用户原本只是选一下行,结果进入编辑状态,还弹出编辑器,体验很差。双击的节奏刚刚好,经验之谈。

3.2 主表数据采集与明细数据序列化

保存按钮做的事情,简单说就两步:把主表表单的数据取出来,把明细表格的数据取出来,然后打包成一个JSON提交给后端。

主表部分用EasyUI的form组件,但serialize返回的是查询串,不是对象,所以DaleCloud框架里通常有个扩展方法formSerialize或类似辅助把它转成JSON对象。如果框架里没有现成的,可以自己封装一个:

function getFormData() { var data = {}; $('#masterForm').serializeArray().forEach(function(item) { data[item.name] = item.value; }); return data; }

这里就引出一个注意点:主表表单的input的name属性必须和后端模型属性名精确对应。我见过很多人前端字段叫SupplierId,后端模型叫SupplierID,大小写不匹配,绑定半天全是默认值,排查起来特别费劲。确定一次命名规则,前后端严格一致。

明细部分的序列化是我自己优先处理的环节。datagrid取数据不能直接用getRows,因为如果当前还有行处于编辑状态,getRows拿到的是编辑前的旧值。必须先endEdit,再getRows。

所以保存时的操作顺序是:

function doSave() { // 1. 先关闭正在编辑的行,让所有改动落定 if (!endEdit()) { $.messager.alert('提示', '请检查明细数据是否填写完整', 'error'); return; } // 2. 校验主表 if (!$('#masterForm').form('validate')) { $.messager.alert('提示', '请检查主表信息', 'error'); return; } // 3. 取主表和明细数据 var master = getFormData(); var details = $('#detailGrid').datagrid('getRows'); if (details.length == 0) { $.messager.alert('提示', '请至少添加一条明细', 'error'); return; } // 4. 计算合计金额 master.TotalAmount = calcTotalAmount(details); // 5. 打包提交 var postData = { Master: master, Details: details }; $.post('/Order/SaveOrder', postData, function(result) { if (result.State) { $.messager.show({ title: '操作提示', msg: result.Message }); // 刷新或关闭 } else { $.messager.alert('操作失败', result.Message, 'error'); } }, 'json'); }

calcTotalAmount的逻辑很简单,把每一行的Quantity乘Price累加。如果你用了税率还得加上税额,但核心逻辑一样,就是循环明细。这里我顺带提一句,很多系统要求前端把金额算好,后端还要再算一次校验。后端校验更可靠,毕竟用户可能改请求数据造假,前端计算主要是为了界面上给用户看合计,后端计算才是落库的最终依据。别只靠前端算完直接存,这是做单据业务的基本素养。

3.3 回显时的数据装载

编辑老单据时必须能把已有的主从表数据回显到页面。主表好办,直接$('#masterForm').form('load', masterData)就行。明细表我更习惯用loadData方法,而不是load。loadData是给静态数据用的,不触发任何后台请求,直接刷新网格内容。

function loadDetailData(loadId) { $.post('/Order/GetDetailList', { masterId: loadId }, function(result) { if (result.State) { var rows = result.Data; $('#detailGrid').datagrid('loadData', rows); } }, 'json'); }

这里有个我自己踩过的坑:如果直接用datagrid('load', ...),它会自动把表格的url属性作为数据源去请求,并把传的参数附加上去。这个行为本身没错,但容易让新手困惑“为什么我已经传了rows进去,表格却还是空白的”,其实是因为load方法绕过了你传的数据,重新去url要数据了。解决问题的方法也很简单:回显用loadData,新增时候需要走后台分页查询才用load。

4. 后端事务处理与控制器封装

4.1 控制器接收主从表数据

前端已经打包好Master和Details两个属性,后端用对应的视图模型接收。在DaleCloud(原NFine)的项目里,控制器一般长这样:

public class ScenarioInput { public OrderMasterEntity Master { get; set; } public List<OrderDetailEntity> Details { get; set; } } [HttpPost] public ActionResult SaveOrder(ScenarioInput input) { try { // 调用业务层,返回提示信息 var result = orderService.SaveOrder(input.Master, input.Details); return Content(new { State = true, Message = result }.ToJson()); } catch (Exception ex) { return Content(new { State = false, Message = ex.Message }.ToJson()); } }

实体类直接复用框架里已有的OrderMasterEntity和OrderDetailEntity,省去手写Request.Form收集字段的过程,模型绑定会按名称自动匹配。

有个细节是:如果前端提交的是空数组或者某个属性为null,ASP.NET MVC模型绑定可能报错,也可能拿到一个null的Details。所以在业务层入口先判断一下:

if (input == null || input.Master == null || input.Details == null || input.Details.Count == 0) { return "主表或明细数据不能为空"; }

这个判断必须写在所有逻辑之前。别嫌啰嗦,线上跑的单据模块,各种脏数据都可能提交过来,入口把关能省很多后患。

4.2 业务层的保存策略

保存主从表,我在实践中归纳出三种策略,各有适用场景:

第一种:删除重建。删除主表下的所有明细,再重新插入。适合明细行简单、不需要保留行ID、没有子表再引用明细行的场景。优点是代码简单,缺点是不是自增主键而是GUID时无所谓,遇到自增主键被其他表引用就麻烦。

第二种:增量更新。前端把每一行的操作类型标记一下,新增、修改、删除。后端根据标记做差异处理。优点是不用全部删除,适合明细被其他数据引用的场景。缺点是要处理行级操作标记,代码量多一些。

第三种:状态比对。前端把当前所有明细行传过来,后端把数据库原有的明细取出来,根据主键是否存在判断是新增还是修改还是删除。这种最万能,不需要前端额外标操作类型,但要注意主键为空的视为新增。

我个人在开发单据模块时,绝大多数场景用的都是第三种。它天然支持用户“删了几行又改了几行再新增几行”的混合操作,因为后端拿旧数据和新数据一比,差异就出来了。实现思路如下:

public string SaveOrder(OrderMasterEntity master, List<OrderDetailEntity> details) { using (var db = new MyDbContext()) { using (var tx = db.Database.BeginTransaction()) { try { // 1. 保存主表(新增或更新) if (string.IsNullOrEmpty(master.Id)) { master.Id = Guid.NewGuid().ToString("N"); master.CreateTime = DateTime.Now; db.Set<OrderMasterEntity>().Add(master); } else { var oldMaster = db.Set<OrderMasterEntity>().Find(master.Id); if (oldMaster == null) return "主表记录不存在"; // 把新值赋给旧对象,而不是整体替换 db.Entry(oldMaster).CurrentValues.SetValues(master); } db.SaveChanges(); // 2. 对比明细差异 var oldDetails = db.Set<OrderDetailEntity>() .Where(d => d.MasterId == master.Id) .ToList(); // 找出需要删除的明细:旧数据中主键不在新数据里的 var newIds = details.Where(d => !string.IsNullOrEmpty(d.Id)) .Select(d => d.Id) .ToList(); var toDelete = oldDetails.Where(d => !newIds.Contains(d.Id)).ToList(); foreach (var detail in toDelete) { db.Set<OrderDetailEntity>().Remove(detail); } // 新增或更新明细 foreach (var detail in details) { if (string.IsNullOrEmpty(detail.Id)) { detail.Id = Guid.NewGuid().ToString("N"); detail.MasterId = master.Id; db.Set<OrderDetailEntity>().Add(detail); } else { var oldDetail = db.Set<OrderDetailEntity>().Find(detail.Id); if (oldDetail != null) { db.Entry(oldDetail).CurrentValues.SetValues(detail); oldDetail.MasterId = master.Id; // 防止主键变更 } } } db.SaveChanges(); tx.Commit(); return "保存成功"; } catch (Exception ex) { tx.Rollback(); // 记日志 return "保存失败:" + ex.Message; } } } }

这套代码的设计亮点在“差异比对”上。不需要前端标记每一行的状态,也不用额外定义枚举,只需要把当前最终状态的明细传过来,后端自然知道该删谁、该改谁、该加谁。这个思路在DaleCloud(原NFine)这类EF框架下非常干净。但注意一点:CurrentValues.SetValues这个方法会把主键字段也覆盖掉,如果前端不小心传了不一样的主键,关联关系就乱了。所以我在更新完明细后强制再把MasterId赋一次,双保险。

4.3 事务边界与调用链分层

主从表保存必须在一个事务里,这是铁律。上面的代码中使用BeginTransaction就是显式开启事务,调Commit提交,异常时Rollback回滚。

还有个小提示:EF的SaveChanges本身就是一个隐式事务,如果你只是插入主表然后插入明细不查不删,单次SaveChanges能保证整体成功或失败。但是主从表场景基本都有“先查旧明细再删再增”的过程,必须显式开事务,才能把查询、删除、更新、插入全部包在同一个事务的范围内。

在DaleCloud的架构里,通常还有一层Service(业务服务)来封装调用链。也就是说控制器不直接写EF代码,而是调用Service,Service里管事务、写日志,最后把结果返回给控制器。这样做的分层好处是:如果以后要从MVC换到WebAPI,或者同一个保存逻辑还要被另一个接口复用,业务代码不用改,只换外层调用接口就行。我在实际项目里一直坚持这个习惯,短期看是多写一层接口,长期看维护成本低得多。

5. 表单校验与金额计算细节

5.1 必填项的校验策略

主表校验用EasyUI的required规则就够了。比如供应商必选、日期必填:

<input class="easyui-combobox" name="SupplierId">columns: [[ { field: 'MaterialCode', title: '物料编码', width: 100, editor: { type: 'validatebox', options: { required: true } } }, { field: 'Quantity', title: '数量', width: 80, editor: { type: 'numberbox', options: { precision: 3, required: true } } }, { field: 'Price', title: '单价', width: 80, editor: { type: 'numberbox', options: { precision: 2, required: true } } } ]]

validateRow返回false时,前端把提示信息弹出来,不执行保存。这个校验只解决“前端用户填没填”的问题,后端还要再做一次模型校验或者业务规则校验,比如数量必须大于0、物料编码必须在物料表存在。前端校验是体验,后端校验是安全,两者都不能少。

5.2 金额实时计算的行交互

单据明细里最让人舒服的操作体验是:填写数量和单价后,金额自动带出来,合计栏自动更新,不用手动点按钮算。这个用datagrid的onAfterEdit事件实现,只要一行编辑完成,就自动算该行的金额。

onAfterEdit: function(index, row, changes) { if (changes && changes.Quantity !== undefined || changes && changes.Price !== undefined) { row.Amount = (row.Quantity * row.Price).toFixed(2); // 更新这一行的显示 $('#detailGrid').datagrid('refreshRow', index); // 重算合计 updateTotal(); } }

updateTotal就是循环所有行,把Amount相加,然后写到页面下方的合计栏,或者在保存时再算一遍。这里要提醒一个细节点:row对象在编辑结束后是“被修改过的引用”,但refreshRow会从datagrid内部的数据源重新加载该行数据,所以你在onAfterEdit里直接改row.Amount,再用refreshRow刷新,显示就是最新的。这个操作顺序要写对,先改值,后刷新。

5.3 单号自动生成的常见做法

单据的单号通常要求唯一、有顺序、可读。常见的生成规则有:

  • 前缀加日期加流水号,例如:PO + 20250501 + 001
  • 前缀加年月加流水号,例如:SO202505001

我推使用数据库序列或者独立单号表来生成,不要用“查询最大单号加1”,因为并发时很容易产生重复。有些老项目图省事直接查Max再加,一旦两个人同时开单,就可能撞号。用独立计数器表,每次取号时用数据库事务保证不重复,虽然多查一次表,但安全性高得多。前端只要在页面加载时向后端接口要一个新单号并填到单号输入框里即可。

6. 常见问题与排查技巧

6.1 数据集成了主表却没了明细

这是主从表提交里最常遇到的问题:保存成功后一刷新,主表在,明细全没了。

排查顺序我一般是:

  • 先看后端日志,确认SaveOrder方法到底执行到哪里。
  • 看提交的Details是否能被模型绑定正确接收。如果Details是null或空,说明前端的字段名和后端模型不对应。
  • 看事务是否提交成功。如果提交失败,主从表都不该有数据;如果主表有数据而明细没有,大概率是明细插入那段循环没执行,或者执行了但MasterId没有正确赋值,导致明细的主表外键是空的。
  • 还有一种隐蔽情况:EF实体间有级联关系,你插入主表时把明细集合对象也附加到了主表实体上,然后又显式插入明细,导致重复插入或者外键为null。

我给的建议是,先做最小复现:手动后端代码里写死一条主表和两条明细调用Service,看能不能保存成功。能成功就说明前端序列化或参数传递有问题,不能成功就断点打进去查事务内部逻辑。

6.2 明细行编辑状态残留导致获取不到新值

很多人在保存时遇到一个现象:界面明明看到了修改后的数量,但提交到后端一看,还是旧值。

这个就是我前面强调的:一定要先endEdit()再getRows()。getRows只有在所有编辑都结束的情况下才能拿到最新数据,只要有一个行处于编辑态,当前这一行的数据可能还是“编辑前的快照”。你看到的是编辑器里的值,但网格内部的数据源还没更新。所以保存第一步先endEdit,并把editIndex重置为undefined,这是必须写好的逻辑。如果用户当前焦点正在某个行编辑器里,endEdit会尝试结束编辑并校验,校验不通过时中断保存,这个时机是合理的。

6.3 日期时间格式的坑

主从表数据提交时,日期字段经常被前端序列化成“2025-05-01T00:00:00”这种格式,后端模型绑定到DateTime时会报转换错误,或者默默变成默认值。DaleCloud里很多页面用的日期控件是datebox,它返回的文本格式可能是“2025-05-01”,直接提交没问题。但如果你用了H5的input type="datetime-local",格式就会带T,绑定就可能出问题。

我的处理办法是:前端在提交前统一把日期字段格式化成“yyyy-MM-dd HH:mm:ss”,或者后端定义一个string类型的接收属性,进入Service后再用DateTime.Parse转换成标准类型。两种都行,关键是保持统一,别今天前端传这种、明天传那种,后端解析逻辑就会写得特别碎。

6.4 大明细数据量的性能考虑

一个单据几十行很常见,上百行也存在。datagrid渲染上百行没问题,但每次增删行都全量刷新肯定卡。

用appendRow、deleteRow操作单行比loadData重新加载整表要流畅得多。保存时一次性把所有明细放到一个Details列表里传给后端,也比循环调用一个“保存单行明细”的接口快得多。这其实是主从表提交设计上的一个优势——一次网络往返搞定所有数据。

后端处理时,如果明细行很多,建议关闭EF的自动跟踪再插入,或者使用批量插入的扩展。我在DaleCloud中见过用DbContext.Configuration.AutoDetectChangesEnabled = false配合SaveChanges前统一AddRange的写法,性能有明显提升。几百行的单据,配合这些优化能快到毫秒级。

6.5 并发修改同一张单据问题

两个人同时打开同一个采购订单,A保存把备注改成了“加急”,B保存把数量改成了100,后保存的那个人可能把先保存的人的修改覆盖掉。这就是典型的并发冲突。

简单的方案是加入版本号字段(Timestamp或RowVersion),更新的时候比对版本号,不一致就提示“该单据已被他人修改,请刷新后再操作”。DaleCloud(原NFine)部分表结构里有F_LastModifyTime这类字段,但是没有强制做并发控制。我在做核心单据模块时,会额外加一个Version字段,配合EF的RowVersion,并发问题能完美解决。业务量不大时可能觉得没必要,一旦出现两个业务员同时抢着改同一张单,就知道这个字段有多重要了。

7. 实操心得与后续扩展建议

做完一个单据模块的主从表提交,我最大的感受是:这个功能不难,但细节特别多。尤其是前端结束编辑的时机、后端事务边界、差异比对的策略,这三处只要有一处想得不清楚,后面必出问题。做得多了,你会发现一套固定的模式——前端收集数据,后端开事务做差异保存,前端校验管体验、后端校验管安全,这套模式放之四海而皆准。

如果你在DaleCloud里正准备做多个单据业务(采购单、销售单、出入库单),完全可以抽一个公共基类或者扩展方法来统一处理。比如“结束编辑并校验”“计算合计”“构建提交数据包”这些都是公共逻辑,把它们抽出来放到公共JS里,每个单据页面直接引用就好。后端“开事务—保存主表—比对明细—提交”的骨架逻辑也高度雷同,可以做成泛型基类方法,明细差异比对这部分抽成公共方法后,不同单据的Service就能复用,缩短开发量。

最后再分享一个小技巧:前端保存按钮的防重复提交一定要做。保存接口如果处理慢,用户使劲点两三次,后端会同时收到几个请求,轻则重复出入库,重则数据错乱。我一般点完保存立即把按钮禁用,等回调成功后再恢复,这是低成本但极其有效的保护手段。

主从表提交是单据类业务的标配能力。你在DaleCloud(原NFine)里把一个单据模块做通了,再做其他单据,基本就是复制粘贴再改字段名的熟练工活。关键是把事务和差异校验这套思维练成习惯,单据再多也不会慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询