☰
C#+EasyUI后台管理模板:增删改查、Excel导出与文件上传一次搞定
2026/10/15 8:31:03 网站建设 项目流程

简介:一份面向C#与EasyUI开发者的入门到进阶示例,基于VS2013与MVC架构,搭配SQL Server 2014数据库,覆盖单表维护场景下的数据管理全流程,适合后台管理系统开发初学者和需要掌握常见增删改查功能的技术人员。核心功能包括新增、修改、删除、分页查询、导出Excel与图片上传,其中datagrid分页特意给出SQL Server 2012新关键字分页以及2005/2008旧版分页两种实现方式,并在相关代码中直接提供getPage2005等分页方法作为对照,便于不同数据库环境下的适配与迁移。压缩包采用RAR格式,整体约28.54MB,页面暂未显示具体文件数量与类型明细,包内主要包含MVC项目工程、EasyUI前端页面和数据库配置相关资源,整体目录结构清晰简单,便于按模块查找定位。目前已有218人学习下载。这份资源的价值在于提供一套可直接运行的完整示例,读者既能从代码层面理解C#后端与EasyUI前端的交互流程,也能熟悉分页、导出、上传等功能的实现套路;通过新旧分页版本对比,还能加深对SQL Server不同版本语法兼容性的认识,为后续在实际项目中扩展维护提供可靠参考。尽管仅涉及一张业务表,但示例覆盖了后台管理中使用频率很高的操作组合,值得边练边学。

1. 这套 C#+EasyUI 后台管理页面,把增删改查做成了能直接复用的东西

如果你做过企业后台管理系统,大概率经历过这种场景:业务部门提了个需求,要在一个页面里完成数据登记、修改、删除,还要把列表导成 Excel 发给财务,偶尔还要上传个附件。单独看每个功能都不难,但凑在一起,前端布局、后端接口、文件处理、导出格式,每一块都要重写一遍。这套基于 C#+EasyUI 实现的项目,就是把这条链路整体打通了——前端用 EasyUI 的 datagrid 和 layout 搭界面,后端用 C# 提供标准的增删改查接口,外加 NPOI 导出 Excel 和文件上传功能。适合正在做 .NET 平台管理系统、又不想在前端框架上投入太多精力的开发者,拿来改改就能嵌进自己的项目里。

2. 项目结构与技术选型:为什么是 C# 搭配 EasyUI

2.1 这套组合解决的是后台管理的真实痛点

先聊技术选型。C# 这边,最常见的是 ASP.NET Web API 或 MVC 项目,提供 REST 风格的接口;前端选 EasyUI,而不是 Vue 或 React,是因为后台管理系统的核心诉求是「快速出活」。EasyUI 的 datagrid 自带分页、排序、编辑、选择框,form 组件自带校验,不需要额外引入 Element UI 或 Ant Design 那套体系。对于企业内部系统,交互要求没那么高,但开发效率要求很高,这个组合非常顺手。

项目整体结构大概是这样的(以典型的 Web API 项目为例):

├── Controllers/ │ ├── UserController.cs // 用户信息增删改查接口 │ ├── FileUploadController.cs // 文件上传接口 │ └── ExportController.cs // 导出 Excel 接口 ├── Models/ │ └── UserInfo.cs // 实体类,对应数据库表 ├── Content/ │ ├── easyui/ // EasyUI 静态资源 │ └── css/ // 自定义样式 ├── Scripts/ │ └── user.js // 页面交互脚本 ├── Views/ │ └── User/ │ └── Index.cshtml // 列表主页面 └── App_Data/ └── UploadFiles/ // 上传文件存储目录

Controllers 接收前端的 HTTP 请求,Models 做数据映射,Views 里的页面用 EasyUI 组件拼界面,Scripts 里放的是 datagrid 初始化和事件绑定。这套分层逻辑很直观:页面归页面、接口归接口、数据归数据,二次开发时按层找文件就行。

2.2 EasyUI 布局:用 layout 组件三分钟搭出后台框架

EasyUI 的 layout 组件是后台页面的骨架。典型做法是 east-west-north-south 分区:北边放顶部工具栏,西边放菜单树,中间放 datagrid 数据列表。下面是一个最小可用的布局代码:

