☰
ASP.NET MVC + Layui 增删改查完整实战模板
2026/10/16 1:36:57 网站建设 项目流程

简介:本资源是一套基于Asp.Net MVC框架与Layui前端UI实现的完整CRUD(增删改查)功能示例项目,面向Web开发初学者及.NET全栈入门者,聚焦MVC三层架构实践、前后端协同交互与基础数据管理能力训练。压缩包共687个文件,含250个运行依赖dll、54个Layui及业务逻辑js、35个cshtml视图页、33个config配置文件、30个css样式资源,以及cs模型类、xml配置、sqlite轻量数据库等,整体65.44MB,结构完整覆盖Model-View-Controller全流程。已有494人学习下载,项目可直接运行,包含用户管理典型场景:Layui表格动态渲染、弹窗表单提交、AJAX异步交互、服务端数据验证与异常处理,同时集成分页、搜索等实用扩展功能,是理解ASP.NET MVC与现代前端框架协同开发的优质实操范例。

1. Asp.Net MVC + Layui 增删改查实战:一个能直接跑通、带完整前后端联调逻辑的轻量级模板

你是不是也遇到过这样的情况:网上搜“MVC 增删改查”,结果全是只贴 Controller 里几行return View()的空壳代码,前端 HTML 连表单验证都没有,Layui 的表格渲染写得像谜语,F5 刷新后数据全丢?更糟的是,数据库连接字符串硬编码在 Web.config 里,Model 层和 View 层混着写,一改就崩。这不是教学 Demo,是生产环境里会真实翻车的黑匣子。这份资源不是教你怎么从零建项目,而是给你一套已通过 IIS Express 和本地 IIS 双环境实测、含完整 Entity Framework Code First 数据迁移、Layui 表格+弹窗+表单联动、且所有接口返回统一 JSON 格式的最小可行闭环。它不追求炫技,但每一步都经得起打断点调试——适合刚脱离 ASP.NET Web Forms 的转型者快速上手,也适合需要快速交付内部管理后台的工程师直接拆解复用。核心价值就一条:把“增删改查”这四个字,真正落到可部署、可调试、可扩展的代码行上。


2. 项目结构与技术选型:为什么是 MVC + Layui 而不是 Blazor 或 Vue?

2.1 技术栈选择背后的现实约束

很多新手会疑惑:现在都 2024 年了,为什么还要用 Asp.Net MVC(非 Core)配 Layui?答案很实在:存量系统维护、内网低带宽环境、以及对 .NET Framework 生态的强依赖。某高校实验室曾接手一个运行在 Windows Server 2012 上的旧资产管理系统,服务器不允许升级 .NET 版本,前端又必须兼容 IE11 —— 此时强行上 Vue3 或 React 就是给自己挖坑。Layui 虽然官方已停止维护,但它 2.8.18 版本的模块化设计、极低的 JS 加载体积(压缩后仅 127KB)、以及对传统表单提交的天然支持,让它在老旧基础设施上反而比现代 SPA 框架更稳。而 MVC 5.2.9(对应 .NET Framework 4.7.2)提供了成熟的 Razor 视图引擎、模型绑定、验证管道,配合 EF6.4 的 Code First,能用最少代码完成“数据库变更 → Model 更新 → 页面渲染”的链路闭环。这不是技术怀旧,是权衡之后的务实选择。

2.2 项目目录结构解析:拒绝“一个 Controllers 文件夹塞 20 个类”的混乱

本资源采用分层清晰、职责明确的物理结构,所有文件均按功能归类,而非按技术类型堆砌:

/Controllers ├── HomeController.cs // 首页路由,不处理业务 └── ProductController.cs // 核心业务控制器,仅包含 CRUD Action /Models ├── Product.cs // 实体类,含 DataAnnotations 验证 └── ProductContext.cs // DbContext,含 OnModelCreating 配置 /Views ├── Home │ └── Index.cshtml // 首页,含 Layui 导航栏 └── Product ├── Index.cshtml // 主列表页(Layui table 渲染) ├── Create.cshtml // 新增弹窗表单(Layui form + layer.open) ├── Edit.cshtml // 编辑弹窗表单(复用 Create 结构) └── _ListPartial.cshtml // 表格数据局部视图,供 AJAX 刷新 /Scripts ├── app │ ├── product.js // 业务 JS:封装 table 初始化、按钮事件、表单提交 │ └── utils.js // 工具函数:统一错误提示、loading 状态控制 └── layui // Layui 2.8.18 官方源码(已去除非必要模块) /Content └── css └── custom.css // 覆盖 Layui 默认样式:调整表单宽度、按钮间距等

