ASP.NET Core 快速入门(FineUICore + Razor Pages + Entity Framework Core)

引子

自从 2009 年开始在博客园写文章,这是目前我写的最长的一篇文章了。

前前后后,我总共花了 5 天的时间,每天超过 3 小时不间断写作和代码调试。总共有 8 篇文章,每篇 5~6 个小结,总截图数高达 60 多个。

 

俗话说,桃李不言下自成蹊。

希望我的辛苦和努力能得到你的认可,并对你的学习和工作有所帮助。

欢迎评论和  (这是一个可以点击的按钮,点击即可推荐本文!)

 

 

前言

这是一个系列教程,以自微软的官方文档为基础,与微软官方文档的区别主要有如下几点:

  1. 更通俗易懂的语言
  2. 从代码入手(而非依赖VS的基架模板)
  3. 关键知识点的深入解读
  4. 加入和 WebForms / MVC 的对比
  5. 使用 FineUICore 控件库(而非原生的控件)
  6. 更少的代码和更现代化的界面(得益于FineUICore强大的控件库)

 

本教程包含如下内容:

  1. Razor Pages 项目
    1. 安装软件
    2. 下载 FineUICore 空项目
    3. 项目目录
    4. 项目运行截图
  2. 向 Razor Pages 添加模型
    1. POCO 类
    2. DbContext 类
    3. 配置数据库连接字符串
    4. 在 Startup.cs 中注册数据库服务
    5. 初始化数据库和数据迁移
  3. 列表页面
    1. 新增 Movie 页面
    2. 默认生成的页面和模型类
    3. 异步获取数据并通过表格控件展示
    4. 列标题文字是怎么来的?
    5. 格式化显示日期
  4. 新增页面
    1. 新增页面模型
    2. 新增页面视图
    3. 查看 HTTP POST 请求的数据
    4. 客户端模型验证
    5. 自定义 JavaScript 来绕开客户端验证
    6. 自定义模型验证错误消息
  5. 编辑页面
    1. 编辑页面模型
    2. 编辑页面视图
    3. 路由模板
    4. 更新电影信息
    5. 处理并发冲突  
  6. 列表页面和弹出窗体
    1. 更新表格页面
    2. 行编辑按钮
    3. 窗体的关闭事件
    4. 更新编辑页面
    5. 先弹出提示对话框,再关闭当前窗体
    6. 表格与窗体互动(动图)
  7. 搜索框与行删除按钮
    1. 行删除按钮
    2. 行删除按钮的自定义回发
    3. 行删除事件
    4. 搜索框
    5. 搜索框事件
    6. 服务端标记搜索框不能为空  
  8. 分页与排序
    1. 数据库分页
    2. 保持分页状态和搜索状态
    3. 将 5 个回发事件合并为 1 个
    4. 排序
    5. SortBy 扩展方法
  9. 对比 ASP.NET Core 和 FineUICore 创建的页面
    1. 列表页面的表格
    2. 编辑页面的表单
    3. 多个主题的页面截图赏析
  10. 下载项目源代码

最终完整的作品是一个简单的电影数据管理页面,如下所示:

 

如果你希望了解 ASP.NET MVC 的基础知识,请查阅我之前写的系列教程:ASP.NET MVC快速入门(MVC5+EF6)

一、Razor Pages项目

1.1、安装软件

在进行本教程之前需要安装如下两个软件:

  1. VS2019(需要选择 ASP.NET and web development 工作负载)
  2. .NET Core SDK 最新版:https://dotnet.microsoft.com/download

 

1.2、下载 FineUICore 空项目

FineUICore 相关产品可以到我的知识星球内下载:https://fineui.com/fans/

FineUICore空项目已经完成相关的配置,并可以 F5 直接运行。建议初学者从空项目入手,在熟悉 ASP.NET Core 开发流程后再自行创建项目。

在知识星球内,我们提供两个空项目,分别是:

  1. 【空项目】FineUICore_EmptyProject_RazorPages_vxxx.zip
  2. 【空项目】FineUICore_EmptyProject_vxxx.zip

其中,不带 RazorPages 字符串的是基于 MVC 架构的项目,而本教程需要使用的是带 RazorPages 标识的。

 

