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 就做這棵大樹上一朵綻放的花朵吧。

 

 

 

歡迎評論和  (這是一個可以點擊的按鈕,點擊即可推薦本文!)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM