ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WebForms+EasyUI全链路CRUD实战:Excel导出与文件上传

C# WebForms+EasyUI全链路CRUD实战:Excel导出与文件上传

简介:这是一套基于C#与EasyUI框架开发的Web后台管理功能完整示例,面向.NET初学者及Web前端交互实践者,解决MVC架构下常见CRUD操作与文件交互(上传/导出)的集成难题。项目采用VS2013开发环境,后端基于SQL Server 2014(兼容2005/2008,含分页适配说明),涵盖用户信息表的新增、修改、删除、分页展示、Excel批量导出及图片上传等核心功能,代码结构清晰,便于理解前后端数据交互逻辑与EasyUI组件调用方式。压缩包为RAR格式,共包含多个.cs业务逻辑文件、.aspx视图页、JavaScript脚本及数据库脚本等,整体大小28.54MB,适合作为教学参考或快速二次开发基础模板。已有219人学习下载,读者可直接运行调试,掌握EasyUI布局与增删改查全流程、SQL Server分页实现差异处理、NPOI导出Excel及FileUpload控件上传机制等实用技能。

1. 一个被低估的“老派”前端组合:C# + EasyUI 实现的 CRUD+Excel+上传全链路,至今在政企内网、制造业MES、教育教务系统里跑得比某些新框架还稳

你可能觉得 EasyUI 过时了,但某高校教务处的排课系统、某制造企业车间报工终端、某区级档案管理系统,至今还在用它——不是因为技术选型多先进,而是因为「改不动」+「够用」+「没人敢动」。这个 C#+EasyUI 的完整 CRUD 示例,不是玩具 Demo,它把新增、修改、删除、Excel 导出、文件上传这五项高频后台操作,全部串成一条可落地、可调试、可二次部署的完整链路。它不依赖 Vue/React 构建流程,不搞 Webpack 配置玄学,IIS 一放就能跑;后端是标准 WebForms 或 ASP.NET MVC(本项目为 WebForms),数据库直连 SQL Server,所有交互通过 JSON 接口完成。如果你正接手一个维护中的老系统,或者需要快速交付一个给非技术人员用的内部管理页,又或者想带新人理解「前后端分离雏形」怎么从零搭起——这个包就是你的后悔药。它没用 SignalR 做实时,没上 Redis 缓存,但每个按钮点击后,你能清晰看到请求发了什么、后端怎么解析、JSON 怎么序列化、Excel 文件怎么生成并流式返回——没有黑匣子,全是可打断点、可加日志、可改字段的实打实代码。


2. 项目结构与技术栈解剖:WebForms 为壳,EasyUI 为皮,ADO.NET 为骨,Excel 操作靠 NPOI 扛大旗

这个项目不是 SPA,也不是前后端完全分离架构,而是一个典型的「服务端渲染 + 前端组件增强」模式。它用 WebForms 的 .aspx 页面承载 UI,用 EasyUI 的 JS/CSS 提供表格、表单、弹窗等交互能力,后端逻辑写在 .aspx.cs 中,数据访问层直接调用 ADO.NET,Excel 导出和文件上传则分别由 NPOI 和标准HttpPostedFile处理。整个结构轻量、无构建步骤、无 node_modules 膨胀,适合部署在老旧 Windows Server + IIS 环境中。

2.1 目录结构与核心文件职责说明

项目解压后共 12 个关键文件,不含 bin 和 obj(这些需编译生成):

/Default.aspx ← 主入口页,含 EasyUI DataGrid + Toolbar /Default.aspx.cs ← 后端事件处理:绑定表格、响应增删改按钮 /EditForm.aspx ← 弹窗编辑页(新增/修改共用),含 EasyUI Form 表单 /EditForm.aspx.cs ← 表单提交逻辑:参数校验、SQL 插入/更新、返回 JSON /ExportHandler.ashx ← 通用 Excel 导出处理器(非 .aspx,性能更可控) /UploadHandler.ashx ← 文件上传处理器,支持单文件、限制类型/大小 /Scripts/easyui/ ← EasyUI v1.5.3 官方 JS/CSS(已精简,去除了不需要的插件) /Content/themes/ ← EasyUI 自定义主题(蓝色系,适配内网风格) /Models/Student.cs ← 简单实体类(示例为 Student,字段含 ID/Name/Class/Score) /Utils/ExcelHelper.cs ← NPOI 封装类:CreateExcelFromDataTable、WriteToResponse /Utils/DbHelper.cs ← ADO.NET 封装:ExecuteScalar、ExecuteNonQuery、FillDataTable /Web.config ← 关键配置:<httpRuntime maxRequestLength="10240" />(10MB 上传上限)

