C#+EasyUI实战:增删改查、Excel导出与文件上传避坑指南

📅 发布时间:2026/10/8 8:29:54
C#+EasyUI实战:增删改查、Excel导出与文件上传避坑指南
简介一套基于C#与EasyUI的Web管理功能示例工程面向需要快速掌握MVCEasyUI增删改查、分页、导入导出及文件上传的初中级.NET开发者。工程使用VS2013编译采用MVCeasyuisqlserver2014架构围绕单张数据表展示一套完整的业务操作闭环页面采用EasyUI布局表格使用datagrid展示与分页后端提供新增、修改、删除接口并集成Excel导出与图片上传功能。分页默认基于sqlserver2012关键字实现同时源码中保留2005/2008数据库的兼容分页方法UserInfoDAL.cs中的getPage2005遇到低版本数据库可直接替换调用。压缩包为rar格式大小28.54MB内含完整工程源码、前端页面与数据库相关配置解压后在VS中转换版本即可调试运行适合以此为基础扩展字段和业务逻辑。已有218人学习下载可用作企业后台管理模块的参考模板也适合课程设计或面试前动手练习能直观理解EasyUI datagrid与后端数据交互、Excel导出及文件上传的常见实现思路。1. C#EasyUI这套组合还被项目追着用先分清谁管数据谁管界面接手一个内部管理系统需求单上往往就是这句话“做几个页面能新增、修改、删除数据能导出Excel还要支持上传附件。”一眼看上去不复杂但真动手就知道麻烦全在前后端的对接格式上表格怎么翻页、表单怎么回填、Excel怎么不出现乱码、上传的配置怎么在IIS和ASP.NET之间不打架。C#提供接口和数据处理能力EasyUI负责把表格、对话框、表单这些交互元素直接渲染出来两者分工明确在C#上位机和中小型管理系统里是出现频率很高的一套组合。这篇内容顺着需求单上的五件事把方案讲成可以直接照做的步骤新手能跟得上熟手可以对照参数和边界条件做取舍不用再去翻零散的片段。2. 搭出能用的数据表格URL绑定、JSON返回格式与增删改的接口链路2.1 架构上先定规矩后端只出JSONEasyUI只负责渲染做这类需求时我的固定做法是后端用一般处理程序.ashx或MVC的Controller输出JSON字符串前端用EasyUI的datagrid、dialog、form三个组件对接。不建议用WebForms的服务器控件去做交互因为回发机制把数据格式和页面状态搅在一起后续接查询、接导出都要额外绕路。选择ashx还是Controller看项目底子老项目很多已经是ashx新项目直接上Controller也没什么学习成本。EasyUI对后端几乎没有要求只要接口能返回它认识的JSON。所以这套方案的核心不是框架本身而是“你返回的JSON结构是否正确”。datagrid认识的结构是固定的外层要有一个total表示总行数内层用rows放数据数组。后端的每一行序列化成普通对象字段名要和前端列定义的field一一对应。2.2 表格加载与分页page和rows这对参数是分页的生命线先看前端初始化datagrid的最小配置。$(#dg).datagrid({ url: list.ashx, method: post, pagination: true, pageSize: 20, pageList: [10, 20, 50, 100], fitColumns: true, singleSelect: false, columns: [[ { field: ck, checkbox: true }, { field: id, title: ID, width: 60 }, { field: name, title: 姓名, width: 100 }, { field: createTime, title: 创建时间, width: 140 } ]], queryParams: { keyword: } });url指向后端接口method必须写post。分页开启后datagrid每次加载都会自动带上page和rows两个参数page是第几页rows是每页条数。这两个参数不是你想传才传而是框架固定会传后端必须按这两个名字取。queryParams里放的是附加查询条件后面做搜索框时把输入值塞进去即可。后端一般处理程序里对应的取法是这样public void ProcessRequest(HttpContext context) { int page int.Parse(context.Request[page] ?? 1); int rows int.Parse(context.Request[rows] ?? 20); string keyword context.Request[keyword] ?? ; int total 0; DataTable dt GetPageData(page, rows, keyword, out total); // 把DataTable转成ListDictionarystring,object日期在这里统一转成字符串 var list new ListDictionarystring, object(); foreach (DataRow row in dt.Rows) { var item new Dictionarystring, object(); foreach (DataColumn col in dt.Columns) { item[col.ColumnName] row[col].ToString(); } list.Add(item); } var result new { total total, rows list }; context.Response.ContentType application/json; charsetutf-8; context.Response.Write(new JavaScriptSerializer().Serialize(result)); }page和rows用字符串取出来再Parse是因为EasyUI默认通过表单形式提交这两个参数。GetPageData是业务查询部分按keyword拼条件按page和rows做分页。total是带条件后的总记录数这个值直接决定分页栏显示几页取值错误会导致分页直接错乱宁可多查一次count也不要漏条件。这里有几个细节值得注意一是DataTable到Dictionary的转换过程中DateTime默认会带着毫秒序列化输出前我先ToString既避免后面避坑章节里那个/Date(...)/问题也顺便把格式调成“yyyy-MM-dd HH:mm:ss”。二是前端列定义中的field必须和后端返回的字段名完全一致大小写也要一致否则表格里永远空着。2.3 新增对话框dialog套form提交时校验再发送新增和修改我习惯共用一个对话框加一个表单减少页面上的重复元素。结构如下。div iddlg classeasyui-dialog title编辑 stylewidth:420px;height:320px;padding:10px >function save() { $(#fm).form(submit, { url: save.ashx, onSubmit: function () { return $(this).form(validate); }, success: function (data) { var r JSON.parse(data); if (r.success) { $(#dlg).dialog(close); $(#dg).datagrid(reload); } else { $.messager.alert(错误, r.msg); } } }); }onSubmit里调用form(validate)easyui-textbox上配了required:true的字段都会被检查没填就不提交这是最常见的表单拦截方式。后端save.ashx里只需要读Request.Form[id]、Request.Form[name]、Request.Form[remark]然后按id是否有值决定执行Insert还是Update最后返回{ success: true }这样的JSON。这里有个常见误用有些人直接用$(#fm).form(submit)没写onSubmit校验也没在success里做JSON.parse导致后端返回的字符串被当成HTML处理弹窗不关闭表格也不刷新。success回调里拿到的data是字符串不是对象先parse再判断是基本盘。2.4 修改回填form(load, row)的前提是字段名对得上修改操作的关键是回填。选中一行把这一行的数据塞进表单。function edit() { var row $(#dg).datagrid(getSelected); if (!row) { $.messager.alert(提示, 请先选择一行); return; } $(#fm).form(clear); $(#fm).form(load, row); $(#dlg).dialog(open).dialog(setTitle, 修改); }form(load, row)内部拿着row对象去匹配表单里的name匹配上就赋值匹配不上就空着。所以第2.2节里返回的字段名和这里的input name要约定好。比如返回的字段是createTime表单里如果没有createTime这个name那时间就不会显示在修改页里这不算bug而是EasyUI的工作方式。另外一个容易翻车的地方如果行里某个字段值为0或falseform(load)可能不赋值因为源码里对值做了空判断。处理办法是后端在返回JSON时就把这类字段转成字符串“0”“false”或者回填时单独用textbox(setValue)处理。多数情况下我建议后端统一ToString省得在前端逐个排查。2.5 删除要区分单条和多选getSelected与getSelections的差异删除按钮常见做法是支持多选批量删除。datagrid开启singleSelect:false后每行前面出现复选框。取选中行的接口有两个getSelected只返回第一条选中行getSelections返回数组。这就是为什么有人写完删除测试时发现永远只删了一条。function del() { var rows $(#dg).datagrid(getSelections); if (rows.length 0) { $.messager.alert(提示, 请先选择要删除的记录); return; } var ids rows.map(function (row) { return row.id; }).join(,); $.messager.confirm(确认, 确定删除选中的 rows.length 条记录吗, function (r) { if (!r) return; $.post(delete.ashx, { ids: ids }, function (data) { var d JSON.parse(data); if (d.success) { $(#dg).datagrid(reload); } else { $.messager.alert(错误, d.msg); } }, text); }); }后端delete.ashx按逗号拆分ids拼成IN条件。这里有个安全细节id如果是数字拆分后逐个int.TryParse过滤掉非法值再拼接SQLid如果是字符串至少要做参数化查询不要把ids直接拼进SQL——这个接口开着会被批量删库。批量删除是给运维用的能力不是给测试脚本用的玩具。到这里新增、修改、删除和数据展示已经串成一条完整的链路下一步处理导出Excel。3. 导出ExcelNPOI与EPPlus选型、流式输出和中文文件名编码3.1 选型NPOI是默认选项EPPlus要注意授权导出Excel在.NET生态里绕不开NPOI和EPPlus两个库。我一般默认用NPOI免费、支持xls和xlsx两种格式、API稳定、社区资料多中小型项目的导出需求用它都够。EPPlus操作体验更现代但5.0之后改了商业授权条款企业商用需要购买授权个人项目或者已经确认license许可的可以用否则容易给自己埋雷。选型的核心原则是别为了一个“写单元格样式更顺手”的理由把license问题带进公司项目。还有一类做法是直接用Office COM组件导出Excel这是我不推荐的方式服务器上要装Office并发导出时COM实例互相抢占回收进程麻烦不说翻车概率极高。NuGet上装NPOI包代码里引用NPOI、NPOI.XSSF.UserModel两个命名空间即可。3.2 核心代码从DataTable生成xlsx并输出到响应流导出功能一般不跟分页绑定而是把当前查询条件下的所有数据一次性导出。后端代码分两步查数据、写Excel。public void ProcessRequest(HttpContext context) { DataTable dt GetAllData(); // 按当前查询条件取全量数据 IWorkbook workbook new XSSFWorkbook(); ISheet sheet workbook.CreateSheet(数据); // 表头 IRow headerRow sheet.CreateRow(0); string[] columns { ID, 姓名, 备注, 创建时间 }; for (int i 0; i columns.Length; i) { headerRow.CreateCell(i).SetCellValue(columns[i]); sheet.SetColumnWidth(i, columns[i].Length * 256 200); } // 数据行 for (int r 0; r dt.Rows.Count; r) { IRow row sheet.CreateRow(r 1); row.CreateCell(0).SetCellValue(dt.Rows[r][id].ToString()); row.CreateCell(1).SetCellValue(dt.Rows[r][name].ToString()); row.CreateCell(2).SetCellValue(dt.Rows[r][remark].ToString()); row.CreateCell(3).SetCellValue(Convert.ToDateTime(dt.Rows[r][createTime]).ToString(yyyy-MM-dd HH:mm:ss)); } // 输出 string fileName 用户数据_ DateTime.Now.ToString(yyyyMMddHHmmss) .xlsx; context.Response.Clear(); context.Response.ContentType application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; context.Response.AppendHeader(Content-Disposition, attachment;filename HttpUtility.UrlEncode(fileName, Encoding.UTF8)); using (MemoryStream ms new MemoryStream()) { workbook.Write(ms); context.Response.BinaryWrite(ms.ToArray()); } context.Response.End(); }XSSFWorkbook对应.xlsx格式HSSFWorkbook对应旧版.xls格式二选一不要混。xls格式单sheet最多65535行超出后要分sheet写入xlsx这个上限是1048576行一般业务很难触顶。SetColumnWidth设置列宽单位是字符宽度的1/256直接拿列名字符串长度算出来的宽度只是起步值中文按两倍字符估算更接近实际效果你可以按业务列的实际内容微调。数据行用CreateCell逐个SetCellValue单元格类型由写入的值自动推导。日期我建议先转成字符串写入让Excel里显示的是“2025-06-12 10:30:00”这种可读格式而不是一串序列号。最后用MemoryStream把workbook写进响应流Response.End收尾这是最常见的ashx导出写法。3.3 中文文件名不转码就会被浏览器截断导出接口里最容易翻车的细节是文件名。直接写成attachment;filename用户数据.xlsx在Chrome下可能正常在IE和部分国产浏览器里中文会被截断成“_”或乱码下载后文件名变成“__.xlsx”。解决办法是输出前对文件名做一次UrlEncode再用UTF-8编码写进Content-Dispositioncontext.Response.AppendHeader(Content-Disposition, attachment;filename HttpUtility.UrlEncode(fileName, Encoding.UTF8));HttpUtility.UrlEncode会把中文转成百分号编码浏览器收到后自动解码不同浏览器对编码格式的兼容性最好。这个处理不建议省哪怕你现在只在Chrome里测过也先加上免得换台电脑就翻车。还有文件名里别带空格和斜杠空格在某些浏览器里会截断后半截斜杠会直接导致下载失败。3.4 大数据量导出的边界先算清行数再决定方案数据量是导出功能的分水岭。几千行到几万行同步导出没有压力接口等一两秒浏览器开始下载体验可以接受。十万行往上单个响应里同时做查询、写Excel、输出流内存和响应时间都会吃紧直接的表现是接口超时或者服务器内存暴涨。我一般会在产品层面先挡一道导出前加一个“导出条数确认”弹窗显示当前条件下有多少条数据超过两万行时提示用户缩小时间范围或查询条件。这是性价比最高的做法不需要动架构。真要支持大文件后台导出常规方案是后台任务生成Excel文件到服务端生成完成后给前端一个下载链接期间用户可以做别的操作。这个方案已经超出“新增、修改、删除、导出Excel”这个标题的最小实现范围属于后续增强这里不做展开。4. 文件上传easyui-filebox取文件、FormData传输和两处大小限制4.1 前端取文件filebox底层就是input[typefile]EasyUI的filebox是原生文件选择框的封装初始化后页面里仍然是一个input只是样式被换成按钮加文本框。取文件不通过val()而是走filebox自己的方法。input idfileBox classeasyui-filebox >$(#btnUpload).click(function () { var file $(#fileBox).filebox(files)[0]; if (!file) { $.messager.alert(提示, 请先选择文件); return; } // file.name、file.size、file.type在这里都能拿到 console.log(file.name, file.size); });filebox(files)返回的是一个数组即使只选了一个文件也要取下标0。这里经常有人写成$(#fileBox).filebox(getValue)拿到的是文件路径字符串不是文件对象后面往FormData里塞的时候直接报错。“取文件对象用files()取显示路径用getValue()”这个区别值得记住。4.2 上传动作FormData加上两个必须写的false上传文件不能再用form表单序列化要用XMLHttpRequest级别的FormData。EasyUI没有单独封装上传插件但ajax和原生FormData配合没有任何障碍。$(#btnUpload).click(function () { var file $(#fileBox).filebox(files)[0]; if (!file) return; // 前端先拦一道大小限制避免无效请求打到后端 if (file.size 20 * 1024 * 1024) { $.messager.alert(错误, 文件不能超过20MB); return; } var formData new FormData(); formData.append(file, file); $.ajax({ url: upload.ashx, type: POST, data: formData, processData: false, contentType: false, success: function (data) { var r JSON.parse(data); if (r.success) { $.messager.show({ title: 提示, msg: 上传成功 }); } else { $.messager.alert(错误, r.msg); } }, error: function (xhr) { $.messager.alert(错误, HTTP xhr.status); } }); });processData:false告诉jQuery不要尝试把FormData转成查询字符串contentType:false告诉jQuery不要覆盖multipart/form-data边界。这两个不写上传必然失败是上传代码里最典型的翻车点。error回调里把xhr.status打出来排错会快很多别只弹一个“网络错误”让用户猜。后端接收端用HttpRequest.Files取文件。完整处理包括类型校验、落盘和返回结果public void ProcessRequest(HttpContext context) { try { HttpPostedFile file context.Request.Files[file]; if (file null || file.ContentLength 0) { WriteJson(context, false, 没有收到文件); return; } // 扩展名校验注意转成小写再比较 string ext Path.GetExtension(file.FileName).ToLower(); string[] allowed { .xlsx, .xls, .png, .jpg, .jpeg }; if (!allowed.Contains(ext)) { WriteJson(context, false, 不支持的文件类型); return; } // 按月份分目录GUID重命名避免重名和单目录文件过多 string dir ~/Uploads/ DateTime.Now.ToString(yyyyMM); string physicalDir context.Server.MapPath(dir); if (!Directory.Exists(physicalDir)) { Directory.CreateDirectory(physicalDir); } string newName Guid.NewGuid().ToString(N) ext; file.SaveAs(Path.Combine(physicalDir, newName)); WriteJson(context, true, newName); } catch (Exception ex) { WriteJson(context, false, ex.Message); } } private void WriteJson(HttpContext context, bool success, string msg) { string json new JavaScriptSerializer().Serialize(new { success success, msg msg }); context.Response.ContentType application/json; charsetutf-8; context.Response.Write(json); }按扩展名判断类型只是一个前置筛选严格场景下还要校验文件头字节防止改名绕过。实际项目里如果上传的是Excel且后续要做解析入库我会再加一层“打开Excel验证结构”的步骤把格式问题挡在入库之前。保存路径按月份分目录物理文件名用GUID原始文件名存到数据库字段这是处理重名和目录膨胀的常规做法。4.3 两处大小限制web.config里的两个配置都要调上传文件大小被限制排错经常卡在两个层面ASP.NET的httpRuntime和IIS的requestFiltering。先看配置文件system.web httpRuntime maxRequestLength20480 executionTimeout120 / /system.web system.webServer security requestFiltering requestLimits maxAllowedContentLength20971520 / /requestFiltering /security /system.webServer配置项单位示例值超限表现httpRuntime/maxRequestLengthKB2048020MB抛“超出最大请求长度”异常requestLimits/maxAllowedContentLength字节2097152020MBIIS直接返回404.13这两处是同一道关卡的两道门第一个限制ASP.NET接受请求体的体积第二个限制IIS接收的请求内容长度超了直接返回404.13请求根本进不到你的处理程序里。只改一处的话表现很迷惑本地调试正常发布到IIS后大文件上传直接404。所以做上传功能时我先确认两处都改了再拿一个大文件做回归测试。如果你的项目反向代理层还有更严格的内容长度限制也要一并检查前端报错时先用Fiddler看响应状态码404.13基本可以断定是IIS这层的问题。4.4 保存策略与后续入库上传只是第一步上传成功后返回给前端的是物理文件名或者ID。如果附件只是存证那么文件名、大小、上传人、时间落在数据库里就行。如果上传的是Excel且业务要求把里面的数据导入数据库那么上传和导入是两个动作上传把文件落到服务器导入用一个后台接口读取Excel并逐行校验入库。这个需求在中小型项目里出现频率极高做完“导出Excel”之后紧接着就有人提“要把填好的Excel模板传上来批量导入”。两个功能共用一套文件处理思路校验扩展名、限制大小、文件落盘、解析入库。所以第4章这段方案不只是做了“上传”这一个动作它为后续做Excel导入数据库打好了地基——你已经有文件有物理路径有统一的Uploads目录再接一层解析逻辑即可。5. EasyUI与C#联调避坑JSON日期、查询条件、回填与上传的6条记录以下6条都是实际联调中反复出现的坑不是玄学是血泪经验。每一条都按现象、原因、解决三个步骤写方便你对照排查。5.1 表格里日期显示成 /Date(1591234567890)/ 怎么办现象datagrid的列里直接显示一串类似/Date(1591234567890)/的字符串完全不是日期。原因JavaScriptSerializer序列化DateTime对象时默认输出这种格式EasyUI拿到后原样渲染。前端没有转换逻辑的接口多半会翻车。解决后端在输出JSON前统一处理。最省事的方式是查询后就做ToString转换我在2.2节代码里已经这么处理。如果想保留DateTime类型则在EasyUI的columns定义里给该列加formatter前端用parseInt配合new Date()转换。我一般坚持后端转字符串因为前端每个日期列都要写一次formatter容易漏漏一列就乱一处。5.2 reload之后查询条件丢了页数跳了但数据是全量的现象第一次加载按关键词过滤正常点下一页或点刷新后关键词条件消失表格又显示了所有数据。原因datagrid的queryParams只在初始化和手动指定时生效内部reload不保留二次修改的条件。很多人把keyword写死在queryParams里reload时框架只带page和rows条件就没了。解决每次点查询、点刷新都重新加载数据并显式带上条件不要依赖queryParams被自动记忆function reloadGrid() { $(#dg).datagrid(load, { keyword: $(#kw).textbox(getValue) }); }load方法的参数会合并到当前请求参数里分页参数page和rows由框架自动补上。这样无论切页还是刷新条件始终跟着走。5.3 修改弹窗打开后表单是空的form(load)的匹配规则现象点击编辑对话框打开了但姓名、备注都是空的。原因form(load, row)是按name去匹配row对象的字段。要么后端返回的字段名和表单name对不上要么row本身不对。还有一种隐蔽情况row里的字段值是null或空字符串form(load)会跳过赋值。解决先确认后端返回的JSON字段名和表单name完全一致包括大小写。再确认字段都有值后端查询时对可能为null的列用IsNull或“空字符串”兜底。排查时可以临时在edit函数里console.log(row)先确认拿到的数据本身有没有值再谈赋值问题。5.4 多选删除只删了一条现象勾选了三行点删除结果只删掉第一行或者只删掉最后一行。原因代码里用了getSelected而不是getSelections。getSelected只返回当前选中的第一行适合单选场景多选必须遍历getSelections返回的数组。解决按2.5节的写法用getSelections拼装id列表一次接口提交所有id。如果你设置了singleSelect:true那getSelections永远只有一条多选之前先检查表格配置。提示多选前先确认datagrid没有把singleSelect设为true这个配置在页面初始化时很容易被从示例代码里直接带过来。5.5 导出Excel文件名在IE和部分浏览器里乱码现象Chrome里下载文件名正常IE或旧内核浏览器里下载下来的文件叫“__.xlsx”或一串乱码。原因Content-Disposition的filename参数没有做编码处理浏览器对中文解释不一致。解决输出前对文件名做HttpUtility.UrlEncode并用UTF-8编码写入这节在3.3节已经给出代码。另一个保险措施是文件名里不要带冒号、斜杠这类Windows文件名非法字符生成时间戳文件名时用“yyyyMMddHHmmss”这种纯数字格式避免麻烦。5.6 上传十几MB的文件报404.13现象小文件上传正常超过一定大小后就报404.13或者直接返回错误页。原因IIS的requestFiltering限制比ASP.NET的maxRequestLength更早生效。发布环境IIS默认对请求内容的长度有上限超出后请求根本到不了你的处理程序。解决把web.config里两个限制同时调大单位注意区分maxRequestLength是KBmaxAllowedContentLength是字节。改完后重启应用池再测。顺便检查一下反向代理的客户端请求体大小限制。有三层以上的情况我会在前端的error回调里打印完整xhr.status先定位是哪一层拦的再动手改。6. 验证与进阶用Fiddler看交互、给表格加行内编辑、把ashx迁移到Controller6.1 验证三板斧Fiddler看请求地址、JSON结构和状态码这套组合联调不顺绝大多数问题出在接口层面而不是组件用法。我的固定排查顺序是打开Fiddler先看请求有没有发出去、URL对不对再看响应JSON的total和rows结构对不对最后看状态码404和500分别指向路由和代码异常。用键盘F12里的Network面板也可以但Fiddler能看到请求头里的Content-Disposition和JSON原始串排查上传和导出问题时更直接。接口通了再动前端顺序反了会让你在弹窗和表格之间来回猜。6.2 给表格加行内编辑edatagrid的取舍如果后面需求从“新增修改删除”进化到“表格里直接改”EasyUI有一个叫edatagrid的扩展插件继承自datagrid多出endEdit、destroyRow等方法。它的做法是编辑完一行自动调用保存接口对熟练操作的人效率高但实现时要多处理一个问题编辑未保存就切页数据会丢。我一般只在单页、数据量小、不允许批量操作的场景用行内编辑否则老老实实走对话框提交交互虽慢但数据安全。把ashx迁移到MVC Controller的时机当项目里的接口超过十个、开始需要统一的参数校验、日志、异常过滤时ashx的重复样板代码会变多。这个迁移是线性成本接口里面只做数据查询和JSON输出迁移时把ProcessRequest的逻辑搬到Controller的Action即可前端URL从list.ashx变成/List/Query其余不用动。如果你只是维护一个小项目接口不超过十个ashx完全够用不必为了框架升级而升级。我做这类页面固定顺序是先定接口返回的JSON字段再写页面最后联调。以前总喜欢先把EasyUI表格摆漂亮再回头补接口结果十个里有八个要改字段名和格式。现在先拿Fiddler确认一道接口返回干净了再做页面返工率低了很多。希望这篇C#EasyUI的实战笔记能帮你少踩几个坑照着做把这五个需求干净利落地交出去。本文还有配套的精品资源点击获取