引子
自從 2009 年開始在博客園寫文章,這是目前我寫的最長的一篇文章了。
前前后后,我總共花了 5 天的時間,每天超過 3 小時不間斷寫作和代碼調試。總共有 8 篇文章,每篇 5~6 個小結,總截圖數高達 60 多個。
俗話說,桃李不言下自成蹊。
希望我的辛苦和努力能得到你的認可,並對你的學習和工作有所幫助。
歡迎評論和 (這是一個可以點擊的按鈕,點擊即可推薦本文!)
前言
這是一個系列教程,以自微軟的官方文檔為基礎,與微軟官方文檔的區別主要有如下幾點:
- 更通俗易懂的語言
- 從代碼入手(而非依賴VS的基架模板)
- 關鍵知識點的深入解讀
- 加入和 WebForms / MVC 的對比
- 使用 FineUICore 控件庫(而非原生的控件)
- 更少的代碼和更現代化的界面(得益於FineUICore強大的控件庫)
本教程包含如下內容:
- Razor Pages 項目
- 安裝軟件
- 下載 FineUICore 空項目
- 項目目錄
- 項目運行截圖
- 向 Razor Pages 添加模型
- POCO 類
- DbContext 類
- 配置數據庫連接字符串
- 在 Startup.cs 中注冊數據庫服務
- 初始化數據庫和數據遷移
- 列表頁面
- 新增 Movie 頁面
- 默認生成的頁面和模型類
- 異步獲取數據並通過表格控件展示
- 列標題文字是怎么來的?
- 格式化顯示日期
- 新增頁面
- 新增頁面模型
- 新增頁面視圖
- 查看 HTTP POST 請求的數據
- 客戶端模型驗證
- 自定義 JavaScript 來繞開客戶端驗證
- 自定義模型驗證錯誤消息
- 編輯頁面
- 編輯頁面模型
- 編輯頁面視圖
- 路由模板
- 更新電影信息
- 處理並發沖突
- 列表頁面和彈出窗體
- 更新表格頁面
- 行編輯按鈕
- 窗體的關閉事件
- 更新編輯頁面
- 先彈出提示對話框,再關閉當前窗體
- 表格與窗體互動(動圖)
- 搜索框與行刪除按鈕
- 行刪除按鈕
- 行刪除按鈕的自定義回發
- 行刪除事件
- 搜索框
- 搜索框事件
- 服務端標記搜索框不能為空
- 分頁與排序
- 數據庫分頁
- 保持分頁狀態和搜索狀態
- 將 5 個回發事件合並為 1 個
- 排序
- SortBy 擴展方法
- 對比 ASP.NET Core 和 FineUICore 創建的頁面
- 列表頁面的表格
- 編輯頁面的表單
- 多個主題的頁面截圖賞析
- 下載項目源代碼
最終完整的作品是一個簡單的電影數據管理頁面,如下所示:
如果你希望了解 ASP.NET MVC 的基礎知識,請查閱我之前寫的系列教程:ASP.NET MVC快速入門(MVC5+EF6)
一、Razor Pages項目
1.1、安裝軟件
在進行本教程之前需要安裝如下兩個軟件:
- VS2019(需要選擇 ASP.NET and web development 工作負載)
- .NET Core SDK 最新版:https://dotnet.microsoft.com/download
1.2、下載 FineUICore 空項目
FineUICore 相關產品可以到我的知識星球內下載:https://fineui.com/fans/
FineUICore空項目已經完成相關的配置,並可以 F5 直接運行。建議初學者從空項目入手,在熟悉 ASP.NET Core 開發流程后再自行創建項目。
在知識星球內,我們提供兩個空項目,分別是:
- 【空項目】FineUICore_EmptyProject_RazorPages_vxxx.zip
- 【空項目】FineUICore_EmptyProject_vxxx.zip
其中,不帶 RazorPages 字符串的是基於 MVC 架構的項目,而本教程需要使用的是帶 RazorPages 標識的。
在 FineUICore_EmptyProject_RazorPages 項目中,頁面視圖中使用了 TagHelpers 標簽,使得頁面結構更加清晰,和 WebForms 的標簽更加類似。
我之前曾經寫過一篇文章,對比 RazorPages + TagHelpers 的項目和傳統的 ASP.NET MVC + HtmlHelpers 的區別,有興趣可以了解一下:
1.3、項目目錄
這里面有一些主要的文件和目錄,從上到下分別是:
1. wwwroot 目錄
包含靜態文件,如 HTML 文件、JavaScript 文件和 CSS 文件。
這是 ASP.NET Core 引入的一個命名約定,將全部的靜態資源放置於 wwwroot 目錄有助於保持項目結構的清晰,之前的ASP.NET MVC 和 WebForms項目,我們一般都自行創建一個 res 目錄。
我的理解,這樣的結構有助於提高項目的編譯速度,如果對比 ASP.NET MVC/WebForms 和 ASP.NET Core 的項目文件(.csproj),你會發現之前的文件是顯式包含進來的:
<ItemGroup> <Content Include="res\images\themes\vader.png" /> <Compile Include="Areas\Button\Controllers\ButtonController.cs" /> ... </ItemGroup>
而 ASP.NET Core 項目文件已經沒有了這些配置項,說明是隱式包含的,也就是說:
- wwwroot 目錄中的是網站內容,無需編譯
- 其他目錄中的需要編譯
2. Code 目錄
自行創建的目錄,主要放置頁面基類,已經自定義類。
3. Pages 目錄
包含 Razor 頁面和幫助文件(以下划線開頭)。
每個 Razor 頁面都由兩個文件組成:
- 一個 .cshtml 文件,其中包含使用 Razor 語法的 C# 代碼的 HTML 標簽 。
- 一個 .cshtml.cs 文件,其中包含處理頁面事件的 C# 代碼 。
Razor 頁面的訪問遵循着簡單的目錄結構,比如:
- Pages/Index.cshtml 的訪問URL地址:/Index 或者 /
- Pages/Admin/Users.cshtml 的訪問URL地址:/Admin/Users
相比 ASP.NET MVC 架構的頁面,這是一個巨大的進步,在 MVC 中我們需要借助於抽象的 Areas 目錄,並且很難支持 3 級以上的URL網址,比如:/Mobile/Button/Group
幫助文件主要有如下幾個:
- Shared/_Layout.cshtml:主要放置頁面框架標簽,比如頁面<html><head><body>標簽,以及引入共用的css和js文件,類似於 WebForms 中的母版頁(Master Page)。
- _ViewImports.cshtml:一個 using 指令和 addTagHelpers 指令,以便在 Razor 頁面使用不加前綴的控件名和標簽。
- _ViewStart.cshtml:Razor頁面的啟動文件,會在頁面執行之前調用,默認包含了對布局頁面的調用。這個文件是可以在目錄中嵌套的,運行是會先執行最外層目錄中的_ViewStart.cshtml文件,再執行內層目錄中的_ViewStart.cshtml。這也很好理解,為了確保最靠近Razor頁面的內層定義覆蓋外層定義。
4. appSettings.json
包含配置數據,如數據庫連接字符串。默認包含了 FineUICore 的一些全局配置信息:
5. Program.cs
包含程序的入口點。
6. Startup.cs
包含配置應用行為的代碼。 這個文件非常關鍵,里面定義了用於依賴注入的配置項,已經執行 ASP.NET Core HTTP請求管道的插件。
當然,對於初學者不需要關注這些細節問題,我們簡單看下在請求管道中添加 FineUICore 插件的地方:
1.4、項目運行截圖
可以直接 Ctrl + F5 不調試運行項目,運行截圖如下:
項目默認的是 Pure_Black 主題,這個在 appSettings.json 中有定義 。
為了和VS2019的深色主題相配,我們特意選取了 Dark_Hive 深色主題:
二、向 Razor Pages 添加模型
2.1、POCO類
本示例將實現一個簡單的電影管理頁面,所以需要添加一個數據模型,也稱為POCO類(plain-old CLR objects),因為它們與 EF Core 沒有任何依賴關系。
在 Code 目錄中新建一個 Movie.cs 文件:
using System; using System.ComponentModel.DataAnnotations; namespace FineUICore.EmptyProject.RazorPages { public class Movie { public int ID { get; set; } [Required] [Display(Name = "名稱")] public string Title { get; set; } [DataType(DataType.Date)] [Display(Name = "發布日期")] public DateTime ReleaseDate { get; set; } [Display(Name = "類型")] public string Genre { get; set; } [Display(Name = "價格")] public decimal Price { get; set; } } }
Movie 類包含:
- ID 字段:數據庫表主鍵,遵循命名約定,可以是ID或者MovieID。
- [Require]:指定字段為必填項。
- [Display(Name = "名稱")]:指定字段在前端界面的顯示名稱,主要用於如下兩個地方:
- 表格的表頭文字
- 表單字段的標題文字
- [DataType(DataType.Date)]:指定此字段的數據類型為日期。 這個特性有兩個作用:
- 不僅影響數據庫中的字段類型(僅包含日期部分,需要包含時間);
- 也影響客戶端的表格展示,和數據錄入。
2.2、DbContext類
為了能正確初始化數據庫,我們還需要一個繼承自 DbContext的類,如下所示:
namespace FineUICore.EmptyProject.RazorPages { public class MovieContext : DbContext { public MovieContext(DbContextOptions<MovieContext> options) : base(options) { } public DbSet<Movie> Movies { get; set; } } }
由於空項目尚未引入 EF Core,所以上述代碼會有錯誤提示。
下面我們需要安裝 EntityFrameworkCore 相關程序包,打開菜單【工具】->【Nuget包管理器】:
我們需要安裝如下兩個程序包:
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.SqlServer:Microsoft SqlServer數據庫支持。
- Microsoft.EntityFrameworkCore.Tools:用於在包管理控制台使用 EF Core 的數據遷移命令,比如Add-Migration等。
安裝完成后,我們需要更新 MovieContext.cs 文件,在文件頭添加如下指令:
using Microsoft.EntityFrameworkCore;
2.3、配置數據庫連接字符串
本示例使用LocalDb數據庫,LocalDb是輕型版的 SQL Server Express 數據庫引擎,主要用於開發階段。默認情況下,LocalDB 數據庫在 C:\Users\<user>\AppData 目錄下創建 *.mdf 文件。
從【視圖】菜單中,打開【SQL Server 對象資源管理器】,如下所示:
在SQL Server 節點上點擊右鍵,選中【添加 SQL Server ...】:
這時,可以看到我們連接的LocalDb數據庫:
右鍵,點擊【屬性】,找到【連接字符串】:
將這個數據庫字符串拷貝出來,放到 appSettings.json 文件中:
{ "FineUI": { "DebugMode": false, "CustomTheme": "pure_black", "EnableAnimation": false }, "ConnectionStrings": { "MovieContext": "Data Source=(localdb)\\MSSQLLocalDB;Database=MovieContext;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False" } }
注意:在數據庫連接字符串中添加 Database=MovieContext; 用來指定我們自己的數據庫,否則新建的表都會添加到系統庫 master 中。
2.4、在Startup.cs中注冊數據庫服務
ASP.NET Core 內置了依賴注入的支持。我們首先需要在 Startup.cs 中注冊各種服務(比如 Razor Pages、FineUICore以及 EF Core 服務),然后在頁面中通過構造函數傳入已經注冊的服務。
簡化后的代碼:
public void ConfigureServices(IServiceCollection services) { // FineUI 服務 services.AddFineUI(Configuration); services.AddRazorPages(); services.AddDbContext<MovieContext>(options => options.UseSqlServer(Configuration.GetConnectionString("MovieContext"))); }
在 AddDbContext 中,我們通過 Configuration 來獲取 appSettings.json 中定義的數據庫連接字符串。
2.5、初始化數據庫和數據遷移
這一節,我們會使用 EF Core 提供的數據遷移工具(Data Migration)來初始化數據庫。
首先打開VS的包管理控制台(Package Manager Console),位於菜單項【工具】下面:
在 PM> 提示符下輸入:Add-Migration InitialCreate
安裝完成后,我們的項目多了一個 Migrations 目錄,里面有一個類似 20200309093752_InitialCreate.cs 的文件。
這個就是初始化遷移腳本,里面包含一個 Up 方法和一個Down 方法,分別對應於應用本遷移和取消本遷移:
上面的 Up 方法主要做了是三個事情:
- 創建名為 Movies 的表格
- 分別定義表格列ID、Title、ReleaseDate....
- 定義表格主鍵為列ID
此時數據庫尚未創建 Movies 表,為了執行 Up 函數,我們還需要執行 Update-Database 命令。
在 PM> 提示符下輸入:Update-Database
運行結束后,在【Sql Server對象資源管理器】面板中,找到剛剛創建的 MovieContext 數據庫:
查看 Movies 的視圖設計器:
通過Movies 的數據預覽面板,我們還可以新增一條數據:
三、列表頁面
3.1、新增 Movie 頁面
在VS的資源管理器面板,Pages目錄右鍵,並添加一個 Razor 頁面,命名為 Movie:
這個面板中,使用布局頁留空,默認使用 _ViewStart.cshtml 中定義的布局文件(Shared/_Layout.cshtml)。
默認生成的頁面文件 Movie.cshtml:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } <h1>Movie</h1>
在這個頁面中:
- @page:指示這是一個頁面,可以通過命名約定來訪問(/Movie),@page指令必須是頁面上的第一個指令。
- @model:指示本頁面對應的頁面模型,類似於WebForms的后台文件。
- ViewData:用來在模型和視圖之間,以及視圖之間傳值,可以在 Shared/_Layout.cshtml 訪問這里定義的 ViewData["Title"] 數據。
3.2、默認生成的頁面和模型類
默認生成的頁面文件 Movie.cshtml.cs 模型類:
using Microsoft.AspNetCore.Mvc.RazorPages; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { public void OnGet() { } } }
這是一個繼承自 PageModel 的類,OnGet方法用來初始化頁面數據,ASP.NET Core還支持異步調用,這個函數的異步簽名如下所示:
public async Task OnGetAsync() { await _context.Students.ToListAsync(); }
通過在 OnGet 后面添加 Async,並且返回 async Task 這樣的命名約定來啟用異步調用。
本示例中的HTTP請求(Get,Post)以及對數據庫的操作我們都將使用異步調用的形式,以提高性能。
3.3、異步獲取數據並通過表格控件展示
將 Movie.cshtml.cs 模型類更新為:
using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } public async Task OnGetAsync() { Movies = await _context.Movies.ToListAsync(); } } }
這段代碼中:
- 構造函數使用依賴注入將數據庫上下文DbContext添加到頁面中
- 屬性Movies保存獲取的電影列表
- _context.Movies.ToListAsync() 通過異步的方法獲取電影列表
頁面上通過一個FineUICore表格控件,用來展示電影列表數據,修改后的 Movie.cshtml 文件:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Grid ID="Grid1" ShowBorder="true" ShowHeader="true" Title="電影列表" IsViewPort="true" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> </Columns> </f:Grid> }
打開 Index.cshtml 框架頁,將 Movie 頁面添加到左側菜單項:
<f:TreeNode Text="默認分類" Expanded="true"> <f:TreeNode Text="開始頁面" NavigateUrl="@Url.Content("~/Hello")"></f:TreeNode> <f:TreeNode Text="登錄頁面" NavigateUrl="@Url.Content("~/Login")"></f:TreeNode> <f:TreeNode Text="電影管理" NavigateUrl="@Url.Content("~/Movie")"></f:TreeNode> </f:TreeNode>
Ctrl+F5 運行,此時的頁面效果如下所示:
現在,我們已經完成了對數據庫的讀操作,並通過 FineUICore 的表格控件展現出來。
3.4、列標題文字是怎么來的?
如果你細心觀察,可以發現在 Movie.cshtml 的表格控件中,我們並沒有顯示的定義表格列標題,而實際頁面是有的,這是怎么回事?
其實這個功能是 ASP.NET Core 和 FineUICore 共同努力的結果:
1. 首先 Movie.cs 模型中使用 Display 注解來標識列的顯示文本
[Display(Name = "名稱")]
public string Title { get; set; }
2. 然后 FineUICore 的表格控件通過 RenderField 的 For 屬性來關聯模型類屬性
<f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" />
其實這個代碼等效於如下標簽:
<f:RenderField DataField="Title" HeaderText="名稱" ExpandUnusedSpace="true" />
但是這樣的話,我們就丟失了兩個優點:
- For屬性指定的是C#代碼,而DataField指定的是字符串。強類型在代碼編寫時有很多好處:
- 編譯時錯誤檢查,特別是以后更改模型類屬性名時,可以在編譯時發現錯誤,而不是等到運行時才發現這個名字忘記改了。
- VS貼心的智能提示。
- HeaderText同樣是字符串,不僅容易寫錯,而且在兩處定義相同的代碼會產生冗余數據。
3.5、格式化顯示日期
上面顯示的發布日期是不友好的,我們可以在頁面標簽中指定格式化字符串,修改后的代碼:
<f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" />
此時的頁面顯示效果:
四、新增頁面
4.1、新增頁面模型
新建一個 MovieNew 頁面,將頁面模型類修改為:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; namespace FineUICore.EmptyProject.RazorPages { public class MovieNewModel : PageModel { private readonly MovieContext _context; public MovieNewModel(MovieContext context) { _context = context; } public void OnGet() { } [BindProperty] public Movie Movie { get; set; } public async Task<IActionResult> OnPostBtnSave_ClickAsync() { if (ModelState.IsValid) { _context.Movies.Add(Movie); await _context.SaveChangesAsync(); Alert.Show("保存成功!"); } return UIHelper.Result(); } } }
這段代碼主要有三部分組成:
- 通過構造函數注入的數據庫上下文(MovieContext):用於數據庫查詢和更新操作
- 使用 BindProperty 修飾的 Movie 屬性:BindProperty一般用於模型類的屬性,執行頁面回發時的數據綁定(雖然回發是WebForms中的一個術語,但用在這里也恰如其分),ASP.NET Core會從HTTP請求的各個地方(URL,Headers,Forms)查找與BindProperty相匹配的鍵值,並對屬性進行賦值。
- OnPostXXXXAsync:這個稱為頁面模型處理器(Handler),用於執行頁面上的【保存】按鈕的回發操作。
在OnPostXXXXAsync處理程序中,執行如下操作:
- 判斷模型是否有效(ModelState.IsValid):這是 ASP.NET Core 提供的一個屬性,在執行模型綁定之后會緊接着進行模型驗證,驗證規則定義在模型類(Movie),比如[Required],[DataType(DataType.Date)]就是常見的驗證規則。
- 將綁定后的Movie屬性添加到數據庫上下文(Movies.Add)並執行數據庫保存操作(SaveChangesAsync):在Movies.Add操作時,只是將內存中的Movie屬性添加一個新數據的標記,並沒有真正執行數據庫操作,只有在調用SaveChangesAsync異步方法時EF Core才會動態生成SQL語句並執行。
- 返回 FineUICore.UIHelper.Result():這是 FineUICore 提供的一個方法,FineUICore 中所有頁面回發都是 HTTP AJAX 請求(而非整個頁面的表單提交),都需要返回 UIHelper.Result()。
之前我曾寫過一篇文章專門介紹 UIHelper,感興趣的同學可以參考一下:FineUIMvc隨筆(5)UIHelper是個什么梗?
4.2、新增頁面視圖
將頁面視圖文件修改為:
@page @model FineUICore.EmptyProject.RazorPages.MovieNewModel @{ ViewData["Title"] = "MovieNew"; } @section body { <f:SimpleForm ID="SimpleForm1" ShowBorder="true" ShowHeader="true" BodyPadding="10" Title="新建" IsViewPort="true"> <Items> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> <f:Button ID="BtnSave" ValidateForms="SimpleForm1" Icon="SystemSave" OnClick="@Url.Handler("BtnSave_Click")" OnClickFields="SimpleForm1" Text="保存"></f:Button> </Items> </f:SimpleForm> }
頁面顯示效果:
點擊保存按鈕:
返回列表頁面,可以看到我們剛剛新增的數據:
這里使用了 FineUICore 提供的一些表單控件:
- SimpleForm作為一個表單容器:不僅在UI上提供視覺上的面板樣式,而且在點擊【保存】按鈕時,可以通過 OnClickFields="SimpleForm1" 來指定回發操作時需要提交的表單數據。
- TextBox、DatePicker、NumberBox:這些表單字段分別對應於不同的數據表字段類型,For屬性對應一個C#表達式,這種強名稱的寫法不僅可以在編譯時錯誤檢查,而且可以充分利用VS的智能提示。同時 FineUICore 會將相應的模型類注解解析成對應的控件屬性應用到控件上,比較[Required]注解對應於TextBox控件的 Required=true屬性。
- 按鈕的點擊事件OnClick:通過Url.Handler 來生成一個服務器請求處理URL,本示例中也就是:MovieNew?handler=BtnSave_Click
- ValidateForms="SimpleForm1":指定點擊按鈕回發之前需要執行的客戶端驗證表單。
- OnClickFields="SimpleForm1":指定點擊按鈕回發時需要提交的表單數據。
4.3、查看 HTTP POST 請求的數據
下面,我們通過瀏覽器的調試工具來觀察點擊【保存】按鈕時的HTTP POST請求:
這里的每個地方都是可追溯的:
- Request URL:是我們通過 Url.Handler("BtnSave_Click") 生成的,對應於頁面模型類的 OnPostBtnSave_ClickAsync
- Form Data:里面的 Movie.Title 等字段的值是我們通過 OnClickFields="SimpleForm1" 指定的,FineUICore 會自動計算表單內所有字段的值,並添加到 HTTP POST 請求正文中。
- _RequestVerificationToken:是我們在 Shared/_Layout.cshtml 中通過 @Html.AntiForgeryToken() 指定的。ASP.NET Core 將此字段用於阻止CSRF工具,無需特別關注。
4.4、客戶端模型驗證
前面我們多次提到了模型驗證,具體來說分為:
- 客戶端模型驗證:使用 FineUICore 控件的內置支持,可以在回發事件之前觸發表單的JavaScript驗證(來源於模型類的數據注解)。
- 服務端模型驗證:使用 ASP.NET Core 的內置支持,ModelState.IsValid 可以用來在服務端驗證模型(來源於模型類的數據注解),並在失敗時調用 FineUICore.Alert.Show 在前端顯示提示對話框。
上述兩個驗證都是利用了模型類的數據注解,這也是 ASP.NET Core 一個強大的地方,無需我們在多處維護驗證規則和驗證提示。而 FineUICore 表單控件的內置屬性支持,將進一步簡化開發人員的代碼編寫,提升產品的可維護性。
在前端,如果未輸入【名稱】,點擊【保存】按鈕時就會彈出提示框,並阻止進一步的回發操作:
這個大家都能看明白。那有的網友就有疑問了,既然模型驗證已經在客戶端被阻止了,服務器端驗證又有什么用呢?
其實服務器端驗證非常重要!
因為客戶端驗證可以很輕松的被有經驗的開發人員繞過!我之前在講解《ASP.NET MVC快速入門》時,曾經有過詳細的剖析,感興趣的可以看一下。
4.5、自定義JavaScript來繞開客戶端驗證
這里,我們就使用一個簡單的 JavaScript 調用,來繞開客戶端驗證。
在 MovieNew 頁面,F12打開瀏覽器調試工具,執行如下 JS 片段:
F.doPostBack('/MovieNew?handler=BtnSave_Click', 'SimpleForm1')
在服務器模型驗證失敗,FineUICore會自動處理並彈出錯誤提示對話框:
4.6、自定義模型驗證錯誤消息
上面的服務端模型驗證錯誤消息是英文的,並且和客戶端的驗證消息不一致。其實我們可以自定義驗證錯誤消息,修改 Movie 模型類:
[Required(ErrorMessage = "名稱不能為空!")] [Display(Name = "名稱")] public string Title { get; set; }
為 Required 數據注解增加了 ErrorMessage 參數,現在再驗證上述的兩個驗證界面:
五、編輯頁面
5.1、編輯頁面模型
新建一個 MovieEdit 頁面,將頁面模型類修改為:
using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieEditModel : PageModel { private readonly MovieContext _context; public MovieEditModel(MovieContext context) { _context = context; } [BindProperty] public Movie Movie { get; set; } public async Task<IActionResult> OnGetAsync(int id) { Movie = await _context.Movies.FirstOrDefaultAsync(m => m.ID == id); if (Movie == null) { return NotFound(); } return Page(); } public async Task<IActionResult> OnPostBtnSave_ClickAsync() { if (ModelState.IsValid) { _context.Attach(Movie).State = EntityState.Modified; try { await _context.SaveChangesAsync(); Alert.Show("修改成功!"); } catch (DbUpdateConcurrencyException) { if (!_context.Movies.Any(e => e.ID == Movie.ID)) { Alert.Show("指定的電影不存在:" + Movie.Title); } else { throw; } } } return UIHelper.Result(); } } }
這段代碼主要有如下幾個部分:
- 通過構造函數注入的數據庫上下文(MovieContext)
- 使用[BindProperty]修飾的Movie屬性,有兩個作用:
- 在 OnGet 時將數據從模型類傳入頁面視圖
- 在 OnPost 時,ASP.NET Core執行模型綁定,將HTTP POST提交的數據綁定到 Movie 屬性
- OnGetAsync:頁面初始化代碼,從數據庫檢索數據,並保存到Movie屬性
- OnPostBtnSave_ClickAsync:點擊【保存】按鈕時對應的頁面模型處理器(Handler)
5.2、編輯頁面視圖
將編輯頁面視圖代碼修改為:
@page "{id:int}" @model FineUICore.EmptyProject.RazorPages.MovieEditModel @{ ViewData["Title"] = "MovieEdit"; } @section body { <f:SimpleForm ID="SimpleForm1" ShowBorder="true" ShowHeader="true" BodyPadding="10" Title="編輯" IsViewPort="true"> <Items> <f:HiddenField For="Movie.ID"></f:HiddenField> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> <f:Button ID="BtnSave" ValidateForms="SimpleForm1" Icon="SystemSave" OnClick="@Url.Handler("BtnSave_Click")" OnClickFields="SimpleForm1" Text="保存"></f:Button> </Items> </f:SimpleForm> }
這個頁面和 MovieNew 很相似,主要有兩個不同的地方:
- @page 后面多了個參數
- 新增了HiddenField表單字段保存當前電影的ID
5.3、路由模板
首先來看下 @page 指令后面的參數 {id:int},這是一個路由模板,指定了訪問頁面的URL中必須帶一個不為空的整形參數。
在瀏覽器中,我們可以通過類似的URL訪問:/MovieEdit/2
如果在訪問路徑中缺少了后面的 /2 ,ASP.NET Core 路由引擎會直接返回 HTTP 404:
下面看下 OnGet 的初始化處理:
Movie = await _context.Movies.FirstOrDefaultAsync(m => m.ID == id); if (Movie == null) { return NotFound(); }
首先在數據庫中查找 ID 為傳入值的電影,如果指定的電影不存在,則返回 NotFound ,ASP.NET Core會將此解析為一個 HTTP 404 響應,如下所示:
5.4、更新電影信息
更新當前電影信息的邏輯如下所示:
_context.Attach(Movie).State = EntityState.Modified; await _context.SaveChangesAsync(); Alert.Show("修改成功!");
這段代碼涉及三個操作:
- Attach操作將一個實體對象添加到數據庫上下文中,並將其狀態更新為 Modified。我之前曾寫過一篇剖析Attach的文章,感興趣的同學可以自行查閱:AppBox升級進行時 - Attach陷阱(Entity Framework)
- SaveChangesAsync會執行數據庫更新操作,EF Core會生成Update的SQL語句,並在Where字句中通過ID來指定需要更新的數據。
- FineUICore.Alert在前台界面給用戶一個明確的提示。
正常操作完畢之后,頁面是這樣的:
5.5、處理並發沖突
上面的更新操作放在一個try-catch語句中,catch的DbUpdateConcurrencyException參數表明我們需要捕獲並發沖突的異常。
if (!_context.Movies.Any(e => e.ID == Movie.ID)) { Alert.Show("指定的電影不存在:" + Movie.Title); }
在這段邏輯中,首先查找指定 Movie.ID 的數據是否存在,如果不存在則提示用戶。
什么情況下會出現這個異常呢?
當我們(張三)打開某個電影的編輯頁面之后,另一個用戶(李四)在表格頁面刪除了相同的電影,然后張三更新這個電影信息。很明顯,此時這條電影信息已經被刪除了。
我們可以手工重現:
- 打開頁面 /MovieEdit/2
- 在點擊【保存】按鈕之前,在 VS 中打開【SQL Server資源管理器】面板,並刪除ID==2的這個數據
- 點擊【保存】按鈕,此時會出現錯誤提示。
六、列表頁面和彈出窗體
前面的新增頁面和編輯頁面,我們都是通過URL直接訪問的,如何將其整合到列表頁面呢?
我們可以使用內嵌IFrame的Window控件,首先在頁面上定義一個 Window 控件:
<f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window>
然后在點擊新增按鈕時,顯示這個Window控件並傳入IFrame網址:
function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); }
6.1、更新表格頁面
更新后的 Movie.cshtml 代碼:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="Fit" ShowHeader="false" Title="用戶管理" IsViewPort="true"> <Items> <f:Grid ID="Grid1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> </Columns> </f:Grid> </Items> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '編輯'); } }); }); </script> }
相比之前的代碼,這次的更新主要集中在以下幾點:
- 為了將【新增】按鈕放在在工具欄中,並為以后的搜索框預留位置,我們在 Grid 控件的外面嵌套了一個面板控件(Panel1)。
- 更新布局:去除Grid1的 IsViewPort 屬性,為Panel1增加 IsViewPort=true和 Layout=Fit,這兩個屬性是讓面板(Panel1)占據整個頁面,並讓內部的表格(Grid1)填充整個面板區域。
- 放置於工具欄的【新增】按鈕,並通過Listener標簽來定義客戶端的點擊腳本。
- 表格新增一個編輯列,並通過 RendererFunction來指定客戶端渲染函數。
現在頁面的顯示效果如下所示:
6.2、行編輯按鈕
行編輯按鈕是通過一個JS渲染出來的,RenderField的RendererFunction可以指定一個渲染函數,表格在進行行渲染時會調用此函數:
function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; }
這個函數返回一個HTML片段,一個可點擊的超鏈接,顯示內容則是一個編輯圖標。
基於頁面標簽和JS代碼分離的原則,我們把超鏈接的 href 屬性留空(href="javascript:;"),並使用如下腳本注冊編輯按鈕的點擊事件:
F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '編輯'); } }); });
在這段JS代碼中:
- F.ready 是由 FineUICore 提供的一個入口點,會在頁面上控件初始化完畢后調用。所有自定義的初始化代碼都應該放在 F.ready 的回調函數中。
- 通過 F.ui.Grid1 獲取表格控件的客戶端實例,並通過 jQuery 的 on 函數來注冊行編輯按鈕的點擊事件。F.ui.Grid1.el 表示的是表格控件的最外層元素。
- 通過 F.ui.Grid1.getRowData 獲取行信息,其中 rowData.id 對應當前行標識符(由表格的DataIdField指定對應於數據庫表的哪個字段)。
- 使用 F.ui.Windows.show 來彈出窗體,並傳入編輯頁面的URL:/MovieEdit/2
6.3、窗體的關閉事件
在前面窗體(Window1)的標簽定義中,我們看到有 OnClose 事件處理函數:
OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"
但是我們嘗試點擊彈出窗體右上角的關閉按鈕,發現並不能觸發這個關閉事件。
這是因為窗體有個控制關閉行為的屬性CloseAction="Hide",默認值Hide是意思就是簡單關閉,如果希望關閉之后還觸發OnClose事件,我們需要設置: CloseAction="HidePostBack"
這個回發在什么情況下觸發呢?
在彈出窗體IFrame頁面內,保存成功時(不管是新增還是編輯)都會導致表格數據的改變,此時我們需要通知窗體(Window1)觸發關閉事件。
在窗體關閉事件中:
public async Task<IActionResult> OnPostWindow1_CloseAsync(string[] Grid1_fields) { var Grid1 = UIHelper.Grid("Grid1"); var movies = await _context.Movies.ToListAsync(); Grid1.DataSource(movies, Grid1_fields); return UIHelper.Result(); }
- 首先通過 UIHelper.Grid 獲取表格控件幫助類,這是由 FineUICore 提供的一個輔助方法,注意這個獲取的 Grid1 僅僅是一個幫助類,而非表格控件對象。因為在 ASP.NET MVC/Core 中,回發時不會帶上頁面狀態信息(沒有了WebForms中ViewState機制),因此在服務器端無法還原表格控件及其屬性。
- 重新獲取電影數據,並通過表格幫助類提供的 DataSource 函數來更新表格。
6.4、更新編輯頁面
將編輯頁面的代碼更新為:
@page "{id:int}" @model FineUICore.EmptyProject.RazorPages.MovieEditModel @{ ViewData["Title"] = "MovieEdit"; } @section body { <f:Panel ID="Panel1" ShowBorder="false" ShowHeader="false" AutoScroll="true" IsViewPort="true" Layout="Fit"> <Toolbars> <f:Toolbar Position="Bottom" ToolbarAlign="Center"> <Items> <f:Button ID="BtnClose" IconFont="Close" Text="關閉"> <Listeners> <f:Listener Event="click" Handler="F.activeWindow.hide();"></f:Listener> </Listeners> </f:Button> <f:ToolbarSeparator></f:ToolbarSeparator> <f:Button ID="BtnSave" ValidateForms="SimpleForm1" IconFont="Save" OnClick="@Url.Handler("BtnSave_Click")" OnClickFields="SimpleForm1" Text="保存后關閉"></f:Button> </Items> </f:Toolbar> </Toolbars> <Items> <f:SimpleForm ID="SimpleForm1" ShowBorder="false" ShowHeader="false" BodyPadding="10"> <Items> <f:HiddenField For="Movie.ID"></f:HiddenField> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> </Items> </f:SimpleForm> </Items> </f:Panel> }
和之前的代碼相比,主要的改動:
- 為了在工具欄中放置【關閉】和【保存后關閉】按鈕,我們在SimpleForm外面嵌套了一個面板(Panel1)控件。
- 布局的調整和列表頁面是一樣的。
- 【關閉】按鈕的行為直接通過內聯JavaScript腳本定義:F.activeWindow.hide(); 也即是關閉當前激活的窗體對象(在當前頁面外部定義的Window1控件)
- 【保存后關閉】按鈕的標簽無變化,但是為了在關閉后刷新表格(也就是調用Window1的OnClose事件),我們需要在 BtnSave_Click 事件中進行處理。
6.5、先彈出提示對話框,再關閉當前窗體
我們來看下【保存后關閉】按鈕的點擊事件:
public async Task<IActionResult> OnPostBtnSave_ClickAsync() { if (ModelState.IsValid) { _context.Attach(Movie).State = EntityState.Modified; try { await _context.SaveChangesAsync(); Alert.Show("修改成功!", string.Empty, MessageBoxIcon.Success, ActiveWindow.GetHidePostBackReference()); } catch (DbUpdateConcurrencyException) { if (!_context.Movies.Any(e => e.ID == Movie.ID)) { Alert.Show("指定的電影不存在:" + Movie.Title); } else { throw; } } } return UIHelper.Result(); }
如果你對之前的代碼還有印象,你會發現上面的代碼只有一處改動,那就是把原來的:
Alert.Show("修改成功!");
改為了:
Alert.Show("修改成功!", string.Empty, MessageBoxIcon.Success, ActiveWindow.GetHidePostBackReference());
這么一個小小的改動卻包含着一個大的操作流程變化:
- 首先:保存成功后,彈出提示對話框
- 其次:用戶點擊提示對話框的【確定】按鈕時,執行腳本:ActiveWindow.GetHidePostBackReference()
- 再次:這個腳本會先關閉當前IFrame所在的窗體控件(也就是在外部頁面定義的Window1控件)
- 之后:觸發Window1控件的關閉事件(OnClose)
- 最后:在Window1的關閉事件中,重新綁定表格(以反映最新的數據更改)
一個看似不起眼的功能,FineUICore卻花費了大量的心思來精雕細琢,確保開發人員以盡量少的代碼完成所需的業務功能。
6.6、表格與窗體互動(動圖)
最后,通過一個動態(GIF)來看下表格和窗體是如何交互的:
七、搜索框與行刪除按鈕
7.1、行刪除按鈕
前面我們已經為表格增加了行編輯按鈕,現在照葫蘆畫瓢,我們再增加一個行刪除按鈕:
<f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField>
列渲染函數定義:
function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; }
這是一個包含了刪除圖標的超鏈接,其中 f-icon f-icon-trash 指定了一個刪除樣式的字體圖標。這個是 FineUICore 內置的,可以在這里查看所有可用的字體圖標。
7.2、行刪除按鈕的自定義回發
下面為行刪除按鈕添加點擊事件,並將數據傳入后台執行刪除事件。
好吧,這些還是WebForms的習慣用語,其實挺親切的,也沒有違和感,當然你也可以按照 ASP.NET Core 的說法來:發起一個HTTP POST請求到頁面模型處理器。
F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '確定刪除此記錄?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', { deletedRowID: rowData.id }); } }); } }); });
這段代碼中:
- 首先彈出一個確認對話框(F.confirm),在得到用戶的許可后,再執行回發操作(發起HTTP POST請求)
- 這個回發操作是由 FineUICore 提供的 F.doPostBack 進行,這里有一篇文章詳細講解 F.doPostBack 使用細節。
F.doPostBack的函數簽名如下所示:
F.doPostBack(url, fields, params)
三個參數分別是:
- url:發送請求的地址
- fields:【可選】發送到服務器的表單字段數據,以逗號分隔多個表單字段(如果是容器,則查找容器內的所有表單字段)
- params:【可選】發送到服務器的數據
此時點擊行刪除按鈕,頁面的顯示效果:
7.3、行刪除事件
用戶點擊確認對話框的【確定】按鈕時,才會發起回發請求:
public async Task<IActionResult> OnPostGrid_RowDeleteAsync(string[] Grid1_fields, int deletedRowID) { var Grid1 = UIHelper.Grid("Grid1"); var movie = await _context.Movies.FindAsync(deletedRowID); if (movie != null) { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); var movies = await _context.Movies.ToListAsync(); Grid1.DataSource(movies, Grid1_fields); } return UIHelper.Result(); }
這個處理器接受兩個參數:
- Grid1_fields:這個是由 F.doPostBack 時第二個參數 'Panel1' 傳入的。這個參數表示表格用到的數據字段列表,在數據綁定時用來限制哪些列的數據返回客戶端。
- deletedRowID:這個是由 F.doPostBack 時第三個參數 { deletedRowID: rowData.id } 傳入的。特別注意,指定參數類型為int就可以避免通過C#進行強制類型轉換,因為數據模型中ID為整形(而不是字符串)。
處理器的主體代碼中:
- 首先根據表主鍵查找指定的movie
- 然后從數據庫上下文刪除這個movie,注意此時僅僅是將movie標記為刪除項,而非真正的數據庫刪除操作
- 其次SaveChanges動態創建刪除SQL語句並執行
- 最后查詢所有的電影列表,並重新綁定表格
7.4、搜索框
為了添加搜索框,我們需要再次調整頁面布局,在面板中放入一個 Form 控件,此時的面板標簽:
<f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用戶管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名稱中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1" OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel>
相比之前的代碼,主要的調整為:
- 新增一個觸發器輸入框控件 TwinTriggerBox,並放置於一個 Form 面板中。
- 將Form面板放在 Grid 的前面。
- 調整布局:外部面板(Panel1)的布局由(Layout=Fit)改為(Layout=VBox),並為表格增加(BoxFlex=1)。這個調整的目的是讓Form控件自適應高度,而Grid占據剩余的全部高度。
- 將Toolbars由原來Panel1移到Grid1里面,這樣可以確保【新增】按鈕在表格里面,也就是搜索框的下面。
早在 2012 年,我就寫過一系列文章介紹 FineUI 的布局,現在仍然可以作為參考而不過時:https://www.cnblogs.com/sanshi/archive/2012/07/27/2611116.html
現在的頁面效果:
7.5、搜索框事件
在搜索框的標簽定義中,有兩個回發事件的定義,如下所示:
OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1"
OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"
這兩個事件分別對應觸發器輸入框的兩個觸發按鈕:
- 清空圖標:OnTrigger1Click
- 搜索圖標:OnTrigger2Click
由於這兩個事件都需要進行表格的重新綁定,所以我們先將其提取為一個獨立的方法:
private async Task ReloadGrid(string[] Grid1_fields, string searchMessage) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } Movies = await q.ToListAsync(); UIHelper.Grid("Grid1").DataSource(Movies, Grid1_fields); }
這段代碼中,為了將檢索條件帶入數據庫查詢,我們做了一些改變:
- IQueryable<Movie>:是 System.Linq 提供的一個查詢功能,在各種查詢條件以及分頁排序時都需要用到,非常重要。
- q.Where:指定具體的查詢條件
- q.ToList:執行數據庫查詢操作
下面看下搜索框的兩個事件定義:
public async Task<IActionResult> OnPostTBSearchMessage_Trigger1Async(string[] Grid1_fields) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 清空搜索框,並隱藏清空圖標 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 重新加載表格數據 await ReloadGrid(Grid1_fields, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 顯示清空圖標 TBSearchMessageUI.ShowTrigger1(true); // 重新加載表格數據 await ReloadGrid(Grid1_fields, TBSearchMessage); return UIHelper.Result(); }
這兩個事件邏輯對比着看就很清楚了:
- 點擊清空圖標:清空搜索框文本,隱藏清空圖標,重新加載表格
- 點擊搜索圖標:顯示清空圖標,重新加載表格
7.6、服務端標記搜索框不能為空
在上面的實現中,如果用戶將搜索框留空並點擊搜索圖標,還是會觸發搜索事件。
我們在服務器端阻止這個行為,FineUICore提供了標記某個字段無效的方法:
public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能為空!"); } else { // 顯示清空圖標 TBSearchMessageUI.ShowTrigger1(true); // 重新加載表格數據 await ReloadGrid(Grid1_fields, TBSearchMessage); } return UIHelper.Result(); }
在這段代碼中,如果搜索文本為空,會調用文本框的 MarkInvalid 方法將文本框標記為無效。
看下實際的效果:
目前為止,我們來看下更新后的列表頁面視圖和模型類的代碼:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用戶管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名稱中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1" OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '確定刪除此記錄?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', { deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '編輯'); } }); }); </script> }
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } public async Task OnGetAsync() { Movies = await _context.Movies.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, string searchMessage) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } Movies = await q.ToListAsync(); UIHelper.Grid("Grid1").DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostWindow1_CloseAsync(string[] Grid1_fields) { // 重新加載表格數據 await ReloadGrid(Grid1_fields, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostGrid_RowDeleteAsync(string[] Grid1_fields, int deletedRowID) { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie != null) { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); // 重新加載表格數據 await ReloadGrid(Grid1_fields, string.Empty); } return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger1Async(string[] Grid1_fields) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 清空搜索框,並隱藏清空圖標 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 重新加載表格數據 await ReloadGrid(Grid1_fields, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能為空!"); } else { // 顯示清空圖標 TBSearchMessageUI.ShowTrigger1(true); // 重新加載表格數據 await ReloadGrid(Grid1_fields, TBSearchMessage); } return UIHelper.Result(); } } }
八、分頁與排序
8.1、數據庫分頁
這一節我們會給表格控件增加分頁和排序,首先來看下分頁的標簽定義:
<f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Grid1_PageIndexChanged")" OnPageIndexChangedFields="Panel1">
為了支持數據庫分頁,我們增加如下一些屬性:
- AllowPaging:啟用分頁
- IsDatabasePaging:啟用數據庫分頁
- PageSize:每頁顯示的記錄數
- RecordCount:總記錄數
- OnPageIndexChanged:分頁改變事件
其中 PageSize 和 RecordCount 數據來自於模型類屬性:
// 每頁顯示記錄數 public int PageSize { get; set; } = 5; // 總記錄數 public int RecordCount { get; set; }
由於需要在頁面第一次加載時(OnGet)和HTTP POST請求時(OnPost)獲取表格數據,我們將獲取表格分頁數據的方法提取為一個公共函數:
private async Task PrepareGridData(string searchMessage, int pageIndex) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //對傳入的 pageIndex 進行有效性驗證 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (pageIndex > pageCount - 1) { pageIndex = pageCount - 1; } if (pageIndex < 0) { pageIndex = 0; } // 分頁 q = q.Skip(pageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); }
這個函數中會對 RecordCount 和 Movies 屬性進行賦值,其中 Movies 表示的就是當前分頁的數據(數據庫分頁)。
在頁面第一次加載時的調用:
public async Task OnGetAsync() { await PrepareGridData(string.Empty, 0); }
在分頁改變事件中的調用:
public async Task<IActionResult> OnPostGrid1_PageIndexChangedAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); }
private async Task ReloadGrid(string[] Grid1_fields, int Grid1_pageIndex, string searchMessage) { await PrepareGridData(searchMessage, Grid1_pageIndex); var Grid1UI = UIHelper.Grid("Grid1"); // 設置總記錄數 Grid1UI.RecordCount(RecordCount); // 設置分頁數據 Grid1UI.DataSource(Movies, Grid1_fields); }
此時的分頁效果:
8.2、保持分頁狀態和搜索狀態
不僅如此,我們還需要對 Window1_Close、Grid_RowDelete、TBSearchMessage_Trigger1、TBSearchMessage_Trigger2 的事件處理函數進行重構,傳入 Grid1_pageIndex 和 TBSearchMessage 參數。
原因是我們希望在用戶關閉窗體時、行刪除時,以及搜索時,能夠保持頁面上的狀態不丟失,目前的狀態主要有兩個:
- 當前正在展現表格的哪一頁?
- 當前正在搜索哪個關鍵詞?
更新后的模型類代碼如下所示:
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } // 每頁顯示記錄數 public int PageSize { get; set; } = 5; // 總記錄數 public int RecordCount { get; set; } public async Task OnGetAsync() { await PrepareGridData(string.Empty, 0); } private async Task PrepareGridData(string searchMessage, int pageIndex) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //對傳入的 pageIndex 進行有效性驗證 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (pageIndex > pageCount - 1) { pageIndex = pageCount - 1; } if (pageIndex < 0) { pageIndex = 0; } // 分頁 q = q.Skip(pageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, int Grid1_pageIndex, string searchMessage) { await PrepareGridData(searchMessage, Grid1_pageIndex); var Grid1UI = UIHelper.Grid("Grid1"); // 設置總記錄數 Grid1UI.RecordCount(RecordCount); // 設置分頁數據 Grid1UI.DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostWindow1_CloseAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); } public async Task<IActionResult> OnPostGrid_RowDeleteAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage, int deletedRowID) { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie != null) { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); } return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger1Async(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); // 清空搜索框,並隱藏清空圖標 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, string.Empty); return UIHelper.Result(); } public async Task<IActionResult> OnPostTBSearchMessage_Trigger2Async(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能為空!"); } else { // 顯示清空圖標 TBSearchMessageUI.ShowTrigger1(true); // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); } return UIHelper.Result(); } public async Task<IActionResult> OnPostGrid1_PageIndexChangedAsync(string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage) { // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); } } }
此時對應的頁面視圖:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用戶管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名稱中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1" OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Grid1_PageIndexChanged")" OnPageIndexChangedFields="Panel1"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '確定刪除此記錄?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', { deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '編輯'); } }); }); </script> }
為了驗證狀態保持效果,我們進行如下操作步驟:
- 轉到第2頁
- 搜索關鍵詞:【星球】,此時能保持狀態:表格處於第2頁
- 轉到第1頁,此時能保持狀態:關鍵詞為【星球】
- 修改一條記錄並返回,此時能保持狀態:表格處於第2頁 + 關鍵詞為【星球】
下面的動圖展示了這一系列操作:
8.3、將 5 個回發事件合並為 1 個
你可能也注意到了,上述 5 個回發事件都需要接受如下三個參數:
- string[] Grid1_fields:表格需要用到的數據字段(對應模型類屬性名列表)
- int Grid1_pageIndex:表格當前位於第幾頁
- string TBSearchMessage:搜索關鍵詞
並且這 5 個回發事件最后都要重新綁定表格數據,造成很多代碼都是重復的。
隨着程序功能的增加,這個重復會越來越多,比如更多的查詢條件,以及后面要添加的表格排序,都需要添加更多的參數。
對於一個注重自我修養的程序員,如此的代碼重復是我們不能容忍的,重構在所難免。
為了合並 5 個事件處理函數,我們需要從視圖代碼入手,通過參數指定需要進行的操作,所有需要回發的地方都要修改。
1. 行刪除事件
F.doPostBack('@Url.Handler("Grid_RowDelete")', 'Panel1', {
deletedRowID: rowData.id
});
修改為:
F.doPostBack('@Url.Handler("Movie_PostBack")', 'Panel1', { actionType: 'delete', deletedRowID: rowData.id });
2. 窗體關閉事件
OnClose="@Url.Handler("Window1_Close")" OnCloseFields="Panel1"
修改為:
OnClose="@Url.Handler("Movie_PostBack")" OnCloseFields="Panel1" OnCloseParameter1="@(new Parameter("actionType", "close", ParameterMode.String))"
3. 表格分頁事件
OnPageIndexChanged="@Url.Handler("Grid1_PageIndexChanged")" OnPageIndexChangedFields="Panel1"
修改為:
OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))"
4. 搜索框事件
OnTrigger1Click="@Url.Handler("TBSearchMessage_Trigger1")" OnTrigger1ClickFields="Panel1"
OnTrigger2Click="@Url.Handler("TBSearchMessage_Trigger2")" OnTrigger2ClickFields="Panel1"
修改為:
OnTrigger1Click="@Url.Handler("Movie_PostBack")" OnTrigger1ClickFields="Panel1" OnTrigger1ClickParameter1="@(new Parameter("actionType", "trigger1", ParameterMode.String))"
OnTrigger2Click="@Url.Handler("Movie_PostBack")" OnTrigger2ClickFields="Panel1" OnTrigger2ClickParameter1="@(new Parameter("actionType", "trigger2", ParameterMode.String))"
更新后的視圖文件:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用戶管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名稱中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("Movie_PostBack")" OnTrigger1ClickFields="Panel1" OnTrigger1ClickParameter1="@(new Parameter("actionType", "trigger1", ParameterMode.String))" OnTrigger2Click="@Url.Handler("Movie_PostBack")" OnTrigger2ClickFields="Panel1" OnTrigger2ClickParameter1="@(new Parameter("actionType", "trigger2", ParameterMode.String))"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Movie_PostBack")" OnCloseFields="Panel1" OnCloseParameter1="@(new Parameter("actionType", "close", ParameterMode.String))"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '確定刪除此記錄?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Movie_PostBack")', 'Panel1', { actionType: 'delete', deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '編輯'); } }); }); </script> }
更新后的模型類:
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } // 每頁顯示記錄數 public int PageSize { get; set; } = 5; // 總記錄數 public int RecordCount { get; set; } public async Task OnGetAsync() { await PrepareGridData(string.Empty, 0); } private async Task PrepareGridData(string searchMessage, int pageIndex) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //對傳入的 pageIndex 進行有效性驗證 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (pageIndex > pageCount - 1) { pageIndex = pageCount - 1; } if (pageIndex < 0) { pageIndex = 0; } // 分頁 q = q.Skip(pageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, int Grid1_pageIndex, string searchMessage) { await PrepareGridData(searchMessage, Grid1_pageIndex); var Grid1UI = UIHelper.Grid("Grid1"); // 設置總記錄數 Grid1UI.RecordCount(RecordCount); // 設置分頁數據 Grid1UI.DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostMovie_PostBackAsync(string actionType, string[] Grid1_fields, int Grid1_pageIndex, string TBSearchMessage, int deletedRowID) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (actionType == "delete") { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie == null) { Alert.Show("指定的電影不存在!"); return UIHelper.Result(); } else { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); } } else if (actionType == "trigger1") { // 清空搜索框,並隱藏清空圖標 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 不要忘記設置搜索文本為空字符串 TBSearchMessage = string.Empty; } else if (actionType == "trigger2") { if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能為空!"); return UIHelper.Result(); } else { // 顯示清空圖標 TBSearchMessageUI.ShowTrigger1(true); } } // actionType: page, close 無需特殊處理 // 重新加載表格數據 await ReloadGrid(Grid1_fields, Grid1_pageIndex, TBSearchMessage); return UIHelper.Result(); } } }
這段代碼中:
- 通過 actionType 獲取當前需要執行的操作
- 點擊清空圖標時,要設置 TBSearchMessage = string.Empty; 因為后面重新綁定表格數據時用到這個變量
- 表格分頁和窗體關閉事件無需特殊處理,只需要重新綁定表格即可
8.4、排序
在前面表格分頁實現之后,再添加排序操作就輕車熟路了。首先看下表格的標簽定義:
<f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))" AllowSorting="true" SortField="@Model.SortField" SortDirection="@Model.SortDirection" OnSort="@Url.Handler("Movie_PostBack")" OnSortFields="Panel1" OnSortParameter1="@(new Parameter("actionType", "sort", ParameterMode.String))"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" SortField="Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" SortField="ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" SortField="Genre" /> <f:RenderField For="Movies.First().Price" SortField="Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> </f:Grid>
注意新增的部分:
- AllowSorting、SortField、SortDirection:啟用排序,設置表格默認的排序字段和排序方向。
- OnSort:排序事件
- 為需要排序的列添加SortField屬性,比如名稱列:For="Movies.First().Title" SortField="Title"
模型類中的修改不多,只需要在分頁之前添加排序代碼即可:
// 排序 q = q.SortBy(SortField + " " + SortDirection); // 分頁 q = q.Skip(PageIndex * PageSize).Take(PageSize);
注意,這里的 SortBy 並非 .NET Core 原生支持的方法,而是我們自定義的一個擴展方法。
8.5、SortBy 擴展方法
因為 .NET Core 提供的 OrderByDescending 和 OrderBy 不支持字符串參數,因為要支持我們的 SortField 和 SortDirection,我們需要寫一堆id-else語句,類似如下所示:
if (SortDirection == "DESC") { if (SortField == "Title") { q = q.OrderByDescending(q => q.Title); } else if (SortField == "ReleaseDate") { q = q.OrderByDescending(q => q.ReleaseDate); } else if (SortField == "Price") { q = q.OrderByDescending(q => q.Price); } else if (SortField == "Genre") { q = q.OrderByDescending(q => q.Genre); } } else { if (SortField == "Title") { q = q.OrderBy(q => q.Title); } else if (SortField == "ReleaseDate") { q = q.OrderBy(q => q.ReleaseDate); } else if (SortField == "Price") { q = q.OrderBy(q => q.Price); } else if (SortField == "Genre") { q = q.OrderBy(q => q.Genre); } }
這個代碼是如此的丑陋,以至於我根本無需下手......
早在 2013年我們更新 AppBoxPro 時就曾提出這個問題,並綜合大家的代碼給我了我們的解決辦法:AppBox升級進行時 - 如何向OrderBy傳遞字符串參數(Entity Framework)
那就是自定義擴展方法,如下所示:
現在我們來看下列表頁面的完整視圖代碼:
@page @model FineUICore.EmptyProject.RazorPages.MovieModel @{ ViewData["Title"] = "Movie"; } @section body { <f:Panel ID="Panel1" BodyPadding="10" ShowBorder="false" Layout="VBox" ShowHeader="false" Title="用戶管理" IsViewPort="true"> <Items> <f:Form ShowBorder="false" ShowHeader="false"> <Rows> <f:FormRow> <Items> <f:TwinTriggerBox ID="TBSearchMessage" ShowLabel="false" EmptyText="在名稱中搜索" Trigger1Icon="Clear" ShowTrigger1="false" Trigger2Icon="Search" OnTrigger1Click="@Url.Handler("Movie_PostBack")" OnTrigger1ClickFields="Panel1" OnTrigger1ClickParameter1="@(new Parameter("actionType", "trigger1", ParameterMode.String))" OnTrigger2Click="@Url.Handler("Movie_PostBack")" OnTrigger2ClickFields="Panel1" OnTrigger2ClickParameter1="@(new Parameter("actionType", "trigger2", ParameterMode.String))"> </f:TwinTriggerBox> <f:Label></f:Label> </Items> </f:FormRow> </Rows> </f:Form> <f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies" AllowPaging="true" IsDatabasePaging="true" PageSize="@Model.PageSize" RecordCount="@Model.RecordCount" OnPageIndexChanged="@Url.Handler("Movie_PostBack")" OnPageIndexChangedFields="Panel1" OnPageIndexChangedParameter1="@(new Parameter("actionType", "page", ParameterMode.String))" AllowSorting="true" SortField="@Model.SortField" SortDirection="@Model.SortDirection" OnSort="@Url.Handler("Movie_PostBack")" OnSortFields="Panel1" OnSortParameter1="@(new Parameter("actionType", "sort", ParameterMode.String))"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" SortField="Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" SortField="ReleaseDate" FieldFormat="yyyy-MM-dd" Width="200" /> <f:RenderField For="Movies.First().Genre" SortField="Genre" /> <f:RenderField For="Movies.First().Price" SortField="Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> <Toolbars> <f:Toolbar ID="Toolbar1" Position="Top"> <Items> <f:ToolbarFill></f:ToolbarFill> <f:Button ID="btnNew" IconFont="PlusCircle" Text="新增"> <Listeners> <f:Listener Event="click" Handler="onNewClick"></f:Listener> </Listeners> </f:Button> </Items> </f:Toolbar> </Toolbars> </f:Grid> </Items> </f:Panel> <f:Window ID="Window1" IsModal="true" Hidden="true" Target="Top" EnableResize="true" EnableMaximize="true" EnableIFrame="true" Width="650" Height="400" OnClose="@Url.Handler("Movie_PostBack")" OnCloseFields="Panel1" OnCloseParameter1="@(new Parameter("actionType", "close", ParameterMode.String))"> </f:Window> } @section script { <script> function onNewClick(event) { F.ui.Window1.show('@Url.Content("~/MovieNew")', '新增'); } function renderActionEdit(value, params) { return '<a class="action-btn edit" href="javascript:;"><i class="f-icon f-icon-pencil f-grid-cell-iconfont"></a>'; } function renderActionDelete(value, params) { return '<a class="action-btn delete" href="javascript:;"><i class="f-icon f-icon-trash f-grid-cell-iconfont"></a>'; } F.ready(function () { var grid1 = F.ui.Grid1; grid1.el.on('click', 'a.action-btn', function (event) { var cnode = $(this); var rowData = grid1.getRowData(cnode.closest('.f-grid-row')); if (cnode.hasClass('delete')) { F.confirm({ message: '確定刪除此記錄?', target: '_top', ok: function () { F.doPostBack('@Url.Handler("Movie_PostBack")', 'Panel1', { actionType: 'delete', deletedRowID: rowData.id }); } }); } else if (cnode.hasClass('edit')) { F.ui.Window1.show('@Url.Content("~/MovieEdit/")' + rowData.id, '編輯'); } }); }); </script> }
列表頁面完整的模型類代碼:
using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; namespace FineUICore.EmptyProject.RazorPages { public class MovieModel : PageModel { private readonly MovieContext _context; public MovieModel(MovieContext context) { _context = context; } public IList<Movie> Movies { get; set; } // 當前所在的頁 public int PageIndex { get; set; } = 0; // 每頁顯示記錄數 public int PageSize { get; set; } = 5; // 總記錄數 public int RecordCount { get; set; } // 排序字段名稱 public string SortField { get; set; } = "Title"; // 排序方向(DESC:倒序,ASC:正序) public string SortDirection { get; set; } = "DESC"; public async Task OnGetAsync() { await PrepareGridData(string.Empty); } private async Task PrepareGridData(string searchMessage) { IQueryable<Movie> q = _context.Movies; // 搜索框 searchMessage = searchMessage?.Trim(); if (!string.IsNullOrEmpty(searchMessage)) { q = q.Where(s => s.Title.Contains(searchMessage)); } RecordCount = await q.CountAsync(); //對傳入的 pageIndex 進行有效性驗證 int pageCount = RecordCount / PageSize; if (RecordCount % PageSize != 0) { pageCount++; } if (PageIndex > pageCount - 1) { PageIndex = pageCount - 1; } if (PageIndex < 0) { PageIndex = 0; } // 排序 q = q.SortBy(SortField + " " + SortDirection); // 分頁 q = q.Skip(PageIndex * PageSize).Take(PageSize); Movies = await q.ToListAsync(); } private async Task ReloadGrid(string[] Grid1_fields, string searchMessage) { await PrepareGridData(searchMessage); var Grid1UI = UIHelper.Grid("Grid1"); // 設置總記錄數 Grid1UI.RecordCount(RecordCount); // 設置分頁數據 Grid1UI.DataSource(Movies, Grid1_fields); } public async Task<IActionResult> OnPostMovie_PostBackAsync(string actionType, string[] Grid1_fields, int Grid1_pageIndex, string Grid1_sortField, string Grid1_sortDirection, string TBSearchMessage, int deletedRowID) { var TBSearchMessageUI = UIHelper.TwinTriggerBox("TBSearchMessage"); if (actionType == "delete") { var movie = await _context.Movies.FindAsync(deletedRowID); if (movie == null) { Alert.Show("指定的電影不存在!"); return UIHelper.Result(); } else { _context.Movies.Remove(movie); await _context.SaveChangesAsync(); } } else if (actionType == "trigger1") { // 清空搜索框,並隱藏清空圖標 TBSearchMessageUI.Text(string.Empty); TBSearchMessageUI.ShowTrigger1(false); // 不要忘記設置搜索文本為空字符串 TBSearchMessage = string.Empty; } else if (actionType == "trigger2") { if (string.IsNullOrEmpty(TBSearchMessage)) { TBSearchMessageUI.MarkInvalid("搜索文本不能為空!"); return UIHelper.Result(); } else { // 顯示清空圖標 TBSearchMessageUI.ShowTrigger1(true); } } // actionType: page, close 無需特殊處理 PageIndex = Grid1_pageIndex; SortField = Grid1_sortField; SortDirection = Grid1_sortDirection; // 重新加載表格數據 await ReloadGrid(Grid1_fields, TBSearchMessage); return UIHelper.Result(); } } }
現在,來進行最后一波操作,看下我們的勞動成果:
九、對比 ASP.NET Core 和 FineUICore 創建的頁面
FineUICore控件不僅帶來了漂亮的界面,而且能進一步簡化代碼編寫工作。
這一節我們就簡單對比下 ASP.NET Core 原生實現的頁面和 FineUICore 控件庫實現的頁面。
列表頁面的表格
ASP.NET Core原生實現的表格
<table class="table"> <thead> <tr> <th> @Html.DisplayNameFor(model => model.Movie[0].Title) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].ReleaseDate) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].Genre) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].Price) </th> <th> @Html.DisplayNameFor(model => model.Movie[0].Rating) </th> <th></th> </tr> </thead> <tbody> @foreach (var item in Model.Movie) { <tr> <td> @Html.DisplayFor(modelItem => item.Title) </td> <td> @Html.DisplayFor(modelItem => item.ReleaseDate) </td> <td> @Html.DisplayFor(modelItem => item.Genre) </td> <td> @Html.DisplayFor(modelItem => item.Price) </td> <td> @Html.DisplayFor(modelItem => item.Rating) </td> <td> <a asp-page="./Edit" asp-route-id="@item.ID">Edit</a> | <a asp-page="./Details" asp-route-id="@item.ID">Details</a> | <a asp-page="./Delete" asp-route-id="@item.ID">Delete</a> </td> </tr> } </tbody> </table>
原生實現中,我們不僅要接觸原生的<table><td>標簽,而且需要單獨創建表頭,並對列表集合進行foreach循環遍歷。
顯示效果:
FineUICore控件實現的表格
<f:Grid ID="Grid1" BoxFlex="1" ShowBorder="true" ShowHeader="false" DataIDField="ID" DataTextField="Title" DataSource="@Model.Movies"> <Columns> <f:RowNumberField /> <f:RenderField For="Movies.First().Title" ExpandUnusedSpace="true" /> <f:RenderField For="Movies.First().ReleaseDate" FieldFormat="yyyy-MM-dd" Width="150" /> <f:RenderField For="Movies.First().Genre" /> <f:RenderField For="Movies.First().Price" /> <f:RenderField Width="50" RendererFunction="renderActionEdit"></f:RenderField> <f:RenderField Width="50" RendererFunction="renderActionDelete"></f:RenderField> </Columns> </f:Grid>
FineUICore實現的表格更加架構化和標簽化,其中沒有混雜各種 C# 代碼,只需要指定數據源 DataSource 即可,代碼更加簡潔。
顯示效果:
編輯頁面的表單
ASP.NET Core原生實現的表單
<form method="post"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <input type="hidden" asp-for="Movie.ID" /> <div class="form-group"> <label asp-for="Movie.Title" class="control-label"></label> <input asp-for="Movie.Title" class="form-control" /> <span asp-validation-for="Movie.Title" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.ReleaseDate" class="control-label"></label> <input asp-for="Movie.ReleaseDate" class="form-control" /> <span asp-validation-for="Movie.ReleaseDate" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.Genre" class="control-label"></label> <input asp-for="Movie.Genre" class="form-control" /> <span asp-validation-for="Movie.Genre" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.Price" class="control-label"></label> <input asp-for="Movie.Price" class="form-control" /> <span asp-validation-for="Movie.Price" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Movie.Rating" class="control-label"></label> <input asp-for="Movie.Rating" class="form-control" /> <span asp-validation-for="Movie.Rating" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Save" class="btn btn-primary" /> </div> </form>
在這段代碼中,我們不僅需要接觸<form><label><input>等原生HTML標簽,還要考慮布局<div class=form-group>和樣式class=control-label,更糟糕的是,對於每個表單字段都需要三個標簽實現:
- label:顯示表單字段的標題文本
- input:表單字段
- span:驗證失敗的提示信息
顯示效果:
FineUICore控件實現的表單
<f:SimpleForm ID="SimpleForm1" ShowBorder="false" ShowHeader="false" BodyPadding="10"> <Items> <f:HiddenField For="Movie.ID"></f:HiddenField> <f:TextBox For="Movie.Title"></f:TextBox> <f:DatePicker For="Movie.ReleaseDate"></f:DatePicker> <f:TextBox For="Movie.Genre"></f:TextBox> <f:NumberBox For="Movie.Price"></f:NumberBox> </Items> </f:SimpleForm>
FineUICore的實現更加標簽化,其中沒有混雜C#和HTML代碼,並且對於每個表單字段只需要一個控件就行了(想想ASP.NET Core原生的3個標簽實現),而且不用考慮布局和字段的樣式問題。
顯示效果:
多個主題的頁面截圖賞析
十、下載項目源代碼
FineUICore(基礎版)非免費軟件,你可以加入【三石和他的朋友們】知識星球下載本教程的完整項目源代碼:
FineUICore算是國內堅持在ASP.NET Core陣營僅有的控件庫了,我們花了大量的心思在里面,細節上追求精益求精,希望大家能善待之。
俗話說,三十年河東,三十年河西,讓我們共同來迎接 ASP.NET Core 的春天,讓 FineUICore 就做這棵大樹上一朵綻放的花朵吧。
歡迎評論和 (這是一個可以點擊的按鈕,點擊即可推薦本文!)