
简介这是一套基于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 请求、调用数据访问、返回 JSONModel 对应数据库表结构View 里放 EasyUI 页面。业务逻辑量小的时候直接在 Controller 里查数据库没问题如果后面有跨表事务或复杂计算再拆一个 Service 层不要一上来就过度设计。这里有一个我踩过的坑直接返回 EF 实体对象给前端序列化时很容易把导航属性带出来出现循环引用报错或者把用户表里的密码字段整个暴露到页面上。所以接口返回前一律 Select 成匿名对象只挑前端要用的字段。这个习惯在后面导出 Excel 时同样受益。2.3 环境准备与基础配置jQuery 引用、datagrid 默认参数页面引用 EasyUI 的顺序有讲究jQuery 必须在 EasyUI 之前加载CSS 放在最前面否则控件样式会闪一下再加载完成。以常用的 1.4 版本为例页面头部这样写link href~/easyui/themes/default/easyui.css relstylesheet / link href~/easyui/themes/icon.css relstylesheet / 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 设成 20singleSelect 设成 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 iddlg classeasyui-dialog stylewidth: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 后端按列模板生成 xlsNPOI 的单元格写入导出 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 typehidden namekeyword /).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、onUploadSuccessEasyUI 的上传入口通常用 filebox 控件它本质是文本框加按钮的组合accept 只做选择文件时的过滤不承担文件校验职责真正的格式和大小检查要放到后端。选择完文件后手动触发上传用 jQuery 的 ajax 配合 FormDatainput idfileInput classeasyui-filebox stylewidth: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 会把空格转成加号所以还要把加号替换成 %20var 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 结构、日期格式、分页参数这三个基础项无误再往上加业务逻辑这个习惯帮我省下了大量返工时间。这套资源已经把上述所有环节串成完整可运行的工程对照本文的接口结构部署就能看到全流程效果希望帮到你。本文还有配套的精品资源点击获取