简介:一份将ASP.NET MVC与Layui结合起来的增删改查(CRUD)入门示例,主要面向刚接触Web开发的初学者,也适合需要系统回顾MVC分层与前端交互的开发者。资源包共687个文件,压缩后大小约65.44MB,文件类型以dll程序集、cshtml视图页面、cs控制器与模型源码、js与css前端资源为主,同时包含config配置、sln解决方案、sqlite数据库等项目依赖文件,结构完整,便于直接运行和对照学习。目前已有493人学习下载。示例在基础的用户增删改查之外,加入了分页、排序、搜索、AJAX异步数据交互、表单校验、异常处理等常见功能,并通过Entity Framework简化数据库操作,配合Layui的表格、弹出层组件实现界面展示,可帮助学习者理清从数据模型、控制器动作到视图渲染的完整开发流程,同时关注输入验证与SQL注入等数据安全问题,是入门Web全栈开发很实用的参考案例。 做企业级管理系统这些年,我发现在国内中小型项目中,Asp.Net MVC配合Layui的组合依然非常能打。很多人一开始纠结要不要上前后端分离、要不要用Element Plus,结果项目排期一压,发现Layui这种“服务端渲染+前端组件库”的模式反而是最快出活儿的方案。这篇文章我就拿一个资产管理系统的核心功能——增删改查来做完整拆解,从数据库设计、后端Action编写到Layui表格渲染、弹窗表单提交,全程带你走一遍,顺便把我踩过的坑也一并交代清楚。
不管你是有一定基础的.NET开发,还是刚接触MVC的新手,这篇都适合你照着敲。我尽量少讲虚的,全是能直接落地的代码和配置。
1. 项目整体设计与选型分析
这个项目从需求层面看非常简单:维护资产信息,包含新增、编辑、删除、查询列表。但越是简单的东西,越能看出选型和架构上的门道。
1.1 为什么选Asp.Net MVC而不是前后端分离
现在的技术圈风向确实偏向Vue/React + WebApi,但落到实际业务场景,如果你的团队本来就没有专门的前端工程师,或者项目只需要在内部局域网运行,那么MVC这种服务端渲染模式反而更合适。理由有三点:
- 开发效率高:一个页面从后端到前端全在一个项目里,不用维护两套代码仓库,不需要处理跨域和Token认证逻辑。
- 学习曲线低:新来的同事只要会C#基础,拖过WebForm,基本一天就能上手MVC。
- 部署成本低:发布后直接挂IIS,不用装Node环境,不用配置Nginx反向代理。
Layui的价值在于它不像Bootstrap那样只提供CSS样式,而是把表格、分页、弹窗、日期选择这些高频组件全部封装好了,直接调用table.render()、layer.open()就能出一个漂亮的后台界面,省去大量手写DOM事件的功夫。而且Layui的文档是中文的,遇到问题查起来非常快,这在国内开发环境里是实打实的优势。
1.2 项目分层与职责边界
虽然是一个简单的增删改查,我还是建议按MVC的分层思想把代码理顺:
| 层级 | 职责 | 本项目对应的内容 |
|---|---|---|
| Model层 | 数据结构定义 | Asset实体类、数据库上下文 |
| View层 | 页面展示与用户交互 | Razor视图 + Layui组件 |
| Controller层 | 请求路由与业务调度 | AssetController中的Action方法 |
| Service层(可选) | 业务逻辑封装 | 资产增删改查的方法封装 |
我见过太多人把所有代码全塞进Controller,一个Action写两百行,增删改查看着能用,但后续加个字段都提心吊胆。这个项目虽然小,我还是单独拆了一个AssetService来放数据操作逻辑,Controller只负责接收参数和返回JsonResult,这样后期扩展统计报表之类的接口时,服务层可以直接复用,不用动页面代码。
2. 环境搭建与数据库准备
既然标题写的是Asp.Net MVC,那默认你已经装了Visual Studio。我这里用的是VS2019 + .NET Framework 4.7.2,这套组合在老旧系统和新的WinServer上都能稳定运行。
2.1 创建项目并引入Layui
打开VS,新建项目,选择“ASP.NET Web应用程序(.NET Framework)”,模板选“MVC”,身份验证这里可以直接选“不进行身份验证”,因为我们后面通过Controller控制访问权限。
然后去Layui官网下载最新版的压缩包,解压后把layui文件夹整个拷到项目的Scripts目录下。我建议用本地文件,别用CDN。原因很现实:内网系统经常没有外网权限,你线上CDN加载速度可能不错,但客户的内网环境基本访问不了外网。引用方式在布局页_Layout.cshtml里加上:
<link href="~/Scripts/layui/css/layui.css" rel="stylesheet" /> <script src="~/Scripts/layui/layui.js"></script>注意Layui的模块化加载机制:页面里通过layui.use(['table', 'layer', 'form'], function () { ... })来加载需要的模块。不是一次性把整个库暴露到全局,这也让它在老浏览器上不会爆出一堆兼容性错误。
2.2 创建资产表与实体模型
数据库我直接用SQL Server,表结构按最常规的方式设计:
CREATE TABLE [dbo].[Asset] ( [Id] INT IDENTITY (1, 1) NOT NULL, [Name] NVARCHAR (50) NOT NULL, [Category] NVARCHAR (20) NULL, [Status] INT DEFAULT ((0)) NOT NULL, [PurchaseDate] DATETIME NULL, [Remark] NVARCHAR (200) NULL, PRIMARY KEY CLUSTERED ([Id] ASC) );字段含义很直白:Name资产名称,Category分类(办公设备、IT耗材、家具等),Status状态,0表示在库、1表示已领用、2表示维修中,PurchaseDate购入日期,Remark备注。
对应地在项目里建一个实体类,然后我用Entity Framework的Database First模式,直接通过VS的“添加→ADO.NET实体数据模型”从数据库反向生成DbContext。如果你不熟悉EF,直接用SqlConnection写SQL也行,但EF的语法简洁,且自带参数化查询,能有效防止注入,我还是推荐用它。
3. 查询功能:Layui Table与后端接口对接
查询是列表页的命脉,也是Layui表格组件最核心的使用场景。页面加载时向服务器发请求拉取数据,前端做分页展示。
3.1 后端分页接口
Layui的table组件在请求时会自动带上两个关键参数:page当前页码,limit每页条数。所以后端接收参数很固定:
public ActionResult Page(string keyword, int page = 1, int limit = 10) { var service = new AssetService(); var list = service.GetPageList(keyword, page, limit); var count = service.GetCount(keyword); return Json(new { code = 0, msg = "", count, data = list }, JsonRequestBehavior.AllowGet); }这里有个新人最容易踩的坑:MVC的Json()方法默认不允许GET请求返回JSON,必须显式加上JsonRequestBehavior.AllowGet,否则浏览器控制台会报“此请求已被阻止”。Layui Table默认就是用AJAX的GET方式拉数据的,所以这个参数不能省。
服务层里的分页查询也很常规,注意EF的IQueryable延迟执行的特性:
public List<Asset> GetPageList(string keyword, int page, int limit) { using (var db = new AssetDbContext()) { var query = db.Assets.AsQueryable(); if (!string.IsNullOrEmpty(keyword)) query = query.Where(a => a.Name.Contains(keyword)); return query.OrderByDescending(a => a.Id) .Skip((page - 1) * limit) .Take(limit) .ToList(); } }AsQueryable()是懒加载查询的关键,它允许我们先拼接Where条件,再在真正ToList()时才生成SQL去数据库执行。
3.2 前端表格渲染
前端页面里,我建一个Asset/Index.cshtml视图,里面只放一个<table id="assetTable"></table>,然后在script里做渲染:
layui.use(['table', 'layer', 'form'], function () { var table = layui.table; table.render({ elem: '#assetTable', url: '/Asset/Page', page: true, cols: [[ { field: 'Id', title: '编号', width: 80, sort: true }, { field: 'Name', title: '资产名称', minWidth: 120 }, { field: 'Category', title: '分类', width: 100 }, { field: 'Status', title: '状态', width: 100, templet: '#statusTpl' }, { field: 'PurchaseDate', title: '购入日期', width: 120 }, { fixed: 'right', title: '操作', toolbar: '#rowBar', width: 180 } ]], parseData: function (res) { return { "code": res.code, "msg": res.msg, "count": res.count, "data": res.data }; } }); });注意两点:
一是Layui对接口返回格式有约定,必须是{code: 0, msg: "", count: 100, data: []}这种结构,缺一个字段表格都渲染不出来。如果你的返回格式对不上,可以在parseData里做转换兜底。
二是状态字段的显示。数据库里存的是0和1,页面上直接显示数字非常难看。我用了templet模板,在上面的html里定义:
<script type="text/html" id="statusTpl"> {{# if (d.Status === 0) { }} <span style="color:green;">在库</span> {{# } else if (d.Status === 1) { }} <span style="color:orange;">已领用</span> {{# } else { }} <span style="color:red;">维修中</span> {{# } }} </script>Layui的模板语法是{{# ... }}里面跑JavaScript,用起来非常灵活,比在Controller里拼字符串再返回优雅得多。
4. 新增与编辑功能的完整实现
增和改在本质上是一件事:向服务器提交一组字段,唯一区别是主键Id是否存在。所以我习惯把这两个操作合并成一个Save接口来实现,前端弹窗共用同一个表单。
4.1 弹窗如何承载表单
Layui的layer.open弹窗支持两种常见方式:一种是用type: 2加载一个iframe子页面,另一种是type: 1直接把页面上的DOM作为弹窗内容。我推荐后者,因为不用多写一个完整视图,传值和回填都简单。
在Index.cshtml里用script标签包住表单模板,设置display:none防止页面加载时就显示出来:
<script type="text/html" id="editFormWrap"> <form class="layui-form" lay-filter="assetForm" style="padding: 20px;"> <input type="hidden" name="Id" id="editId" /> <div class="layui-form-item"> <label class="layui-form-label">资产名称</label> <div class="layui-input-block"> <input type="text" name="Name" required lay-verify="required" placeholder="请输入名称" class="layui-input" /> </div> </div> <div class="layui-form-item"> <label class="layui-form-label">分类</label> <div class="layui-input-block"> <input type="text" name="Category" class="layui-input" /> </div> </div> <div class="layui-form-item"> <label class="layui-form-label">状态</label> <div class="layui-input-block"> <select name="Status"> <option value="0">在库</option> <option value="1">已领用</option> <option value="2">维修中</option> </select> </div> </div> <div class="layui-form-item"> <label class="layui-form-label">购入日期</label> <div class="layui-input-block"> <input type="text" name="PurchaseDate" id="purchaseDate" class="layui-input" /> </div> </div> <div class="layui-form-item"> <label class="layui-form-label">备注</label> <div class="layui-input-block"> <textarea name="Remark" class="layui-textarea"></textarea> </div> </div> <div class="layui-form-item" style="text-align:center;"> <button class="layui-btn" lay-submit lay-filter="assetSubmit">保存</button> </div> </form> </script>操作列上加两个按钮,通过layui-event绑定点击:
<script type="text/html" id="rowBar"> <a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a> <a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a> </script>表格的table.on('tool(assetTable)')事件里根据obj.event来判断是编辑还是删除。
4.2 新增和编辑的前端处理逻辑
打开弹窗的逻辑比较简单:新增时直接弹窗,编辑时先用ajax查详情回填到表单里。关键代码如下:
var active = { add: function () { $('#editId').val(0); $('#editFormWrap form')[0].reset(); // 清空表单 layer.open({ type: 1, title: '新增资产', area: ['680px', '520px'], content: $('#editFormWrap').html(), success: function (layero) { layui.form.render(); // 重新渲染下拉框等 layui.laydate.render({ elem: layero.find('#purchaseDate')[0] }); } }); }, edit: function (obj) { layer.open({ type: 1, title: '编辑资产', area: ['680px', '520px'], content: $('#editFormWrap').html(), success: function (layero) { var form = layui.form; $.get('/Asset/Detail', { id: obj.data.Id }, function (res) { form.val('assetForm', res.data); // 批量赋值 layui.laydate.render({ elem: layero.find('#purchaseDate')[0] }); }); } }); } };这里有个非常重要的点:$('#editFormWrap').html()拿到的是静态HTML字符串,里面的<select>和日期控件不会自动变成Layui样式,必须在layer.open的success回调里用form.render()和laydate.render()重新初始化。我一开始没加这步,结果下拉框显示为原生样式且无法联动,查了半天。
这就是Layui开发的一个核心思维:任何动态插入到页面的DOM,只要涉及表单控件,都得重新render,否则样式和事件都不生效。
4.3 后端保存接口
在前端表单提交事件里拦截并转为AJAX请求:
form.on('submit(assetSubmit)', function (data) { var field = data.field; field.PurchaseDate = field.PurchaseDate ? new Date(field.PurchaseDate) : null; $.ajax({ url: '/Asset/Save', type: 'POST', data: JSON.stringify(field), contentType: 'application/json', dataType: 'json', success: function (res) { if (res.code === 0) { layer.closeAll('page'); table.reload('assetTable'); layer.msg('保存成功'); } else { layer.msg(res.msg); } } }); return false; // 阻止表单默认提交 });后端的Save方法判断Id是否为0决定新增还是修改:
[HttpPost] public ActionResult Save(Asset model) { if (ModelState.IsValid == false) return Json(new { code = 1, msg = "数据校验不通过" }); var service = new AssetService(); if (model.Id == 0) service.Insert(model); else service.Update(model); return Json(new { code = 0, msg = "保存成功" }); }这里解释一下为什么推荐用JSON.stringify提交而不是传统的$.post。因为MVC的模型绑定器对JSON反序列化比较智能,遇到DateTime、子对象这类复杂类型时,JSON方式不易出错。如果你的项目对命名风格有要求,比如字段是驼峰命名,MVC端配置一下JSON.Net的序列化设置就能无缝绑定。
4.4 编辑时的时间回填问题
编辑回填的时候,很多人会栽在日期格式上。EF把数据库的datetime读出来是DateTime类型,直接赋给<input>显示是2024-01-15T10:30:00这样的ISO格式,Layui的日期控件根本认不出它。我推荐在后端返回时就把日期格式化为字符串:
public ActionResult Detail(int id) { var model = assetService.GetById(id); var data = new { Id = model.Id, Name = model.Name, Category = model.Category, Status = model.Status, PurchaseDate = model.PurchaseDate?.ToString("yyyy-MM-dd"), Remark = model.Remark }; return Json(new { code = 0, data }, JsonRequestBehavior.AllowGet); }这样前端form.val()赋值的时候,日期框直接显示2024-01-15,不会出现格式错乱。
5. 删除功能与状态管理的细节
删除操作很简单,但我也要详细说说,因为它牵涉到“物理删除还是逻辑删除”的取舍。
5.1 后端删除接口
后端提供两个方案:
[HttpPost] public ActionResult Delete(int id) { assetService.Delete(id); // 物理删除 return Json(new { code = 0 }); } [HttpPost] public ActionResult SoftDelete(int id) { assetService.SoftDelete(id); // 逻辑删除,把Status改成某个特殊值 return Json(new { code = 0 }); }实际开发中我强烈建议使用逻辑删除。资产管理这类系统,审计追踪很重要,你不希望谁手抖删了一条资产记录就彻底找不回来了。逻辑删除的实现就是在表里加一个IsDeleted标记,查询时统一过滤掉。
前端删除的交互用layer.confirm做一个二次确认:
table.on('tool(assetTable)', function (obj) { if (obj.event === 'del') { layer.confirm('确定删除该资产吗?', { icon: 3, title: '提示' }, function (index) { $.post('/Asset/Delete', { id: obj.data.Id }, function (res) { if (res.code === 0) { layer.close(index); table.reload('assetTable'); } }); }); } });5.2 批量删除的实现思路
另一个高频需求是批量删除。实现思路是给表格加一列checkbox,然后获取选中行的数据:
// 列配置里加一行 { type: 'checkbox', width: 50 } // 批量删除按钮 $('#btnBatchDelete').click(function () { var checkStatus = table.checkStatus('assetTable'); var ids = checkStatus.data.map(function (item) { return item.Id; }); if (ids.length === 0) { layer.msg('请先勾选要删除的记录'); return; } $.ajax({ url: '/Asset/BatchDelete', type: 'POST', data: { ids: ids }, traditional: true, success: function (res) { table.reload('assetTable'); } }); });很多人的误区是以为table.checkStatus返回的是当前页选中的数据。实际上Layui会记住跨页勾选的状态,前提是你在table.render时开启了page分页,并且每一行的data里包含唯一主键Id,它才能正确追踪。这个机制很好用。
后端接收:
[HttpPost] public ActionResult BatchDelete(List<int> ids) { assetService.BatchDelete(ids); return Json(new { code = 0 }); }MVC模型绑定器会自动把ids[]或逗号拼接的字符串映射到List<int>参数上,不用自己Split。
6. 常见问题与排查技巧实录
做这类MVC+Layui增删改查项目,我总结了一些高频故障和相应的处理办法,列成一份速查表,希望对你有帮助。
6.1 表格数据加载不出来
最常见的三个原因:
- 接口没有返回Layui约定的格式,比如缺少
count字段,表格就显示不出分页。打开浏览器F12,直接看响应体,一眼就能定位。 - Controller返回的Json被全局过滤器拦截了。如果项目里配置了登录认证,未通过认证时请求会被重定向到登录页,此时接口返回的实际是HTML,表格自然渲染失败。这种情况要检查
Global.asax里的过滤器或者改在Web.config里配置匿名访问。 - 接口报500错误。接口代码里写了
db.Dispose()之类,或者用了using之后又访问了实体对象,都会报错。建议在Global.asax里注册一个Application_Error事件,把异常写到日志,排查效率高很多。
6.2 日期控件日历面板显示位置偏移
Layui的日期选择器默认渲染在触发元素的父级position: relative容器里,如果你的弹窗容器设置了overflow: hidden,日历面板就会被裁掉或者位置错乱。解决办法有三:
- 给弹窗内容容器加上
style="overflow: visible;" - 给laydate的
position参数设成fixed - 把弹窗的
area宽度调大,给日历面板留出空间
我试下来最省事的是设position: fixed,它让日历面板脱离弹窗的层叠上下文,基本不会再被裁切。
6.3 时间类型序列化问题
默认的JavaScriptSerializer会把DateTime序列化成/Date(1700000000000)/这样一段东西,前端解析很麻烦。推荐的方案是将Json序列化器改成Newtonsoft.Json,在Global.asax里配置:
GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings = new JsonSerializerSettings { DateFormatString = "yyyy-MM-dd HH:mm:ss" };或者在实体对应属性上标注[JsonConverter(typeof(DateFormatConverter))],哪种都行。我习惯用第一种,全局生效,省得每个属性都加特性。
6.4 表单提交时提示“防伪令牌”错误
MVC默认在表单里生成AntiforgeryToken,如果我们用AJAX提交,这个Token不会自动带上,于是服务端校验直接失败。解决方法就是在页面加载时把Token放到请求头:
$.ajaxSetup({ headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() } });另一个思路是直接在Save相关的Action上标注[ValidateAntiForgeryToken],如果不放Token就返回403,这样其实更安全,但排查问题时更容易让新人懵。我建议在项目初期先不加这个特性的验证,等所有增删改查跑通了再统一加上,免得调试时多一层干扰。
6.5 刷新表格后停留在第一页
table.reload('assetTable')默认回到第一页,这往往不是用户想要的。比如我在第5页编辑了一条记录,保存后应该刷新并还留在第5页。要记得读取当前页码再传回去:
var page = table.reload('assetTable', { page: { curr: table.cache.assetTable.page } });table.cache里保存着当前表格的页码、排序信息,用这种方式刷新体验好很多。
6.6 工具栏按钮事件不触发
遇到table.on('tool(assetTable)')不执行的情况,先确认tool事件绑定的表格ID是否和table.render里的elem一致。另外,如果操作列是通过toolbar指定外部模板ID,比如toolbar: '#rowBar',模板里的lay-event属性必须写对。有一次我把lay-event="edit"写成了lay-event="Edit",大小写不一致,事件死活用不了。
还有一个小众注意点:Layui官方在2.8版本之后,部分模块的引入方式有变化,老版本的项目直接升级后可能初始化报错。如果项目已经稳定,不建议频繁升级Layui版本,踩过的坑没必要再踩一遍。
7. 关于这个项目,我最后想分享的一点经验
整个资产管理系统的增删改查,逻辑上并不复杂,核心代码加起来几百行。但如果你真的从头到尾把这块写完跑通,你会理解MVC的请求管道是怎么运作的,Layui的组件是如何通过JSON和服务器通信的,以及一个合格的后台管理页面应该注意哪些交互细节。
我实际开发中最大的体会是:别小看这种看似“简单”的CRUD功能,很多系统上线后出问题,恰恰是列表加载慢、批量操作丢失、日期格式错乱这类细节没有处理好。把增删改查这块地基打扎实,后续你在上面加报表、加导入导出、加权限控制,都会顺手很多。
最后再送你一个小技巧:开发过程中打开浏览器的F12,Network面板里多看看请求的Payload和Response,特别是前后端字段名不一致的问题,比对一下Payload里的JSON和Controller接收的模型参数,往往一眼就能找到原因。这个习惯比任何调试工具都管用,也是排查前后端联调问题最高效的路径。
本文还有配套的精品资源,点击获取