提示:_ListPartial.cshtml是关键设计。它不包含<table>标签本身,只渲染<tbody>内容,由主页面Index.cshtml通过@Html.Partial("_ListPartial", Model)或 AJAX 成功回调中$("#table-body").html(data)动态注入。这样既保证首次加载 SEO 友好,又支持无刷新更新,避免整页重绘导致 Layui table 实例丢失。

2.3 数据库与 EF Code First:从空库到自动建表的三步落地

本方案使用 SQL Server LocalDB(开发机默认自带),无需额外安装数据库服务。EF6.4 的 Code First 流程被精简为可复现的三步:

  1. 定义实体与上下文(Models/Product.cs与Models/ProductContext.cs)
  2. 启用迁移并生成初始脚本(Package Manager Console 中执行)
  3. 应用迁移创建数据库(命令行或 Visual Studio GUI)
# 在 Package Manager Console 中,确保 Default Project 为你的 Web 项目 Enable-Migrations -ContextTypeName Models.ProductContext -MigrationsDirectory "Migrations" Add-Migration InitialCreate Update-Database

执行后,EF 会在项目根目录生成Migrations/202405150823456_InitialCreate.cs文件,其中Up(MigrationBuilder migrationBuilder)方法包含完整的CREATE TABLE [Products]语句。Update-Database命令会自动创建ProductContext.mdf数据库文件(位于App_Data目录下),并执行建表。关键参数说明:-ContextTypeName必须精确匹配 DbContext 类的全名(含命名空间),否则迁移无法识别上下文;-MigrationsDirectory指定迁移文件存放路径,避免与Controllers或Models混淆。

注意:若执行Update-Database报错 “无法连接到 LocalDB”,请检查 SQL Server Express 是否已安装,或在Web.config的 connectionStrings 中将Data Source改为(localdb)\mssqllocaldb(VS2019+ 默认实例名)。这是新手最常卡住的第一关。


3. Layui 前端集成:不只是套个皮肤,而是让表格、弹窗、表单真正联动

3.1 Layui table 初始化:如何让后端 JSON 数据精准映射到列

Layui table 的核心在于cols配置项与后端返回 JSON 字段的严格对应。本资源后端ProductController.Index()返回标准 JSON:

public ActionResult Index() { var products = db.Products.ToList(); return Json(new { code = 0, msg = "", count = products.Count, data = products }, JsonRequestBehavior.AllowGet); }

前端product.js中初始化 table 时,cols必须与data数组中每个对象的属性名完全一致(区分大小写):

// Scripts/app/product.js layui.use(['table', 'layer', 'form'], function(){ var table = layui.table; var layer = layui.layer; var form = layui.form; // 初始化表格 table.render({ elem: '#productTable' ,url: '/Product/GetList' // 注意:此处为 GET 接口,非 Index ,method: 'get' ,page: true ,limit: 10 ,cols: [[ {type: 'checkbox', fixed: 'left'} ,{field:'Id', title:'ID', width:80, sort: true, fixed: 'left'} ,{field:'Name', title:'商品名称', width:180, edit: 'text'} // 启用单元格编辑 ,{field:'Price', title:'价格(元)', width:120, templet: '<div>{{d.Price.toFixed(2)}}</div>'} ,{field:'Category', title:'分类', width:120} ,{title:'操作', width:180, toolbar: '#barDemo', fixed: 'right'} // 使用 toolbar 模板 ]] }); // 监听工具条事件(编辑、删除) table.on('tool(productTable)', function(obj){ var data = obj.data; if(obj.event === 'edit'){ openEditDialog(data.Id); // 打开编辑弹窗 } else if(obj.event === 'del'){ deleteProduct(data.Id); // 执行删除 } }); });

