简介:面向 C# 与前端开发者的 EasyUI 后台管理示例工程,基于 VS2013、MVC 与 SQL Server 2014,演示单表数据管理完整流程。功能覆盖新增、修改、删除、分页、导出 Excel 与图片上传,核心是 UserInfoDAL.cs 中的分页实现:默认使用 SQL Server 2012 关键字分页,若数据库为 2005/2008 可切换 getPage2005 方法,便于读者对照不同数据库版本的分页差异。资源为 RAR 压缩包,包体约 28.54MB,页面标注文件总数为 0,文件类型明细暂未公开;从功能描述看,内部应包含可编译的 MVC 项目源码、数据库脚本及示例页面。目前已有 218 人学习下载,适合正在学习 C# + EasyUI 整合开发、需要参考增删改查与导出上传实践的初中级开发者,按示例稍作改造即可接入自身项目。
1. C# + EasyUI 后台管理:一个能直接抄的增删改查模板
很多人一听 EasyUI 觉得过时了,但在企业内部管理系统这个场景里,它是少有的“不需要前端工程化、打开即用”的方案。最近我拆了一个用 C# + EasyUI 写的后台管理项目,麻雀虽小,但新增、修改、删除、导出 Excel、文件上传这些高频操作全都齐了,而且没有用任何重量级框架,后端就是 ASP.NET 的一般处理程序,前端就是 EasyUI 的 datagrid 和 dialog。如果你的场景正好是快速交付的内部系统——进销存、订单登记、工单管理——这套写法比折腾 Vue + Element Admin 要快得多,后面改起来也直观。这篇文章我会从工程结构拆到核心代码,再讲几个网上经常语焉不详、但实际必踩的坑。
2. 架构与布局:EasyUI 前端与 C# 后端的工程骨架
先说结论:EasyUI 项目最容易翻车的点不是功能写不出来,而是页面的布局、弹窗和表格之间的关系没理清。我拆的这个项目是一个典型的订单管理后台,浏览器打开后是一个左侧菜单、顶部标题、中间 TAB 页的标准布局,所有业务页面都通过 TAB 加载进 iframe。后端接口全部走 .ashx 一般处理程序,返回 JSON 字符串,前端 datagrid 拿到后自动渲染表格。整体没有用 MVC,也没有前后端分离,放在今天看很“复古”,但胜在部署简单——一个网站目录丢到 IIS 上就能跑。
2.1 技术选型:为什么 EasyUI 还在被大量内部系统使用
这个项目的技术栈非常老派:ASP.NET WebForm 时代的一般处理程序(ashx)、ADO.NET 手写数据访问、前端 jQuery + EasyUI。我最开始觉得这是历史包袱,但看完代码后意识到,对于内部管理系统来说,EasyUI 的“所见即所得”确实有不可替代的优势:datagrid 自带分页、排序、行选择,dialog 自带拖拽和遮罩,tree 自带级联展开,这些在 Element UI 里要写一堆配置的东西,EasyUI 一个 class 属性就搞定,而且不需要 node_modules。
常见做法是后端保持“返回 JSON 就好”的形态,前端用 EasyUI 的组件去消费。这个项目里所有列表页的返回格式都遵循同一个约定:
{ "total": 120, "rows": [ { "OrderID": 1, "OrderNo": "SO2024001" } ] }只有遵守这个结构,datagrid 才能直接绑定数据。其实 EasyUI 不关心后端是 ashx、WebService 还是 WebAPI,它只认这个 JSON 形状。我一般会把这段逻辑抽成一个公共方法,所有列表接口共用,避免每个页面写一遍分页代码。后端返回的时候还要注意字段名大小写,JavaScript 对大小写敏感,OrderID和orderId是两回事。
2.2 目录结构与页面骨架:一个最小后台长什么样
项目的目录结构不复杂,但分层很清楚,业务代码集中在三个地方:
/OrderManager /ashx /OrderHandler.ashx // 订单模块统一入口 /views /order.aspx // 订单列表页面 /orderEdit.aspx // 订单新增/编辑弹窗页面(或纯 div) /js /order.js // 页面交互逻辑 /css /Scripts /jquery.min.js /jquery.easyui.min.js /App_Code /DbHelper.cs // 数据库连接辅助类 /OrderDal.cs // 订单数据访问层 /Upload // 上传文件保存目录 /index.html // 后台主框架主框架用的是 EasyUI 的 layout 布局,代码很短,但它是整个后台的地基:
<body class="easyui-layout"> <div>$('#menuTree').tree({ onClick: function (node) { var url = node.attributes ? node.attributes.url : ''; if (!url) return; $('#mainTabs').tabs('add', { title: node.text, href: url, closable: true }); } });这里有个参数问题要注意:tabs('add')的href是把远程页面直接渲染进当前 DOM,iframe 方式则会让子页面里的easyui-datagrid宽度计算异常。实际项目中我更喜欢在 TAB 里加一个 iframe,让每个页面保持独立的脚本上下文,刷新、关闭、重新打开都不会互相污染变量。这个选择直接影响后面所有页面的写法,建议先想清楚再动手。
3. 核心模块拆解:一个订单从新增到导出的完整链路
这个项目的核心价值就在这一章:增删改查、导出 Excel、上传文件这五个功能,几乎可以原样搬到任何一张业务表上。我按一条订单数据从录入到导出的完整路径来讲,每个环节都给出关键代码。
3.1 后端统一入口:OrderHandler 怎么分发 action
一般处理程序是 C# 后端最轻的入口,所有操作通过action参数区分:
public class OrderHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { context.Response.ContentType = "application/json"; context.Response.Charset = "utf-8"; string action = context.Request["action"]; string result = ""; switch (action) { case "list": result = GetList(context); break; case "add": result = Save(context, isUpdate: false); break; case "update": result = Save(context, isUpdate: true); break; case "delete": result = Delete(context); break; case "export": ExportExcel(context); return; // 导出返回的是文件流,不走 JSON case "upload": result = Upload(context); break; default: result = "{\"success\":false,\"msg\":\"未知操作\"}"; break; } context.Response.Write(result); } public bool IsReusable { get { return false; } } }逻辑说明:这里的ProcessRequest是所有请求的网关。export单独处理,因为它要Response.End()输出文件流,如果走统一的 JSON 返回会破坏文件内容。upload虽然也返回 JSON,但本质上是在处理 multipart/form-data,所以它接收的不是普通表单字段,而是文件流。
参数说明:action是每次请求必须带的参数;add和update共用Save方法,靠isUpdate决定是执行 INSERT 还是 UPDATE,这样可以保证新增和编辑对字段的处理逻辑完全一致。很多新手在新增和编辑里各写一套赋值代码,后面加了字段要改两处,非常容易漏。
3.2 新增与修改:dialog 表单回填与保存
列表页的工具栏放了“新增”“编辑”“删除”“导出”“上传附件”五个按钮。新增和编辑共用同一个 dialog,区别只在打开时是否回填数据:
function openEdit(title, row) { $('#dlg').dialog('open').dialog('setTitle', title); if (row) { // row 来自 datagrid 当前选中行 $('#fm').form('load', row); } else { $('#fm').form('clear'); } } function saveOrder() { $('#fm').form('submit', { url: 'ashx/OrderHandler.ashx?action=' + ($('#OrderID').val() ? 'update' : 'add'), onSubmit: function () { return $(this).form('validate'); }, success: function (data) { var r = JSON.parse(data); if (r.success) { $('#dlg').dialog('close'); $('#dg').datagrid('reload'); } else { alert(r.msg); } } }); }逻辑说明:$('#fm').form('load', row)是 EasyUI 的关键方法,它会自动把 row 对象里同名字段填进表单,所以后端返回的字段名必须和表单控件的 name 完全一致。保存时通过OrderID是否有值来判断是新增还是修改,避免两个按钮两套逻辑。form('validate')会触发表单里所有 easyui-textbox 的 required 校验,不通过就不会提交。
后端Save方法对应的核心逻辑是参数接收和实体赋值:
private string Save(HttpContext context, bool isUpdate) { string orderNo = context.Request["OrderNo"]; string customer = context.Request["Customer"]; decimal amount = Convert.ToDecimal(context.Request["Amount"]); int result = isUpdate ? OrderDal.Update(orderNo, customer, amount) : OrderDal.Insert(orderNo, customer, amount); return "{\"success\":" + (result > 0) + ",\"msg\":\"保存成功\"}"; }这里有个容易被忽略的点:context.Request["OrderNo"]拿到的值如果没填会是空字符串,不会为 null。所以在后端校验必填项时,要判断string.IsNullOrEmpty而不是== null,否则空表单提交时校验形同虚设。
3.3 删除:单条与批量删除的请求拼接
删除操作看似简单,但批量删除的传参方式最容易翻车。前端选中多行后,把主键拼成逗号分隔的字符串传给后端:
function deleteOrders() { var rows = $('#dg').datagrid('getSelections'); if (rows.length === 0) { alert('请先选择要删除的记录'); return; } var ids = rows.map(function (r) { return r.OrderID; }).join(','); $.post('ashx/OrderHandler.ashx', { action: 'delete', ids: ids }, function (data) { var r = JSON.parse(data); if (r.success) { $('#dg').datagrid('reload'); } else { alert(r.msg); } }); }后端接收时,不能直接把 ids 拼进 SQL,这是血泪经验。我见过太多项目因为这里偷懒,导致删一条数据把整张表清空。常见的稳健做法是先用正则卡死输入范围:
private string Delete(HttpContext context) { string ids = context.Request["ids"]; if (string.IsNullOrEmpty(ids) || !Regex.IsMatch(ids, "^[0-9,]+$")) { return "{\"success\":false,\"msg\":\"参数不合法\"}"; } int result = OrderDal.Delete(ids); return "{\"success\":" + (result > 0) + ",\"msg\":\"删除成功\"}"; }参数说明:正则^[0-9,]+$限定了 ids 只能是数字和逗号,这意味着即使有人把异常参数传进来,也不会进入 SQL 拼接。在 DAL 层删除时仍然建议用参数化写法把 ids 作为整体传进去,配合数据库层面的外键约束,双保险。如果业务要求只能删一条,前端就不应该把getSelections的代码写进去,直接取第一行即可。
3.4 导出 Excel:NPOI 的引入与导出流程
导出是这个项目里最实在的功能,用的常见方案是 NPOI,因为它不需要服务器装 Office,也不依赖 COM 组件。核心流程分三步:查数据、写工作簿、输出响应流。
private void ExportExcel(HttpContext context) { var list = OrderDal.GetAllForExport(); // 取出全量或筛选后的数据 HSSFWorkbook workbook = new HSSFWorkbook(); ISheet sheet = workbook.CreateSheet("订单列表"); string[] headers = { "订单号", "客户名称", "金额", "创建时间" }; IRow headerRow = sheet.CreateRow(0); for (int i = 0; i < headers.Length; i++) { headerRow.CreateCell(i).SetCellValue(headers[i]); } int rowIndex = 1; foreach (var item in list) { IRow row = sheet.CreateRow(rowIndex++); row.CreateCell(0).SetCellValue(item.OrderNo); row.CreateCell(1).SetCellValue(item.Customer); row.CreateCell(2).SetCellValue(Convert.ToDouble(item.Amount)); row.CreateCell(3).SetCellValue(item.CreateTime.ToString("yyyy-MM-dd HH:mm:ss")); } context.Response.Clear(); context.Response.ContentType = "application/vnd.ms-excel"; context.Response.AddHeader("Content-Disposition", "attachment;filename=" + HttpUtility.UrlEncode("订单导出.xls")); workbook.Write(context.Response.OutputStream); context.Response.End(); }逻辑说明:导出列表的列顺序最好和页面上 datagrid 的列保持一致,这样用户对导出的文件结构有预期,不会拿到手还要猜哪列是什么。Content-Disposition里的文件名必须用UrlEncode包一层,否则中文文件名在部分浏览器里会变成乱码或者干脆下载失败,这一点在后面的避坑章节会详细说。金额用Convert.ToDouble而不是直接存字符串,是因为 Excel 里数字列如果以字符串写入,后续求和、排序都会遇到问题。
这个导出方法还承担了一个隐藏职责:它是整个项目的数据质量检测器。如果一个表格能正常显示但导出报错,多半是字段类型在绑定和写入之间发生了不一致,这时候排查顺序建议先看实体类的属性类型,再看 SQL 查询里的 CAST 和 CONVERT。
3.5 上传文件:异步上传到服务器并回填路径
上传功能在这个项目里是给订单挂附件用的,比如合同扫描件、对账单。前端用的是 EasyUI 的 filebox,但实际提交不是走 EasyUI 自带的 upload 控件,而是直接通过隐藏表单提交到 ashx。我的做法是用一个普通 form 包裹 filebox,触发onChange后立即提交:
<form id="uploadForm" enctype="multipart/form-data" method="post"> <input id="fileBox" name="file" class="easyui-filebox" >private string Upload(HttpContext context) { HttpFileCollection files = context.Request.Files; if (files.Count == 0) { return "{\"success\":false,\"msg\":\"没有接收到文件\"}"; } HttpPostedFile file = files[0]; string ext = Path.GetExtension(file.FileName).ToLower(); // 只允许白名单内的扩展名,防止上传恶意文件 string[] allowed = { ".jpg", ".png", ".pdf", ".xls", ".xlsx", ".doc", ".docx" }; if (!allowed.Contains(ext)) { return "{\"success\":false,\"msg\":\"不允许的文件类型\"}"; } string dir = "/Upload/" + DateTime.Now.ToString("yyyyMM"); string physicalPath = context.Server.MapPath(dir); if (!Directory.Exists(physicalPath)) Directory.CreateDirectory(physicalPath); string newName = DateTime.Now.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N").Substring(0, 8) + ext; file.SaveAs(Path.Combine(physicalPath, newName)); string url = dir + "/" + newName; return "{\"success\":true,\"url\":\"" + url + "\"}"; }逻辑说明:文件名的重命名很关键,不能直接用原始文件名保存,否则同名的文件会把之前的覆盖掉。我用时间戳加随机串保证唯一性,同时保留了扩展名。按月份建目录是为了避免单个文件夹文件过多。
参数说明:file.SaveAs的参数必须是服务器物理路径,所以要先MapPath把虚拟路径转成物理路径。前端拿到返回的url后,一般会把它写进一个隐藏域,等保存订单时一起提交到后端。还有一种做法是上传后直接把附件信息写到附件表,订单主表只存关联 ID,这个项目用的是前者——URL 存在订单表的AttachmentUrl字段里,简单直接。
4. 避坑与排查:C# + EasyUI 项目里的 4 个高频问题
这个部分的内容来自我实际跑这个项目时踩过的坑,有些是 EasyUI 的“黑匣子”行为,有些是 C# 后端和浏览器配合的经典问题。按“现象 → 原因 → 解决”梳理出来,给看到此文的人一个后悔药。
4.1 datagrid 时间字段显示成 /Date(1717...)/ 格式
现象:列表页能正常加载,但凡是日期字段都显示成类似/Date(1717353600000)/的字符串,用户看着完全不知道是什么时间。
原因:后端用 JavaScriptSerializer 或部分 JSON 序列化组件对 DateTime 序列化时,会产生这种微软特有的日期格式。EasyUI 的 datagrid 不会自动识别它,直接当普通文本渲染。
解决:有两种常用办法。第一种是在后端序列化前,把所有 DateTime 字段统一转成字符串,比如item.CreateTime.ToString("yyyy-MM-dd HH:mm:ss"),这也是这个项目采用的方式。第二种是在前端列配置里写 formatter 处理:
{ field: 'CreateTime', title: '创建时间', formatter: function (value) { if (!value) return ''; return value.replace(/\/Date\((\d+)\)\//, function (_, t) { return new Date(parseInt(t)).toLocaleString(); }); } }我的建议是优先在后端解决。前端 formatter 虽然能解决展示,但导出、编辑回填、条件查询时还要再处理一遍,不如源头就给出干净的字符串。
4.2 导出 Excel 文件名变成下载下载乱码
现象:点击导出后浏览器能下载文件,但文件名是一串百分号编码,或者直接变成____.xls,用户根本不知道文件内容是什么。
原因:Content-Disposition头里的filename如果直接写中文,HTTP 头不支持非 ASCII 字符,浏览器没法正确解码。不同浏览器对未编码中文的兼容性不一致,Chrome 可能正常,IE 和部分国产浏览器就乱码。
解决:在设置响应头时做一次 URL 编码,也就是前文代码里写的HttpUtility.UrlEncode("订单导出.xls")。这个写法在 IIS 部署的项目里是标准的。同时要注意UrlEncode默认编码是 UTF-8,如果页面请求本身是 GB2312(老项目常见),需要指定编码:
context.Response.AddHeader("Content-Disposition", "attachment;filename=" + HttpUtility.UrlEncode("订单导出.xls", System.Text.Encoding.UTF8));4.3 批量删除时后端拿不到 ids
现象:点击批量删除后弹窗提示“参数不合法”或者干脆报 400,调试发现后端context.Request["ids"]为 null。
原因:前端代码里用的是$('#dg').datagrid('getSelections'),但 datagrid 如果没配置idField,getSelections返回的行对象里的主键字段名是随机的,或者为空。还有一个常见原因是用 GET 请求传了一串很长的逗号分隔 ID,超过 URL 长度限制,被服务器直接拒绝。
解决:前端必须先确认 datagrid 配置了idField: 'OrderID',这样getSelections才能正确返回选中的行。传参方式统一用 POST,把 ids 放在请求体里而不是 URL 上:
$.post('ashx/OrderHandler.ashx', { action: 'delete', ids: ids }, function (data) { ... });后端拿到 ids 后先做正则校验,确认是纯数字加逗号再进 DAL,这样即使传参格式有问题,也不会是 SQL 层的错误。
4.4 上传文件提交后直接 404 或者没有响应
现象:上传小文件正常,但文件超过几 MB 后,提交请求直接失败,服务器没有返回任何 JSON。有时候错误页显示 404,有时候显示 500。
原因:这是 IIS 默认的上传大小限制在拦截,不是代码的问题。IIS 7 以上有两个地方限制请求体大小:ASP.NET 层面的httpRuntime和 IIS 层面的requestFiltering,只改一处经常会发现不生效。
解决:在web.config里同时配置两层:
<system.web> <httpRuntime maxRequestLength="51200" executionTimeout="120" /> </system.web> <system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="52428800" /> </requestFiltering> </security> </system.webServer>参数说明:maxRequestLength单位是 KB,51200 即 50MB;maxAllowedContentLength单位是字节,52428800 也是 50MB。两个值建议保持一致。改完配置要重启 IIS 应用程序池才生效,这也是很多人改了没用就放弃排查的原因,玄学背后其实是没回收工作进程。
5. 二次开发:把订单模板改造成通用 CRUD 生成器的思路
如果你拿到这个项目只是为了照抄一个订单页面,那前面的内容已经够了。但如果你想把这套东西复用到 N 张表上,我强烈建议走完下面的改造,这也是我第二次接手类似项目时的教训:不要每张表都复制一遍OrderHandler.ashx和order.js,否则改一个公共逻辑要动几十个文件。
5.1 后端:用泛型把增删改查收敛到基类
改造思路是把OrderHandler里的公共逻辑上浮到一个BaseHandler<T>,泛型 T 代表业务实体,实体类上用特性标注表名和主键:
public class BaseHandler<T> : IHttpHandler where T : class, new() { protected virtual string TableName { get { return typeof(T).Name; } } protected virtual string PrimaryKey { get { return "ID"; } } public void ProcessRequest(HttpContext context) { string action = context.Request["action"]; switch (action) { case "list": // 从 Request 读分页参数 page, rows // 用反射读取 T 的属性,拼接 WHERE 条件 break; case "add": // 从 Request.Form 反射给实体赋值 // INSERT INTO TableName(...) VALUES(...) break; case "update": // 同上,按主键 UPDATE break; case "delete": // 正则校验 ids 后 DELETE FROM TableName WHERE PrimaryKey IN (ids) break; } } }反射赋值的核心代码大概是这样:
foreach (var prop in typeof(T).GetProperties()) { string value = context.Request[prop.Name]; if (value == null) continue; var converted = Convert.ChangeType(value, prop.PropertyType); prop.SetValue(entity, converted); }参数说明:Convert.ChangeType对普通类型够用,但遇到 DateTime、decimal 时要注意当前线程的区域性设置,建议统一用CultureInfo.InvariantCulture。有了这个基类,新加一个业务模块只需要三样东西:一个实体类、一个继承BaseHandler<T>的空壳 ashx、一个前端列配置 JSON。之前五小时的工作量能压缩到一小时。
5.2 前端:把 datagrid 列配置抽成 JSON 驱动表单
前端同样要做一层收敛。核心是定义一张“表结构配置”,让 datagrid 的列、dialog 里的表单项都从这份配置生成:
var orderCols = [ { name: 'OrderID', label: '订单号', hidden: true }, { name: 'OrderNo', label: '订单号', required: true, width: 120 }, { name: 'Customer', label: '客户名称', required: true, width: 100 }, { name: 'Amount', label: '金额', required: true, editor: 'numberbox' }, { name: 'CreateTime', label: '创建时间', required: false, width: 150 } ]; function buildDatagridCols(cols) { return cols.filter(function (c) { return !c.hidden; }) .map(function (c) { return { field: c.name, title: c.label, width: c.width }; }); } function buildFormHtml(cols) { var html = ''; cols.forEach(function (c) { if (c.hidden) { html += '<input type="hidden" name="' + c.name + '" id="' + c.name + '" />'; } else { var required = c.required ? 'required:true' : 'required:false'; html += '<input name="' + c.name + '" class="easyui-textbox" ' + 'data-options="label:\'' + c.label + '\',' + required + '" />'; } }); return html; }逻辑说明:buildFormHtml生成的表单还需要在 dialog 打开时用上一章说到的form('load', row)回填,隐藏域会自动接收主键值。这套改造做完后,新增一个模块的路径就变成了:建表 → 建实体类 → 写一份列配置 JSON → 页面里调用buildDatagridCols和buildFormHtml完成渲染,CRUD 逻辑全部走基类。列配置 JSON 成了系统里最核心的资产,业务调整字段时只改这个文件,前后端同步变化。
最后再说一个验证习惯:我每接一张新表,都会强制走一遍这个流程——建表、写实体、配列 JSON、生成页面、导出一次 Excel。导出是最吃字段类型的一步,能导出成功基本说明整条链路的映射是对的,这一步跑通,剩下的就是业务字段层面的微调了。希望帮到你。
本文还有配套的精品资源,点击获取