一个共通的viewModel搞定所有的分页查询一览及数据导出(easyui + knockoutjs + mvc4.0)
前言
大家看标题就明白了我想写什么了,在做企业信息化系统中可能大家写的最多的一种页面就是查询页面了。其实每个查询页面,除了条件不太一样,数据不太一样,其它的其实都差不多。所以我就想提取一些共通的东西出来,再写查询时只要引入我共通的东西,再加上极少的代码就能完成。我个人比较崇尚代码简洁干净,有不合理的地方欢迎大家指出。
这篇文章主要介绍两个重点:1、前台viewModel的实现。2、后台服务端如何简洁的处理查询请求。
需求分析
查询页面要有哪些功能呢
1、有条件部输入查询条件(这个不打算做成共通的,因为共通的查询拼接条件那种都很不好用)
2、在条件的右边放置:a查询按钮:根据条件查询数据,b清空按钮:清空条件并查询
4、因为这是个一览页面、还可能有新增数据、修改数据的跳转按钮,以及删除数据、审核数据、及导出数据的功能,所以还要一个工具栏放置这些按钮
技术实现
前端要实现的就是
1、页面布局
2、绑定每个输入控件及数据列表控件
3、每个按钮操作对应的脚本
后台web api要实现的包括
1、根据前台传过来的每个字段的值定义为什么查询(equal、like、greater…这个本来我是放在前台定义的,考虑到安全性问题,移到后台),值为空是否要不加为条件。
2、根据这些定义及分页信息、排序信息查询我需要的数据。
3、返回数据到前台
我的项目代码都在对应的区域中,比如我们在Mms项目加一个查询
首先这对应一个页面,所以在Controller中创建一个空的mvc controller,取名RecieveController.cs
using System; using System.Web.Mvc; using Zephyr.Core; using Zephyr.Models; using Zephyr.Web.Areas.Mms.Common; namespace Zephyr.Areas.Mms.Controllers { public class ReceiveController : Controller { public ActionResult Index() { return View(); } }
然后在Views创建一个Receive文件夹,添加一个Index.cshtml的Razor页面
@{ ViewBag.Title = "title"; Layout = "~/Views/Shared/_Layout.cshtml"; } @section scripts{ <script src="~/Areas/Mms/ViewModels/mms.com.js"></script> <script src="~/Areas/Mms/ViewModels/mms.viewModel.search.js"></script> <script type="text/javascript"> var data = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(Model)); var viewModel = mms.viewModel.search; ko.bindingViewModel(new viewModel(data)); </script> } <div class="z-toolbar"> <a href="#" plain="true" class="easyui-linkbutton" icon="icon-arrow_refresh" title="刷新" data-bind="click:refreshClick">刷新</a> <a href="#" plain="true" class="easyui-linkbutton" icon="icon-add" title="新增" data-bind="click:addClick" >新增</a> <a href="#" plain="true" class="easyui-linkbutton" icon="icon-edit" title="编辑" data-bind="click:editClick" >编辑</a> <a href="#" plain="true" class="easyui-linkbutton" icon="icon-cross" title="删除" data-bind="click:deleteClick" >删除</a> <a href="#" plain="true" class="easyui-linkbutton" icon="icon-user-accept" title="审核" data-bind="click:auditClick" >审核</a> <a href="#" plain="true" class="easyui-splitbutton" data-options="menu:'#dropdown',iconCls:'icon-download'" >导出</a> </div> <div id="dropdown" style="width:100px; display:none;"> <div data-options="iconCls:'icon-ext-xls'" suffix="xls" data-bind="click:downloadClick">Excel2003 </div> <div data-options="iconCls:'icon-page_excel'" suffix="xlsx" data-bind="click:downloadClick">Excel2007 </div> <div data-options="iconCls:'icon-ext-doc'" suffix="doc" data-bind="click:downloadClick">Word2003 </div> </div> <div id="condition" class="container_12" style="position:relative;"> <div class="grid_1 lbl">收料单号</div> <div class="grid_2 val"><input type="text" data-bind="value:form.BillNo" class="z-txt easyui-autocomplete" data-options="url:'/api/mms/receive/getbillno'"/></div> <div class="grid_1 lbl">项目名称</div> <div class="grid_2 val"><input type="text" data-bind="value:form.ProjectName" class="z-txt easyui-autocomplete" data-options="url:'/api/mms/project/getprojectname'"/></div> <div class="grid_1 lbl">供应商</div> <div class="grid_2 val"><input type="text" data-bind="value:form.SupplierName" class="z-txt easyui-autocomplete" data-options="url:'/api/mms/merchant/getnames'"/></div> <div class="clear"></div> <div class="grid_1 lbl">仓库</div> <div class="grid_2 val"><input type="text" data-bind="datasource:dataSource.warehouseItems ,comboboxValue:form.WarehouseCode" class="z-txt easyui-combobox" data-options="showblank:true"/></div> <div class="grid_1 lbl">材料类别</div> <div class="grid_2 val"><input type="text" data-bind="lookupValue:form.MaterialType" class="z-txt easyui-lookup" data-options="lookupType:'materialtype',parentField:'pid'"/></div> <div class="grid_1 lbl">发货日期</div> <div class="grid_2 val"><input type="text" data-bind="value:form.ReceiveDate" class="z-txt easyui-daterange"/></div> <div class="clear"></div> <div class="prefix_9" style="position:absolute;top:5px;height:0;"> <a id="a_search" href="#" class="buttonHuge button-blue" data-bind="click:searchClick" style="margin:0 15px;">查询</a> <a id="a_reset" href="#" class="buttonHuge button-blue" data-bind="click:clearClick">清空</a> </div> </div> <table id="gridlist" data-bind="datagrid:grid"> <thead> <tr> <th field="BillNo" sortable="true" align="left" width="90" >收料单号 </th> <th field="ProjectName" sortable="true" align="left" width="80" >项目名称 </th> <th field="SupplierName" sortable="true" align="left" width="150" >供应商 </th> <th field="ContractCode" sortable="true" align="left" width="80" >合同名称 </th> <th field="WarehouseName" sortable="true" align="left" width="100" >仓库 </th> <th field="ReceiveDate" sortable="true" align="center" width="70" formatter="com.formatDate" >发料日期 </th> <th field="MaterialTypeName" sortable="true" align="left" width="100" >材料类别 </th> <th field="TotalMoney" sortable="true" align="right" width="50" formatter="com.formatMoney" >金额 </th> <th field="OriginalNum" sortable="true" align="left" width="90" >原始票号 </th> <th field="CreatePerson" sortable="true" align="left" width="50" >编制人 </th> <th field="CreateDate" sortable="true" align="center" width="70" formatter="com.formatDate" >编制日期 </th> <th field="Remark" sortable="true" align="left" width="150" >备注 </th> </tr> </thead> </table>
代码贴上来有换行了,我本机没换行,看着整齐些。这些代码大家应该基本上都明白,所有的data-bind都是knouckoutjs的写法,class=”easyui-xxxx"及data-options=”{}"的是easyui的写法。我的data-bind=”datagrid:grid”这个是我用knouckout去初始化grid。上面的三个脚本我要解释下,第一个是我项目共通的js,第二个就是我的查询页面的共通viewModel,第三个是接收本页面的mvc后台数据Model,传递给viewModel,并且绑定到页面上。这样view已经写好了。如果要写下一个查询页面,就是上页这一段拿来改改查询条件啊,数据列啊基本就好了,其它的都是共通的。
接下来我们看看这个共通的viewModel,其实也就不到100行代码
/** * 模块名:mms viewModel * 程序名: mms.viewModel.search.js * Copyright(c) 2013-2015 liuhuisheng [ liuhuisheng.xm@gmail.com ] **/ var mms = mms || {}; mms.viewModel = mms.viewModel || {}; mms.viewModel.search = function (data) { var self = this; this.idField = data.idField || "BillNo"; this.urls = data.urls; this.resx = data.resx; this.dataSource = data.dataSource; this.form = ko.mapping.fromJS(data.form); delete this.form.__ko_mapping__; this.grid = { size: { w: 4, h: 94 }, url: self.urls.query, queryParams: ko.observable(), pagination: true, customLoad: false }; this.grid.queryParams(data.form); this.searchClick = function () { var param = ko.toJS(this.form); this.grid.queryParams(param); }; this.clearClick = function () { $.each(self.form, function () { this(''); }); this.searchClick(); }; this.refreshClick = function () { window.location.reload(); }; this.addClick = function () { com.ajax({ type: 'GET', url: self.urls.billno, success: function (d) { com.openTab(self.resx.detailTitle, self.urls.edit + d); } }); }; this.deleteClick = function () { var row = self.grid.datagrid('getSelected'); if (!row) return com.message('warning', self.resx.noneSelect); com.message('confirm', self.resx.deleteConfirm, function (b) { if (b) { com.ajax({ type: 'DELETE', url: self.urls.remove + row[self.idField], success: function () { com.message('success', self.resx.deleteSuccess); self.searchClick(); } }); } }); }; this.editClick = function () { var row = self.grid.datagrid('getSelected'); if (!row) return com.message('warning', self.resx.noneSelect); com.openTab(self.resx.detailTitle, self.urls.edit + row[self.idField]); }; this.grid.onDblClickRow = this.editClick; this.auditClick = function () { var row = self.grid.datagrid('getSelected'); if (!row) return com.message('warning', self.resx.noneSelect); com.auditDialog(function (d) { com.ajax({ type: 'POST', url: self.urls.audit + row[self.idField], data: JSON.stringify(d), success: function () { com.message('success', self.resx.auditSuccess); } }); }); }; this.downloadClick = function (vm, event) { com.exportFile(self.grid).download($(event.currentTarget).attr("suffix")); }; };
下面我解释下这个viewModel中定义的几个变量
idField: 这是告诉我哪个是字段的主键,编辑按钮中有使用,要取得行数据中的这个主键值传给编辑页面。
urls: 告诉我每个数据服务api地址:如 urls={query:’api/xxxx/’,audit:’api/xxxx/audit/’,…}
resx: 存储一些消息标题等中文,这样比较好国际化,如 resx={noneSelect:’请先选择一条数据再操作!’,deleteSuccess:’删除成功!’}
dataSource:这个是用来存储数据源的对象,如 dataSource = {warehouseItems: [{text:’A仓库’,value:’A001’},{},…]}
form: 这个是用来存储条件部值的对象,可以查看条件中的data-bind都是绑定到form.xxxx字段。
这些值我们都可以在后台定义好传递到这个viewModel,所以程序就可以做到共通了。那么这个viewModel也就写好了,如果还有什么问题大家可以给我留言,我再给大家解释。
接下来我们写好了这个viewModel,它还需要一些参数,我们要从后台返回给它,我们回过头来修改一下那个mvc controller
public ActionResult Index() { var model = new { urls = new { query = "/api/mms/receive", remove = "/api/mms/receive/", billno = "/api/mms/receive/getnewbillno", audit = "/api/mms/receive/audit/", edit = "/mms/receive/edit/" }, resx = new { detailTitle = "收料单明细", noneSelect = "请先选择一条收料单!", deleteConfirm = "确定要删除选中的收料单吗?", deleteSuccess = "删除成功!", auditSuccess = "单据已审核!" }, dataSource = new{ warehouseItems = new mms_warehouseService().GetWarehouseItems(MmsHelper.GetCurrentProject()) }, form = new{ BillNo = "", ProjectName = "", SupplierName = "", WarehouseCode = "", MaterialType = "", ReceiveDate = "" } }; return View(model); }
好了,这就是viewModel需要的全部参数了。返回不同数据给viewModel,它就能创建不同的实例了。
再说一下上面代码中的url,实际上是指api的地址,
query:数据查询服务地址
remove:数据删除服务地址
audit:数据审核的服务地址
edit:这个不是api服务,这个地址是编辑跳转的页面地址。
那么接下来我们就要写这些web api了。那么我们地新建一个api controller,那么这么一来我们就有两个控制器了,感觉代码分得太开,复杂了。我就把这两个类放在一个文件中吧,这样会更简洁点,而且业务名字也都一样,也算合理。
using System;
using System.Web.Mvc;
using Zephyr.Core;
using Zephyr.Models;
using Zephyr.Web.Areas.Mms.Common;
namespace Zephyr.Areas.Mms.Controllers { public class ReceiveController : Controller { public ActionResult Index() { } } public class ReceiveApiController : ApiController { } }
这里本来有一个问题,mvc cotroller和api controller的类名后缀都得叫Controller,我加了些配置把api控制器的后缀改成了ApiController,具体参照我的上一篇博客。
我们开始在ReceiveApiController类中添加查询的数据服务
// 查询:GET api/mms/receive public List<dynamic> Get(RequestWrapper query) { query.LoadSettingXmlString(@" <settings defaultOrderBy='BillNo'> <select> A.*, B.ProjectName, C.MaterialTypeName, D.WarehouseName as WarehouseName, E.MerchantsName AS SupplierName </select> <from> mms_receive A left join mms_project B on B.ProjectCode = A.ProjectCode left join mms_materialType C on C.MaterialType = A.MaterialType left join mms_warehouse D on D.WarehouseCode = A.WarehouseCode left join mms_merchants E on E.MerchantsCode = A.SupplierCode </from> <where defaultForAll='true' defaultCp='equal' defaultIgnoreEmpty='true' > <field name='BillNo' cp='equal' ></field> <field name='ProjectName' cp='like' ></field> <field name='E.MerchantsName' cp='like' variable='SupplierName' ></field> <field name='A.WarehouseCode' cp='equal' ></field> <field name='A.MaterialType' cp='equal' ></field> <field name='ReceiveDate' cp='daterange' ></field> </where> </settings>"); var ReceiveService = new mms_receiveService(); var pQuery = query.ToParamQuery(); var result = ReceiveService.GetDynamicListWithPaging(pQuery); return result; }
上面这段代码是利用了我的框架写出来的。我解释下为什么要这么写:
首先这个服务要接收条件部的参数form={a:’’,b:’’,…}还要接收grid中的参数排序sort=a order=desc以及分页请求page=1,rows=20,这些功能我们都要去实现。
还有一个最烦人的东西,我们的查询条件连接,估计大家都写过这种代码:
if (SrcBillType.Length > 0) { sWhere += string.Format(" And T.SrcBillType='{0}'", SrcBillType); }
一两个还好,那如果我的条件一多,那就一串很恶心的代码。而且还是拼接sql文,如果不处理还会有sql注入的危险。所以我就一直在想怎么改进这种代码。
我是这样想的,我们正常的查询一般都是前台给我们json数据,不带任何逻辑的。我们后台应该要有一段逻辑的配置,我的这个配置+前台送过来的数据,应该就可以得到我需要的查询。
有些人前台传过来就逻辑都处理好了,是一个自定义的复杂的数据结构,后台再根据这个转换成我们需要的参数,我个人觉得这样做的话一来是存在安全性问题,二来前台也会相对复杂,我现在设计前台传递过来就是最简单的json数据结构,如{a:’’,b:’’,c:’’},其它的后台处理。所以我设计了一个类RequestWrapper业实现。
RequestWrapper中包含请求的数据和查询的逻辑配置,我把这个配置定义成一段xml,上面我数据查询服务中的那个,包括select、from、where及一些其它节点,然后我们可以在这个里面定义查询。这个就让我们想起著名的iBatisNet框架了,它的SQL是存放在xml中的,所以我们可以把这段配置也放在xml文件中,当然也可以写在代码里。RequestWrapper如果有接收到分页或排序的请求时,还会自动处理。
如上Get方法中的配置,不用我解释大家基本能理解,在where中我稍微说一下,field的name对应的是字段,cp对应的是我在框架中预定义好的查询逻辑,全部放在Zephyr.Core.Cp类下面,相当于一个lambda表达式wheredata=>return conditionSql这种形式。 如下左图是我框架中预定义好的。如果我预定义的不够用,我们每个项目中还可以去拓展这个类。这样还有一个好处就是我可以很简单的处理一个复杂的东西,比如我们查询条件中的 接收日期我用了一个日期区间控件,它得到的值是类似:2013-05-17 到 2013-06-01
而且有可能是from没有,或者是to没有,那么我们就需要在DateRange中定义好它的规则可以直接生成一个或两个条件返回。
说到这里,大家应该对我的这个RequestWrapper有一点点了解了吧。所以我们写代码就这样
public List<dynamic> Get(RequestWrapper query) { query.LoadSettingXmlString(@"xml...."); var ReceiveService = new mms_receiveService(); var pQuery = query.ToParamQuery(); var result = ReceiveService.GetDynamicListWithPaging(pQuery); return result; }
我在Web api的参数绑定时已经把request的值放到requestWrapper中,所以我们只要接收这个参数就已经有请求的数据了。所以我们需要用query.LoadSettingXmlString的方式加载一段xml的逻辑设置。那么这个查询参数就基本完成了。
我们再new一个mms_receiveService数据服务,这个数据服务其实就是我的Model层的代码,如下
using System; using System.Collections.Generic; using System.Text; using Zephyr.Core; namespace Zephyr.Models { [Module("Mms")] public class mms_receiveService : ServiceBase<mms_receive> { } public class mms_receive : ModelBase { [PrimaryKey] public string BillNo{ get; set; } public DateTime? BillDate{ get; set; } public string DoPerson{ get; set; } public string ProjectCode{ get; set; } public string SupplierCode{ get; set; } public string ContractCode{ get; set; } public string WarehouseCode{ get; set; } public DateTime? ReceiveDate{ get; set; } public string MaterialType{ get; set; } public string SupplyType{ get; set; } public decimal? TotalMoney{ get; set; } public string PayKind{ get; set; } public string OriginalNum{ get; set; } public string ApproveState{ get; set; } public string ApprovePerson{ get; set; } public DateTime? ApproveDate{ get; set; } public string ApproveRemark{ get; set; } public string CreatePerson{ get; set; } public DateTime? CreateDate{ get; set; } public string UpdatePerson{ get; set; } public DateTime? UpdateDate{ get; set; } public string Remark{ get; set; } } }
Models层中的的代码是创建项目时我用我生成器批量生成的,我在这里面不用写任何代码。
然后我们调用ServiceBase基类中的方法ReceiveService.GetDynamicListWithPaging即可完成分页查询。(基类中有很多方法,以后博客中再跟大家说)
所以我们的后台查询就上面Get中的几行代码就搞定了。而且这个方法也可以共通起来,把配置放到xml中去,请求时告诉我你查询配置是哪个文件,我就自动去加载,实际上一个api就可以搞定所有查询了。如果要修改查询,只要修改xml文件就好了。
接下来我们还要在ReceiveApiController类中添加删除及审核的数据服务:
// 删除:DELETE api/mms/send public void Delete(string id) { var service = new mms_receiveService(); var result = service.Delete(ParamDelete.Instance().AndWhere("BillNo", id)); MmsHelper.ThrowHttpExceptionWhen(result <= 0,"收料单删除失败[BillNo={0}],请重试或联系管理员!",id); } // 审核:POST api/mms/send/audit [System.Web.Http.HttpPost] public void Audit(string id,JObject data) { var status = data["status"].ToString(); var comment = data["comment"].ToString(); var result = new MmsService().Audit("mms_receive", id, status, comment); MmsHelper.ThrowHttpExceptionWhen(result <= 0,"审核收料单失败[BillNo={0}],请重试或联系管理员!",id); }
这样后台就全部写完了,是不是很简洁。至此,我们的查询功能就全部完成了。
效果展示
收料单号,项目名称,供应商名称,都为自动完成控件,跟百度的差不多,会有建议提示下拉。
分页测试,数据只做了11条,把每页行数调到10
第一页,测试ok
第二页,测试ok
按钮测试
刷新测试OK,新增、修改会打开新的一个Tab页 如 /mms/receive/edit/201212260001
导出pdf原来也有做,但是有一个中文乱码的问题还没得到解决。
在使用这个viewModel的情况下,我开发一个一般的查询页面,基本上可以5-10分钟完成。
大家是不是也觉得功能还算挺多的页面,开发也很简单~^o^~。
这篇利用基础框架的是我第一篇博客中有介绍:
第一次发博客-说说我的B/S开发框架(asp.net mvc + web api + easyui):http://www.cnblogs.com/xqin/archive/2013/05/29/3105291.html
后述
在这篇中我们主要是说如何前台用一个共通的viewModel,后台利用框架几句简单的代码实现一个复杂的查询。在这里实际上还涉及到很多其它的问题:
1、如那些控件是怎么做的,那些条件部的控件,除了下拉框easyui中都没有,其实这些是我自己在easyui的基础上拓展的,日期期间是国外的一个控件,我改改也放进来了,都统一成easyui的写法。
2、框架底层实现了哪些东西,基类中有哪些现成的方法。
3、共通的审核、下载是如何做的
4、共通的js中,如何knouckoutjs是怎么和easyui绑定的等等
这些问题我在以后的博客中会一一写出来,接下来我可能会先写 如何用一个共通的viewModel搞定所有的编辑页面,这个比较复杂一点,可能要分几篇写,如果大家感兴趣,帮我顶下,谢谢大家~~