1. 程式人生 > >ASP.NET Core 快速入門(FineUICore + Razor Pages + Entity Framework Core)

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();
        }