
简介这是一份基于Asp.Net MVC与Layui框架实现增删改查功能的入门资源面向需要快速掌握Web开发基础流程的初学者。资源以完整项目工程为载体涵盖Model、View、Controller三层结构演示用户管理场景下的添加、删除、编辑、列表展示及AJAX交互并涉及分页、搜索、表单验证与基础安全处理。包体共687个文件以dll库、cs源码、cshtml视图、js脚本、css样式及config配置为主整体大小65.44MB解压后目录明确、可直接运行参考。目前已有493人学习浏览适合自学练习或作为课程配套案例使用。通过对照源码与运行效果读者能快速理解MVC请求处理流程掌握Layui表格、弹出层等组件与实际项目的结合方式为后续开发更完整的业务系统打下扎实基础。 搜索框里输入“资产管理系统 asp.net mvc web 免费 下载”的人不在少数我猜你们真正想要的不是一套多前沿的架构而是一个能快速搭出后台管理界面、页面又不会太丑的骨架。这篇文章就围绕这个需求来写用 Asp.Net MVC 做服务端用 Layui 做前端 UI实现最常见的增删改查CRUD。之所以把这两个东西配在一起是因为它们的分工足够干脆MVC 控制器负责接收请求、操作数据库、返回视图或 JSONLayui 负责表格渲染、弹窗、表单校验和提示。前后的交互边界就是一条 URL 和一组 JSON 字段理解成本远低于前后端分离那套。适合谁看适合已经会 C# 基础语法但没完整做过 MVC 项目的后端开发也适合只会 jQuery、想少写点拼接字符串的前端同学。下面我会按从后端到前端的顺序把整个 CRUD 拆开讲最后再聊两个我实际踩过的高频坑。1. 为什么这套组合值得用选型逻辑先讲明白很多人在做企业级后台时第一反应是 Bootstrap jQuery 手写表格和分页。真做起来你会发现带搜索条件的表格、分页页码、操作列的事件绑定都得自己维护列表一多代码就重复得让人头皮发麻。Layui 的 table 模块很大程度上就是为这种场景设计的——你只需要在页面上定义一个table标签然后在 JS 里传入 url 和列配置它自己会拉数据、渲染表头、算分页。尤其是table.reload这个接口配合搜索表单做条件查询时几乎是一行代码的事。Asp.Net MVC 这边Controller 的 Action 天然适合当 API 接口。同一个控制器里Index()返回列表视图GetList()返回 Layui table 需要的 JSONAdd()、Edit()、Delete()接收表单参数并处理数据变更路由规则清晰每个请求的入口和出口都看得见。对于后端同事来说比 WebForms 那套事件驱动直观得多。用这套组合还有一个实际好处团队里如果有人不熟悉前端工程化那一套Layui 2.x 这种“下载即用”的方式几乎没有门槛。不会 Node、不会 Webpack 也能正常开发。真要后面系统复杂了MVC Area 可以拆模块Layui 模块化也可以拆 JS 文件扩展路径是现成的。1.1 Layui 在这套组合里到底做了哪些事Layui 对增删改查的贡献主要集中在四个模块table负责列表渲染、分页、排序、列模板、工具事件监听。layer负责新增/编辑弹窗、删除确认提示框、操作成功后的 toast。form负责表单渲染、输入校验、提交事件拦截。laydate负责日期选择和面板定位后面专门讲那个坑。这四个模块组合起来的常规套路是列表页由 table 渲染操作列里放“编辑”“删除”按钮点新增或编辑时用 layer 打开一个 iframe 弹窗里面加载一个局部视图表单表单提交时用 form 模块拦截发 ajax 到 MVC 的 Action成功之后关闭弹窗并刷新表格。这个模式我用了很多项目稳定、好维护、新人上手快。1.2 MVC 端的约定要提前统一前后端联调时最容易混乱的就是 JSON 数据格式。Layui table 默认期望服务端返回这样一组字段{ code: 0, msg: , count: 120, data: [] }code表示业务状态码0 是成功count是总记录数data是当前页数据。这个格式在 MVC 控制器 Action 里很好构造return Json(new { code 0, msg , count total, data list }, JsonRequestBehavior.AllowGet);这里有个很容易忽略的细节ASP.NET MVC 默认的Json()方法在 GET 请求下会报错必须带上JsonRequestBehavior.AllowGet否则 Layui table 发起 GET 请求时会直接拿不到数据。项目里可以在具体 Action 上带也可以统一封装一个JsonNetResult不过小项目直接每次都写清楚就行。还有一个是日期格式。MVC 自带序列化器会把DateTime序列化成/Date(1700000000000)/这种形式Layui table 拿到后显示出来就是一行莫名其妙的字符。我通常直接在构造返回对象时把日期字段转成字符串PurchaseDate x.PurchaseDate?.ToString(yyyy-MM-dd),这样前端不用做二次处理展示效果也好控制。这个经验放前面说是因为后面列表页代码会大量用到这个模式。2. 先把后端跑通数据表设计与数据访问层前面把选型原因讲完了这节开始动手。我们以资产管理场景为例建立一张Asset表字段包括资产编码、名称、分类、购置日期、价格、状态。这个例子并不复杂但它能完整覆盖增删改查的所有动作。2.1 建表语句和实体模型SQL Server 建表语句如下CREATE TABLE Asset ( Id INT IDENTITY PRIMARY KEY, AssetCode NVARCHAR(50) NOT NULL, AssetName NVARCHAR(100) NOT NULL, Category NVARCHAR(50) NULL, PurchaseDate DATETIME NULL, Price DECIMAL(18,2) DEFAULT 0, Status INT DEFAULT 1 );对应的 C# 实体类public class Asset { public int Id { get; set; } public string AssetCode { get; set; } public string AssetName { get; set; } public string Category { get; set; } public DateTime? PurchaseDate { get; set; } public decimal Price { get; set; } public int Status { get; set; } }实际项目里我建议在仓储层使用 Dapper因为 Dapper 的查询结果可以直接映射到上面的实体类写起 SQL 来也灵活。相比 EF 在简单项目里的上下文管理Dapper 更加轻量也方便优化复杂查询。2.2 用 Dapper 封装基础 CRUD仓储层代码public class AssetRepository { private readonly string _connStr ConfigurationManager.ConnectionStrings[DefaultConnection].ConnectionString; public ListAsset GetList(string keyword, int page, int limit, out int total) { using (var conn new SqlConnection(_connStr)) { var where WHERE 11 ; var param new DynamicParameters(); if (!string.IsNullOrEmpty(keyword)) { where AND (AssetCode LIKE kw OR AssetName LIKE kw) ; param.Add(kw, % keyword %); } total conn.ExecuteScalarint( SELECT COUNT(*) FROM Asset where, param); string sql SELECT * FROM Asset where ORDER BY Id DESC OFFSET offset ROWS FETCH NEXT limit ROWS ONLY; var list conn.QueryAsset(sql, new { offset (page - 1) * limit, limit limit }).ToList(); return list; } } public Asset GetById(int id) { using (var conn new SqlConnection(_connStr)) { return conn.QueryFirstOrDefaultAsset( SELECT * FROM Asset WHERE Id id, new { id }); } } public int Add(Asset asset) { using (var conn new SqlConnection(_connStr)) { string sql INSERT INTO Asset(AssetCode, AssetName, Category, PurchaseDate, Price, Status) VALUES(AssetCode, AssetName, Category, PurchaseDate, Price, Status); SELECT CAST(SCOPE_IDENTITY() AS INT);; return conn.ExecuteScalarint(sql, asset); } } public int Update(Asset asset) { using (var conn new SqlConnection(_connStr)) { string sql UPDATE Asset SET AssetCode AssetCode, AssetName AssetName, Category Category, PurchaseDate PurchaseDate, Price Price, Status Status WHERE Id Id;; return conn.Execute(sql, asset); } } public int Delete(int id) { using (var conn new SqlConnection(_connStr)) { return conn.Execute(DELETE FROM Asset WHERE Id id, new { id }); } } }分页这里用了 SQL Server 2012 开始支持的OFFSET...FETCH如果你们还跑在 2008 上改成ROW_NUMBER()那套写法就行。COUNT(*)单独查一次是为了返回总数给 Layui 分页组件这个步骤不能省否则分页条不会出现。2.3 控制器 Action 的职责划分控制器按“一个动作对应一个操作”来组织public class AssetController : Controller { private readonly AssetRepository _repo new AssetRepository(); public ActionResult Index() { return View(); } public JsonResult GetList(string keyword, int page 1, int limit 10) { int total; var list _repo.GetList(keyword, page, limit, out total); var result list.Select(x new { x.Id, x.AssetCode, x.AssetName, x.Category, PurchaseDate x.PurchaseDate?.ToString(yyyy-MM-dd), x.Price, x.Status }); return Json(new { code 0, msg , count total, data result }, JsonRequestBehavior.AllowGet); } [HttpPost] public JsonResult Add(Asset asset) { try { _repo.Add(asset); return Json(new { code 0, msg 新增成功 }); } catch (Exception ex) { return Json(new { code 500, msg ex.Message }); } } [HttpPost] public JsonResult Edit(Asset asset) { try { _repo.Update(asset); return Json(new { code 0, msg 保存成功 }); } catch (Exception ex) { return Json(new { code 500, msg ex.Message }); } } [HttpPost] public JsonResult Delete(int id) { try { _repo.Delete(id); return Json(new { code 0, msg 删除成功 }); } catch (Exception ex) { return Json(new { code 500, msg ex.Message }); } } }注意GetList和增删改查的动作分开一个是 GET 返回表格数据另外三种都用[HttpPost]目的很直接——防止查询操作意外触发数据变更。这里每个 Action 都尽量小只做一件事后续加权限拦截、日志记录都会容易很多。3. 页面列表接入 Layui table后端接口有了下面到前端列表页。这里的核心是配置好table.render让表格自动从/Asset/GetList拉取数据并渲染分页。3.1 表格渲染的最小配置页面先引入 Layui 的 CSS 和 JS然后放一个表格容器和搜索栏div classlayui-form stylepadding: 15px; div classlayui-form-item div classlayui-inline input typetext idkeyword placeholder资产编码/名称 classlayui-input stylewidth: 220px; / /div div classlayui-inline button classlayui-btn idbtnSearch搜索/button button classlayui-btn layui-btn-primary idbtnReset重置/button /div div classlayui-inline stylefloat: right; button classlayui-btn layui-btn-normal idbtnAdd新增资产/button /div /div /div table idassetTable lay-filterassetTable/tableJS 初始化layui.use([table, layer, form, laydate], function () { var table layui.table, layer layui.layer, form layui.form; table.render({ elem: #assetTable, url: /Asset/GetList, page: true, cols: [[ { field: Id, title: ID, width: 60 }, { field: AssetCode, title: 资产编码, width: 120 }, { field: AssetName, title: 资产名称, minWidth: 150 }, { field: Category, title: 分类, width: 110 }, { field: PurchaseDate, title: 购置日期, width: 120 }, { field: Price, title: 价格, width: 100 }, { field: Status, title: 状态, width: 90, templet: #statusTpl }, { title: 操作, width: 150, toolbar: #toolbarTpl, fixed: right } ]], limit: 10, limits: [10, 20, 30] }); });这里有几处设计细节值得说。page: true开启分页后Layui 会自动向服务端传page和limit两个参数正好对应控制器里GetList的形参。toolbar: #toolbarTpl指向一段自定义模板里面放“编辑”“删除”按钮。fixed: right让操作列固定在表格右侧数据横向滚动时不会被挤走。3.2 用 templet 格式化状态列状态字段我的实体里存的是 int页面上要显示成“在用/闲置”这种文本可以用两种方式。一种是在后端直接转好字符串另一种是前端用模板。我偏好前端用模板因为保存回显时还需要原始 int 值script typetext/html idstatusTpl {{# if(d.Status 1){ }} span classlayui-badge layui-bg-green在用/span {{# } else { }} span classlayui-badge layui-bg-gray闲置/span {{# } }} /scriptLayui table 的模板语法用{{# }}执行 JS 逻辑d代表当前行数据。这样表格列上就显示徽章样式状态一眼能分辨。操作列模板script typetext/html idtoolbarTpl a classlayui-btn layui-btn-xs lay-eventedit编辑/a a classlayui-btn layui-btn-danger layui-btn-xs lay-eventdel删除/a /script关键写法是每个按钮上的lay-event属性它跟后面要讲的table.on(tool(assetTable))事件监听一一对应。3.3 控制器的 JSON 格式要和 table 字段完全一致再强调一次data数组里每个对象的字段名必须和cols里的field一一对应。比如我在后端用匿名对象拼了PurchaseDate字符串那前端列上就写{ field: PurchaseDate }。如果后端返回的是 PascalCase 属性名前端 field 也要跟着写PurchaseDate而不是随手改成驼峰。新手第一次对接十有八九是这里对不上导致表格空白。4. 新增、编辑、删除的完整交互闭环列表只是展示真正的业务动作在增删改查。这一部分的套路是新增和编辑共用一个局部视图表单用 layer 的 iframe 弹窗打开删除用 layer.confirm 二次确认所有操作成功后统一table.reload刷新。4.1 新增弹窗 表单 控制器 Insert给“新增资产”按钮绑定打开弹窗事件$(#btnAdd).on(click, function () { layer.open({ type: 2, title: 新增资产, area: [600px, 500px], content: /Asset/AddForm }); });type: 2表示 iframe 弹窗content指向 MVC 的一个 Action专门返回局部视图。控制器加两个方法public ActionResult AddForm() { return PartialView(_Form, new Asset()); } public ActionResult EditForm(int id) { var asset _repo.GetById(id); return PartialView(_Form, asset); }局部视图_Form.cshtml里用普通 form 承载字段不需要using (Html.BeginForm())因为后面会用 ajax 提交model Asset form classlayui-form lay-filterassetForm input typehidden nameId valueModel.Id / div classlayui-form-item label classlayui-form-label资产编码/label div classlayui-input-block input typetext nameAssetCode valueModel.AssetCode placeholder请输入资产编码 classlayui-input lay-verifyrequired / /div /div div classlayui-form-item label classlayui-form-label资产名称/label div classlayui-input-block input typetext nameAssetName valueModel.AssetName placeholder请输入资产名称 classlayui-input lay-verifyrequired / /div /div div classlayui-form-item label classlayui-form-label分类/label div classlayui-input-block input typetext nameCategory valueModel.Category classlayui-input / /div /div div classlayui-form-item label classlayui-form-label购置日期/label div classlayui-input-block input typetext namePurchaseDate idpurchaseDate value(Model.PurchaseDate?.ToString(yyyy-MM-dd)) classlayui-input autocompleteoff / /div /div div classlayui-form-item label classlayui-form-label价格/label div classlayui-input-block input typetext namePrice valueModel.Price classlayui-input lay-verifynumber / /div /div div classlayui-form-item label classlayui-form-label状态/label div classlayui-input-block select nameStatus option value1 selected(Model.Status 1)在用/option option value0 selected(Model.Status 0)闲置/option /select /div /div div classlayui-form-item styletext-align: center; margin-top: 20px; button classlayui-btn lay-submit lay-filtersaveAsset保存/button button typereset classlayui-btn layui-btn-primary重置/button /div /form表单提交事件在弹窗页面的脚本里写layui.use([form, layer, laydate], function () { var form layui.form, layer layui.layer; laydate.render({ elem: #purchaseDate, position: fixed }); form.on(submit(saveAsset), function (data) { var field data.field; var url field.Id ? /Asset/Edit : /Asset/Add; $.post(url, field, function (res) { if (res.code 0) { parent.layer.msg(res.msg); var index parent.layer.getFrameIndex(window.name); parent.layer.close(index); parent.layui.table.reload(assetTable); } else { layer.msg(res.msg, { icon: 2 }); } }, json); return false; }); });注意url的判断逻辑表单里隐藏域Id有值时走 Edit没有值时走 Add。新增和编辑共用一个表单视图提交逻辑也复用少写很多重复代码。4.2 编辑数据回填和局部视图的关系编辑弹窗和新增几乎一样只是打开时带了 Id。控制器EditForm(int id)根据 Id 查库并填充到ModelRazor 在服务端把字段值渲染进 value 属性。这里有个隐藏动作要注意局部视图渲染后Layui 的 select 控件不会自动套用原生样子必须在页面脚本里调用一次form.render()否则下拉框显示的值可能不对提交的时候拿到的值也有问题。我一般直接在文档加载后执行$(function () { form.render(); });弹窗页面虽然内容简单但这个form.render()不能省不然 select 美化样式不生效状态字段会莫名其妙丢值或者多值。4.3 删除二次确认和删除后的体验删除按钮的事件在列表页监听table.on(tool(assetTable), function (obj) { var data obj.data; if (obj.event edit) { // 打开编辑弹窗 layer.open({ type: 2, title: 编辑资产, area: [600px, 500px], content: /Asset/EditForm/ data.Id }); } else if (obj.event del) { layer.confirm(确定删除该资产吗删除后不可恢复。, { icon: 3 }, function (index) { $.post(/Asset/Delete, { id: data.Id }, function (res) { if (res.code 0) { layer.msg(res.msg); table.reload(assetTable); } else { layer.msg(res.msg, { icon: 2 }); } }, json); layer.close(index); }); } });这里有个交互细节删除成功后我返回layer.msg提示但不用关闭确认弹窗的index变量去二次关闭。因为layer.confirm的回调里已经执行了layer.close(index)如果删除成功后再弹了layer.msg两者是独立 DOM不会重叠。很多新手在这里会写错把layer.close(index)放在 ajax 回调里导致确认框一直不消失或者删除成功后又手动reload但忘了layer.close(index)确认框残留。正确的顺序是确认弹窗关闭和业务请求没有强耦合先关闭确认框再处理后续提示和刷新。5. 条件查询怎么和表格联动搜索功能看着简单但“带关键词的分页查询”是最容易踩坑的地方。核心是点击搜索后要把输入框里的参数传给table.reload并且让分页回到第一页。5.1 搜索按钮和重置按钮的绑定列表页脚本里加$(#btnSearch).on(click, function () { var keyword $(#keyword).val().trim(); table.reload(assetTable, { where: { keyword: keyword }, page: { curr: 1 } }); }); $(#btnReset).on(click, function () { $(#keyword).val(); table.reload(assetTable, { where: { keyword: }, page: { curr: 1 } }); });where里的键名keyword必须和控制器GetList的参数名一致MVC 默认模型绑定器才能把值传进去。page: { curr: 1 }表示每次搜索都从第 1 页开始否则你搜索时还停留在第 5 页Layui 会带着旧的page5请求而查询条件已经变了用户会看到“结果在第 5 页却只有 1 条数据”的诡异现象。5.2 reload 的请求参数重名问题实际项目中搜索条件往往不止一个关键词比如分类和日期范围。这时where对象会变成这样table.reload(assetTable, { where: { keyword: $(#keyword).val(), category: $(#category).val() }, page: { curr: 1 } });对应的控制器GetList也要加参数。这里最隐蔽的坑是Layui 组件如果启用了autoSort排序参数field和order也会出现在请求里这时候服务端分页字段名不能和排序字段名冲突。我们这里没做排序所以暂时不需要关注。但如果哪天你看到点击表头后搜索词失效了多半是where参数被 table 内置参数占了名字。6. 实际操作里踩过的两个高频坑这一节是根据我接触到的真实问题总结的也是搜索热词中出现频率最高的两件事。6.1 Layui table 单个列能加点击事件吗能加但不要用最直觉的写法——在 templet 拼接 HTML 时顺手写onclickxxx()。Layui table 的表格是动态渲染的每次分页、重载都会重新生成 DOM全局函数在页面刷新和弹窗嵌套时很容易失效而且变量作用域混乱。正确的做法是利用lay-eventtool事件的委托机制。比如想让“资产编码”这一列可点击并弹出一个详情窗口先给列加一个自定义 templatecols: [[ { field: AssetCode, title: 资产编码, templet: function (d) { return a classlink lay-eventassetDetail d.AssetCode /a; } } ]]然后在table.on(tool(assetTable), ...)里监听table.on(tool(assetTable), function (obj) { if (obj.event assetDetail) { layer.open({ type: 2, title: 资产详情, area: [600px, 400px], content: /Asset/Detail/ obj.data.Id }); } });事件名称assetDetail是和 template 里的lay-event对应的。因为监听是绑定在 table 容器上的不管表格怎么重载只要容器还在点击就能触发避免了你手动绑定 click 事件后刷新列表失效的坑。这个方法同样适用于列里有下载链接、查看图片、复制编号等场景。6.2 日期控件日历面板位置偏移怎么调使用laydate.render渲染日期输入框后点击输入框弹出的日历面板位置不对是后台管理系统里非常常见的问题。通常在两种场景出现一是页面本身很长出现了滚动条二是日历控件被嵌在 layer 弹窗、tab 切换或带 overflow 属性的容器里。基本修复思路是明确设置定位方式。我的常规做法是laydate.render({ elem: #purchaseDate, position: fixed, trigger: click });position: fixed表示日历面板相对浏览器窗口固定定位页面滚动时不会跟着输入框上下错位。如果你的页面里日历面板不是被滚动条影响而是被一个有position: relativeoverflow: hidden的容器裁切了那 fixed 偶尔也不管用因为某些浏览器里 transform 和 filter 属性会改变 fixed 的参照系。这时候可以退一步用默认的 absolute 定位并确保日期输入框的父容器没有被 overflow 裁剪。还有一个场景是日期控件在 iframe 弹窗里。如果 iframe 高度不够日历面板可能被弹窗底部切掉一半。这时先把弹窗area的高度调大一点比如 500px 以上再配position: fixed实测基本能解决。要是弹窗内部还有非常复杂的排版直接把日期组件放到弹窗底部给日历面板留足显示空间。7. 最后再分享一点个人习惯整套 CRUD 搭完之后我一般还会做两件事。第一把所有 Action 的返回格式统一成{ code, msg, data }的结构前端 js 只看 code 做分支后面接权限、接审批流都方便。第二列表页的搜索和表格 reload 逻辑单独抽一个公共函数因为增删改查多个模块的搜索逻辑几乎一样能少写就少写。另外提醒一句这套组合虽简单但别在真实生产环境中裸奔。表单参数校验、SQL 注入、xss 输出转义该做的还是得做。Dapper 的参数化查询已经帮我们挡掉了一部分注入风险但前端传进来的字符串长度、类型后端一定要二次校验不能完全信任 lay-verify 的那种纯前端校验。把这些细节补齐一个小型资产管理系统或后台管理脚手架就能真正拿去给业务部门用了。本文还有配套的精品资源点击获取