提示:所有 .aspx 页面均未启用 ViewState(EnableViewState="false"),避免大数据量表格回传时页面臃肿;EasyUI 版本锁定为 1.5.3(兼容 IE10+,且与 WebForms postback 兼容性最佳),不建议升级到 1.6+,否则datagrid的onSelect回调会与__EVENTTARGET冲突。

2.2 技术选型背后的务实逻辑

  • 为什么用 WebForms 而非 MVC?
    WebForms 的Page_Load+Button_Click模型,对刚从 WinForm 转来的开发者极友好;<%= %>表达式可直接嵌入 JS 变量(如url: '<%= ResolveUrl("~/EditForm.aspx") %>'),省去配置路由或全局变量注入;ViewState 关闭后,性能损耗几乎可忽略。

  • 为什么 EasyUI 不换 Element UI 或 LayUI?
    EasyUI 的datagrid支持remoteSort: true+remoteFilter: true,配合 WebForms 的Request["sort"]和Request["order"]可直接做服务端排序,无需重写分页逻辑;其form.load()方法能自动将 JSON 数据映射到表单字段,省去手写$("#name").val(data.name);更重要的是——它有官方中文文档,且错误提示明确(比如parser error一定是因为 JSON 格式错,而不是 Promise reject 黑盒)。

  • NPOI 为何比 EPPlus 更适合此场景?
    EPPlus 依赖 Office Open XML SDK,在 Server 2012 R2 上需额外安装运行时,且对.xlsx格式强绑定;NPOI 纯托管实现,HSSFWorkbook(xls)和XSSFWorkbook(xlsx)双模支持,导出时可动态判断:数据量 < 65536 行用 xls(兼容老 Excel),否则用 xlsx。本项目ExcelHelper.cs中已封装该判断逻辑。

2.3 后端数据流全景图:从按钮点击到数据库写入的七步链路

以「新增学生」为例,完整链路如下(每步均可打断点验证):

  1. 用户点击 toolbar 上的「新增」按钮 → 触发 EasyUI$('#dg').datagrid('addRow', {...})
  2. JS 调用$('#w').window('open')打开EditForm.aspx弹窗
  3. 弹窗加载时,JS 发起 GET 请求/EditForm.aspx?mode=add,后端Page_Load中根据Request["mode"]渲染空表单
  4. 用户填写后点击「保存」→ JS 序列化表单为 JSON,POST 到/EditForm.aspx(注意:不是 AJAX,是传统 form submit)
  5. EditForm.aspx.cs中protected void Page_Load(object sender, EventArgs e)捕获Request.HttpMethod == "POST",调用Request.Form["name"]等取值
  6. 校验通过后,拼接 SQL INSERT 语句,交由DbHelper.ExecuteNonQuery(sql, params)执行
  7. 成功后Response.Write("{\"success\":true,\"msg\":\"添加成功\"}"); Response.End();,前端 JS 解析 JSON 并关闭窗口

注意:所有后端接口均未使用[WebMethod]或ScriptManager,避免 WebForms 的__VIEWSTATE干扰 JSON 返回;Response.Write后必须跟Response.End(),否则 IIS 可能追加 HTML 尾部导致 JSON 解析失败。


3. 核心模块逐行拆解:DataGrid 初始化、弹窗表单、Excel 导出、文件上传四块硬骨头

3.1 DataGrid 初始化与 CRUD 交互:如何让 EasyUI 表格真正“活”起来

Default.aspx中的 DataGrid 初始化代码,表面看只是配置项堆砌,实则每项都对应一个易翻车点:

