ASP.NET Core 快速入门(FineUICore + Razor Pages + Entity Framework Core)
引子
自从 2009 年开始在博客园写文章,这是目前我写的最长的一篇文章了。
前前后后,我总共花了 5 天的时间,每天超过 3 小时不间断写作和代码调试。总共有 8 篇文章,每篇 5~6 个小结,总截图数高达 60 多个。
俗话说,桃李不言下自成蹊。
希望我的辛苦和努力能得到你的认可,并对你的学习和工作有所帮助。
欢迎评论和 (这是一个可以点击的按钮,点击即可推荐本文!)
前言
这是一个系列教程,以自微软的官方文档为基础,与微软官方文档的区别主要有如下几点:
- 更通俗易懂的语言
- 从代码入手(而非依赖VS的基架模板)
- 关键知识点的深入解读
- 加入和 WebForms / MVC 的对比
- 使用 FineUICore 控件库(而非原生的控件)
- 更少的代码和更现代化的界面(得益于FineUICore强大的控件库)
本教程包含如下内容:
- Razor Pages 项目
- 安装软件
- 下载 FineUICore 空项目
- 项目目录
- 项目运行截图
- 向 Razor Pages 添加模型
- POCO 类
- DbContext 类
- 配置数据库连接字符串
- 在 Startup.cs 中注册数据库服务
- 初始化数据库和数据迁移
- 列表页面
- 新增 Movie 页面
- 默认生成的页面和模型类
- 异步获取数据并通过表格控件展示
- 列标题文字是怎么来的?
- 格式化显示日期
- 新增页面
- 新增页面模型
- 新增页面视图
- 查看 HTTP POST 请求的数据
- 客户端模型验证
- 自定义 JavaScript 来绕开客户端验证
- 自定义模型验证错误消息
- 编辑页面
- 编辑页面模型
- 编辑页面视图
- 路由模板
- 更新电影信息
- 处理并发冲突
- 列表页面和弹出窗体
- 更新表格页面
- 行编辑按钮
- 窗体的关闭事件
- 更新编辑页面
- 先弹出提示对话框,再关闭当前窗体
- 表格与窗体互动(动图)
- 搜索框与行删除按钮
- 行删除按钮
- 行删除按钮的自定义回发
- 行删除事件
- 搜索框
- 搜索框事件
- 服务端标记搜索框不能为空
- 分页与排序
- 数据库分页
- 保持分页状态和搜索状态
- 将 5 个回发事件合并为 1 个
- 排序
- SortBy 扩展方法
- 对比 ASP.NET Core 和 FineUICore 创建的页面
- 列表页面的表格
- 编辑页面的表单
- 多个主题的页面截图赏析
- 下载项目源代码
最终完整的作品是一个简单的电影数据管理页面,如下所示:
如果你希望了解 ASP.NET MVC 的基础知识,请查阅我之前写的系列教程:ASP.NET MVC快速入门(MVC5+EF6)
一、Razor Pages项目
1.1、安装软件
在进行本教程之前需要安装如下两个软件:
- VS2019(需要选择 ASP.NET and web development 工作负载)
- .NET Core SDK 最新版:https://dotnet.microsoft.com/download
1.2、下载 FineUICore 空项目
FineUICore 相关产品可以到我的知识星球内下载:https://fineui.com/fans/
FineUICore空项目已经完成相关的配置,并可以 F5 直接运行。建议初学者从空项目入手,在熟悉 ASP.NET Core 开发流程后再自行创建项目。
在知识星球内,我们提供两个空项目,分别是:
- 【空项目】FineUICore_EmptyProject_RazorPages_vxxx.zip
- 【空项目】FineUICore_EmptyProject_vxxx.zip
其中,不带 RazorPages 字符串的是基于 MVC 架构的项目,而本教程需要使用的是带 RazorPages 标识的。
在 FineUICore_EmptyProject_RazorPages 项目中,页面视图中使用了 TagHelpers 标签,使得页面结构更加清晰,和 WebForms 的标签更加类似。
我之前曾经写过一篇文章,对比 RazorPages + TagHelpers 的项目和传统的 ASP.NET MVC + HtmlHelpers 的区别,有兴趣可以了解一下:
1.3、项目目录
这里面有一些主要的文件和目录,从上到下分别是:
1. wwwroot 目录
包含静态文件,如 HTML 文件、JavaScript 文件和 CSS 文件。
这是 ASP.NET Core 引入的一个命名约定,将全部的静态资源放置于 wwwroot 目录有助于保持项目结构的清晰,之前的ASP.NET MVC 和 WebForms项目,我们一般都自行创建一个 res 目录。
我的理解,这样的结构有助于提高项目的编译速度,如果对比 ASP.NET MVC/WebForms 和 ASP.NET Core 的项目文件(.csproj),你会发现之前的文件是显式包含进来的:
<ItemGroup> <Content Include="res\images\themes\vader.png" /> <Compile Include="Areas\Button\Controllers\ButtonController.cs" /> ... </ItemGroup>
而 ASP.NET Core 项目文件已经没有了这些配置项,说明是隐式包含的,也就是说:
- wwwroot 目录中的是网站内容,无需编译
- 其他目录中的需要编译
2. Code 目录
自行创建的目录,主要放置页面基类,已经自定义类。
3. Pages 目录
包含 Razor 页面和帮助文件(以下划线开头)。
每个 Razor 页面都由两个文件组成:
- 一个 .cshtml 文件,其中包含使用 Razor 语法的 C# 代码的 HTML 标签 。
- 一个 .cshtml.cs 文件,其中包含处理页面事件的 C# 代码 。
Razor 页面的访问遵循着简单的目录结构,比如:
- Pages/Index.cshtml 的访问URL地址:/Index 或者 /
- Pages/Admin/Users.cshtml 的访问URL地址:/Admin/Users
相比 ASP.NET MVC 架构的页面,这是一个巨大的进步,在 MVC 中我们需要借助于抽象的 Areas 目录,并且很难支持 3 级以上的URL网址,比如:/Mobile/Button/Group
帮助文件主要有如下几个:
- Shared/_Layout.cshtml:主要放置页面框架标签,比如页面<html><head><body>标签,以及引入共用的css和js文件,类似于 WebForms 中的母版页(Master Page)。
- _ViewImports.cshtml:一个 using 指令和 addTagHelpers 指令,以便在 Razor 页面使用不加前缀的控件名和标签。
- _ViewStart.cshtml:Razor页面的启动文件,会在页面执行之前调用,默认包含了对布局页面的调用。这个文件是可以在目录中嵌套的,运行是会先执行最外层目录中的_ViewStart.cshtml文件,再执行内层目录中的_ViewStart.cshtml。这也很好理解,为了确保最靠近Razor页面的内层定义覆盖外层定义。
4. appSettings.json
包含配置数据,如数据库连接字符串。默认包含了 FineUICore 的一些全局配置信息:
5. Program.cs
包含程序的入口点。
6. Startup.cs
包含配置应用行为的代码。 这个文件非常关键,里面定义了用于依赖注入的配置项,已经执行 ASP.NET Core HTTP请求管道的插件。
当然,对于初学者不需要关注这些细节问题,我们简单看下在请求管道中添加 FineUICore 插件的地方:
1.4、项目运行截图
可以直接 Ctrl + F5 不调试运行项目,运行截图如下:
项目默认的是 Pure_Black 主题,这个在 appSettings.json 中有定义 。
为了和VS2019的深色主题相配,我们特意选取了 Dark_Hive 深色主题:
二、向 Razor Pages 添加模型
2.1、POCO类
本示例将实现一个简单的电影管理页面,所以需要添加一个数据模型,也称为POCO类(plain-old CLR objects),因为它们与 EF Core 没有任何依赖关系。
在 Code 目录中新建一个 Movie.cs 文件:
using System; using System.ComponentModel.DataAnnotations; namespace FineUICore.EmptyProject.RazorPages { public class Movie { public int ID { get; set; } [Required] [Display(Name = "名称")] public string Title { get; set; } [DataType(DataType.Date)] [Display(Name = "发布日期")] public DateTime ReleaseDate { get; set; } [Display(Name = "类型")] public string Genre { get; set; } [Display(Name = "价格")] public decimal Price { get; set; } } }
Movie 类包含:
- ID 字段:数据库表主键,遵循命名约定,可以是ID或者MovieID。
- [Require]:指定字段为必填项。
- [Display(Name = "名称")]:指定字段在前端界面的显示名称,主要用于如下两个地方:
- 表格的表头文字
- 表单字段的标题文字
- [DataType(DataType.Date)]:指定此字段的数据类型为日期。 这个特性有两个作用:
- 不仅影响数据库中的字段类型(仅包含日期部分,需要包含时间);
- 也影响客户端的表格展示,和数据录入。
2.2、DbContext类
为了能正确初始化数据库,我们还需要一个继承自 DbContext的类,如下所示:
namespace FineUICore.EmptyProject.RazorPages { public class MovieContext : DbContext { public MovieContext(DbContextOptions<MovieContext> options) : base(options) { } public DbSet<Movie> Movies { get; set; } } }
由于空项目尚未引入 EF Core,所以上述代码会有错误提示。
下面我们需要安装 EntityFrameworkCore 相关程序包,打开菜单【工具】->【Nuget包管理器】:
我们需要安装如下两个程序包:
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.SqlServer:Microsoft SqlServer数据库支持。
- Microsoft.EntityFrameworkCore.Tools:用于在包管理控制台使用 EF Core 的数据迁移命令,比如Add-Migration等。
安装完成后,我们需要更新 MovieContext.cs 文件,在文件头添加如下指令:
using Microsoft.EntityFrameworkCore;
2.3、配置数据库连接字符串
本示例使用LocalDb数据库,LocalDb是轻型版的 SQL Server Express 数据库引擎,主要用于开发阶段。默认情况下,LocalDB 数据库在 C:\Users\<user>\AppData 目录下创建 *.mdf 文件。
从【视图】菜单中,打开【SQL Server 对象资源管理器】,如下所示:
在SQL Server 节点上点击右键,选中【添加 SQL Server ...】:
这时,可以看到我们连接的LocalDb数据库:
右键,点击【属性】,找到【连接字符串】:
将这个数据库字符串拷贝出来,放到 appSettings.json 文件中:
{ "FineUI": { "DebugMode": false, "CustomTheme": "pure_black", "EnableAnimation": false }, "ConnectionStrings": { "MovieContext": "Data Source=(localdb)\\MSSQLLocalDB;Database=MovieContext;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False" } }
注意:在数据库连接字符串中添加 Database=MovieContext; 用来指定我们自己的数据库,否则新建的表都会添加到系统库 master 中。
2.4、在Startup.cs中注册数据库服务
ASP.NET Core 内置了依赖注入的支持。我们首先需要在 Startup.cs 中注册各种服务(比如 Razor Pages、FineUICore以及 EF Core 服务),然后在页面中通过构造函数传入已经注册的服务。
简化后的代码:
public void ConfigureServices(IServiceCollection services) { // FineUI 服务 services.AddFineUI(Configuration); services.AddRazorPages(); services.AddDbContext<MovieContext>(options => options.UseSqlServer(Configuration.GetConnectionString("MovieContext"))); }
在 AddDbContext 中,我们通过 Configuration 来获取 appSettings.json 中定义的数据库连接字符串。
2.5、初始化数据库和数据迁移
这一节,我们会使用 EF Core 提供的数据迁移工具(Data Migration)来初始化数据库。
首先打开VS的包管理控制台(Package Manager Console),位于菜单项【工具】下面:
在 PM> 提示符下输入:Add-Migration InitialCreate
安装完成后,我们的项目多了一个 Migrations 目录,里面有一个类似 20200309093752_InitialCreate.cs 的文件。
这个就是初始化迁移脚本,里面包含一个 Up 方法和一个Down 方法,分别对应于应用本迁移和取消本迁移:
上面的 Up 方法主要做了是三个事情:
- 创建名为 Movies 的表格
- 分别定义表格列ID、Title、ReleaseDate....
- 定义表格主键为列ID
此时数据库尚未创建 Movies 表,为了执行 Up 函数,我们还需要执行 Update-Database 命令。
在 PM> 提示符下输入:Update-Database
运行结束后,在【Sql Server对象资源管理器】面板中,找到刚刚创建的 MovieContext 数据库:
查看 Movies 的视图设计器:
通过Movies 的数据预览面板,我们还可以新增一条数据:
三、列表页面
3.1、新增 Movie 页面
在VS的资源管理器面板,Pages目录右键,并添加一个 Razor 页面,命名为 Movie:
这个面板中,使用布局页留空,默认使用 _ViewStart.cshtml 中定义的布局文件(Shared/_Layout.cshtml)。
默认生成的页面文件 Movie.cshtml:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } <h1>Movie</h1>
在这个页面中:
- @page:指示这是一个页面,可以通过命名约定来访问(/Movie),@page指令必须是页面上的第一个指令。
- @model:指示本页面对应的页面模型,类似于WebForms的后台文件。
- ViewData:用来在模型和视图之间,以及视图之间传值,可以在 Shared/_Layout.cshtml 访问这里定义的 ViewData["Title"] 数据。
3.2、默认生成的页面和模型类
默认生成的页面文件 Movie.cshtml.cs 模型类:
using Microsoft.AspNetCore.Mvc.RazorPages; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { public void OnGet() { } } }
这是一个继承自 PageModel 的类,OnGet方法用来初始化页面数据,ASP.NET Core还支持异步调用,这个函数的异步签名如下所示:
public async Task OnGetAsync() { await _context.Students.ToListAsync(); }
通过在 OnGet 后面添加 Async,并且返回 async Task 这样的命名约定来启用异步调用。
本示例中的HTTP请求(Get,Post)以及对数据库的操作我们都将使用异步调用的形式,以提高性能。
3.3、异步获取数据并通过表格控件展示
将 Movie.cshtml.cs 模型类更新为:
using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } public async Task OnGetAsync() { Movies = await _context.Movies.ToListAsync(); } } }
这段代码中:
- 构造函数使用依赖注入将数据库上下文DbContext添加到页面中
- 属性Movies保存获取的电影列表
- _context.Movies.ToListAsync() 通过异步的方法获取电影列表
页面上通过一个FineUICore表格控件,用来展示电影列表数据,修改后的 Movie.cshtml 文件:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Grid ID="Grid1" ShowBorder="true" ShowHeader="true" Title="电影列表" IsViewPort="true" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> </Columns> </f:Grid> }
打开 Index.cshtml 框架页,将 Movie 页面添加到左侧菜单项:
<f:TreeNode Text="默认分类" Expanded="true"> <f:TreeNode Text="开始页面" NavigateUrl="@Url.Content("~/Hello")"></f:TreeNode> <f:TreeNode Text="登录页面" NavigateUrl="@Url.Content("~/Login")"></f:TreeNode> <f:TreeNode Text="电影管理" NavigateUrl="@Url.Content("~/Movie")"></f:TreeNode> </f:TreeNode>
Ctrl+F5 运行,此时的页面效果如下所示:
现在,我们已经完成了对数据库的读操作,并通过 FineUICore 的表格控件展现出来。
3.4、列标题文字是怎么来的?
如果你细心观察,可以发现在 Movie.cshtml 的表格控件中,我们并没有显示的定义表格列标题,而实际页面是有的,这是怎么回事?
其实这个功能是 ASP.NET Core 和 FineUICore 共同努力的结果:
1. 首先 Movie.cs 模型中使用 Display 注解来标识列的显示文本
[Display(Name = "名称")]
public string Title { get; set; }
2. 然后 FineUICore 的表格控件通过 RenderField 的 For 属性来关联模型类属性
<f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" />
其实这个代码等效于如下标签:
<f:RenderField DataField="Title" HeaderText="名称" ExpandUnusedSpace="true" />
但是这样的话,我们就丢失了两个优点:
- For属性指定的是C#代码,而DataField指定的是字符串。强类型在代码编写时有很多好处:
- 编译时错误检查,特别是以后更改模型类属性名时,可以在编译时发现错误,而不是等到运行时才发现这个名字忘记改了。
- VS贴心的智能提示。
- HeaderText同样是字符串,不仅容易写错,而且在两处定义相同的代码会产生冗余数据。
3.5、格式化显示日期
上面显示的发布日期是不友好的,我们可以在页面标签中指定格式化字符串,修改后的代码:
<f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" />
此时的页面显示效果:
四、新增页面
4.1、新增页面模型
新建一个 MovieNew 页面,将页面模型类修改为:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; namespace FineUICore.EmptyProject.RazorPages { public class MovieNewModel : PageModel { private readonly MovieContext _context; public MovieNewModel(MovieContext context) { _context = context; } public void OnGet() { } [BindProperty] public Movie Movie { get; set; } public async Task<IActionResult> OnPostBtnSave_ClickAsync() { if (ModelState.IsValid) { _context.Movies.Add(Movie); await _context.SaveChangesAsync(); Alert.Show("保存成功!"); } return UIHelper.Result(); } } }
这段代码主要有三部分组成:
- 通过构造函数注入的数据库上下文(MovieContext):用于数据库查询和更新操作
- 使用 BindProperty 修饰的 Movie 属性:BindProperty一般用于模型类的属性,执行页面回发时的数据绑定(虽然回发是WebForms中的一个术语,但用在这里也恰如其分),ASP.NET Core会从HTTP请求的各个地方(URL,Headers,Forms)查找与BindProperty相匹配的键值,并对属性进行赋值。
- OnPostXXXXAsync:这个称为页面模型处理器(Handler),用于执行页面上的【保存】按钮的回发操作。
在OnPostXXXXAsync处理程序中,执行如下操作:
- 判断模型是否有效(ModelState.IsValid):这是 ASP.NET Core 提供的一个属性,在执行模型绑定之后会紧接着进行模型验证,验证规则定义在模型类(Movie),比如[Required],[DataType(DataType.Date)]就是常见的验证规则。
- 将绑定后的Movie属性添加到数据库上下文(Movies.Add)并执行数据库保存操作(SaveChangesAsync):在Movies.Add操作时,只是将内存中的Movie属性添加一个新数据的标记,并没有真正执行数据库操作,只有在调用SaveChangesAsync异步方法时EF Core才会动态生成SQL语句并执行。
- 返回 FineUICore.UIHelper.Result():这是 FineUICore 提供的一个方法,FineUICore 中所有页面回发都是 HTTP AJAX 请求(而非整个页面的表单提交),都需要返回 UIHelper.Result()。
之前我曾写过一篇文章专门介绍 UIHelper,感兴趣的同学可以参考一下:FineUIMvc随笔(5)UIHelper是个什么梗?
4.2、新增页面视图
将页面视图文件修改为:
@page @model FineUICore.EmptyProject.RazorPages.MovieNewModel @{ ViewData["Title"] = "MovieNew"; } @section body { <f:SimpleForm ID="SimpleForm1" ShowBorder="true" ShowHeader="true" BodyPadding="10" Title="新建" IsViewPort="true"> <Items> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> <f:Button ID="BtnSave" ValidateForms="SimpleForm1" Icon="SystemSave" OnClick="@Url.Handler("BtnSave_Click")" OnClickFields="SimpleForm1" Text="保存"></f:Button> </Items> </f:SimpleForm> }
页面显示效果:
点击保存按钮:
返回列表页面,可以看到我们刚刚新增的数据:
这里使用了 FineUICore 提供的一些表单控件:
- SimpleForm作为一个表单容器:不仅在UI上提供视觉上的面板样式,而且在点击【保存】按钮时,可以通过 OnClickFields="SimpleForm1" 来指定回发操作时需要提交的表单数据。
- TextBox、DatePicker、NumberBox:这些表单字段分别对应于不同的数据表字段类型,For属性对应一个C#表达式,这种强名称的写法不仅可以在编译时错误检查,而且可以充分利用VS的智能提示。同时 FineUICore 会将相应的模型类注解解析成对应的控件属性应用到控件上,比较[Required]注解对应于TextBox控件的 Required=true属性。
- 按钮的点击事件OnClick:通过Url.Handler 来生成一个服务器请求处理URL,本示例中也就是:MovieNew?handler=BtnSave_Click
- ValidateForms="SimpleForm1":指定点击按钮回发之前需要执行的客户端验证表单。
- OnClickFields="SimpleForm1":指定点击按钮回发时需要提交的表单数据。
4.3、查看 HTTP POST 请求的数据
下面,我们通过浏览器的调试工具来观察点击【保存】按钮时的HTTP POST请求:
这里的每个地方都是可追溯的:
- Request URL:是我们通过 Url.Handler("BtnSave_Click") 生成的,对应于页面模型类的 OnPostBtnSave_ClickAsync
- Form Data:里面的 Movie.Title 等字段的值是我们通过 OnClickFields="SimpleForm1" 指定的,FineUICore 会自动计算表单内所有字段的值,并添加到 HTTP POST 请求正文中。
- _RequestVerificationToken:是我们在 Shared/_Layout.cshtml 中通过 @Html.AntiForgeryToken() 指定的。ASP.NET Core 将此字段用于阻止CSRF工具,无需特别关注。
4.4、客户端模型验证
前面我们多次提到了模型验证,具体来说分为:
- 客户端模型验证:使用 FineUICore 控件的内置支持,可以在回发事件之前触发表单的JavaScript验证(来源于模型类的数据注解)。
- 服务端模型验证:使用 ASP.NET Core 的内置支持,ModelState.IsValid 可以用来在服务端验证模型(来源于模型类的数据注解),并在失败时调用 FineUICore.Alert.Show 在前端显示提示对话框。
上述两个验证都是利用了模型类的数据注解,这也是 ASP.NET Core 一个强大的地方,无需我们在多处维护验证规则和验证提示。而 FineUICore 表单控件的内置属性支持,将进一步简化开发人员的代码编写,提升产品的可维护性。
在前端,如果未输入【名称】,点击【保存】按钮时就会弹出提示框,并阻止进一步的回发操作:
这个大家都能看明白。那有的网友就有疑问了,既然模型验证已经在客户端被阻止了,服务器端验证又有什么用呢?
其实服务器端验证非常重要!
因为客户端验证可以很轻松的被有经验的开发人员绕过!我之前在讲解《ASP.NET MVC快速入门》时,曾经有过详细的剖析,感兴趣的可以看一下。
4.5、自定义JavaScript来绕开客户端验证
这里,我们就使用一个简单的 JavaScript 调用,来绕开客户端验证。
在 MovieNew 页面,F12打开浏览器调试工具,执行如下 JS 片段:
F.doPostBack('/MovieNew?handler=BtnSave_Click', 'SimpleForm1')
在服务器模型验证失败,FineUICore会自动处理并弹出错误提示对话框:
4.6、自定义模型验证错误消息
上面的服务端模型验证错误消息是英文的,并且和客户端的验证消息不一致。其实我们可以自定义验证错误消息,修改 Movie 模型类:
[Required(ErrorMessage = "名称不能为空!")] [Display(Name = "名称")] public string Title { get; set; }
为 Required 数据注解增加了 ErrorMessage 参数,现在再验证上述的两个验证界面:
五、编辑页面
5.1、编辑页面模型
新建一个 MovieEdit 页面,将页面模型类修改为:
using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieEditModel : PageModel { private readonly MovieContext _context; public MovieEditModel(MovieContext context) { _context = context; } [BindProperty] public Movie Movie { get; set; } public async Task<IActionResult> OnGetAsync(int id) { Movie = await _context.Movies.FirstOrDefaultAsync(m => m.ID == id); if (Movie == null) { return NotFound(); } return Page(); } public async Task<IActionResult> OnPostBtnSave_ClickAsync() { if (ModelState.IsValid) { _context.Attach(Movie).State = EntityState.Modified; try { await _context.SaveChangesAsync(); Alert.Show("修改成功!"); } catch (DbUpdateConcurrencyException) { if (!_context.Movies.Any(e => e.ID == Movie.ID)) { Alert.Show("指定的电影不存在:" + Movie.Title); } else { throw; } } } return UIHelper.Result(); } } }
这段代码主要有如下几个部分:
- 通过构造函数注入的数据库上下文(MovieContext)
- 使用[BindProperty]修饰的Movie属性,有两个作用:
- 在 OnGet 时将数据从模型类传入页面视图
- 在 OnPost 时,ASP.NET Core执行模型绑定,将HTTP POST提交的数据绑定到 Movie 属性
- OnGetAsync:页面初始化代码,从数据库检索数据,并保存到Movie属性
- OnPostBtnSave_ClickAsync:点击【保存】按钮时对应的页面模型处理器(Handler)
5.2、编辑页面视图
将编辑页面视图代码修改为:
@page "{id:int}" @model FineUICore.EmptyProject.RazorPages.MovieEditModel @{ ViewData["Title"] = "MovieEdit"; } @section body { <f:SimpleForm ID="SimpleForm1" ShowBorder="true" ShowHeader="true" BodyPadding="10" Title="编辑" IsViewPort="true"> <Items> <f:HiddenField For="Movie.ID"></f:HiddenField> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> <f:Button ID="BtnSave" ValidateForms="SimpleForm1" Icon="SystemSave" OnClick="@Url.Handler("BtnSave_Click")" OnClickFields="SimpleForm1" Text="保存"></f:Button> </Items> </f:SimpleForm> }
这个页面和 MovieNew 很相似,主要有两个不同的地方:
- @page 后面多了个参数
- 新增了HiddenField表单字段保存当前电影的ID
5.3、路由模板
首先来看下 @page 指令后面的参数 {id:int},这是一个路由模板,指定了访问页面的URL中必须带一个不为空的整形参数。
在浏览器中,我们可以通过类似的URL访问:/MovieEdit/2
如果在访问路径中缺少了后面的 /2 ,ASP.NET Core 路由引擎会直接返回 HTTP 404:
下面看下 OnGet 的初始化处理:
Movie = await _context.Movies.FirstOrDefaultAsync(m => m.ID == id); if (Movie == null) { return NotFound(); }
首先在数据库中查找 ID 为传入值的电影,如果指定的电影不存在,则返回 NotFound ,ASP.NET Core会将此解析为一个 HTTP 404 响应,如下所示:
5.4、更新电影信息
更新当前电影信息的逻辑如下所示:
_context.Attach(Movie).State = EntityState.Modified; await _context.SaveChangesAsync(); Alert.Show("修改成功!");
这段代码涉及三个操作:
- Attach操作将一个实体对象添加到数据库上下文中,并将其状态更新为 Modified。我之前曾写过一篇剖析Attach的文章,感兴趣的同学可以自行查阅:AppBox升级进行时 - Attach陷阱(Entity Framework)
- SaveChangesAsync会执行数据库更新操作,EF Core会生成Update的SQL语句,并在Where字句中通过ID来指定需要更新的数据。
- FineUICore.Alert在前台界面给用户一个明确的提示。
正常操作完毕之后,页面是这样的:
5.5、处理并发冲突
上面的更新操作放在一个try-catch语句中,catch的DbUpdateConcurrencyException参数表明我们需要捕获并发冲突的异常。
if (!_context.Movies.Any(e => e.ID == Movie.ID)) { Alert.Show("指定的电影不存在:" + Movie.Title); }
在这段逻辑中,首先查找指定 Movie.ID 的数据是否存在,如果不存在则提示用户。
什么情况下会出现这个异常呢?
当我们(张三)打开某个电影的编辑页面之后,另一个用户(李四)在表格页面删除了相同的电影,然后张三更新这个电影信息。很明显,此时这条电影信息已经被删除了。
我们可以手工重现:
- 打开页面 /MovieEdit/2
- 在点击【保存】按钮之前,在 VS 中打开【SQL Server资源管理器】面板,并删除ID==2的这个数据
- 点击【保存】按钮,此时会出现错误提示。
六、列表页面和弹出窗体
前面的新增页面和编辑页面,我们都是通过URL直接访问的,如何将其整合到列表页面呢?
我们可以使用内嵌IFrame的Window控件,首先在页面上定义一个 Window 控件:
<f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window>
然后在点击新增按钮时,显示这个Window控件并传入IFrame网址:
function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); }
6.1、更新表格页面
更新后的 Movie.cshtml 代码:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="Fit" ShowHeader="false" Title="用户管理" IsViewPort="true"> <Items> <f:Grid ID="Grid1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> </Columns> </f:Grid> </Items> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '编辑'); } }); }); </script> }
相比之前的代码,这次的更新主要集中在以下几点:
- 为了将【新增】按钮放在在工具栏中,并为以后的搜索框预留位置,我们在 Grid 控件的外面嵌套了一个面板控件(Panel1)。
- 更新布局:去除Grid1的 IsViewPort 属性,为Panel1增加 IsViewPort=true和 Layout=Fit,这两个属性是让面板(Panel1)占据整个页面,并让内部的表格(Grid1)填充整个面板区域。
- 放置于工具栏的【新增】按钮,并通过Listener标签来定义客户端的点击脚本。
- 表格新增一个编辑列,并通过 RendererFunction来指定客户端渲染函数。
现在页面的显示效果如下所示:
6.2、行编辑按钮
行编辑按钮是通过一个JS渲染出来的,RenderField的RendererFunction可以指定一个渲染函数,表格在进行行渲染时会调用此函数:
function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; }
这个函数返回一个HTML片段,一个可点击的超链接,显示内容则是一个编辑图标。
基于页面标签和JS代码分离的原则,我们把超链接的 href 属性留空(href="javascript:;"),并使用如下脚本注册编辑按钮的点击事件:
F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '编辑'); } }); });
在这段JS代码中:
- F.ready 是由 FineUICore 提供的一个入口点,会在页面上控件初始化完毕后调用。所有自定义的初始化代码都应该放在 F.ready 的回调函数中。
- 通过 F.ui.Grid1 获取表格控件的客户端实例,并通过 jQuery 的 on 函数来注册行编辑按钮的点击事件。F.ui.Grid1.el 表示的是表格控件的最外层元素。
- 通过 F.ui.Grid1.getRowData 获取行信息,其中 rowData.id 对应当前行标识符(由表格的DataIdField指定对应于数据库表的哪个字段)。
- 使用 F.ui.Windows.show 来弹出窗体,并传入编辑页面的URL:/MovieEdit/2
6.3、窗体的关闭事件
在前面窗体(Window1)的标签定义中,我们看到有 OnClose 事件处理函数:
OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"
但是我们尝试点击弹出窗体右上角的关闭按钮,发现并不能触发这个关闭事件。
这是因为窗体有个控制关闭行为的属性CloseAction="Hide",默认值Hide是意思就是简单关闭,如果希望关闭之后还触发OnClose事件,我们需要设置: CloseAction="HidePostBack"
这个回发在什么情况下触发呢?
在弹出窗体IFrame页面内,保存成功时(不管是新增还是编辑)都会导致表格数据的改变,此时我们需要通知窗体(Window1)触发关闭事件。
在窗体关闭事件中:
public async Task<IActionResult> OnPostWindow1_CloseAsync(string[] Grid1_fields) { var Grid1 = UIHelper.Grid("Grid1"); var movies = await _context.Movies.ToListAsync(); Grid1.DataSource(movies, Grid1_fields); return UIHelper.Result(); }
- 首先通过 UIHelper.Grid 获取表格控件帮助类,这是由 FineUICore 提供的一个辅助方法,注意这个获取的 Grid1 仅仅是一个帮助类,而非表格控件对象。因为在 ASP.NET MVC/Core 中,回发时不会带上页面状态信息(没有了WebForms中ViewState机制),因此在服务器端无法还原表格控件及其属性。
- 重新获取电影数据,并通过表格帮助类提供的 DataSource 函数来更新表格。
6.4、更新编辑页面
将编辑页面的代码更新为:
@page "{id:int}" @model FineUICore.EmptyProject.RazorPages.MovieEditModel @{ ViewData["Title"] = "MovieEdit"; } @section body { <f:Panel ID="Panel1" ShowBorder="false" ShowHeader="false" AutoScroll="true" IsViewPort="true" Layout="Fit"> <Toolbars> <f:Toolbar Position="Bottom" ToolbarAlign="Center"> <Items> <f:Button ID="BtnClose" IconFont="Close" Text="关闭"> <Listeners> <f:Listener Event="click" Handler="F.activeWindow.hide();"></f:Listener> </Listeners> </f:Button> <f:ToolbarSeparator></f:ToolbarSeparator> <f:Button ID="BtnSave" ValidateForms="SimpleForm1" IconFont="Save" OnClick="@Url.Handler("BtnSave_Click")" OnClickFields="SimpleForm1" Text="保存后关闭"></f:Button> </Items> </f:Toolbar> </Toolbars> <Items> <f:SimpleForm ID="SimpleForm1" ShowBorder="false" ShowHeader="false" BodyPadding="10"> <Items> <f:HiddenField For="Movie.ID"></f:HiddenField> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> </Items> </f:SimpleForm> </Items> </f:Panel> }
和之前的代码相比,主要的改动:
- 为了在工具栏中放置【关闭】和【保存后关闭】按钮,我们在SimpleForm外面嵌套了一个面板(Panel1)控件。
- 布局的调整和列表页面是一样的。
- 【关闭】按钮的行为直接通过内联JavaScript脚本定义:F.activeWindow.hide(); 也即是关闭当前激活的窗体对象(在当前页面外部定义的Window1控件)
- 【保存后关闭】按钮的标签无变化,但是为了在关闭后刷新表格(也就是调用Window1的OnClose事件),我们需要在 BtnSave_Click 事件中进行处理。
6.5、先弹出提示对话框,再关闭当前窗体
我们来看下【保存后关闭】按钮的点击事件:
public async Task<IActionResult> OnPostBtnSave_ClickAsync() { if (ModelState.IsValid) { _context.Attach(Movie).State = EntityState.Modified; try { await _context.SaveChangesAsync(); Alert.Show("修改成功!", string.Empty, MessageBoxIcon.Success, ActiveWindow.GetHidePostBackReference()); } catch (DbUpdateConcurrencyException) { if (!_context.Movies.Any(e => e.ID == Movie.ID)) { Alert.Show("指定的电影不存在:" + Movie.Title); } else { throw; } } } return UIHelper.Result(); }
如果你对之前的代码还有印象,你会发现上面的代码只有一处改动,那就是把原来的:
Alert.Show("修改成功!");
改为了:
Alert.Show("修改成功!", string.Empty, MessageBoxIcon.Success, ActiveWindow.GetHidePostBackReference());
这么一个小小的改动却包含着一个大的操作流程变化:
- 首先:保存成功后,弹出提示对话框
- 其次:用户点击提示对话框的【确定】按钮时,执行脚本:ActiveWindow.GetHidePostBackReference()
- 再次:这个脚本会先关闭当前IFrame所在的窗体控件(也就是在外部页面定义的Window1控件)
- 之后:触发Window1控件的关闭事件(OnClose)
- 最后:在Window1的关闭事件中,重新绑定表格(以反映最新的数据更改)
一个看似不起眼的功能,FineUICore却花费了大量的心思来精雕细琢,确保开发人员以尽量少的代码完成所需的业务功能。
6.6、表格与窗体互动(动图)
最后,通过一个动态(GIF)来看下表格和窗体是如何交互的:
七、搜索框与行删除按钮
7.1、行删除按钮
前面我们已经为表格增加了行编辑按钮,现在照葫芦画瓢,我们再增加一个行删除按钮:
<f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField>
列渲染函数定义:
function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; }
这是一个包含了删除图标的超链接,其中 f-icon f-icon-trash 指定了一个删除样式的字体图标。这个是 FineUICore 内置的,可以在这里查看所有可用的字体图标。
7.2、行删除按钮的自定义回发
下面为行删除按钮添加点击事件,并将数据传入后台执行删除事件。
好吧,这些还是WebForms的习惯用语,其实挺亲切的,也没有违和感,当然你也可以按照 ASP.NET Core 的说法来:发起一个HTTP POST请求到页面模型处理器。
F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '确定删除此记录?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', { deletedRowID: rowData.id }); } }); } }); });
这段代码中:
- 首先弹出一个确认对话框(F.confirm),在得到用户的许可后,再执行回发操作(发起HTTP POST请求)
- 这个回发操作是由 FineUICore 提供的 F.doPostBack 进行,这里有一篇文章详细讲解 F.doPostBack 使用细节。
F.doPostBack的函数签名如下所示:
F.doPostBack(url, fields, params)
三个参数分别是:
- url:发送请求的地址
- fields:【可选】发送到服务器的表单字段数据,以逗号分隔多个表单字段(如果是容器,则查找容器内的所有表单字段)
- params:【可选】发送到服务器的数据
此时点击行删除按钮,页面的显示效果:
7.3、行删除事件
用户点击确认对话框的【确定】按钮时,才会发起回发请求:
public async Task<IActionResult> OnPostGrid_RowDeleteAsync(string[] Grid1_fields, int deletedRowID) { var Grid1 = UIHelper.Grid("Grid1"); var movie = await _context.Movies.FindAsync(deletedRowID); if (movie != null) { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); var movies = await _context.Movies.ToListAsync(); Grid1.DataSource(movies, Grid1_fields); } return UIHelper.Result(); }
这个处理器接受两个参数:
- Grid1_fields:这个是由 F.doPostBack 时第二个参数 'Panel1' 传入的。这个参数表示表格用到的数据字段列表,在数据绑定时用来限制哪些列的数据返回客户端。
- deletedRowID:这个是由 F.doPostBack 时第三个参数 { deletedRowID: rowData.id } 传入的。特别注意,指定参数类型为int就可以避免通过C#进行强制类型转换,因为数据模型中ID为整形(而不是字符串)。
处理器的主体代码中:
- 首先根据表主键查找指定的movie
- 然后从数据库上下文删除这个movie,注意此时仅仅是将movie标记为删除项,而非真正的数据库删除操作
- 其次SaveChanges动态创建删除SQL语句并执行
- 最后查询所有的电影列表,并重新绑定表格
7.4、搜索框
为了添加搜索框,我们需要再次调整页面布局,在面板中放入一个 Form 控件,此时的面板标签:
<f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用户管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名称中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1" OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel>
相比之前的代码,主要的调整为:
- 新增一个触发器输入框控件 TwinTriggerBox,并放置于一个 Form 面板中。
- 将Form面板放在 Grid 的前面。
- 调整布局:外部面板(Panel1)的布局由(Layout=Fit)改为(Layout=VBox),并为表格增加(BoxFlex=1)。这个调整的目的是让Form控件自适应高度,而Grid占据剩余的全部高度。
- 将Toolbars由原来Panel1移到Grid1里面,这样可以确保【新增】按钮在表格里面,也就是搜索框的下面。
早在 2012 年,我就写过一系列文章介绍 FineUI 的布局,现在仍然可以作为参考而不过时:https://www.cnblogs.com/sanshi/archive/2012/07/27/2611116.html
现在的页面效果:
7.5、搜索框事件
在搜索框的标签定义中,有两个回发事件的定义,如下所示:
OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1"
OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"
这两个事件分别对应触发器输入框的两个触发按钮:
- 清空图标:OnTrigger1Click
- 搜索图标:OnTrigger2Click
由于这两个事件都需要进行表格的重新绑定,所以我们先将其提取为一个独立的方法:
private async Task ReloadGrid(string[] Grid1_fields, string searchMessage) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } Movies = await q.ToListAsync(); UIHelper.Grid("Grid1").DataSource(Movies, Grid1_fields); }
这段代码中,为了将检索条件带入数据库查询,我们做了一些改变:
- IQueryable<Movie>:是 System.Linq 提供的一个查询功能,在各种查询条件以及分页排序时都需要用到,非常重要。
- q.Where:指定具体的查询条件
- q.ToList:执行数据库查询操作
下面看下搜索框的两个事件定义:
public async Task<IActionResult> OnPostTBSearchMessage_Trigger1Async(string[] Grid1_fields) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 清空搜索框,并隐藏清空图标 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 重新加载表格数据 await ReloadGrid(Grid1_fields, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 显示清空图标 TBSearchMessageUI.ShowTrigger1(true); // 重新加载表格数据 await ReloadGrid(Grid1_fields, TBSearchMessage); return UIHelper.Result(); }
这两个事件逻辑对比着看就很清楚了:
- 点击清空图标:清空搜索框文本,隐藏清空图标,重新加载表格
- 点击搜索图标:显示清空图标,重新加载表格
7.6、服务端标记搜索框不能为空
在上面的实现中,如果用户将搜索框留空并点击搜索图标,还是会触发搜索事件。
我们在服务器端阻止这个行为,FineUICore提供了标记某个字段无效的方法:
public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能为空!"); } else { // 显示清空图标 TBSearchMessageUI.ShowTrigger1(true); // 重新加载表格数据 await ReloadGrid(Grid1_fields, TBSearchMessage); } return UIHelper.Result(); }
在这段代码中,如果搜索文本为空,会调用文本框的 MarkInvalid 方法将文本框标记为无效。
看下实际的效果:
目前为止,我们来看下更新后的列表页面视图和模型类的代码:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用户管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名称中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1" OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '确定删除此记录?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', { deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '编辑'); } }); }); </script> }
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } public async Task OnGetAsync() { Movies = await _context.Movies.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, string searchMessage) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } Movies = await q.ToListAsync(); UIHelper.Grid("Grid1").DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostWindow1_CloseAsync(string[] Grid1_fields) { // 重新加载表格数据 await ReloadGrid(Grid1_fields, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostGrid_RowDeleteAsync(string[] Grid1_fields, int deletedRowID) { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie != null) { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); // 重新加载表格数据 await ReloadGrid(Grid1_fields, string.Empty); } return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger1Async(string[] Grid1_fields) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 清空搜索框,并隐藏清空图标 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 重新加载表格数据 await ReloadGrid(Grid1_fields, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能为空!"); } else { // 显示清空图标 TBSearchMessageUI.ShowTrigger1(true); // 重新加载表格数据 await ReloadGrid(Grid1_fields, TBSearchMessage); } return UIHelper.Result(); } } }
八、分页与排序
8.1、数据库分页
这一节我们会给表格控件增加分页和排序,首先来看下分页的标签定义:
<f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Grid1_PageIndexChanged")" OnPageIndexChangedFields="Panel1">
为了支持数据库分页,我们增加如下一些属性:
- AllowPaging:启用分页
- IsDatabasePaging:启用数据库分页
- PageSize:每页显示的记录数
- RecordCount:总记录数
- OnPageIndexChanged:分页改变事件
其中 PageSize 和 RecordCount 数据来自于模型类属性:
// 每页显示记录数 public int PageSize { get; set; } = 5; // 总记录数 public int RecordCount { get; set; }
由于需要在页面第一次加载时(OnGet)和HTTP POST请求时(OnPost)获取表格数据,我们将获取表格分页数据的方法提取为一个公共函数:
private async Task PrepareGridData(string searchMessage, int pageIndex) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //对传入的 pageIndex 进行有效性验证 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (pageIndex > pageCount - 1) { pageIndex = pageCount - 1; } if (pageIndex < 0) { pageIndex = 0; } // 分页 q = q.Skip(pageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); }
这个函数中会对 RecordCount 和 Movies 属性进行赋值,其中 Movies 表示的就是当前分页的数据(数据库分页)。
在页面第一次加载时的调用:
public async Task OnGetAsync() { await PrepareGridData(string.Empty, 0); }
在分页改变事件中的调用:
public async Task<IActionResult> OnPostGrid1_PageIndexChangedAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); }
private async Task ReloadGrid(string[] Grid1_fields, int Grid1_pageIndex, string searchMessage) { await PrepareGridData(searchMessage, Grid1_pageIndex); var Grid1UI = UIHelper.Grid("Grid1"); // 设置总记录数 Grid1UI.RecordCount(RecordCount); // 设置分页数据 Grid1UI.DataSource(Movies, Grid1_fields); }
此时的分页效果:
8.2、保持分页状态和搜索状态
不仅如此,我们还需要对 Window1_Close、Grid_RowDelete、TBSearchMessage_Trigger1、TBSearchMessage_Trigger2 的事件处理函数进行重构,传入 Grid1_pageIndex 和 TBSearchMessage 参数。
原因是我们希望在用户关闭窗体时、行删除时,以及搜索时,能够保持页面上的状态不丢失,目前的状态主要有两个:
- 当前正在展现表格的哪一页?
- 当前正在搜索哪个关键词?
更新后的模型类代码如下所示:
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } // 每页显示记录数 public int PageSize { get; set; } = 5; // 总记录数 public int RecordCount { get; set; } public async Task OnGetAsync() { await PrepareGridData(string.Empty, 0); } private async Task PrepareGridData(string searchMessage, int pageIndex) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //对传入的 pageIndex 进行有效性验证 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (pageIndex > pageCount - 1) { pageIndex = pageCount - 1; } if (pageIndex < 0) { pageIndex = 0; } // 分页 q = q.Skip(pageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, int Grid1_pageIndex, string searchMessage) { await PrepareGridData(searchMessage, Grid1_pageIndex); var Grid1UI = UIHelper.Grid("Grid1"); // 设置总记录数 Grid1UI.RecordCount(RecordCount); // 设置分页数据 Grid1UI.DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostWindow1_CloseAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); } public async Task<IActionResult> OnPostGrid_RowDeleteAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage, int deletedRowID) { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie != null) { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); } return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger1Async(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 清空搜索框,并隐藏清空图标 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能为空!"); } else { // 显示清空图标 TBSearchMessageUI.ShowTrigger1(true); // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); } return UIHelper.Result(); } public async Task<IActionResult> OnPostGrid1_PageIndexChangedAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); } } }
此时对应的页面视图:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用户管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名称中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1" OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Grid1_PageIndexChanged")" OnPageIndexChangedFields="Panel1"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '确定删除此记录?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', { deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '编辑'); } }); }); </script> }
为了验证状态保持效果,我们进行如下操作步骤:
- 转到第2页
- 搜索关键词:【星球】,此时能保持状态:表格处于第2页
- 转到第1页,此时能保持状态:关键词为【星球】
- 修改一条记录并返回,此时能保持状态:表格处于第2页 + 关键词为【星球】
下面的动图展示了这一系列操作:
8.3、将 5 个回发事件合并为 1 个
你可能也注意到了,上述 5 个回发事件都需要接受如下三个参数:
- string[] Grid1_fields:表格需要用到的数据字段(对应模型类属性名列表)
- int Grid1_pageIndex:表格当前位于第几页
- string TBSearchMessage:搜索关键词
并且这 5 个回发事件最后都要重新绑定表格数据,造成很多代码都是重复的。
随着程序功能的增加,这个重复会越来越多,比如更多的查询条件,以及后面要添加的表格排序,都需要添加更多的参数。
对于一个注重自我修养的程序员,如此的代码重复是我们不能容忍的,重构在所难免。
为了合并 5 个事件处理函数,我们需要从视图代码入手,通过参数指定需要进行的操作,所有需要回发的地方都要修改。
1. 行删除事件
F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', {
deletedRowID: rowData.id
});
修改为:
F.doPostBack('@Url.Handler("Movie_PostBack")', 'Panel1', { actionType: 'delete', deletedRowID: rowData.id });
2. 窗体关闭事件
OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"
修改为:
OnClose="@Url.Handler("Movie_PostBack")" OnCloseFields="Panel1" OnCloseParameter1="@(new Parameter("actionType", "close", ParameterMode.String))"
3. 表格分页事件
OnPageIndexChanged="@Url.Handler("Grid1_PageIndexChanged")" OnPageIndexChangedFields="Panel1"
修改为:
OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))"
4. 搜索框事件
OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1"
OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"
修改为:
OnTrigger1Click="@Url.Handler("Movie_PostBack")" OnTrigger1ClickFields="Panel1" OnTrigger1ClickParameter1="@(new Parameter("actionType", "trigger1", ParameterMode.String))"
OnTrigger2Click="@Url.Handler("Movie_PostBack")" OnTrigger2ClickFields="Panel1" OnTrigger2ClickParameter1="@(new Parameter("actionType", "trigger2", ParameterMode.String))"
更新后的视图文件:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用户管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名称中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("Movie_PostBack")" OnTrigger1ClickFields="Panel1" OnTrigger1ClickParameter1="@(new Parameter("actionType", "trigger1", ParameterMode.String))" OnTrigger2Click="@Url.Handler("Movie_PostBack")" OnTrigger2ClickFields="Panel1" OnTrigger2ClickParameter1="@(new Parameter("actionType", "trigger2", ParameterMode.String))"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Movie_PostBack")" OnCloseFields="Panel1" OnCloseParameter1="@(new Parameter("actionType", "close", ParameterMode.String))"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '确定删除此记录?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Movie_PostBack")', 'Panel1', { actionType: 'delete', deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '编辑'); } }); }); </script> }
更新后的模型类:
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } // 每页显示记录数 public int PageSize { get; set; } = 5; // 总记录数 public int RecordCount { get; set; } public async Task OnGetAsync() { await PrepareGridData(string.Empty, 0); } private async Task PrepareGridData(string searchMessage, int pageIndex) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //对传入的 pageIndex 进行有效性验证 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (pageIndex > pageCount - 1) { pageIndex = pageCount - 1; } if (pageIndex < 0) { pageIndex = 0; } // 分页 q = q.Skip(pageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, int Grid1_pageIndex, string searchMessage) { await PrepareGridData(searchMessage, Grid1_pageIndex); var Grid1UI = UIHelper.Grid("Grid1"); // 设置总记录数 Grid1UI.RecordCount(RecordCount); // 设置分页数据 Grid1UI.DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostMovie_PostBackAsync(string actionType, string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage, int deletedRowID) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (actionType == "delete") { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie == null) { Alert.Show("指定的电影不存在!"); return UIHelper.Result(); } else { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); } } else if (actionType == "trigger1") { // 清空搜索框,并隐藏清空图标 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 不要忘记设置搜索文本为空字符串 TBSearchMessage = string.Empty; } else if (actionType == "trigger2") { if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能为空!"); return UIHelper.Result(); } else { // 显示清空图标 TBSearchMessageUI.ShowTrigger1(true); } } // actionType: page, close 无需特殊处理 // 重新加载表格数据 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); } } }
这段代码中:
- 通过 actionType 获取当前需要执行的操作
- 点击清空图标时,要设置 TBSearchMessage = string.Empty; 因为后面重新绑定表格数据时用到这个变量
- 表格分页和窗体关闭事件无需特殊处理,只需要重新绑定表格即可
8.4、排序
在前面表格分页实现之后,再添加排序操作就轻车熟路了。首先看下表格的标签定义:
<f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))" AllowSorting="true" SortField="@Model.SortField" SortDirection="@Model.SortDirection" OnSort="@Url.Handler("Movie_PostBack")" OnSortFields="Panel1" OnSortParameter1="@(new Parameter("actionType", "sort", ParameterMode.String))"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" SortField="Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" SortField="ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" SortField="Genre" /> <f:RenderField For="Movies.First().Price" SortField="Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> </f:Grid>
注意新增的部分:
- AllowSorting、SortField、SortDirection:启用排序,设置表格默认的排序字段和排序方向。
- OnSort:排序事件
- 为需要排序的列添加SortField属性,比如名称列:For="Movies.First().Title" SortField="Title"
模型类中的修改不多,只需要在分页之前添加排序代码即可:
// 排序 q = q.SortBy(SortField + " " + SortDirection); // 分页 q = q.Skip(PageIndex * PageSize).Take(PageSize);
注意,这里的 SortBy 并非 .NET Core 原生支持的方法,而是我们自定义的一个扩展方法。
8.5、SortBy 扩展方法
因为 .NET Core 提供的 OrderByDescending 和 OrderBy 不支持字符串参数,因为要支持我们的 SortField 和 SortDirection,我们需要写一堆id-else语句,类似如下所示:
if (SortDirection == "DESC") { if (SortField == "Title") { q = q.OrderByDescending(q => q.Title); } else if (SortField == "ReleaseDate") { q = q.OrderByDescending(q => q.ReleaseDate); } else if (SortField == "Price") { q = q.OrderByDescending(q => q.Price); } else if (SortField == "Genre") { q = q.OrderByDescending(q => q.Genre); } } else { if (SortField == "Title") { q = q.OrderBy(q => q.Title); } else if (SortField == "ReleaseDate") { q = q.OrderBy(q => q.ReleaseDate); } else if (SortField == "Price") { q = q.OrderBy(q => q.Price); } else if (SortField == "Genre") { q = q.OrderBy(q => q.Genre); } }
这个代码是如此的丑陋,以至于我根本无需下手......
早在 2013年我们更新 AppBoxPro 时就曾提出这个问题,并综合大家的代码给我了我们的解决办法:AppBox升级进行时 - 如何向OrderBy传递字符串参数(Entity Framework)
那就是自定义扩展方法,如下所示:
现在我们来看下列表页面的完整视图代码:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用户管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名称中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("Movie_PostBack")" OnTrigger1ClickFields="Panel1" OnTrigger1ClickParameter1="@(new Parameter("actionType", "trigger1", ParameterMode.String))" OnTrigger2Click="@Url.Handler("Movie_PostBack")" OnTrigger2ClickFields="Panel1" OnTrigger2ClickParameter1="@(new Parameter("actionType", "trigger2", ParameterMode.String))"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))" AllowSorting="true" SortField="@Model.SortField" SortDirection="@Model.SortDirection" OnSort="@Url.Handler("Movie_PostBack")" OnSortFields="Panel1" OnSortParameter1="@(new Parameter("actionType", "sort", ParameterMode.String))"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" SortField="Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" SortField="ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" SortField="Genre" /> <f:RenderField For="Movies.First().Price" SortField="Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Movie_PostBack")" OnCloseFields="Panel1" OnCloseParameter1="@(new Parameter("actionType", "close", ParameterMode.String))"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '确定删除此记录?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Movie_PostBack")', 'Panel1', { actionType: 'delete', deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '编辑'); } }); }); </script> }
列表页面完整的模型类代码:
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } // 当前所在的页 public int PageIndex { get; set; } = 0; // 每页显示记录数 public int PageSize { get; set; } = 5; // 总记录数 public int RecordCount { get; set; } // 排序字段名称 public string SortField { get; set; } = "Title"; // 排序方向(DESC:倒序,ASC:正序) public string SortDirection { get; set; } = "DESC"; public async Task OnGetAsync() { await PrepareGridData(string.Empty); } private async Task PrepareGridData(string searchMessage) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //对传入的 pageIndex 进行有效性验证 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (PageIndex > pageCount - 1) { PageIndex = pageCount - 1; } if (PageIndex < 0) { PageIndex = 0; } // 排序 q = q.SortBy(SortField + " " + SortDirection); // 分页 q = q.Skip(PageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, string searchMessage) { await PrepareGridData(searchMessage); var Grid1UI = UIHelper.Grid("Grid1"); // 设置总记录数 Grid1UI.RecordCount(RecordCount); // 设置分页数据 Grid1UI.DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostMovie_PostBackAsync(string actionType, string[] Grid1_fields, int Grid1_pageIndex, string Grid1_sortField, string Grid1_sortDirection, string TBSearchMessage, int deletedRowID) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (actionType == "delete") { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie == null) { Alert.Show("指定的电影不存在!"); return UIHelper.Result(); } else { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); } } else if (actionType == "trigger1") { // 清空搜索框,并隐藏清空图标 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 不要忘记设置搜索文本为空字符串 TBSearchMessage = string.Empty; } else if (actionType == "trigger2") { if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能为空!"); return UIHelper.Result(); } else { // 显示清空图标 TBSearchMessageUI.ShowTrigger1(true); } } // actionType: page, close 无需特殊处理 PageIndex = Grid1_pageIndex; SortField = Grid1_sortField; SortDirection = Grid1_sortDirection; // 重新加载表格数据 await ReloadGrid(Grid1_fields, TBSearchMessage); return UIHelper.Result(); } } }
现在,来进行最后一波操作,看下我们的劳动成果:
九、对比 ASP.NET Core 和 FineUICore 创建的页面
FineUICore控件不仅带来了漂亮的界面,而且能进一步简化代码编写工作。
这一节我们就简单对比下 ASP.NET Core 原生实现的页面和 FineUICore 控件库实现的页面。
列表页面的表格
ASP.NET Core原生实现的表格
<table class="table"> <thead> <tr> <th> @Html.DisplayNameFor(model => model.Movie[0].Title) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].ReleaseDate) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].Genre) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].Price) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].Rating) </th> <th></th> </tr> </thead> <tbody> @foreach (var item in Model.Movie) { <tr> <td> @Html.DisplayFor(modelItem => item.Title) </td> <td> @Html.DisplayFor(modelItem => item.ReleaseDate) </td> <td> @Html.DisplayFor(modelItem => item.Genre) </td> <td> @Html.DisplayFor(modelItem => item.Price) </td> <td> @Html.DisplayFor(modelItem => item.Rating) </td> <td> <a asp-page="./Edit" asp-route-id="@item.ID">Edit</a> | <a asp-page="./Details" asp-route-id="@item.ID">Details</a> | <a asp-page="./Delete" asp-route-id="@item.ID">Delete</a> </td> </tr> } </tbody> </table>
原生实现中,我们不仅要接触原生的<table><td>标签,而且需要单独创建表头,并对列表集合进行foreach循环遍历。
显示效果:
FineUICore控件实现的表格
<f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="150" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> </f:Grid>
FineUICore实现的表格更加架构化和标签化,其中没有混杂各种 C# 代码,只需要指定数据源 DataSource 即可,代码更加简洁。
显示效果:
编辑页面的表单
ASP.NET Core原生实现的表单
<form method="post"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <input type="hidden" asp-for="Movie.ID" /> <div class="form-group"> <label asp-for="Movie.Title" class="control-label"></label> <input asp-for="Movie.Title" class="form-control" /> <span asp-validation-for="Movie.Title" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.ReleaseDate" class="control-label"></label> <input asp-for="Movie.ReleaseDate" class="form-control" /> <span asp-validation-for="Movie.ReleaseDate" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.Genre" class="control-label"></label> <input asp-for="Movie.Genre" class="form-control" /> <span asp-validation-for="Movie.Genre" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.Price" class="control-label"></label> <input asp-for="Movie.Price" class="form-control" /> <span asp-validation-for="Movie.Price" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.Rating" class="control-label"></label> <input asp-for="Movie.Rating" class="form-control" /> <span asp-validation-for="Movie.Rating" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Save" class="btn btn-primary" /> </div> </form>
在这段代码中,我们不仅需要接触<form><label><input>等原生HTML标签,还要考虑布局<div class=form-group>和样式class=control-label,更糟糕的是,对于每个表单字段都需要三个标签实现:
- label:显示表单字段的标题文本
- input:表单字段
- span:验证失败的提示信息
显示效果:
FineUICore控件实现的表单
<f:SimpleForm ID="SimpleForm1" ShowBorder="false" ShowHeader="false" BodyPadding="10"> <Items> <f:HiddenField For="Movie.ID"></f:HiddenField> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> </Items> </f:SimpleForm>
FineUICore的实现更加标签化,其中没有混杂C#和HTML代码,并且对于每个表单字段只需要一个控件就行了(想想ASP.NET Core原生的3个标签实现),而且不用考虑布局和字段的样式问题。
显示效果:
多个主题的页面截图赏析
十、下载项目源代码
FineUICore(基础版)非免费软件,你可以加入【三石和他的朋友们】知识星球下载本教程的完整项目源代码:
FineUICore算是国内坚持在ASP.NET Core阵营仅有的控件库了,我们花了大量的心思在里面,细节上追求精益求精,希望大家能善待之。
俗话说,三十年河东,三十年河西,让我们共同来迎接 ASP.NET Core 的春天,让 FineUICore 就做这棵大树上一朵绽放的花朵吧。
欢迎评论和 (这是一个可以点击的按钮,点击即可推荐本文!)