在 FineUICore_EmptyProject_RazorPages 项目中,页面视图中使用了 TagHelpers 标签,使得页面结构更加清晰,和 WebForms 的标签更加类似。

我之前曾经写过一篇文章,对比 RazorPages + TagHelpers 的项目和传统的 ASP.NET MVC + HtmlHelpers 的区别,有兴趣可以了解一下:

全新ASP.NET Core,比WebForms还简单!

 

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 项目文件已经没有了这些配置项,说明是隐式包含的,也就是说:

  1. wwwroot 目录中的是网站内容,无需编译
  2. 其他目录中的需要编译

 

2. Code 目录

自行创建的目录,主要放置页面基类,已经自定义类。

 

3. Pages 目录

包含 Razor 页面和帮助文件(以下划线开头)。

每个 Razor 页面都由两个文件组成:

  1. 一个 .cshtml 文件,其中包含使用 Razor 语法的 C# 代码的 HTML 标签 。
  2. 一个 .cshtml.cs 文件,其中包含处理页面事件的 C# 代码 。

 

Razor 页面的访问遵循着简单的目录结构,比如:

  1. Pages/Index.cshtml 的访问URL地址:/Index 或者 /
  2. Pages/Admin/Users.cshtml 的访问URL地址:/Admin/Users

相比 ASP.NET MVC 架构的页面,这是一个巨大的进步,在 MVC 中我们需要借助于抽象的 Areas 目录,并且很难支持 3 级以上的URL网址,比如:/Mobile/Button/Group

 

帮助文件主要有如下几个:

  1. Shared/_Layout.cshtml:主要放置页面框架标签,比如页面<html><head><body>标签,以及引入共用的css和js文件,类似于 WebForms 中的母版页(Master Page)。
  2. _ViewImports.cshtml:一个 using 指令和 addTagHelpers 指令,以便在 Razor 页面使用不加前缀的控件名和标签。
  3. _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 类包含:

  1. ID 字段:数据库表主键,遵循命名约定,可以是ID或者MovieID。
  2. [Require]:指定字段为必填项。
  3. [Display(Name = "名称")]:指定字段在前端界面的显示名称,主要用于如下两个地方:
    1. 表格的表头文字
    2. 表单字段的标题文字  
  4. [DataType(DataType.Date)]:指定此字段的数据类型为日期。 这个特性有两个作用:
    1. 不仅影响数据库中的字段类型(仅包含日期部分,需要包含时间);
    2. 也影响客户端的表格展示,和数据录入。

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包管理器】:

我们需要安装如下两个程序包:

  1. Microsoft.EntityFrameworkCore
  2. Microsoft.EntityFrameworkCore.SqlServer:Microsoft SqlServer数据库支持。
  3. 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 方法主要做了是三个事情:

  1. 创建名为 Movies 的表格
  2. 分别定义表格列ID、Title、ReleaseDate....
  3. 定义表格主键为列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>

在这个页面中:

  1. @page:指示这是一个页面,可以通过命名约定来访问(/Movie),@page指令必须是页面上的第一个指令。
  2. @model:指示本页面对应的页面模型,类似于WebForms的后台文件。
  3. 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();
        }
    }
}

这段代码中:

  1. 构造函数使用依赖注入将数据库上下文DbContext添加到页面中
  2. 属性Movies保存获取的电影列表
  3. _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" />

但是这样的话,我们就丢失了两个优点:

  1. For属性指定的是C#代码,而DataField指定的是字符串。强类型在代码编写时有很多好处:
    1. 编译时错误检查,特别是以后更改模型类属性名时,可以在编译时发现错误,而不是等到运行时才发现这个名字忘记改了。
    2. VS贴心的智能提示。
  2. 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();
        }

    }
}

 

这段代码主要有三部分组成:

  1. 通过构造函数注入的数据库上下文(MovieContext):用于数据库查询和更新操作
  2. 使用 BindProperty 修饰的 Movie 属性:BindProperty一般用于模型类的属性,执行页面回发时的数据绑定(虽然回发是WebForms中的一个术语,但用在这里也恰如其分),ASP.NET Core会从HTTP请求的各个地方(URL,Headers,Forms)查找与BindProperty相匹配的键值,并对属性进行赋值。
  3. OnPostXXXXAsync:这个称为页面模型处理器(Handler),用于执行页面上的【保存】按钮的回发操作。