<table id="dg" class="easyui-datagrid" >function formatOperate(value, row, index) { return '<a href="javascript:void(0)" onclick="editRow(' + row.id + ')" class="easyui-linkbutton">function editRow(id) { $('#w').window('open'); // 打开编辑窗口 $('#fm').form('load', '/EditForm.aspx?mode=edit&id=' + id); // 加载数据,EasyUI 自动发起 GET }

3.2 弹窗表单(EditForm.aspx):复用同一页面实现新增/修改的底层机制

EditForm.aspx是本项目最精巧的设计点——它用同一个 .aspx 页面,通过 URL 参数mode=add或mode=edit&id=123切换行为,后端Page_Load中据此分支处理:

protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { string mode = Request.QueryString["mode"]; if (mode == "edit") { int id = Convert.ToInt32(Request.QueryString["id"]); var student = DbHelper.FillDataTable("SELECT * FROM Students WHERE ID=@id", new SqlParameter("@id", id)).Rows[0]; // 将数据转为 JSON,注入到页面 JS 变量中 ClientScript.RegisterStartupScript(this.GetType(), "initData", $"var initData = {JsonConvert.SerializeObject(student)};", true); } else if (mode == "add") { ClientScript.RegisterStartupScript(this.GetType(), "initData", "var initData = {};", true); } } }

前端 JS 在$(document).ready()中读取initData并调用$('#fm').form('load', initData),即可自动填充表单。这种「服务端预加载 + 前端自动绑定」模式,比纯 AJAX 加载更稳定(避免跨域、CORS、IE 兼容问题)。

表单提交逻辑在EditForm.aspx.cs的Page_Load中处理:

if (Request.HttpMethod == "POST") { string mode = Request.Form["mode"]; // 注意:这里取 Form 而非 QueryString string name = Request.Form["name"]; string classVal = Request.Form["class"]; string scoreStr = Request.Form["score"]; // 校验(实际项目应抽离为 Validator 类) if (string.IsNullOrWhiteSpace(name)) { WriteJsonError("姓名不能为空"); return; } decimal score; if (!decimal.TryParse(scoreStr, out score) || score < 0 || score > 100) { WriteJsonError("成绩必须是 0~100 的数字"); return; } if (mode == "add") { int newId = DbHelper.ExecuteNonQuery( "INSERT INTO Students (Name, Class, Score) VALUES (@name, @class, @score); SELECT SCOPE_IDENTITY();", new SqlParameter("@name", name), new SqlParameter("@class", classVal), new SqlParameter("@score", score)); WriteJsonSuccess($"添加成功,ID:{newId}"); } else // edit { int id = Convert.ToInt32(Request.Form["id"]); DbHelper.ExecuteNonQuery( "UPDATE Students SET Name=@name, Class=@class, Score=@score WHERE ID=@id", new SqlParameter("@name", name), new SqlParameter("@class", classVal), new SqlParameter("@score", score), new SqlParameter("@id", id)); WriteJsonSuccess("修改成功"); } }

WriteJsonSuccess是封装方法:

private void WriteJsonSuccess(string msg) { Response.Clear(); Response.ContentType = "application/json"; Response.Write($"{{\"success\":true,\"msg\":\"{msg}\"}}"); Response.End(); }

提示:Response.Clear()必须在Write前调用,否则可能混入 HTML 头部;Response.End()阻止后续生命周期执行,避免Page_Render输出多余内容。

3.3 Excel 导出(ExportHandler.ashx):NPOI 流式写入,内存占用低于 5MB 即可导出 10 万行

ExportHandler.ashx是一个通用 HTTP Handler,不走 Page 生命周期,性能更高。它接收GET请求参数(如?table=Students&where=Class='高三1班'),动态生成 Excel:

public void ProcessRequest(HttpContext context) { string table = context.Request.QueryString["table"]; string where = context.Request.QueryString["where"]; string sql = $"SELECT * FROM {table}"; if (!string.IsNullOrEmpty(where)) sql += $" WHERE {where}"; DataTable dt = DbHelper.FillDataTable(sql); // 根据行数选择工作簿类型 IWorkbook workbook = dt.Rows.Count < 65536 ? new HSSFWorkbook() // xls,兼容老版本 Excel : new XSSFWorkbook(); // xlsx ISheet sheet = workbook.CreateSheet("数据导出"); // 写入表头 IRow headerRow = sheet.CreateRow(0); for (int i = 0; i < dt.Columns.Count; i++) { ICell cell = headerRow.CreateCell(i); cell.SetCellValue(dt.Columns[i].ColumnName); } // 写入数据行(关键:避免创建过多 Cell 对象) for (int i = 0; i < dt.Rows.Count; i++) { IRow row = sheet.CreateRow(i + 1); for (int j = 0; j < dt.Columns.Count; j++) { ICell cell = row.CreateCell(j); object value = dt.Rows[i][j]; if (value == DBNull.Value) continue; // 自动识别类型写入,避免全部当字符串 if (value is DateTime) cell.SetCellValue((DateTime)value); else if (value is double || value is float || value is decimal) cell.SetCellValue(Convert.ToDouble(value)); else cell.SetCellValue(value.ToString()); } } // 设置响应头,触发浏览器下载 context.Response.Clear(); context.Response.ContentType = "application/vnd.ms-excel"; string fileName = $"{table}_{DateTime.Now:yyyyMMddHHmmss}.xls"; if (workbook is XSSFWorkbook) fileName = fileName.Replace(".xls", ".xlsx"); context.Response.AddHeader("Content-Disposition", $"attachment;filename={fileName}"); // 流式写入,不生成临时文件 using (MemoryStream ms = new MemoryStream()) { workbook.Write(ms); ms.WriteTo(context.Response.OutputStream); } }

注意:workbook.Write(ms)是关键,它把整个 Excel 结构写入内存流,再直接输出到Response.OutputStream,全程无磁盘 IO;若数据量极大(>50 万行),建议改用SXSSFWorkbook(NPOI 的流式写入版),但本项目未引入,因 90% 场景下 10 万行以内足够。

3.4 文件上传(UploadHandler.ashx):支持单文件、类型白名单、大小限制、安全重命名

UploadHandler.ashx处理 EasyUI 的filebox组件上传请求:

public void ProcessRequest(HttpContext context) { HttpPostedFile file = context.Request.Files["filebox"]; // EasyUI 默认字段名 if (file == null || file.ContentLength == 0) { WriteJsonError(context, "未选择文件"); return; } // 白名单校验(防止 .asp/.php 上传) string[] allowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".doc", ".docx", ".xls", ".xlsx" }; string ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowedExtensions.Contains(ext)) { WriteJsonError(context, $"不支持的文件类型:{ext},仅允许 {string.Join(",", allowedExtensions)}"); return; } // 大小限制(5MB) if (file.ContentLength > 5 * 1024 * 1024) { WriteJsonError(context, "文件大小不能超过 5MB"); return; } // 安全重命名:GUID + 原扩展名,防止中文/特殊字符 string newFileName = Guid.NewGuid().ToString("N") + ext; string uploadPath = context.Server.MapPath("~/Uploads/"); // 创建目录(如果不存在) if (!Directory.Exists(uploadPath)) Directory.CreateDirectory(uploadPath); string fullPath = Path.Combine(uploadPath, newFileName); file.SaveAs(fullPath); // 返回 JSON,包含访问 URL(供前端展示或存库) string fileUrl = $"~/Uploads/{newFileName}"; WriteJsonSuccess(context, new { url = fileUrl, fileName = file.FileName, size = file.ContentLength }); }

EasyUI 前端调用方式:

$('#filebox').filebox({ buttonText: '选择文件', buttonIcon: 'icon-upload', prompt: '请选择要上传的文件...', onChange: function (newValue, oldValue) { // 文件选择后自动上传 $(this).filebox('upload'); } });

提示:filebox的upload方法默认 POST 到当前页面,因此UploadHandler.ashx的 URL 需在 JS 中显式指定:$('#filebox').filebox({ uploader: '<%= ResolveUrl("~/UploadHandler.ashx") %>' });


4. 避坑指南:五个血泪经验总结,专治部署即崩、上传失败、导出乱码、表格不刷新

4.1 现象:点击「导出 Excel」后浏览器卡死,F12 查看 Network 显示 pending,最终超时

原因:ExportHandler.ashx中未设置Response.BufferOutput = false,导致大文件写入时 IIS 缓冲区满而阻塞;或workbook.Write(ms)后未调用ms.Position = 0,导致ms.WriteTo(...)从末尾开始写空数据。
解决:在ExportHandler.ashx开头添加context.Response.BufferOutput = false;;MemoryStream写入前加ms.Position = 0;。

4.2 现象:上传文件后,UploadHandler.ashx报错Object reference not set to an instance of an object,定位到file.ContentLength

原因:Web.config 中<httpRuntime maxRequestLength="10240" />单位是 KB,但executionTimeout默认 110 秒,大文件上传超时后Request.Files为空;或前端filebox未设置name="filebox",导致后端取不到。
解决:在web.config的<system.web>下增加:

<httpRuntime maxRequestLength="10240" executionTimeout="300" />

并在<system.webServer>下补充:

<security> <requestFiltering> <requestLimits maxAllowedContentLength="10485760" /> </requestFiltering> </security>

(注意:maxAllowedContentLength单位是字节,10MB = 10485760)

4.3 现象:DataGrid 分页后,点击「编辑」弹窗,表单数据为空

原因:EditForm.aspx中$('#fm').form('load', ...)加载的是绝对 URL(如/EditForm.aspx?mode=edit&id=123),但 EasyUI 的form.load()默认用 GET 请求,而 WebForms 的Page_Load中IsPostBack为 false 时才执行查询,但form.load()的 GET 请求不会触发Page_Load的IsPostBack==false分支(因为它是独立请求)。
解决:EditForm.aspx中移除if (!IsPostBack)包裹,改为始终执行数据加载逻辑;或改用$.getJSON手动取数据再form.load()。

4.4 现象:导出的 Excel 中中文显示为方框或乱码

原因:NPOI 创建单元格时未设置字体,Excel 默认用 Arial(不支持中文);或Response.ContentType错误设为"application/octet-stream"。
解决:在ExportHandler.ashx中添加字体设置:

IFont font = workbook.CreateFont(); font.FontName = "微软雅黑"; font.FontHeightInPoints = 10; ICellStyle style = workbook.CreateCellStyle(); style.SetFont(font); // 应用到所有单元格 for (int i = 0; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row != null) for (int j = 0; j < row.LastCellNum; j++) { ICell cell = row.GetCell(j); if (cell != null) cell.CellStyle = style; } }

4.5 现象:IIS 部署后,所有 EasyUI 样式丢失,表格变成原始 HTML 表格

原因:EasyUI 的 CSS/JS 文件路径错误,<link href="easyui/themes/default/easyui.css">中的easyui/是相对路径,但 IIS 应用程序池的物理路径与虚拟目录不一致;或Web.config中 MIME 类型未注册.js和.css。
解决:统一用<%= ResolveUrl("~/Scripts/easyui/themes/default/easyui.css") %>;检查Web.config的<system.webServer><staticContent>是否包含:

<mimeMap fileExtension=".js" mimeType="application/javascript" /> <mimeMap fileExtension=".css" mimeType="text/css" />

5. 二次开发实战:三步改造,让这个“老古董”支撑真实业务需求

5.1 改造一:为上传功能增加「多文件 + 进度条」支持(兼容 IE10+)

原filebox仅支持单文件,且无进度反馈。要升级为多文件,需替换为uploadify或原生input[type="file"]+FormData。我选择后者,因无需额外 JS 库:

  1. 前端替换filebox为原生 input
    在Default.aspx中:

    <input type="file" id="multiFile" multiple accept=".jpg,.png,.pdf" style="display:none;" /> <a href="javascript:void(0)" class="easyui-linkbutton">$('#multiFile').change(function () { var files = this.files; if (files.length === 0) return; var formData = new FormData(); for (var i = 0; i < files.length; i++) { formData.append("files", files[i]); // 注意 key 是 "files",非 "filebox" } $.ajax({ url: '<%= ResolveUrl("~/UploadHandler.ashx") %>', type: 'POST', data: formData, processData: false, contentType: false, xhr: function () { var xhr = new window.XMLHttpRequest(); xhr.upload.addEventListener("progress", function (evt) { if (evt.lengthComputable) { var percentComplete = evt.loaded / evt.total * 100; $('#progress').progressbar('setValue', parseInt(percentComplete)); } }, false); return xhr; }, success: function (data) { $.messager.alert('提示', '上传成功:' + data.files.length + ' 个文件'); } }); });
  2. 后端UploadHandler.ashx支持多文件
    修改ProcessRequest:

    // 原来取单文件 // HttpPostedFile file = context.Request.Files["filebox"]; // 改为取文件集合 HttpFileCollection files = context.Request.Files; List<string> uploadedUrls = new List<string>(); for (int i = 0; i < files.Count; i++) { HttpPostedFile file = files[i]; // ...(原有校验逻辑)... string newFileName = Guid.NewGuid().ToString("N") + ext; string fullPath = Path.Combine(uploadPath, newFileName); file.SaveAs(fullPath); uploadedUrls.Add($"~/Uploads/{newFileName}"); } // 返回 JSON 数组 WriteJsonSuccess(context, new { urls = uploadedUrls });

注意:IE10+ 支持XMLHttpRequest.upload,但 IE9 及以下不支持,若需兼容,应回退到 iframe 方案。从那以后我每次接到上传需求,都强制走一遍xhr.upload兼容性测试表(IE10/11、Chrome 60+、Firefox 50+),再决定是否引入 polyfill。

5.2 改造二:为 DataGrid 增加「服务端搜索」,避免前端 filter 性能崩盘

原项目搜索靠 EasyUI 的filter属性,数据全量加载后前端过滤,1 万行就卡顿。改为服务端搜索,只需两处改动:

  • 前端:在 toolbar 添加搜索框,绑定onSearch事件:

    <div id="toolbar"> <a href="javascript:void(0)" class="easyui-linkbutton">function doSearch() { var keyword = $('#searchBox').textbox('getValue'); $('#dg').datagrid('load', { keyword: keyword, page: 1 // 重置到第一页 }); }
  • 后端:修改Default.aspx.cs的GetStudents方法(假设是 WebMethod)或Default.aspx的Page_Load(若用 QueryString):

    // 若用 QueryString 方式(推荐,免去 ScriptManager 配置) string keyword = Request.QueryString["keyword"]; string sql = "SELECT * FROM Students"; List<SqlParameter> parameters = new List<SqlParameter>(); if (!string.IsNullOrEmpty(keyword)) { sql += " WHERE Name LIKE @keyword OR Class LIKE @keyword"; parameters.Add(new SqlParameter("@keyword", "%" + keyword + "%")); } DataTable dt = DbHelper.FillDataTable(sql, parameters.ToArray()); // ...(序列化返回 JSON)...

5.3 改造三:导出 Excel 时自动合并相同班级的单元格,提升报表可读性

业务方常提:“导出的 Excel,同班级学生希望合并‘班级’列”。NPOI 支持区域合并,只需在ExportHandler.ashx的数据写入循环后添加:

// 合并班级列(假设班级在第2列,索引为1) int classColIndex = 1; int startRow = 1; // 第一行是表头 string currentClass = ""; for (int i = 1; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; ICell cell = row.GetCell(classColIndex); string classValue = cell?.ToString() ?? ""; if (classValue != currentClass) { // 结束上一个合并区域 if (i > startRow + 1) // 至少两行才合并 { sheet.AddMergedRegion(new CellRangeAddress(startRow, i - 1, classColIndex, classColIndex)); } currentClass = classValue; startRow = i; } } // 处理最后一组 if (sheet.LastRowNum >= startRow && currentClass != "") { sheet.AddMergedRegion(new CellRangeAddress(startRow, sheet.LastRowNum, classColIndex, classColIndex)); }

提示:AddMergedRegion会覆盖被合并区域内的单元格内容,因此必须在所有数据写入完成后调用;合并后若需居中,需单独设置CellStyle的Alignment属性。从那以后我每次做导出增强,都先确认 NPOI 版本是否支持CellRangeAddress(1.2.3+ 均支持),再动手写合并逻辑。

希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进