逻辑说明:url: '/Product/GetList'对应新增的GetList()Action(非Index),该方法专为 AJAX 提供纯数据,不渲染视图;templet用于格式化价格字段,避免后端传小数位数不一致;toolbar: '#barDemo'引用下方 script 模板,实现按钮复用。参数说明:edit: 'text'表示该列可双击编辑,但需配合table.on('edit(productTable)', ...)监听事件才能保存,本资源暂未启用(避免复杂度),聚焦基础 CRUD。

3.2 Layui 弹窗表单:如何用一个视图文件支撑新增与编辑

Layui 的layer.open()支持 iframe 和 content 两种模式。本方案采用content 模式 + PartialView,优势是:表单逻辑与主页面共享 JS 上下文,无需跨 iframe 通信;且Create.cshtml与Edit.cshtml共用同一套 Razor 代码,仅通过ViewBag.IsEdit区分状态:

<!-- Views/Product/Create.cshtml & Edit.cshtml 共用 --> @model Models.Product @{ ViewBag.Title = ViewBag.IsEdit == true ? "编辑商品" : "新增商品"; } <form class="layui-form" action="" lay-filter="productForm"> <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" value="@Model?.Name" placeholder="请输入商品名称" autocomplete="off" class="layui-input"> </div> </div> <div class="layui-form-item"> <label class="layui-form-label">价格</label> <div class="layui-input-block"> <input type="number" name="Price" required lay-verify="required|number" value="@Model?.Price.ToString("F2")" placeholder="请输入价格" autocomplete="off" class="layui-input"> </div> </div> <!-- 其他字段... --> <div class="layui-form-item" style="text-align: center;"> <button class="layui-btn" lay-submit lay-filter="saveProduct">立即提交</button> </div> </form> <script> // 绑定表单提交事件(在弹窗内执行) layui.use('form', function(){ var form = layui.form; form.on('submit(saveProduct)', function(data){ var url = '@Url.Action("Create")'; if (@ViewBag.IsEdit == true) { url = '@Url.Action("Edit")'; } $.post(url, data.field, function(res){ if(res.code === 0){ layer.msg('操作成功', {icon: 1}); layer.closeAll(); // 关闭弹窗 reloadTable(); // 刷新主表格 } else { layer.msg(res.msg || '操作失败', {icon: 2}); } }); return false; // 阻止表单默认提交 }); }); </script>

关键细节:value="@Model?.Name"使用空条件运算符?避免NullReferenceException;@Model?.Price.ToString("F2")确保价格始终显示两位小数;lay-verify="required|number"启用 Layui 内置验证;$.post()的data.field是 Layui form 模块自动序列化的键值对,无需手动拼接 JSON。

3.3 表单提交与后端模型绑定:如何让 MVC 自动把 JSON 映射到 Product 对象

MVC 的模型绑定(Model Binding)是本方案的隐性核心。当product.js中$.post('/Product/Create', { Name: '手机', Price: 2999 })发送请求时,框架会自动执行以下步骤:

  1. 解析 POST 请求体中的键值对(application/x-www-form-urlencoded格式)
  2. 查找ProductController.Create()方法的参数Product product
  3. 根据Product类的属性名(Name,Price),将键值对赋值给对应属性
  4. 执行DataAnnotations验证(如[Required],[Range(0.01, 100000)])
  5. 若验证失败,ModelState.IsValid为false,返回错误视图
// Controllers/ProductController.cs [HttpPost] public ActionResult Create([Bind(Include = "Name,Price,Category")] Product product) { if (ModelState.IsValid) { db.Products.Add(product); db.SaveChanges(); return Json(new { code = 0, msg = "新增成功" }); } // 验证失败时,返回包含错误信息的 JSON(供前端 layer.msg 显示) var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage); return Json(new { code = -1, msg = string.Join(";", errors) }); }

参数说明:[Bind(Include = "...")]是安全必需项,防止“过度发布”(Overposting)攻击——即客户端恶意提交IsDeleted=true等敏感字段。Include明确指定允许绑定的属性白名单,未列出的属性(如Id,CreatedAt)将被忽略。


4. 常见问题与避坑指南:那些让你调试到凌晨三点的“玄学”错误