在OnPostXXXXAsync处理程序中,执行如下操作:

  1. 判断模型是否有效(ModelState.IsValid):这是 ASP.NET Core 提供的一个属性,在执行模型绑定之后会紧接着进行模型验证,验证规则定义在模型类(Movie),比如[Required],[DataType(DataType.Date)]就是常见的验证规则。
  2. 将绑定后的Movie属性添加到数据库上下文(Movies.Add)并执行数据库保存操作(SaveChangesAsync):在Movies.Add操作时,只是将内存中的Movie属性添加一个新数据的标记,并没有真正执行数据库操作,只有在调用SaveChangesAsync异步方法时EF Core才会动态生成SQL语句并执行。
  3. 返回 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 提供的一些表单控件:

  1. SimpleForm作为一个表单容器:不仅在UI上提供视觉上的面板样式,而且在点击【保存】按钮时,可以通过 OnClickFields="SimpleForm1" 来指定回发操作时需要提交的表单数据。
  2. TextBox、DatePicker、NumberBox:这些表单字段分别对应于不同的数据表字段类型,For属性对应一个C#表达式,这种强名称的写法不仅可以在编译时错误检查,而且可以充分利用VS的智能提示。同时 FineUICore 会将相应的模型类注解解析成对应的控件属性应用到控件上,比较[Required]注解对应于TextBox控件的 Required=true属性。
  3. 按钮的点击事件OnClick:通过Url.Handler 来生成一个服务器请求处理URL,本示例中也就是:MovieNew?handler=BtnSave_Click
    1. ValidateForms="SimpleForm1":指定点击按钮回发之前需要执行的客户端验证表单。
    2. OnClickFields="SimpleForm1":指定点击按钮回发时需要提交的表单数据。

 

4.3、查看 HTTP POST 请求的数据

下面,我们通过浏览器的调试工具来观察点击【保存】按钮时的HTTP POST请求:

这里的每个地方都是可追溯的:

  1. Request URL:是我们通过 Url.Handler("BtnSave_Click") 生成的,对应于页面模型类的 OnPostBtnSave_ClickAsync
  2. Form Data:里面的 Movie.Title 等字段的值是我们通过 OnClickFields="SimpleForm1" 指定的,FineUICore 会自动计算表单内所有字段的值,并添加到 HTTP POST 请求正文中。
  3. _RequestVerificationToken:是我们在 Shared/_Layout.cshtml 中通过 @Html.AntiForgeryToken() 指定的。ASP.NET Core 将此字段用于阻止CSRF工具,无需特别关注。

 

4.4、客户端模型验证

 前面我们多次提到了模型验证,具体来说分为:

  1. 客户端模型验证:使用 FineUICore 控件的内置支持,可以在回发事件之前触发表单的JavaScript验证(来源于模型类的数据注解)。
  2. 服务端模型验证:使用 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();
        }

    }
}

这段代码主要有如下几个部分:

  1. 通过构造函数注入的数据库上下文(MovieContext)
  2. 使用[BindProperty]修饰的Movie属性,有两个作用:
    1. 在 OnGet 时将数据从模型类传入页面视图
    2. 在 OnPost 时,ASP.NET Core执行模型绑定,将HTTP POST提交的数据绑定到 Movie 属性
  3. OnGetAsync:页面初始化代码,从数据库检索数据,并保存到Movie属性
  4. 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 很相似,主要有两个不同的地方:

  1. @page 后面多了个参数
  2. 新增了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("修改成功!");

这段代码涉及三个操作:

  1. Attach操作将一个实体对象添加到数据库上下文中,并将其状态更新为 Modified。我之前曾写过一篇剖析Attach的文章,感兴趣的同学可以自行查阅:AppBox升级进行时 - Attach陷阱(Entity Framework)
  2. SaveChangesAsync会执行数据库更新操作,EF Core会生成Update的SQL语句,并在Where字句中通过ID来指定需要更新的数据。
  3. FineUICore.Alert在前台界面给用户一个明确的提示。

正常操作完毕之后,页面是这样的:

 

5.5、处理并发冲突

上面的更新操作放在一个try-catch语句中,catch的DbUpdateConcurrencyException参数表明我们需要捕获并发冲突的异常。

