简介:这是一套基于C#与EasyUI整合开发的后台管理示例工程,面向需要掌握MVC模式表格增删改查、分页、文件上传及导出Excel的中级.NET开发者。开发环境采用VS2013,数据库为SQL Server 2014,例子围绕一张业务表完整演示了新增、修改、删除、分页、导出Excel和图片上传等常用操作;分页默认使用SQL Server 2012新增关键字方案,同时项目中保留适合2005或2008版本的getPage2005分页方法,只需在数据访问层调整调用即可适配不同数据库。压缩包约28.54MB,目录中完整包含视图页面、控制器、模型和数据访问层等核心实现代码,尤其可重点学习UserInfoDAL.cs中分页与数据交互的写法,帮助理解前端EasyUI布局与后端接口的对应关系。目前已有218人学习下载,适合作为企业内部管理系统开发起步模板,也是C#与EasyUI技术组合实战练习的参考资料。
1. C# 后端 + EasyUI 前端的增删改查:一套能直接落到项目里的完整闭环
很多从前后端分离项目转过来的同事,看到 EasyUI 的第一反应是「这年头还有人用 jQuery 控件」。真正做过企业内部管理系统的人则明白,C# 后端配 EasyUI 的 datagrid,是增删改查场景里投入产出比最高的组合之一:一个控件把表格渲染、分页、行选择、弹窗编辑全部收拢,后端只需要按约定返回 JSON。这套资源把新增、修改、删除、导出 Excel、文件上传五件事做成一个完整闭环,适合正在做 OA、MIS 或后台管理系统的 .NET 开发,也适合想用最短时间搭出内部工具前端的人。整体思路不复杂,但坑都在联调细节里,这篇直接讲清楚。
2. 选型与工程拆分:为什么是 EasyUI 而不是 Vue,分层怎么划
2.1 传统业务系统的舒适区:EasyUI 的控件覆盖度
先解决一个最直接的问题:都 2025 年了,为什么还要选 EasyUI。答案是这类「表格 + 表单 + 弹窗」的管理系统,EasyUI 的控件覆盖度几乎是对着需求设计的。datagrid 自带列模型、排序、分页、多选,一个表格能覆盖列表页 90% 的交互;dialog 加 form 的组合天然适配新增和编辑弹窗;combobox 处理下拉选择,datebox 处理日期,validatebox 做必填和格式校验。这些控件拼起来的整体风格高度统一,不会出现自己写 HTML 时按钮和表格样式各说各话的问题。
更实际的一点是团队成本。EasyUI 的 API 风格是纯 jQuery 的,老一代 .NET 开发基本零上手成本,会写 jQuery 就能改页面。Vue 本身没问题,但管理系统的多数页面是「查出来、改保存」,交互密度不高,引入一套工程化的前端链路反而要把 node、构建、组件通信这些概念全部铺一遍。在内部系统这个场景下,EasyUI 的「落后」恰恰是它的优势:稳定、不折腾、文档全。
2.2 项目分层与文件结构:UI、业务、数据的依赖方向
这套资源用的是 ASP.NET MVC 加三层结构,前端页面和后端接口放在同一个 Web 项目里,不单独拆前端工程。目录划分很常规,依赖方向是视图层依赖控制器层,控制器层依赖模型层,禁止反向引用:
/Controllers /UserController.cs /Models /User.cs /Views /User /Index.cshtml /Scripts /jquery.min.js /jquery.easyui.min.js /easyui /themes/default/easyui.cssController 只负责接收 HTTP 请求、调用数据访问、返回 JSON;Model 对应数据库表结构;View 里放 EasyUI 页面。业务逻辑量小的时候直接在 Controller 里查数据库没问题,如果后面有跨表事务或复杂计算,再拆一个 Service 层,不要一上来就过度设计。
这里有一个我踩过的坑:直接返回 EF 实体对象给前端,序列化时很容易把导航属性带出来,出现循环引用报错,或者把用户表里的密码字段整个暴露到页面上。所以接口返回前一律 Select 成匿名对象,只挑前端要用的字段。这个习惯在后面导出 Excel 时同样受益。
2.3 环境准备与基础配置:jQuery 引用、datagrid 默认参数
页面引用 EasyUI 的顺序有讲究,jQuery 必须在 EasyUI 之前加载,CSS 放在最前面,否则控件样式会闪一下再加载完成。以常用的 1.4+ 版本为例,页面头部这样写:
<link href="~/easyui/themes/default/easyui.css" rel="stylesheet" /> <link href="~/easyui/themes/icon.css" rel="stylesheet" /> <script src="~/Scripts/jquery.min.js"></script> <script src="~/Scripts/jquery.easyui.min.js"></script>提示:EasyUI 1.4 之后主题资源从原来的 jquery-easyui-1.x 目录结构里拆出来了,换主题时只需要换 easyui.css 的引用路径,页面上不需要做任何改动。
datagrid 的默认参数建议在公共 JS 里统一配置,而不是每个页面重复写。这样后续维护时想改默认分页大小,只需要动一个文件。通常我会把 pageSize 设成 20,singleSelect 设成 false 以便支持批量删除,rownumbers 打开让表格左侧显示序号:
$.fn.datagrid.defaults.pagination = true; $.fn.datagrid.defaults.pageSize = 20; $.fn.datagrid.defaults.pageList = [20, 50, 100]; $.fn.datagrid.defaults.rownumbers = true; $.fn.datagrid.defaults.singleSelect = false;前面两项好理解,pageList 是分页控件下拉框里可选的每页条数,rownumbers 的序号在导出 Excel 时是不跟着走的,因为导出的数据来自后端而非前端 DOM,这一点心里有数就行。
3. 把增删改查做到可用状态:datagrid 与后端接口的联调细节
3.1 列表加载:url、queryParams、loadFilter 的配合
datagrid 列表加载有三件套:url 决定请求地址,queryParams 决定查询条件,loadFilter 决定后端返回的数据如何解包。很多第一次用 EasyUI 的人会在 loadFilter 上翻车,因为 EasyUI 默认认{total: 总数, rows: 列表}这层结构,后端的返回格式稍有包装就显示空白。前端初始化这样写:
$('#dg').datagrid({ url: '/User/GetList', method: 'post', pagination: true, queryParams: { keyword: $('#keyword').val() }, columns: [[ { field: 'Id', title: 'ID', width: 60 }, { field: 'UserName', title: '姓名', width: 120 }, { field: 'CreateTime', title: '创建时间', width: 140, formatter: function (v) { if (!v) return ''; return v.replace('T', ' '); } } ]], loadFilter: function (data) { if (data.code === 0) { return { total: data.data.total, rows: data.data.rows }; } $.messager.alert('提示', data.msg, 'error'); return { total: 0, rows: [] }; } });后端对应的 GetList 接口接收 page 和 rows 两个参数,分别对应前端的分页页码和 pageSize。注意 EF 查询里 OrderBy 必须在 Skip 之前,否则会报「必须按顺序应用 Skip」的异常:
public ActionResult GetList(string keyword, int page = 1, int rows = 20) { var query = db.Users.AsQueryable(); if (!string.IsNullOrEmpty(keyword)) { query = query.Where(u => u.UserName.Contains(keyword)); } var total = query.Count(); var list = query.OrderBy(u => u.Id) .Skip((page - 1) * rows) .Take(rows) .Select(u => new { u.Id, u.UserName, u.CreateTime }) .ToList(); return Json(new { code = 0, data = new { total, rows = list } }, JsonRequestBehavior.AllowGet); }说一下 loadFilter 里那两行 return 的逻辑:EasyUI 的 datagrid 在渲染数据前会把原始响应整个丢给 loadFilter,这里解包成它认识的{total, rows}。如果后端返回错误码则弹提示,返回一个空结构让表格保持空白,避免 datagrid 拿到 null 直接报 JS 异常。后端接口里 page 从 1 开始,这和 EasyUI 的页码从 1 开始是一致的,不需要额外减一。
3.2 新增与编辑弹窗:validatebox 校验和表单回填
新增和编辑共用一个弹窗是常见做法,这样能少写一半 HTML。弹窗里放一个 form,内部字段的 name 必须和后端模型的属性名完全一致,因为后续的 form load 和 serialize 都靠 name 来映射。隐藏的 Id 字段用于区分新增和编辑:
<div id="dlg" class="easyui-dialog" style="width:420px;padding:20px" >function openAdd() { $('#fm').form('clear'); $('#dlg').dialog('open').dialog('setTitle', '新增用户'); } function openEdit() { var row = $('#dg').datagrid('getSelected'); if (!row) { $.messager.alert('提示', '请先选择要编辑的行'); return; } $('#fm').form('clear'); $('#fm').form('load', row); $('#dlg').dialog('open').dialog('setTitle', '编辑用户'); }保存时先调 form validate 触发所有 validatebox 校验,再序列化提交。后端 Save 靠 Id 判断是 Add 还是 Update:
[HttpPost] public ActionResult Save(User model) { if (model.Id == 0) { db.Users.Add(model); } else { db.Entry(model).State = EntityState.Modified; } db.SaveChanges(); return Json(new { code = 0, msg = "保存成功" }); }这里有个细节:validatebox 的 validType 是字符串,多个校验规则用逗号分隔;如果要自定义规则,要在页面里先$.fn.validatebox.defaults.rules扩展,别直接改 EasyUI 源码,否则升级控件时会丢。
3.3 删除与批量操作:selected 的行状态边界
删除操作相比新增编辑,更容易出现「看起来没反应」的假象。最常见的原因是没有调用 datagrid reload,删除成功后表格还停留在旧数据上。批量删除通过 getSelections 拿到所有选中行,把主键拼成逗号分隔的字符串一次性传给后端:
function removeRows() { var rows = $('#dg').datagrid('getSelections'); if (!rows.length) { $.messager.alert('提示', '请先勾选要删除的行'); return; } $.messager.confirm('确认', '确定删除选中的 ' + rows.length + ' 条记录?', function (r) { if (!r) return; var ids = []; for (var i = 0; i < rows.length; i++) { ids.push(rows[i].Id); } $.post('/User/Delete', { ids: ids.join(',') }, function (res) { if (res.code === 0) { $('#dg').datagrid('reload'); } else { $.messager.alert('提示', res.msg, 'error'); } }, 'json'); }); }后端 Delete 拿到 id 字符串后用 Split 拆开再循环删除。注意用 Contains 判断而不是拼 SQL 字符串,防注入的习惯在这里依然生效:
[HttpPost] public ActionResult Delete(string ids) { var idList = ids.Split(',').Select(int.Parse).ToList(); var entities = db.Users.Where(u => idList.Contains(u.Id)).ToList(); db.Users.RemoveRange(entities); db.SaveChanges(); return Json(new { code = 0, msg = "删除成功" }); }有两个边界情况值得留意:第一,getSelections 拿到的行只在当前页有效,跨页勾选的话,前一页的选中状态在翻页后会被清空,这是 datagrid 的默认行为,批量删除前要跟用户说明白;第二,如果表格当前有行处于编辑状态,删除前最好先调 endEdit 把编辑状态提交掉,否则可能出现 DOM 行被删、但编辑状态还挂在原行索引上的怪异行为。
4. 导出 Excel 与文件上传:两个最常见的扩展点
4.1 后端按列模板生成 xls:NPOI 的单元格写入
导出 Excel 在 .NET 生态里的首选是 NPOI,而不是微软的 Excel Interop。Interop 依赖服务器安装 Office,环境稍有变动就崩,是个不折不扣的黑匣子;NPOI 是纯托管代码,不依赖任何 Office 组件,生成和读取 xls/xlsx 都稳定。导出接口的流程固定:建工作簿、建工作表、写表头、写数据行、输出到 MemoryStream、用 File 返回:
public ActionResult Export() { var list = db.Users.OrderBy(u => u.Id).ToList(); var workbook = new HSSFWorkbook(); var sheet = workbook.CreateSheet("用户列表"); var header = sheet.CreateRow(0); header.CreateCell(0).SetCellValue("ID"); header.CreateCell(1).SetCellValue("姓名"); header.CreateCell(2).SetCellValue("创建时间"); for (int i = 0; i < list.Count; i++) { var row = sheet.CreateRow(i + 1); row.CreateCell(0).SetCellValue(list[i].Id); row.CreateCell(1).SetCellValue(list[i].UserName ?? ""); row.CreateCell(2).SetCellValue(list[i].CreateTime.ToString("yyyy-MM-dd HH:mm:ss")); } var ms = new MemoryStream(); workbook.Write(ms); ms.Seek(0, SeekOrigin.Begin); var fileName = "用户列表_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".xls"; return File(ms, "application/vnd.ms-excel", fileName); }HSSFWorkbook 对应的是老版 xls 格式,单表最多 65536 行,对于用户表、日志表这类数据量完全够用;如果导出的数据可能超过 6 万行,就改用 XSSFWorkbook 并返回 xlsx 格式,对应 MIME 是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。用 MemoryStream 输出比直接写文件再读取更干净,不会在服务器磁盘上留垃圾文件,也不会因为文件被占用而报错。
注意:导出是同步操作,数据量大时接口耗时会长。先把查询条件过滤到位再导出,比导出来再让用户自己筛选靠谱得多。如果业务上确实要导全量数据,考虑异步生成文件再通知下载,不要让用户盯着浏览器转圈。
4.2 前端触发下载:表单提交与 window.location 的差别
前端触发导出有两条路。无参导出直接 window.location 跳 GET 地址,一条代码搞定;带查询条件的导出建议用动态表单 POST,因为查询关键词可能包含中文和特殊字符,拼在 URL 里容易出编码问题,而且 GET 方式在部分浏览器里会把相同地址的下载请求缓存掉。我一般这样组织:
function exportExcel() { var form = $('<form></form>'); form.attr('action', '/User/Export'); form.attr('method', 'post'); form.append($('<input type="hidden" name="keyword" />').val($('#keyword').val())); form.appendTo('body'); form.submit(); form.remove(); }这段逻辑的核心是把查询条件以隐藏字段的形式带进 POST 请求。form appendTo body 再 submit,是因为不在 DOM 树里的表单在部分浏览器中 submit 会被忽略;submit 完之后立刻 remove 掉临时表单,避免页面上堆垃圾节点。导出按钮的防重复点击可以在函数开头加一个 flag 置灰,等请求完成再放开,因为文件下载这类请求没有 JSON 回调可依赖,只能用这个土办法兜底。
4.3 上传组件的参数:accept、formData、onUploadSuccess
EasyUI 的上传入口通常用 filebox 控件,它本质是文本框加按钮的组合,accept 只做选择文件时的过滤,不承担文件校验职责,真正的格式和大小检查要放到后端。选择完文件后手动触发上传,用 jQuery 的 ajax 配合 FormData:
<input id="fileInput" class="easyui-filebox" style="width:260px" >function uploadFile() { var file = $('#fileInput').filebox('files')[0]; if (!file) { $.messager.alert('提示', '请先选择文件'); return; } var formData = new FormData(); formData.append('file', file); $.ajax({ url: '/User/Import', type: 'post', data: formData, contentType: false, processData: false, success: function (res) { if (res.code === 0) { $.messager.show({ title: '导入成功', msg: '共导入 ' + res.count + ' 条' }); $('#dg').datagrid('reload'); } else { $.messager.alert('提示', res.msg, 'error'); } }, error: function () { $.messager.alert('提示', '上传失败,请检查文件格式', 'error'); } }); }contentType 必须设为 false,否则浏览器会把 FormData 自动生成的 multipart 请求头改成 application/x-www-form-urlencoded,后端就收不到文件了;processData 设为 false 是为了让 jQuery 不去尝试把 FormData 转成查询字符串。后端用 HttpPostedFileBase 接收文件,第一步校验扩展名和文件大小,然后才去解析:
[HttpPost] public ActionResult Import(HttpPostedFileBase file) { if (file == null || file.ContentLength == 0) { return Json(new { code = 1, msg = "请选择文件" }); } var ext = Path.GetExtension(file.FileName).ToLower(); if (ext != ".xls" && ext != ".xlsx") { return Json(new { code = 1, msg = "仅支持 xls 或 xlsx 格式" }); } // 解析 Excel 逐行入库,行号从第 1 行开始跳过表头 return Json(new { code = 0, count = importCount }); }上传成功的回调里有两处容易漏:一是文件入库成功后表格没有 reload,用户以为上传失败,实际数据已经进了库;二是 filebox 选择同一个文件不触发 change,因为文件路径没变,解决方法是每次上传完成后把 filebox 的值清掉,让它下次还能选择同名文件。
5. 避坑指南:EasyUI 项目里的六个真实故障与现场修复
5.1 数据加载与显示:三个让你怀疑人生的翻车现场
表格空白但接口有返回
现象:浏览器 Network 里看到 POST 请求返回了正确的 JSON,包含 total 和 rows,但 datagrid 页面一片空白,连表头都没有。
原因:后端返回的外层结构和 EasyUI 默认解包结构不一致。比如项目里统一接口返回格式是{code:0, data:{total:10, rows:[...]}},而 datagrid 默认按{total:10, rows:[...]}去找,解包失败后整个渲染中断。
解决:给 datagrid 配置 loadFilter,把后端包解一层再交给表格。判断依据是 code 字段而不是简单 return data,这样后端异常时表格不会拿到 undefined 而崩溃。从那以后我所有 EasyUI 页面都会在 loadFilter 里先打一条console.log(data),至少能看到原始结构。
弹窗打开后表单残留上一个用户的数据
现象:编辑用户 A 后关闭弹窗,再点新增按钮,弹窗里显示的还是用户 A 的姓名和年龄。如果手快直接保存,会把 A 的记录修改成空数据。
原因:dialog 关闭只是隐藏,DOM 还在页面里,form 的输入框值没有清空。openAdd 里必须先调 form clear 再 open。
解决:新增和编辑两个入口统一先 clear 再 load。编辑入口先 clear 再 form load 回填;新增入口直接 clear 后打开。这个顺序一旦写反,回填的数据会被后续的 clear 清掉。
日期列显示成 /Date(1577836800000)/
现象:创建时间列显示一串以 /Date 开头的奇怪数字,用户一眼就能看出系统坏了。这是 Json.NET 序列化 DateTime 时按某种格式输出的结果。
原因:后端直接用实体对象的 DateTime 属性序列化,前端没有做 formatter 处理。
解决:两层都做。前端列定义里加 formatter,把 ISO 字符串里的 T 替换成空格;后端 Select 匿名对象时也可以直接输出格式化好的字符串,u.CreateTime.ToString("yyyy-MM-dd HH:mm:ss")。两个方案留一个就够,我都写是因为有些接口不止被这一个页面调用。
5.2 交互与文件操作:三个上传、导出和联动的典型问题
导出的文件名在浏览器里变成一串百分号
现象:点击导出后下载的 xls 文件名是%E7%94%A8%E6%88%B7...或者纯乱码,保存后用户不知道这是什么文件。
原因:Content-Disposition 头里带中文文件名时,部分浏览器和代理服务器对非 ASCII 字符处理不一致。ASP.NET MVC 的 File 方法默认按 RFC 2231 编码,但老旧内核的浏览器不认这个格式。
解决:返回前手动对文件名做 UrlEncode,并指定 UTF-8 编码。注意 UrlEncode 会把空格转成加号,所以还要把加号替换成 %20:
var encodeName = HttpUtility.UrlEncode(fileName, Encoding.UTF8).Replace("+", "%20"); return File(ms, "application/vnd.ms-excel", encodeName);上传成功后表格没刷新,但数据实际已经入库
现象:上传文件后页面没有反应,用户以为失败又传了一次,结果数据库里出现重复数据。
原因:上传成功回调里漏了 datagrid reload。导入接口返回成功后,表格还停留在导入前的数据状态。
解决:在 ajax success 回调里,后端返回 code 为 0 时立即调用$('#dg').datagrid('reload')。如果导入的数据量很大,reload 会重新请求整个列表,参数里最好带上当前分页条件,而不是跳回第一页。还有一个隐藏点:filebox 选择同一个文件不会触发 change 事件,上传完成后要把 filebox 的 value 清空,否则二次选择同名文件时回调不执行。
combobox 联动时第二个下拉框的值回填不上
现象:选择「部门」后,「员工」下拉框的选项确实跟着变了,但之前选中的员工值没有显示出来,或者显示的是 id 数字而不是文本。
原因:第二个下拉框在 onLoadSuccess 里调用 setValue 设置选中值,但此时数据源刚加载完,选项里可能还没有对应 id 的文本映射。另一个常见原因是 valueField 和 textField 配置反了,导致显示的是主键值。
解决:联动逻辑放到 onChange 回调里,先 setValue 清空旧值,再 setUrl 加载新选项;选中值的回填放到 onLoadSuccess 里做。如果回填仍失败,检查第二个下拉框的 valueField 是否和后端返回的 id 字段名一致。这个问题的折磨之处在于它不报错,只有肉眼能看到界面状态不对,属于典型的玄学问题,排查时先打印 setValue 的参数再谈别的。
6. 进阶:行内编辑与状态保持,把 EasyUI 表格操作体验再提一档
6.1 行内编辑:把弹窗操作压缩成点击即改
弹窗增删改查已经能跑通,但操作路径长:点编辑、等弹窗、改字段、点保存。行内编辑模式把这条路压成两步——点击行进入编辑态,修改后直接提交。核心是 beginEdit 和 endEdit 两个方法配合 validateRow 和 getChanges 使用:
var editIndex = undefined; $('#dg').datagrid({ onClickRow: function (index) { if (editIndex != index) { if (endEdit()) { $('#dg').datagrid('beginEdit', index); editIndex = index; } } } }); function endEdit() { if (editIndex == undefined) return true; if ($('#dg').datagrid('validateRow', editIndex)) { $('#dg').datagrid('endEdit', editIndex); var changes = $('#dg').datagrid('getChanges'); if (changes.length) { $.post('/User/Save', JSON.stringify(changes), function () { $('#dg').datagrid('acceptChanges'); }); } editIndex = undefined; return true; } return false; }endEdit 返回 false 时表示校验没通过,行保持在编辑状态,用户能直接看到红色校验提示。getChanges 只返回被修改的行,减少提交数据量,但要注意后端要能接收数组格式的 JSON 对象。
6.2 刷新后保留查询状态:用一段小代码记住用户的操作现场
管理系统的用户经常在查询列表后刷新页面,然后发现查询条件全没了,一切归零。用 sessionStorage 存一份查询条件的现场,刷新后自动还原,这个小改动在体验上非常加分:
$(function () { var saved = sessionStorage.getItem('user_query'); if (saved) { var obj = JSON.parse(saved); $('#keyword').val(obj.keyword); $('#dg').datagrid('load', obj); } }); function doSearch() { var query = { keyword: $('#keyword').val() }; sessionStorage.setItem('user_query', JSON.stringify(query)); $('#dg').datagrid('load', query); }这段代码把查询参数持久化到 sessionStorage,浏览器标签页关掉就自动清理,不会像 localStorage 那样一直留到下次访问。datagrid load 传参数时,会与默认 queryParams 合并,不必担心把 pageSize 覆盖掉。我刚做第一个 EasyUI 项目时,只觉得功能跑通就行,直到被线上「刷新后表格空白」的问题折腾到半夜,才发现是 loadFilter 没配置。从那以后,我每接一个 jQuery 控件类的项目,都会先在本地把最简的 datagrid 跑通,确认 JSON 结构、日期格式、分页参数这三个基础项无误,再往上加业务逻辑,这个习惯帮我省下了大量返工时间。这套资源已经把上述所有环节串成完整可运行的工程,对照本文的接口结构部署就能看到全流程效果,希望帮到你。
本文还有配套的精品资源,点击获取