
简介C#结合EasyUI的Web管理端示例工程面向使用.NET MVC框架进行后台系统开发的初中级工程师可作为后台管理模块的参考脚手架。资源基于VS2013环境构建搭配SQL Server 2014数据库围绕单张业务表完整实现了新增、修改、删除、分页、导出Excel文件、上传图片等常用管理功能。其中datagrid分页默认采用SQL Server 2012新关键字写法代码内同步保留了针对2005/2008版本的兼容替换方案移植时可快速切换省去手工调整分页逻辑的麻烦。压缩包为RAR格式大小28.54MB内含完整VS解决方案、页面布局、前端脚本及数据访问层源码可直接编译运行并对照学习。目前已有218人学习浏览适合需要快速搭建后台管理界面、了解EasyUI布局与MVC整合技巧以及希望学习表格分页、文件导出和图片上传实现细节的开发者无论是入门学习还是项目改造都有实际参考价值。1. C#EasyUI做增删改查导出上传这套组合还值不值得投入“C#EasyUI”这个组合在内部管理系统里的出现频率远高于市面上主推的 VueElement。最常见的场景是一个部门手里一堆 Excel流程靠邮件审批领导要求两周内上线一个在线填写、查询、导出的后台需求刚好就是新增、修改、删除、导出 Excel、上传附件。后端用 C# 写接口干净利落前端用 EasyUI 的 datagrid、dialog、form 几个组件就能把页面快速拼出来不用从零造表格、弹窗和分页。这篇文章用一张员工表把数据访问层、EasyUI 页面、导出和上传的完整实现讲清楚同时把最高频的坑直接标出来。适合正在评估或者准备接手这套技术栈的人怎么判断这套方案好不好用重点看数据流是否清晰、异常能不能及时暴露而不只是看界面能不能点。2. 后端骨架先立稳建表、实体与 EF Core 数据访问的选型和落地2.1 EF Core、SqlSugar、Dapper为什么我默认选 EF Core如果把后端定成 C#第一个绕不开的问题是数据访问层用什么。常见选择有三个EF Core、SqlSugar、Dapper。我在项目里默认 EF Core但也会根据团队情况换 SqlSugar。下面这张表是我做选型时的判断依据。方案适用场景主要风险EF Core微软官方 ORM迁移方便适合长期维护复杂 SQL 要写 Raw SQL性能需要关注SqlSugar国内开源语法贴近 SQL中文资料多更新节奏看社区活跃度Dapper轻量SQL 全手动性能可控实体映射和分页要自己写选型逻辑很简单团队熟 SQL 就上 SqlSugar 或 Dapper想减少模板代码就用 EF Core。EF Core 的迁移工具在表结构变更时非常省事加一列、改一个类型一条命令就能同步到数据库这在迭代快的内部系统里是实打实的生产力。Dapper 虽然快但分页查询、连表映射都要手写 SQL做“新增、修改、删除”这类标准 CRUD 时反而显得啰嗦。2.2 员工表怎么建字段类型、可空与默认值一次定对后端接口没起来之前先把表结构定下来后面所有代码都围着它转。我用一张员工表做演示覆盖了常见的字段类型自增主键、字符串、时间、可空字段、默认值。CREATE TABLE Employees ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Department NVARCHAR(100) NOT NULL, Phone NVARCHAR(20) NULL, HireDate DATETIME NOT NULL, AvatarUrl NVARCHAR(255) NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() );几个字段的选型是有讲究的。Id 用 BIGINT IDENTITY 而不是 GUID是因为内部系统的数据量不会大到需要分布式 ID自增主键在索引和排序上更省Phone 用 NVARCHAR 而不是数字类型因为电话号码可能带分机号或前导零数字类型会把这些信息丢掉AvatarUrl 可空因为不是每个员工都上传头像。HireDate 用 DATETIME 而不用 DATE是考虑到后续可能要用到入职时间的时分秒做排班或者其他业务DATE 一旦落地就补不回来。2.3 实体类与 DbContext 注册把数据库连接接到接口层表建好后C# 侧的实体类要跟表结构一一对应。注意可空字符串要用string?否则数据库里的 NULL 会让反序列化报错。public class Employee { public long Id { get; set; } public string Name { get; set; } string.Empty; public string Department { get; set; } string.Empty; public string? Phone { get; set; } public DateTime HireDate { get; set; } public string? AvatarUrl { get; set; } public DateTime CreateTime { get; set; } }DbContext 和依赖注入的注册是接上数据库的最后一环。public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetEmployee Employees SetEmployee(); }builder.Services.AddDbContextAppDbContext(opt opt.UseSqlServer(builder.Configuration.GetConnectionString(Default)));这里有一个容易翻车的细节AddDbContext 默认的生命周期是 Scoped也就是每次请求创建一次上下文这正好匹配 Web API 的请求模型。把 DbContext 注册成 Singleton 会导致并发请求共用同一个上下文出现“上下文已被释放”的诡异报错。另外连接字符串写在 appsettings.json 里的ConnectionStrings:Default节点下别把密码硬编码在代码里不然代码一上 Git 就全漏了。3. 在 EasyUI 页面上完成增删改和上传DataGrid、弹窗、文件表单的配对方式3.1 DataGrid、工具栏和共用弹窗一个页面的 HTML 骨架后端接口就绪后前端页面是另一个大头。EasyUI 的优势在于它自带 datagrid、dialog、form 这些组件不需要引入一堆 npm 包。页面骨架通常是一个表格 一个工具栏 一个增改共用的弹窗表单。table iddg/table div idtb a idbtnAdd classeasyui-linkbutton>$(#dg).datagrid({ url: /api/Employee/page, method: get, fitColumns: true, rownumbers: true, singleSelect: false, ctrlSelect: true, pagination: true, pageSize: 20, toolbar: #tb, columns: [[ { field: ck, checkbox: true }, { field: Id, title: ID, width: 60 }, { field: Name, title: 姓名, width: 100 }, { field: Department, title: 部门, width: 120 }, { field: HireDate, title: 入职日期, width: 100 } ]] }); function openAdd() { $(#fm).form(clear); $(#dlg).dialog(open); } function openEdit() { var row $(#dg).datagrid(getSelected); if (!row) { $.messager.alert(提示, 请先选择一行); return; } $(#fm).form(load, row); $(#dlg).dialog(open); }这段代码里有个隐藏逻辑新增和修改共用同一个#fm表单用隐藏域Id是否有值来区分。form(clear)会清空所有表单值而form(load, row)会把选中行的数据回填到对应的输入框。这里要注意filebox 是只读的form(load)不会把文件路径回显出来——这是前端组件的限制只能让用户看到“未选择文件”的状态后端保留旧文件即可。3.2 保存时 Add/Update 分流业务字段和文件一起走 multipart保存是整套功能里最容易出问题的地方。EasyUI 的 form 组件提交时如果表单里包含 filebox会自动把请求设为 multipart/form-data后端要用IFormFile接收。提交 URL 的判断放在 form 的 submit 参数里function save() { $(#fm).form(submit, { url: $(#fm input[nameId]).val() ? /api/Employee/update : /api/Employee/add, onSubmit: function() { return $(this).form(validate); }, success: function(data) { var res JSON.parse(data); if (res.ok) { $(#dlg).dialog(close); $(#dg).datagrid(reload); } else { $.messager.alert(提示, res.msg); } } }); }对应后端的接收模型和接口public class EmployeeInput { public long? Id { get; set; } public string Name { get; set; } string.Empty; public string Department { get; set; } string.Empty; public string? Phone { get; set; } public DateTime HireDate { get; set; } public IFormFile? AvatarFile { get; set; } } [HttpPost(add)] public async TaskIActionResult Add([FromForm] EmployeeInput input) { var employee new Employee { Name input.Name, Department input.Department, Phone input.Phone, HireDate input.HireDate, CreateTime DateTime.Now }; if (input.AvatarFile ! null) employee.AvatarUrl await SaveFile(input.AvatarFile); _context.Employees.Add(employee); await _context.SaveChangesAsync(); return Ok(new { ok true }); } [HttpPut(update)] public async TaskIActionResult Update([FromForm] EmployeeInput input) { if (input.Id null) return BadRequest(new { msg 缺少 Id }); var entity await _context.Employees.FindAsync(input.Id.Value); if (entity null) return NotFound(new { msg 数据不存在 }); entity.Name input.Name; entity.Department input.Department; entity.Phone input.Phone; entity.HireDate input.HireDate; if (input.AvatarFile ! null) entity.AvatarUrl await SaveFile(input.AvatarFile); await _context.SaveChangesAsync(); return Ok(new { ok true }); }这里有个细节值得展开Update 场景下用户可能没选新文件所以AvatarFile是可空的只有传了文件才覆盖AvatarUrl否则旧文件 URL 会被保留。这避免了一个常见翻车——用户只改姓名结果头像被清空。SaveFile 方法里用Guid.NewGuid():N生成文件名避免中文名和重名导致的路径问题扩展名从上传文件的原始文件名里取但要白名单过滤不能直接信任。private async Taskstring SaveFile(IFormFile file) { var ext Path.GetExtension(file.FileName).ToLowerInvariant(); var allowed new[] { .jpg, .jpeg, .png, .gif, .pdf, .xlsx }; if (!allowed.Contains(ext)) throw new InvalidOperationException(不支持的文件类型); var dir Path.Combine(_env.WebRootPath, uploads); Directory.CreateDirectory(dir); var name ${Guid.NewGuid():N}{ext}; var fullPath Path.Combine(dir, name); using var fs new FileStream(fullPath, FileMode.Create); await file.CopyToAsync(fs); return $/uploads/{name}; }3.3 批量删除用 JSON 数组替代逗号字符串删除功能看似简单但“批量”两个字会导致后端写法完全不同。前端先把选中的行转成 ID 数组再用 ajax 以 JSON 形式提交function deleteRows() { var rows $(#dg).datagrid(getSelections); if (!rows.length) { $.messager.alert(提示, 请先勾选要删除的行); return; } var ids rows.map(function(r) { return r.Id; }); $.ajax({ url: /api/Employee/delete, type: POST, contentType: application/json, data: JSON.stringify(ids), success: function(res) { if (res.ok) { $(#dg).datagrid(reload); } else { $.messager.alert(提示, res.msg); } } }); }后端用long[]直接接收[HttpPost(delete)] public async TaskIActionResult Delete([FromBody] long[] ids) { if (ids null || ids.Length 0) return BadRequest(new { msg 参数为空 }); var employees await _context.Employees .Where(e ids.Contains(e.Id)) .ToListAsync(); _context.Employees.RemoveRange(employees); await _context.SaveChangesAsync(); return Ok(new { ok true }); }为什么不直接string.Join(,, ids)拼成 1,2,3 传参因为逗号字符串需要后端自己 Split 和转 long多一步解析就多一个问题——比如前端选了 500 行字符串长度超了 GET 请求的 URL 限制。JSON 数组能直接被 ASP.NET Core 模型绑定为long[]长度限制也更宽松。另外RemoveRange在 EF Core 里默认是逐条 DELETE几百条没问题上万条会慢得让人怀疑人生真遇到万级批量删除用ExecuteDeleteAsync一把梭更快。4. 用 NPOI 导出 Excel后端生成文件流与前端触发的完整姿势4.1 为什么不用 Office COM而是选 NPOI导出 Excel 的选型直接决定你在生产环境踩不踩雷。很多第一次做导出的工程师会想到微软官方的 Office COM 组件因为在本地开发时它一切正常。但 COM 组件有几个致命问题服务器必须装 Office且版本不一致就报错并发导出时 Excel 进程会互相干扰经常出现“进程被占用”的尴尬IIS 回收或权限不足时 COM 对象直接失联。这套方案在开发机能跑上服务器就变成玄学我不建议在 Web 服务里碰它。方案优点缺点Office COM入门快、功能强依赖 Office 安装在服务器并发差进程容易残留ClosedXMLAPI 友好、文档多大数据量内存占用高NPOI不依赖 Office、xls/xlsx 都支持API 略旧部分高级功能要手动处理我一般用 NPOI。它直接读写 Excel 文件的开放格式完全不依赖服务器装 Office发到 Linux 容器里也能跑。对于“导出报表给领导看”这种需求它的功能足够覆盖单元格样式、列宽、合并单元格、公式都能写。4.2 导出接口代码与前端触发方式导出的核心流程是三段查数据、写 workbook、返回文件流。下面是一个带部门筛选条件的导出接口[HttpGet(export)] public IActionResult Export(string? dept) { var query _context.Employees.AsNoTracking().AsQueryable(); if (!string.IsNullOrEmpty(dept)) query query.Where(e e.Department.Contains(dept)); var list query.OrderBy(e e.Id).ToList(); using var workbook new XSSFWorkbook(); var sheet workbook.CreateSheet(员工); string[] headers { ID, 姓名, 部门, 电话, 入职日期 }; var headerRow sheet.CreateRow(0); for (int i 0; i headers.Length; i) headerRow.CreateCell(i).SetCellValue(headers[i]); int rowIndex 1; foreach (var e in list) { var row sheet.CreateRow(rowIndex); row.CreateCell(0).SetCellValue(e.Id); row.CreateCell(1).SetCellValue(e.Name); row.CreateCell(2).SetCellValue(e.Department); row.CreateCell(3).SetCellValue(e.Phone ?? ); row.CreateCell(4).SetCellValue(e.HireDate.ToString(yyyy-MM-dd HH:mm:ss)); } for (int i 0; i headers.Length; i) sheet.SetColumnWidth(i, 20 * 256); using var ms new MemoryStream(); workbook.Write(ms); ms.Seek(0, SeekOrigin.Begin); var fileName $员工列表_{DateTime.Now:yyyyMMddHHmmss}.xlsx; return File(ms.ToArray(), application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, fileName); }这里有三个必须知道的点。第一XSSFWorkbook对应的是 .xlsx 格式Excel 2007如果业务方只要 .xls 老格式要换成HSSFWorkbook同时 Content-Type 也要变成application/vnd.ms-excel。第二AsNoTracking()告诉 EF Core 这条查询不需要跟踪实体状态导出只读数据时能省不少内存。第三File(byte[], contentType, fileName)是 ASP.NET Core 的便捷方法它会自动帮你在响应头里写好 Content-Disposition但如果你要兼容很老的浏览器还是手动设置filename*UTF-8更稳。前端触发导出不需要 ajax直接改浏览器的地址栏就可以function exportExcel() { var dept $(#tb_dept).textbox(getValue); var url /api/Employee/export?dept encodeURIComponent(dept); window.location.href url; }用 ajax 拿导出接口的返回值会有一个坑ajax 无法直接触发浏览器的下载行为你必须自己处理 Blob 和 URL.createObjectURL否则文件内容会变成 Response 里的乱码文本。企业内部系统大部分场景用window.location.href就够了只有需要带登录 Token 下载时才考虑 Blob 方式。4.3 大数据量导出分页拉取、异步任务与超时设置export 接口里query.ToList()这种写法在两三万行以内问题不大一旦数据量到十万级XSSFWorkbook的单元格对象全在内存里接口耗时可能从秒级涨到分钟级前端请求直接超时。我做过一次血泪升级一张 40 万行的流水表导出接口跑了 80 秒Kestrel 默认超时把连接掐断用户只看到一个白屏。大数据量导出的落地思路有三个按成本从低到高排序。思路一分页拉取。后端在手写 excle 时不要一次性ToList()改成每 5000 行查一批写一批边查边写 workbook。这能把内存峰值压下来但对接口总耗时没有本质改善。思路二异步导出。请求进来后立即返回“任务已创建”后台用IHostedService或者Channel跑生成任务生成完毕后把文件路径存到数据库前端轮询状态再下载。这是生产环境最可靠的做法也是“怎么判断好不好用”的一个分水岭同步导出只适合两三万行的小表超过这个量就必须转异步。思路三调整超时。如果是少量数据但网络慢把 Kestrel 的KeepAliveTimeout和IIS的requestTimeout调大。这只是缓解不是根治别把它当唯一方案。5. 避坑/排查C#EasyUI 组合下最常见的五个坑5.1 现象datagrid 能加载但 toolbar 的按钮点了没反应原因大概率不是代码逻辑而是脚本加载顺序错了。EasyUI 的 linkbutton 组件是通过扫描classeasyui-linkbutton来渲染的如果自定义 JS 在 EasyUI 的解析器执行之前就绑定了 onclick按钮可能还没渲染完绑定事件就丢了。解决确认 jQuery、EasyUI、自定义脚本依次加载自定义脚本放在/body之前。如果按钮还是没反应打开浏览器控制台看是否有easyui is not defined有的话说明 jQuery 和 EasyUI 的引库顺序反了。5.2 现象修改保存后后端拿到的 Id 是 0这个坑我见过无数次。前端openEdit时用form(load, row)回填数据隐藏域Id明明有值但提交到 Update 接口后input.Id却是 0。原因大多出在数据库的 Id 是 BIGINT而隐藏域的值在 JS 里被当成了字符串EasyUI 的 form 序列化时又没有保留精度或者后端模型绑定到了 int 类型导致溢出归零。解决C# 侧接收模型用long? Id前端的隐藏域不要改名保持nameId这样模型绑定最省事。还有一个隐含坑是form(clear)会把隐藏域也清掉所以新增弹窗不要用form(clear)后再 setValues直接用reset或者逐字段清空。5.3 现象删除接口收到空数组或直接 400前端getSelections拿到的 Id 数组用普通 ajax 提交时如果没有设置contentType: application/jsonASP.NET Core 会默认按表单格式解析long[]绑定不上接口直接 400 或者收到 null。解决按第 3.3 节的方式JSON.stringify(ids)加上application/json。另外检查一下 datagrid 是否开启了checkbox: true列没开的话getSelections永远返回空数组。5.4 现象导出的 Excel 文件名乱码打开报格式不匹配乱码是响应头 Content-Disposition 里的文件名没做 URL 编码旧浏览器解析不了中文。格式不匹配则常见于用 NPOI 的XSSFWorkbook生成了 .xlsx 内容但文件名写成了 .xls或者 Content-Type 写成了application/octet-stream。解决文件名里带时间戳即可别带中文如果业务上必须中文用System.Net.WebUtility.UrlEncode编码后再放进 Content-Disposition。同时确认XSSFWorkbook对应 .xlsxContent-Type 用第 4.2 节写的那一串不要图省事写 application/octet-stream。5.5 现象上传文件后后端Request.Form.Files为空大文件直接 413Request.Form.Files为空十有八九是表单没有以 multipart/form-data 提交。EasyUI 的 form 组件对 filebox 的兼容性不是绝对的一旦 form 里只写了methodpost而忘了enctypemultipart/form-data文件字段不会进入 Files 集合。大文件 413 则是 Kestrel 或 IIS 的请求体大小限制。解决给 form 标签显式加enctypemultipart/form-data后端[FromForm]接收请求体限制在 Program.cs 里配置MaxRequestBodySizeIIS 部署还要在 web.config 里调maxAllowedContentLength。这两个地方都改掉100MB 内的文件才能正常落地。6. 进阶给这套后台加验证与权限让增删改查真正可交付6.1 接口自测清单上传、导出这两条最容易被前端掩盖前端页面上传和导出看着正常不代表接口是好的。我在交付前会按下面这张表逐条过一遍尤其是上传和导出因为这两个场景一旦出错前端往往只表现为“没反应”。验证点操作期望结果新增接口表单带文件提交数据库新增一条记录文件 URL 可直接访问修改接口不上传新文件只改部门旧文件 URL 保留不会被清空删除接口先删单条再勾选多条删数据消失页面刷新后总数减少导出接口带部门筛选条件导出文件能打开表头正确行数与列表一致上传接口上传一个超限文件返回可读的报错而不是白屏或 500这套清单我用 Postman 跑不依赖浏览器。注意导出接口要检查Content-Disposition和Content-Type两个响应头上传接口要检查返回的 JSON 里是否有可访问的 URL。6.2 按钮权限只挡前端不够后端也要带权限校验企业内部系统做到后面一定会提权限需求。EasyUI 侧的做法很简单页面加载时请求一次当前用户的权限码toolbar 的按钮按权限码决定隐藏还是禁用。但这只是体验层面的“看不见”不代表接口安全。真正的权限控制要在后端接口上做。我在 ASP.NET Core 里用一个自定义 Attribute 标接口再在过滤器里统一校验[AttributeUsage(AttributeTargets.Method)] public class PermissionAttribute : Attribute { public string Code { get; set; } string.Empty; } [HttpDelete(delete)] [Permission(Code employee:delete)] public async TaskIActionResult Delete([FromBody] long[] ids) { // ... }过滤器的实现不复杂核心是拿到当前用户的权限码集合后检查是否包含 Attribute 里指定的 Code。前端按钮隐藏只是给操作者减少入口后端拦截才是数据不被乱删的底线。这个思路对导出同样适用导出报表也是一种敏感操作权限码可以单独设一个避免普通用户把整表数据拖走。我养成的习惯是前端把按钮权限做成一个通用 JS 函数后端把权限校验做成一个通用过滤器两个地方各自维护一份权限码表。前端那份是给人看的后端那份是给数据把关的。这套双保险让我在接手这类项目时少背了不少锅。希望帮到你。本文还有配套的精品资源点击获取