if (!_context.Movies.Any(e => e.ID == Movie.ID))
{
    Alert.Show("指定的电影不存在:" + Movie.Title);
}

在这段逻辑中,首先查找指定 Movie.ID 的数据是否存在,如果不存在则提示用户。

 

什么情况下会出现这个异常呢?

当我们(张三)打开某个电影的编辑页面之后,另一个用户(李四)在表格页面删除了相同的电影,然后张三更新这个电影信息。很明显,此时这条电影信息已经被删除了。

我们可以手工重现:

  1. 打开页面 /MovieEdit/2
  2. 在点击【保存】按钮之前,在 VS 中打开【SQL Server资源管理器】面板,并删除ID==2的这个数据
  3. 点击【保存】按钮,此时会出现错误提示。

 

 

 

六、列表页面和弹出窗体

前面的新增页面和编辑页面,我们都是通过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>

}

相比之前的代码,这次的更新主要集中在以下几点:

  1. 为了将【新增】按钮放在在工具栏中,并为以后的搜索框预留位置,我们在 Grid 控件的外面嵌套了一个面板控件(Panel1)。
  2. 更新布局:去除Grid1的 IsViewPort 属性,为Panel1增加 IsViewPort=true和 Layout=Fit,这两个属性是让面板(Panel1)占据整个页面,并让内部的表格(Grid1)填充整个面板区域。
  3. 放置于工具栏的【新增】按钮,并通过Listener标签来定义客户端的点击脚本。
  4. 表格新增一个编辑列,并通过 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代码中:

  1. F.ready 是由 FineUICore 提供的一个入口点,会在页面上控件初始化完毕后调用。所有自定义的初始化代码都应该放在 F.ready 的回调函数中。
  2. 通过 F.ui.Grid1 获取表格控件的客户端实例,并通过 jQuery 的 on 函数来注册行编辑按钮的点击事件。F.ui.Grid1.el 表示的是表格控件的最外层元素。
  3. 通过 F.ui.Grid1.getRowData 获取行信息,其中 rowData.id 对应当前行标识符(由表格的DataIdField指定对应于数据库表的哪个字段)。
  4. 使用 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();
}
  1. 首先通过 UIHelper.Grid 获取表格控件帮助类,这是由 FineUICore 提供的一个辅助方法,注意这个获取的 Grid1 仅仅是一个帮助类,而非表格控件对象。因为在 ASP.NET MVC/Core 中,回发时不会带上页面状态信息(没有了WebForms中ViewState机制),因此在服务器端无法还原表格控件及其属性。
  2. 重新获取电影数据,并通过表格帮助类提供的 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>

}

和之前的代码相比,主要的改动:

  1. 为了在工具栏中放置【关闭】和【保存后关闭】按钮,我们在SimpleForm外面嵌套了一个面板(Panel1)控件。
  2. 布局的调整和列表页面是一样的。
  3. 【关闭】按钮的行为直接通过内联JavaScript脚本定义:F.activeWindow.hide(); 也即是关闭当前激活的窗体对象(在当前页面外部定义的Window1控件)
  4. 【保存后关闭】按钮的标签无变化,但是为了在关闭后刷新表格(也就是调用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());

这么一个小小的改动却包含着一个大的操作流程变化:

  1. 首先:保存成功后,弹出提示对话框
  2. 其次:用户点击提示对话框的【确定】按钮时,执行脚本:ActiveWindow.GetHidePostBackReference()
  3. 再次:这个脚本会先关闭当前IFrame所在的窗体控件(也就是在外部页面定义的Window1控件)
  4. 之后:触发Window1控件的关闭事件(OnClose)
  5. 最后:在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
                    });
                }
            });
        }
    });

});

这段代码中:

  1. 首先弹出一个确认对话框(F.confirm),在得到用户的许可后,再执行回发操作(发起HTTP POST请求)
  2. 这个回发操作是由 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();
}

这个处理器接受两个参数:

  1. Grid1_fields:这个是由 F.doPostBack 时第二个参数 'Panel1' 传入的。这个参数表示表格用到的数据字段列表,在数据绑定时用来限制哪些列的数据返回客户端。
  2. deletedRowID:这个是由 F.doPostBack 时第三个参数 { deletedRowID: rowData.id } 传入的。特别注意,指定参数类型为int就可以避免通过C#进行强制类型转换,因为数据模型中ID为整形(而不是字符串)。

