.NET Core 3.1 + EF Core + LayUI 后台管理系统封装实践
2026/9/13 18:59:23 网站建设 项目流程

简介:基于.Net Core3.1与EF Core搭建、前端使用LayUI封装的MVC版后台管理系统源码包,面向.NET开发者与系统集成人员,可用于快速搭建企业级管理后台或学习主流框架整合方案。包内包含完整的项目工程与数据库备份文件,涵盖实体映射、数据库迁移、服务封装、权限认证等核心模块,并配有前端页面样式与交互脚本,帮助理解分层架构与ORM在真实项目中的落地方式。资源共386个文件,以C#源码(128个cs)、前端样式与脚本(47个js、23个css)、视图页面(23个cshtml)及数据库备份(bak)为主,另含EF迁移记录、配置文件和图标资源,整体压缩包约4.69MB,目录结构清晰,便于按模块检索。目前已有867人学习下载,适合初中级C#开发者对照学习,可显著节省从零搭建框架的时间;其中封装好的基础服务、认证信息处理及数据库初始迁移脚本,尤其适合直接复用到实际业务模块,便于二次开发与功能扩展。

1. 这套技术栈组合,本质是“够用且好维护”的务实选择

如果你接过一个别人写的 .NET 后台项目,打开解决方案发现是 .Net Core 3.1 + EF Core + LayUI,第一反应多半是“这组合有点年头了”。确实,.NET Core 3.1 是微软在 2019 年底发布的 LTS 版本,2020 年 12 月主流支持就已结束,但企业内网系统、外包交付项目、传统行业信息化改造里,它依然是存量最大的技术栈之一。LayUI 同样是典型的 jQuery 时代产物,模块化规范、layui.all.js 一把梭、table 组件直接吃后端 JSON——和当年 MVC 的“Action 返回 View 或 JsonResult”思路天然契合。

这篇文章不为这个组合“翻案”,而是讲清楚一件事:当你拿到或需要维护这样一套“MVC 版后台管理系统”时,代码该怎么拆、EF Core 的坑在哪、LayUI 表格和 MVC 路由怎么对得上。适合刚接手这类项目的开发,也适合想从零快速搭一套内部后台的人——这套组合的开发效率,在“不用前后端分离、不需要构建工具、服务器配置不高”的场景下依然能打。

2. 项目结构与三层架构:从 Controller 到 Repository 的职责边界

2.1 为什么 MVC 项目还需要单独拆 Service 和 Repository

MVC 本身只约束了“请求怎么进、视图怎么出”,它不关心业务逻辑写在哪。很多小项目直接在 Controller 里 new 一个 DbContext 就开始查表,表少还能忍,一旦出现用户、角色、菜单、日志、订单几组模块互相引用,Controller 会迅速膨胀到几千行。更现实的问题是:LayUI 的 table 接口需要一个固定的 JSON 返回结构,分页参数、排序字段、筛选条件都通过 Request 传进来,如果每个 Action 里都重复写这一段解析逻辑,维护成本会直接失控。

常见做法是拆四层:Controller 只做参数接收和结果封装;Service 层处理业务规则、事务边界;Repository 层封装 EF Core 的数据访问;实体层放 POCO 类。标题里提到的“封装”二字,核心价值就在这个分层——它让 LayUI 的请求适配、EF Core 的查询逻辑和业务规则互不污染,换掉任何一层都不会牵连另外两层。

2.1.1 解决方案目录划分
src/ ├── MyAdmin.Web // MVC 层:Controllers、Views、wwwroot ├── MyAdmin.Application // Service 层:接口 + 实现 ├── MyAdmin.Domain // 实体层:POCO、枚举、DTO ├── MyAdmin.Infrastructure // Repository 层:EF Core DbContext、仓储实现

如果你拿到手的项目只有一个 Web 项目,也不要急着重构。先把DbContext和实体类单独挪到 Infrastructure/ Domain,再逐步把 Controller 里的业务代码抽到 Service——这是风险最低的演进路径。

2.2 Startup 里如何装配 EF Core 与路由

MVC 项目的入口在Startup.ConfigureServicesStartup.Configure。.NET Core 3.1 用的还是IApplicationBuilder这套管道模型,没有 .NET 6 之后的WebApplicationBuilder简化语法,这是新手接手时最不习惯的地方。