<div id="cc" class="easyui-layout" style="width:100%;height:100%;"> <!-- 顶部 --> <div>public List<UserInfo> GetUserList(string keyword, int pageIndex, int pageSize, out int total) { using (IDbConnection conn = new SqlConnection(connectionString)) { string where = "WHERE 1=1 "; if (!string.IsNullOrEmpty(keyword)) { where += "AND UserName LIKE @kw "; } string sql = $"SELECT * FROM UserInfo {where} ORDER BY Id DESC " + $"OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY;"; // 注意:SQL Server 2012+ 支持 OFFSET-FETCH 分页 string countSql = $"SELECT COUNT(1) FROM UserInfo {where};"; total = conn.ExecuteScalar<int>(countSql, new { kw = "%" + keyword + "%" }); var list = conn.Query<UserInfo>(sql, new { kw = "%" + keyword + "%", offset = (pageIndex - 1) * pageSize, pageSize = pageSize }).ToList(); return list; } }

这里的OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY是 SQL Server 的分页写法,比ROW_NUMBER()更简洁。注意 Dapper 的参数名要和 SQL 里的@变量名保持一致,关键字为空时kw参数依然要传,否则会报参数缺失。

3. 核心模块拆解:一条数据从页面到数据库再回到页面的完整链路

3.1 列表页渲染:datagrid 的初始化与数据加载

列表是后台管理的门面。EasyUI 的 datagrid 通过url属性指向后端接口,接口返回 JSON 数据后自动填充表格。页面脚本里初始化的写法如下:

$('#dg').datagrid({ url: '/api/user/list', method: 'get', pagination: true, pageSize: 20, pageList: [10, 20, 50, 100], fitColumns: true, singleSelect: true, columns: [[ { field: 'Id', title: 'ID', width: 60 }, { field: 'UserName', title: '姓名', width: 100 }, { field: 'Phone', title: '手机号', width: 120 }, { field: 'CreateTime', title: '创建时间', width: 150, formatter: dateFormatter }, { field: 'Status', title: '状态', width: 80, formatter: statusFormatter }, { field: 'action', title: '操作', width: 120, formatter: actionFormatter } ]] });

pagination: true开启分页,pageList定义每页条数选项,formatter是自定义格式化函数,比如把时间戳转成日期字符串、把状态数字转成文字标签。重点关注actionFormatter,它返回的不是数据,而是一段 HTML 字符串,里面包含「编辑」「删除」按钮,datagrid 会直接把这段 HTML 渲染到单元格里:

function actionFormatter(value, row, index) { return '<a href="javascript:void(0)" onclick="editUser(' + row.Id + ')">编辑</a> ' + '<a href="javascript:void(0)" onclick="deleteUser(' + row.Id + ')">删除</a>'; }

这种方式的好处是灵活,按钮点击时直接拿到这一行的主键 Id。坏处是 HTML 拼在 JS 里,代码稍微丑一点,但对于内部系统来说,可维护性完全够用。

3.2 新增和修改:同一个弹出框,两种提交逻辑

新增和修改在 EasyUI 里通常是同一个 dialog 弹窗,区别只是表单里是否有初始值。打开弹窗时判断操作类型,有 Id 就回填数据走修改,没有就清空表单走新增。前端逻辑如下:

function openAddDialog() { $('#dlg').dialog('open').dialog('setTitle', '新增用户'); $('#fm').form('clear'); $('#fm input[name="Id"]').val(0); // 隐藏域,标记为新增 } function openEditDialog(id) { $('#dlg').dialog('open').dialog('setTitle', '编辑用户'); $('#fm').form('load', '/api/user/get?id=' + id); // form('load') 会自动把后端返回的 JSON 字段填充到同名表单控件中 } function saveUser() { $('#fm').form('submit', { url: '/api/user/save', onSubmit: function() { return $(this).form('validate'); // 表单校验,不全通过则不提交 }, success: function(result) { var data = JSON.parse(result); if (data.success) { $('#dlg').dialog('close'); $('#dg').datagrid('reload'); } else { $.messager.alert('提示', data.msg, 'error'); } } }); }

这段逻辑里有一个容易被忽略的点:form('submit')是 EasyUI 封装的 AJAX 提交,它会把表单里的所有input控件序列化后 POST 到指定 URL。后端只需要写一个save接口,同时处理新增和修改——判断主键Id是否为 0,为 0 就 INSERT,否则 UPDATE。前端通过隐藏域传 Id,后端据此分流,一套接口搞定两个操作。

3.3 后端保存接口:一个方法处理 Insert 和 Update

C# 端对应的接口实现(注意这里用了动态类型接收前端参数,实际项目中推荐定义一个请求 DTO):

[HttpPost] [Route("api/user/save")] public IHttpActionResult SaveUser(UserInfo user) { if (user == null) { return Json(new { success = false, msg = "参数不能为空" }); } string sql; if (user.Id == 0) { // 新增:插入新记录 sql = @"INSERT INTO UserInfo (UserName, Phone, Status, CreateTime) VALUES (@UserName, @Phone, @Status, GETDATE());"; } else { // 修改:更新已有记录 sql = @"UPDATE UserInfo SET UserName = @UserName, Phone = @Phone, Status = @Status WHERE Id = @Id;"; } using (IDbConnection conn = new SqlConnection(connectionString)) { int rows = conn.Execute(sql, user); if (rows > 0) { return Json(new { success = true, msg = "保存成功" }); } return Json(new { success = false, msg = "保存失败" }); } }

这里的user对象来自 ASP.NET Web API 的模型绑定,前端form('submit')提交过来的表单键值对会自动映射到UserInfo的各个属性上。字段名必须和前端表单控件name属性一致,比如前端input name="UserName",后端就对应user.UserName。如果对不上,拿到的是一个全默认值的空对象,这是联调时最常遇到的问题。

3.4 删除操作:批量删还是单条删

删除有两种做法:单条删(点击行内按钮)和批量删(勾选复选框后统一删)。比较完善的实现是两种都支持,后端接口用同一个:

[HttpPost] [Route("api/user/delete")] public IHttpActionResult DeleteUser([FromBody] List<int> ids) { if (ids == null || ids.Count == 0) { return Json(new { success = false, msg = "请选择要删除的数据" }); } // 拼接参数化 IN 查询,避免 SQL 注入 var parameters = new DynamicParameters(); for (int i = 0; i < ids.Count; i++) { parameters.Add("@Id" + i, ids[i]); } string inClause = string.Join(",", ids.Select((_, i) => "@Id" + i)); string sql = $"DELETE FROM UserInfo WHERE Id IN ({inClause});"; using (IDbConnection conn = new SqlConnection(connectionString)) { int rows = conn.Execute(sql, parameters); return Json(new { success = true, msg = $"成功删除 {rows} 条记录" }); } }

前端传递数组有两种方式:一是 POST 原始 JSON 数组,用[FromBody]接收;二是用traditional: true的 jQuery AJAX,把数组序列化成ids[]=1&ids[]=2这样的格式。这里用第一种更直接,前端调用代码:

$.ajax({ url: '/api/user/delete', type: 'post', contentType: 'application/json', data: JSON.stringify(idList), success: function(result) { if (result.success) { $('#dg').datagrid('reload'); } } });

注意contentType: 'application/json'是必须的,否则后端[FromBody]接收不到数组数据,会直接返回空列表。

4. 导出 Excel 与文件上传:两个高频需求的一次性封装

4.1 用 NPOI 导出 Excel:解决中文文件名和格式问题

导出 Excel 是后台管理的刚需。C# 里最常见的方案是 NPOI,原因是它不走 Office COM 组件,服务器上不用装 Excel 也能生成 xlsx 文件。核心逻辑三步走:查询数据、填充单元格、写文件流返回给前端。

我用 NPOI 封装了一个最基础的导出方法,先看代码:

[HttpGet] [Route("api/user/export")] public HttpResponseMessage ExportUser(string keyword) { var list = GetUserList(keyword, 1, 10000, out _); // 1. 创建 xlsx 工作簿 var workbook = new XSSFWorkbook(); var sheet = workbook.CreateSheet("用户列表"); // 2. 写表头 string[] headers = { "ID", "姓名", "手机号", "状态", "创建时间" }; var headerRow = sheet.CreateRow(0); for (int i = 0; i < headers.Length; i++) { headerRow.CreateCell(i).SetCellValue(headers[i]); } // 3. 写数据行 int rowIdx = 1; foreach (var user in list) { var row = sheet.CreateRow(rowIdx++); row.CreateCell(0).SetCellValue(user.Id); row.CreateCell(1).SetCellValue(user.UserName); row.CreateCell(2).SetCellValue(user.Phone); row.CreateCell(3).SetCellValue(user.Status == 1 ? "启用" : "禁用"); row.CreateCell(4).SetCellValue(user.CreateTime.ToString("yyyy-MM-dd HH:mm")); } // 4. 输出到响应流,文件名做 URL 编码防止中文乱码 var ms = new MemoryStream(); workbook.Write(ms); ms.Seek(0, SeekOrigin.Begin); var response = new HttpResponseMessage(HttpStatusCode.OK); response.Content = new ByteArrayContent(ms.ToArray()); response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { FileNameStar = "用户列表_" + DateTime.Now.ToString("yyyyMMddHHmmss") + ".xlsx" }; return response; }

这里有两个关键参数要解释一下。FileNameStar是 RFC 2231 标准的文件名编码方式,支持中文文件名,直接赋中文给FileName会导致部分浏览器下载时乱码;SetCellValue传入数字就是数字单元格,传入字符串就是文本单元格,如果你是导出后还要二次加工的数据,注意类型别混。

前端触发下载最简单的方式是:

window.location.href = '/api/user/export?keyword=' + encodeURIComponent(keyword);

用window.location.href而不是 AJAX,是因为下载走的是浏览器原生行为,响应头里的Content-Disposition会让浏览器直接弹下载框,AJAX 拿不到这个响应。

4.2 文件上传:从接收文件到保存落盘

上传功能的思路也很直接:前端用 EasyUI 的 filebox 组件选文件,提交时用 FormData 以 multipart/form-data 格式 POST 到后端,后端用HttpPostedFileBase接收并保存到指定目录。

前端代码:

<input id="fileUpload" class="easyui-filebox" >function uploadFile() { var file = $('#fileUpload').filebox('getValue'); if (file === '') { $.messager.alert('提示', '请先选择文件', 'warning'); return; } var formData = new FormData(); formData.append('file', $('#fileUpload').filebox('files')[0]); $.ajax({ url: '/api/upload', type: 'post', data: formData, processData: false, // 告诉 jQuery 不要处理数据 contentType: false, // 让浏览器自动生成 multipart 边界 success: function(result) { if (result.success) { $.messager.alert('提示', '上传成功,路径:' + result.filePath, 'info'); } } }); }

这里有两个参数processData: false和contentType: false缺一不可。前者阻止 jQuery 把 FormData 转成查询字符串,后者阻止 jQuery 强行设置 Content-Type。如果漏了其中一个,后端收到的文件就是空的或者直接报错。后端接口:

[HttpPost] [Route("api/upload")] public IHttpActionResult Upload() { var httpRequest = HttpContext.Current.Request; if (httpRequest.Files.Count == 0) { return Json(new { success = false, msg = "未收到文件" }); } var file = httpRequest.Files[0]; string ext = Path.GetExtension(file.FileName).ToLower(); // 白名单校验,防止上传可执行文件 string[] allowedExt = { ".jpg", ".png", ".gif", ".xlsx", ".xls", ".doc", ".docx", ".pdf" }; if (!allowedExt.Contains(ext)) { return Json(new { success = false, msg = "不支持的文件类型:" + ext }); } string dateDir = DateTime.Now.ToString("yyyyMM"); string dirPath = Path.Combine(Server.MapPath("~/App_Data/UploadFiles"), dateDir); if (!Directory.Exists(dirPath)) { Directory.CreateDirectory(dirPath); } string fileName = Guid.NewGuid().ToString("N") + ext; // 重命名,避免文件名冲突 string fullPath = Path.Combine(dirPath, fileName); file.SaveAs(fullPath); string relativePath = "/App_Data/UploadFiles/" + dateDir + "/" + fileName; return Json(new { success = true, filePath = relativePath, fileName = file.FileName }); }

这里有一个常见的取舍:保存文件时用Guid.NewGuid()重命名原始文件,是为了避免多人上传同名文件相互覆盖。如果你需要保留原始文件名,可以存数据库时把file.FileName单独存一个字段,保盘文件名用 GUID。项目里通常会预留这个relativePath返回给前端,后面要展示图片或下载时直接拼接这个路径访问。

5. 避坑记录:EasyUI + C# 联调时的四个高频翻车现场

5.1 datagrid 请求失败时永远只弹一个 status 404

现象:页面打开后 datagrid 区域显示「Not Found」,F12 看到/api/user/list返回 404。

原因:Web API 路由配置没生效,最常见的是项目少了GlobalConfiguration.Configure(WebApiConfig.Register)这一步,或者控制器类没有打[RoutePrefix]特性。

解决:检查Global.asax的Application_Start中是否注册了 Web API 路由;在控制器上显式声明路由,比如[RoutePrefix("api/user")],这样list接口完整路径是/api/user/list,与前端url属性严格对应。这里最大的教训是——不要相信前端 URL,以 F12 网络面板里实际发出的请求地址为准。

5.2 EasyUI 表格时间列显示成一串数字

现象:CreateTime字段后端返回的是2025-06-18 14:30:00,渲染到 datagrid 里变成了1718704200000这种毫秒时间戳。

原因:EasyUI datagrid 默认把日期时间字段当字符串处理,如果后端返回的是 DateTime 对象序列化后的 ISO 字符串还好,但很多接口为了省事直接返回了DateTimeOffset,JSON 序列化后就成了时间戳数字。

解决:前端加formatter做格式化,最稳妥的方案是在columns定义里给该字段挂一个日期格式化函数:

function dateFormatter(value, row, index) { if (!value) return ''; var date = new Date(value); if (isNaN(date.getTime())) return value; // 兼容已经是字符串的情况 var yyyy = date.getFullYear(); var mm = String(date.getMonth() + 1).padStart(2, '0'); var dd = String(date.getDate()).padStart(2, '0'); return yyyy + '-' + mm + '-' + dd; }

5.3 编辑弹窗打开后,下拉框回填不上值

现象:用form('load')回填表单数据,文本框都有值,但 combo 下拉框显示的是 value 而不是 text。

原因:combo 控件的 value 和 text 是分开存储的,form('load')只会给隐藏的 value 赋值,不会触发下拉框的文本渲染。

解决:不要在form上直接依赖自动回填,单独给下拉框赋值:

$('#fm').form('load', userData); $('#statusCombo').combobox('setValue', userData.Status); // 如果还需要同步显示文本,用 setText $('#statusCombo').combobox('setText', userData.Status === 1 ? '启用' : '禁用');

这个问题排查了我一个下午才弄清楚,后来总结成规则:凡是easyui-combobox、easyui-datebox这类复合组件,回填一律手动设置,不用form('load')。

5.4 导出 Excel 时中文文件名变成下划线

现象:下载的 .xlsx 文件名变成_________.xlsx或直接乱码。

原因:Content-Disposition里的FileName属性在非 UTF-8 环境下会丢失中文,需要用FileNameStar字段做 RFC 2231 编码。

解决:上面导出代码里用的就是FileNameStar,如果遇到部分旧浏览器不兼容(极少数情况),可以退一步用HttpUtility.UrlEncode先把文件名编码再赋给FileName:

response.Content.Headers.ContentDisposition.FileName = HttpUtility.UrlEncode("用户列表.xlsx");

6. 二次开发改造:把写死的列表页改成配置驱动的通用模板

如果要给这套项目做一次实质性升级,我建议从「消灭重复代码」入手。现在每个业务模块都有自己的Index.cshtml、自己的xxx.js、自己的 Controller 方法,三张表就要写三套几乎一样的代码。一个更持久的做法是做一个通用的列表页面模板,用配置项驱动渲染。

我一般会设计一个基础的BaseListController,提供通用的列表、保存、删除接口,子类只需要告诉它「你管的是哪张表、主键是什么、哪些字段可查、哪些字段可导出」:

public abstract class BaseListController<T> : ApiController where T : class { protected abstract string TableName { get; } protected abstract string KeyField { get; } [HttpGet] [Route("list")] public IHttpActionResult GetList(int pageIndex, int pageSize, string keyword) { // 通过反射读取 T 的属性列表,动态拼接 SELECT var props = typeof(T).GetProperties().Select(p => p.Name); string columns = string.Join(",", props); string sql = $"SELECT {columns} FROM {TableName} WHERE 1=1"; // ... 后续逻辑与第 3 章一致 } }

前端同理,把columns数组抽出来放在一个pageConfig.js里,每个页面只维护自己的字段配置:

var userConfig = { title: '用户管理', columns: [[ { field: 'Id', title: 'ID', width: 60 }, { field: 'UserName', title: '姓名', width: 100 }, { field: 'Phone', title: '手机号', width: 120 }, { field: 'Status', title: '状态', width: 80, formatter: statusFormatter } ]] };

页面上通用的 datagrid 初始化代码读这个配置,然后一句话启动:

$('#dg').datagrid('load', '/api/user/list');

这样新来一个模块,开发人员只需要写泛型的实体类、继承 BaseListController、配一份前端字段配置,三张表和三十张表的开发成本几乎一样。这套改造做完以后,我才意识到最开始「一表一页面」的模式浪费了多少重复工时。从那以后,只要新起后台项目,我都会强制先把通用列表页搭起来,再往上堆具体业务模块,希望这些经验能帮你在自己的项目里少走一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询