处理器的主体代码中:

  1. 首先根据表主键查找指定的movie
  2. 然后从数据库上下文删除这个movie,注意此时仅仅是将movie标记为删除项,而非真正的数据库删除操作
  3. 其次SaveChanges动态创建删除SQL语句并执行
  4. 最后查询所有的电影列表,并重新绑定表格

 

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>

相比之前的代码,主要的调整为:

  1. 新增一个触发器输入框控件 TwinTriggerBox,并放置于一个 Form 面板中。
  2. 将Form面板放在 Grid 的前面。
  3. 调整布局:外部面板(Panel1)的布局由(Layout=Fit)改为(Layout=VBox),并为表格增加(BoxFlex=1)。这个调整的目的是让Form控件自适应高度,而Grid占据剩余的全部高度。
  4. 将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"

这两个事件分别对应触发器输入框的两个触发按钮:

  1. 清空图标:OnTrigger1Click
  2. 搜索图标: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);
}

这段代码中,为了将检索条件带入数据库查询,我们做了一些改变:

  1. IQueryable<Movie>:是 System.Linq 提供的一个查询功能,在各种查询条件以及分页排序时都需要用到,非常重要。
  2. q.Where:指定具体的查询条件
  3. 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();
}

这两个事件逻辑对比着看就很清楚了:

  1. 点击清空图标:清空搜索框文本,隐藏清空图标,重新加载表格
  2. 点击搜索图标:显示清空图标,重新加载表格

 

 

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">

为了支持数据库分页,我们增加如下一些属性:

  1. AllowPaging:启用分页
  2. IsDatabasePaging:启用数据库分页
  3. PageSize:每页显示的记录数
  4. RecordCount:总记录数
  5. 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 参数。

原因是我们希望在用户关闭窗体时、行删除时,以及搜索时,能够保持页面上的状态不丢失,目前的状态主要有两个:

  1. 当前正在展现表格的哪一页?
  2. 当前正在搜索哪个关键词?

更新后的模型类代码如下所示:

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>

}

 

为了验证状态保持效果,我们进行如下操作步骤:

  1. 转到第2页
  2. 搜索关键词:【星球】,此时能保持状态:表格处于第2页
  3. 转到第1页,此时能保持状态:关键词为【星球】
  4. 修改一条记录并返回,此时能保持状态:表格处于第2页 + 关键词为【星球】

下面的动图展示了这一系列操作:

 

8.3、将 5 个回发事件合并为 1 个

你可能也注意到了,上述 5 个回发事件都需要接受如下三个参数:

  1. string[] Grid1_fields:表格需要用到的数据字段(对应模型类属性名列表)
  2. int Grid1_pageIndex:表格当前位于第几页
  3. 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();
        }

    }
}

这段代码中:

  1. 通过 actionType 获取当前需要执行的操作
  2. 点击清空图标时,要设置 TBSearchMessage = string.Empty; 因为后面重新绑定表格数据时用到这个变量
  3. 表格分页和窗体关闭事件无需特殊处理,只需要重新绑定表格即可

 

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>

注意新增的部分:

  1. AllowSorting、SortField、SortDirection:启用排序,设置表格默认的排序字段和排序方向。
  2. OnSort:排序事件
  3. 为需要排序的列添加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,更糟糕的是,对于每个表单字段都需要三个标签实现:

  1. label:显示表单字段的标题文本
  2. input:表单字段
  3. 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(基础版)非免费软件,你可以加入【三石和他的朋友们】知识星球下载本教程的完整项目源代码:

https://fineui.com/fans/

 

 

FineUICore算是国内坚持在ASP.NET Core阵营仅有的控件库了,我们花了大量的心思在里面,细节上追求精益求精,希望大家能善待之。

俗话说,三十年河东,三十年河西,让我们共同来迎接 ASP.NET Core 的春天,让 FineUICore 就做这棵大树上一朵绽放的花朵吧。

 

 

 

欢迎评论和  (这是一个可以点击的按钮,点击即可推荐本文!)

 

posted @ 2020-03-12 08:33  三生石上(FineUI控件)  阅读(12001)  评论(0编辑  收藏  举报