public void ConfigureServices(IServiceCollection services) { // 注册 DbContext,连接串从 appsettings.json 读取 services.AddDbContext<MyAdminDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("Default"))); // 注册仓储与服务,Scoped 生命周期匹配请求 services.AddScoped<IUserRepository, UserRepository>(); services.AddScoped<IUserService, UserService>(); // MVC 服务,设置路由驼峰命名等选项 services.AddControllersWithViews() .AddJsonOptions(options => { // LayUI 表格默认读取 data 字段,JsonProperty 命名策略留默认即可 }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); }

这段代码有三个值得注意的参数:AddDbContext默认注册的生命周期是Scoped,和 HTTP 请求一一对应,同一个请求内多次注入拿到的是同一个上下文实例,这保证了 EF Core 的变更追踪在同一请求内是连续的;UseStaticFiles必须放在UseRouting之前,否则 LayUI 的 css/js 文件会被路由拦截;MapControllerRoute{id?}的问号表示可选参数,LayUI 表格传?page=1&limit=10时不需要走路由参数,直接Request.Query读取即可。

路由的另一个关键是:LayUI 的table.renderurl字段直接写 Action 路径,比如/User/List。MVC 默认路由会把User映射到UserController,把List映射到List方法。要避免在List方法上再标[HttpPost]却用 GET 访问——LayUI 表格默认用 GET 请求,除非你显式指定method: 'post'

2.3 三层实例:BaseService 和 BaseRepository 怎么共用 CRUD

“BaseService + BaseRepository 创建三层实例”这套模式在好多 .NET 后台项目里都出现过。思路是抽一组泛型基类,把增删改查和分页逻辑收敛到基类里,具体业务的 Service 和 Repository 只写自己的差异化逻辑。EF Core 3.1 里实现起来并不复杂,但有几个泛型约束的细节要处理对。

// 仓储基类 public class BaseRepository<T> : IBaseRepository<T> where T : class { protected readonly MyAdminDbContext _context; protected readonly DbSet<T> _dbSet; public BaseRepository(MyAdminDbContext context) { _context = context; _dbSet = context.Set<T>(); } public async Task<IQueryable<T>> GetQueryAsync() { return await Task.FromResult(_dbSet.AsQueryable()); } public async Task<T> GetByIdAsync(int id) { return await _dbSet.FindAsync(id); } public async Task<int> AddAsync(T entity) { await _dbSet.AddAsync(entity); return await _context.SaveChangesAsync(); } public async Task<int> UpdateAsync(T entity) { _context.Entry(entity).State = EntityState.Modified; return await _context.SaveChangesAsync(); } public async Task<int> DeleteAsync(int id) { var entity = await GetByIdAsync(id); if (entity == null) return 0; _dbSet.Remove(entity); return await _context.SaveChangesAsync(); } }

GetQueryAsync返回IQueryable而不是List,目的是把查询构建推迟到 Service 层,由 EF Core 在真正执行时生成 SQL。UpdateAsync里手动把状态改为Modified只更新非空字段,但有一个天生的缺陷:所有列都会被包含在 UPDATE 语句中,字段多时有一定性能开销。3.1 没有批量更新接口,逐条 SaveChanges 是常态;真需要高效更新某个状态的场景,再回到手写ExecuteSqlRaw或引入第三方库。

Service 基类则面向业务语义暴露方法,内聚事务:

public class BaseService<T> : IBaseService<T> where T : class { protected readonly IBaseRepository<T> _repository; public BaseService(IBaseRepository<T> repository) { _repository = repository; } public async Task<PagedResult<T>> GetPagedListAsync(int page, int limit, Expression<Func<T, bool>>? filter = null) { var query = await _repository.GetQueryAsync(); if (filter != null) { query = query.Where(filter); } var total = await query.CountAsync(); var items = await query .Skip((page - 1) * limit) .Take(limit) .ToListAsync(); return new PagedResult<T> { Total = total, Items = items }; } }

SkipTake对应 LayUI 表格的pagelimit参数,注意页码从 1 开始,所以Skip里要减 1。PagedResult是自定义 DTO,它让 Service 层不依赖 LayUI 的任何类型,Controller 再把它改写成 LayUI 要求的{code:0, msg:"", count:total, data:items}结构。这层隔离很有用——哪天你想把前端从 LayUI 换成 Vue 或 React,Controller 只是多一层数据转换的事。

3. EF Core 3.1 的查询与更新:从导航属性到事务边界

3.1 DbContext 生命周期与导航属性的三种加载方式

很多用 EF Core 卡壳的人,问题多半出在“为什么我查出来的子表是空的”。EF Core 3.1 的导航属性默认不加载,需要显式指定。三种加载方式:Include即时加载、LazyLoadingProxies延迟加载、Select投影加载。后台管理系统里,第一种和第三种最常用,延迟加载因为需要额外装包且序列化时容易搞出循环引用,一般不推荐在这种前后端混用的项目里开。

// 即时加载:查询用户并带上其角色信息 var users = await _context.Users .Include(u => u.UserRoles) .ThenInclude(ur => ur.Role) .Where(u => u.IsDeleted == false) .ToListAsync();

Include会生成 LEFT JOIN,ThenInclude是第二层导航。多级联查时 SQL 会变复杂,查询性能需要自己用 SQL Profiler 或ToQueryString()检查。另一个容易踩的坑是:Include搭配Select投影时会被自动忽略——如果你只需返回UserRole的某几个字段,直接用Select创建匿名类型即可,EF Core 会生成只查所需列的 SQL,既省流量又避免Include被丢弃的困惑。

CountAsyncToListAsync组合是分页接口的标准姿势。EF Core 3.1 不支持单次往返同时拿总数和数据,必须两次查询,这是设计使然,不属于性能问题。

3.2 事务与 SaveChanges 的边界

做后台管理系统的常见场景是“创建用户时顺便分配角色”。两步写操作必须包在同一个事务里,否则角色表写成功而用户表失败,数据就不一致了。EF Core 3.1 提供Database.BeginTransactionAsync,配合SaveChangesAsync一起使用。

public async Task<bool> CreateUserWithRolesAsync(User user, List<int> roleIds) { await using var transaction = await _context.Database.BeginTransactionAsync(); try { _context.Users.Add(user); await _context.SaveChangesAsync(); var userRoles = roleIds.Select(roleId => new UserRole { UserId = user.Id, RoleId = roleId }); await _context.UserRoles.AddRangeAsync(userRoles); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return true; } catch (Exception) { await transaction.RollbackAsync(); return false; } }

BeginTransactionAsync开启的是数据库级别的事务,事务期间SaveChangesAsync不会自动提交,直到显式CommitAsync。如果中途异常,RollbackAsync会把两个写操作全部回滚。一个常见的误用是:在循环里多次调用SaveChangesAsync,每次都是独立事务,性能差且中途失败没法整体回滚。批量添加的场景用AddRangeAsync一次提交,是更合适的写法。

EF Core 3.1 的变更追踪还带来一个隐性坑:同一个DbContext实例查询两次同一实体,第二次返回的是被缓存过的实例——如果你在第一次查询后改了它的属性但没保存,第二次“查询”会直接拿到改过的内存状态,而不是数据库里的最新值。这要求你在 Service 层处理好上下文的作用范围,一个请求一个DbContext实例的原则必须守住。

3.3 用 ExecuteSqlRaw 处理 EF Core 3.1 做不了的批量操作

EF Core 3.1 的RemoveRange删除大表数据时会逐条生成 DELETE 语句,几百条还能接受,几万条会直接把数据库连接拖垮。ExecuteSqlRaw是官方提供的逃生舱。

// 按日期批量删除操作日志,返回受影响行数 var daysAgo = DateTime.Now.AddDays(-30); var affected = await _context.Database .ExecuteSqlRawAsync("DELETE FROM SysLog WHERE CreateTime < {0}", daysAgo);

{0}是参数占位符,EF Core 会把它转换成 SQL 参数而不是字符串拼接,能有效预防注入风险。这个方法的返回值是受影响的行数,可以用来判断删除是否生效。类似的场景还有“把所有用户的最后登录时间统一重置”“把所有订单状态改为已过期”——都需要走原生 SQL。

注意ExecuteSqlRaw不走变更追踪器,执行后_context里已加载的实体状态不会自动同步。如果要继续做后续操作,最好先_context.ChangeTracker.Clear()把追踪清理掉,避免读到脏数据。

4. LayUI 表格与 MVC Action 的对接:请求参数、返回结构与动态渲染

4.1 table.render 的三个关键参数与后端 Action 签名

LayUI 的表格是这套后台系统里使用频率最高的组件。table.render通过url请求后端接口,通过cols定义列,通过page开启分页。一个典型配置长这样:

table.render({ elem: '#userTable', url: '/User/List', page: true, limit: 10, limits: [10, 20, 50, 100], cols: [[ { field: 'id', title: 'ID', width: 80, sort: true }, { field: 'userName', title: '用户名', minWidth: 120 }, { field: 'roleName', title: '角色', minWidth: 100 }, { field: 'createTime', title: '创建时间', templet: function(d) { return layui.util.toDateString(d.createTime, 'yyyy-MM-dd HH:mm:ss'); }}, { title: '操作', toolbar: '#barUser', width: 160 } ]], text: { none: '暂无数据' } });

前端传参方面,LayUI 默认会带上pagelimit两个查询参数,如果开启了排序,还会带上fieldorder。后端 Action 的签名可以直接接收这些参数:

public async Task<IActionResult> List(int page, int limit, string? field, string? order, string? keyword) { var query = _userService.Query(); if (!string.IsNullOrEmpty(keyword)) { query = query.Where(u => u.UserName.Contains(keyword)); } // 排序字段做白名单校验,防止非法字段名注入 if (!string.IsNullOrEmpty(field) && allowedSortFields.Contains(field)) { query = order == "desc" ? query.OrderByDescending(field) : query.OrderBy(field); } else { query = query.OrderByDescending(u => u.Id); } var total = await query.CountAsync(); var items = await query.Skip((page - 1) * limit).Take(limit).ToListAsync(); return Json(new { code = 0, msg = "", count = total, data = items }); }

返回结构是硬约定:code必须为 0 才表示成功,count是总数(LayUI 用它渲染页码),data是当前页数据。一个很容易踩的坑:如果你是 .NET 5 之后的新语法把时间序列化成 ISO 8601 格式,LayUI 的templet里直接用new Date(d.createTime)有时会解析出错。.NET Core 3.1 默认的 JSON 序列化格式是yyyy-MM-ddTHH:mm:ss,在 JS 里能被 Date 正常解析,但如果你自定义了序列化配置,要注意保持一致。

OrderByDescending(field)这种写法需要配合 System.Linq.Dynamic.Core 库,EF Core 原生不支持传字符串排序。没有这个库就老老实实写 switch 表达式映射字段名,这同时也是防止 SQL 注入的一种兜底——表达式树的列名是白名单比对,绝不直接拼字符串。

4.2 日期控件 max 设为当前日期,select 动态赋值

LayUI 的laydate在后台管理系统里经常用在筛选条件中。限制用户不能选未来日期是高频需求,渲染时直接把max定为今天的日期字符串即可:

laydate.render({ elem: '#createTime', type: 'datetime', max: new Date().toISOString().slice(0, 10) // 只允许选今天及之前 });

这段代码的问题在于toISOString()会取 UTC 时间,在中国时区下,本地时间凌晨 8 点之前会得到昨天的日期——如果严格“不能选未来”,这个偏差会造成用户体验困惑。更稳妥的方式是拼一个本地日期字符串:

var now = new Date(); var maxDate = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); laydate.render({ elem: '#createTime', type: 'datetime', max: maxDate });

select动态赋值是另一个高频需求:编辑表单打开时,把用户当前的角色、部门、状态回填到下拉框。LayUI 的form.val可以直接按lay-filter名批量赋值:

// 假设下拉框设置了 lay-filter="roleSelect" form.val('userForm', { 'roleSelect': userData.roleId, // 注意 key 是 select 的 name 值 'status': userData.status });

但有个陷阱:form.val能成功赋值的前提是 select 的选项已经全部渲染完毕。如果选项是异步获取的(比如从/Role/List接口拉),你必须在回调里先渲染选项再赋值。常见做法:

$.get('/Role/List', function(res) { var html = ''; res.data.forEach(function(role) { var selected = role.id === userData.roleId ? 'selected' : ''; html += '<option value="' + role.id + '" ' + selected + '>' + role.name + '</option>'; }); $('#roleSelect').html(html); form.render('select'); // 重新渲染 select,否则样式不生效 });

form.render('select')这一步不能漏。LayUI 对表单控件有自己的一套渲染体系,直接改 DOM 不触发重绘,下拉框会失去 LayUI 的样式和交互。

4.3 LayUI 与 Vue 能不能配合使用

搜得很多的“LayUI 可以用 Vue 吗”这个问题,在 MVC 后台里其实是个伪命题。LayUI 依赖 jQuery 并自管 DOM,Vue 依赖虚拟 DOM 和数据驱动,两个框架同页面共存必然出现抢夺 DOM 控制权的问题。我的建议:选了 LayUI 的 MVC 模板就别强行引入 Vue。真要上 Vue,就应该整体切前后端分离,让后端专心提供 Web API——这也意味着你需要重新设计一套接口规范,工程项目里这是重构级的决策,不是“引入一个库”的问题。

LayUI 2.8 之后引入了layui.use的 ESM 引入方式,但它的核心组件本质上还是面向服务端渲染页面的。如果你的页面里只有一两个表格、几个表单弹窗,LayUI 完完全全够用;如果已经开始在页面里写大量datamethods,说明该换技术栈了。

5. 进阶:从“能跑”到“能扛”的压测、诊断与升级路径

5.1 用 Stopwatch 和日志找出慢接口

后台管理系统“能跑”容易,“能扛”需要数据说话。EF Core 的查询慢不一定慢在 SQL 上,也可能是N+1查询或者IQueryable被多次枚举。动手优化前,先在Startup.Configure里加一个简单的中间件,记录每个请求的处理时间:

app.Use(async (context, next) => { var stopwatch = Stopwatch.StartNew(); await next(); stopwatch.Stop(); if (stopwatch.ElapsedMilliseconds > 500) { // 记录慢请求,输出到 ILogger 或自定义日志表 Console.WriteLine($"[SLOW] {context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms"); } });

这个中间件能快速定位“用户反馈某个页面转圈”的问题。阈值设 500 毫秒比较合理:LayUI 表格初始化时会有 loading 动画,超过 500ms 用户就有感知。

5.2 开启 EF Core 的 SQL 日志排查 N+1 查询

N+1 查询是报表页面性能差的头号元凶。场景:加载订单列表时,每条订单关联一个用户,代码用循环里逐条查用户的方式实现——页面有 100 条订单就会产生 101 条 SQL。用日志的方式判断是否存在 N+1,比傻傻看执行时间更直接:

// 在 ConfigureServices 注册 DbContext 时开启 SQL 日志 services.AddDbContext<MyAdminDbContext>(options => { options.UseSqlServer(Configuration.GetConnectionString("Default")); options.LogTo(Console.WriteLine, LogLevel.Information, DbContextLoggerOptions.SingleLine); });

LogTo是 EF Core 5.0 才引入的 API,3.1 要用UseLoggerFactory或直接在OnConfiguring里配ConsoleLogger。执行一次列表接口,观察输出窗口里有多少条 SELECT——如果条数大于页面当前页的数据行数加 1,基本可以确认存在 N+1。修复方式就是把循环内的查询改成IncludeSelect投影。

5.3 从 3.1 升级到 6/8 的注意点

如果你手里的项目还停在 3.1,且短期内没有重构计划,可以留意升级成本。EF Core 3.1 到 6.0 的核心变化包括:LogTo替代UseLoggerFactoryExecuteUpdate/ExecuteDelete接管了之前只能靠ExecuteSqlRaw做的批量更新删除、OwnedEntity的配置语法有调整。升级前先跑一遍现网接口的自动化测试,EF Core 的查询翻译引擎在 5.0 之后有大幅改动,少数IQueryable表达式可能会从“3.1 能翻译”变成“6.0 直接抛异常”。

如果项目还能维护,优先升级到 .NET 6 或 8 是更划算的方向——3.1 已经停止官方支持,安全补丁不会再有。但如果是内网项目、没有暴露公网风险,停留在 3.1 继续跑也不是不能接受,只是团队里的新人不一定愿意学一套过时的框架。

5.4 静态资源缓存:给 LayUI 的 js/css 加 Cache-Control

LayUI 的 js 库和 css 文件在每次刷新时重新下载,是内网系统“打开页面慢”的隐藏原因之一。静态文件中间件默认支持 ETag 和 Last-Modified,但不强制浏览器缓存。加一行配置就能让浏览器把 LayUI 的资源缓存住:

app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css")) { ctx.Context.Response.Headers["Cache-Control"] = "public,max-age=604800"; } } });

max-age=604800是 7 天,7 天内浏览器不会再向服务器发起对 LayUI js/css 的请求。前端代码发布时,在引用的文件路径后加?v=版本号就能强制更新缓存——这也解释了为什么很多旧项目里会有<script src="/layui/layui.all.js?v=20230101">这种写法。这个细节很小,但对于内网几百人同时在线的后台系统,省下来的带宽和请求耗时立竿见影。

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

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

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

立即咨询