4.1 现象:Layui table 显示“暂无数据”,但 F12 Network 查看/Product/GetList返回了正确 JSON

原因:Layui table 默认期望后端返回的 JSON 中data字段是一个数组,而 MVC 的Json()方法默认将List<Product>序列化为数组,但若你误用了Json(new { data = new List<Product>() }),则data是一个对象,其内部才有data数组。更隐蔽的原因是:GetList()Action 中return Json(...)未加JsonRequestBehavior.AllowGet,导致 GET 请求被 MVC 拒绝(默认禁止 GET 返回 JSON)。
解决:确认GetList()方法签名末尾有JsonRequestBehavior.AllowGet;检查返回 JSON 结构是否为{ "code":0, "msg":"", "count":10, "data":[{...},{...}] };用浏览器直接访问/Product/GetList看原始响应。

4.2 现象:点击“新增”按钮,弹窗打开但表单为空,且控制台报错Uncaught TypeError: Cannot read property 'Name' of undefined

原因:Create.cshtml中@Model?.Name的Model为null,但ViewBag.IsEdit未正确设置,导致视图试图读取null.Name。根源在于ProductController.Create()的 GET 版本(即public ActionResult Create())未初始化ViewBag.IsEdit = false。
解决:在Create()GET Action 中显式设置ViewBag.IsEdit = false;或统一使用ViewBag.Title等更安全的变量,避免直接依赖Model的 null 状态。

4.3 现象:编辑商品后,价格字段在表格中显示为NaN,但数据库已更新成功

原因:Layui table 的templet函数中{{d.Price.toFixed(2)}}要求d.Price是数字类型。若后端Product.Price是decimal?(可空 decimal),而数据库中该值为NULL,则序列化后 JSON 中Price字段为null,JS 中null.toFixed(2)抛出TypeError,导致整行渲染失败。
解决:在Product.cs中将Price改为非空decimal(public decimal Price { get; set; } = 0;),或在GetList()中对null值做默认处理:select new { Id = p.Id, Name = p.Name, Price = p.Price ?? 0m, Category = p.Category }。

4.4 现象:删除操作后,表格未刷新,需手动 F5

原因:deleteProduct(data.Id)函数中$.post()成功回调里调用了reloadTable(),但该函数未正确定义或作用域错误。常见错误是把reloadTable写在table.render()的回调内,导致外部无法调用。
解决:将reloadTable定义为全局函数(或挂载到window),并在table.render()外声明:

// 定义在 product.js 顶部 function reloadTable() { layui.table.reload('productTable', { page: { curr: 1 } // 重载后回到第 1 页 }); }

4.5 现象:部署到 IIS 后,所有 Layui CSS/JS 404,页面变成纯文字

原因:IIS 默认不识别.js和.css文件的 MIME 类型,或web.config中静态文件处理模块被禁用。更常见的是:发布时未勾选“删除目标位置文件”,导致旧版layui文件夹残留,新文件未覆盖。
解决:在 IIS 管理器中,选中站点 → “MIME 类型” → 确保.js类型为application/javascript,.css为text/css;发布前清空目标文件夹;检查Scripts/layui路径是否与Index.cshtml中<script src="~/Scripts/layui/layui.js">的引用路径完全一致(注意~符号在 IIS 中需由 ASP.NET 解析,确保System.Web.Optimization未干扰)。


5. 后端验证与错误处理:让 ModelState 不再是摆设,而是真正的用户防线

5.1 将 DataAnnotations 验证错误转化为前端可读提示

MVC 的ModelState不仅用于服务端拦截,更是前后端协同的桥梁。本方案在Create和Edit的 POST Action 中,当ModelState.IsValid为false时,不再返回通用错误页,而是提取所有验证失败信息,组装成结构化 JSON:

[HttpPost] public ActionResult Edit([Bind(Include = "Id,Name,Price,Category")] Product product) { if (ModelState.IsValid) { var existing = db.Products.Find(product.Id); if (existing == null) return Json(new { code = -1, msg = "记录不存在" }); // 手动赋值,避免 EF 跟踪问题 existing.Name = product.Name; existing.Price = product.Price; existing.Category = product.Category; db.Entry(existing).State = EntityState.Modified; db.SaveChanges(); return Json(new { code = 0, msg = "更新成功" }); } // 提取所有验证错误,按字段分组 var errorDict = new Dictionary<string, string>(); foreach (var state in ModelState) { if (state.Value.Errors.Count > 0) { var errorMsg = string.Join(";", state.Value.Errors.Select(e => e.ErrorMessage)); errorDict[state.Key] = errorMsg; } } return Json(new { code = -1, msg = "验证失败", errors = errorDict }); }

前端product.js中捕获此 JSON,并动态在对应表单元素旁插入红色提示:

// 在 $.post 的 error 回调中 if (res.errors && typeof res.errors === 'object') { // 清除之前的所有错误提示 $('.layui-form-item').removeClass('layui-form-error'); $('.layui-form-danger').remove(); // 为每个有错误的字段添加提示 Object.keys(res.errors).forEach(function(field) { var $input = $('input[name="' + field + '"], select[name="' + field + '"]'); if ($input.length) { $input.closest('.layui-form-item').addClass('layui-form-error'); $input.after('<div class="layui-form-danger">' + res.errors[field] + '</div>'); } }); }

效果:用户看到的不再是“操作失败”,而是“名称:此项为必填项;价格:必须大于 0.01”——这才是真实的用户体验。errorDict的设计让前端可以精准定位到具体字段,避免ModelState.Values.SelectMany(...)返回的扁平化错误列表难以映射。

5.2 防重复提交:一个被严重低估的生产级细节

用户手抖连点两次“提交”按钮,后端若无防护,就会创建两条一模一样的记录。本方案采用Token + Session 校验的轻量方案,不依赖分布式缓存:

// 在 Create.cshtml 和 Edit.cshtml 的 form 标签内,添加隐藏 Token @{ var token = Guid.NewGuid().ToString("N"); Session["FormToken"] = token; } <input type="hidden" name="FormToken" value="@token" /> // Controllers/ProductController.cs 中,POST Action 前添加校验 [HttpPost] [ValidateAntiForgeryToken] // 启用 AntiForgeryToken(需在 View 中 @Html.AntiForgeryToken()) public ActionResult Create([Bind(Include = "Name,Price,Category")] Product product) { // 额外校验自定义 Token if (Session["FormToken"] == null || Session["FormToken"].ToString() != Request.Form["FormToken"]) { return Json(new { code = -1, msg = "请求无效,请勿重复提交" }); } Session["FormToken"] = null; // 消费 Token,防止二次使用 // ...后续逻辑 }

原理:每次打开新增/编辑页面,服务端生成唯一Guid存入Session,并写入表单隐藏域;提交时比对两者,一致则处理并清空Session中的 Token。即使用户禁用 JS,此机制依然有效。这是比单纯前端button.disabled = true更可靠的后悔药。

5.3 数据库并发冲突处理:当两个管理员同时编辑同一条记录

EF6 的DbUpdateConcurrencyException是生产环境高频异常。本方案在EditPOST Action 中捕获并优雅降级:

try { db.Entry(existing).State = EntityState.Modified; db.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { // 获取数据库当前值 var databaseValues = ex.Entries.First().GetDatabaseValues(); if (databaseValues == null) { return Json(new { code = -1, msg = "记录已被删除" }); } // 构造对比提示:告知用户哪些字段被他人修改 var currentValues = ex.Entries.First().CurrentValues; var changedFields = new List<string>(); foreach (var property in databaseValues.Properties) { if (!object.Equals(databaseValues[property], currentValues[property])) { changedFields.Add(property); } } var msg = $"数据已被他人修改:{string.Join("、", changedFields)}。请刷新页面后重试。"; return Json(new { code = -1, msg = msg }); }

血泪经验:不要在 catch 块中直接ex.Entries.First().Reload()—— 这会覆盖用户刚输入的全部内容,引发更大不满。应该让用户知情并主动选择“放弃编辑”或“强制覆盖”。从那以后我每次处理编辑逻辑,都强制走一遍并发测试:开两个浏览器标签,同时编辑同一条记录,看提示是否清晰。

希望帮到你。

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

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

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

立即咨询