教學課程:新增排序、篩選及分頁 - ASP.NET MVC 搭配 EF Core

在上一個教學課程中,您已為學生實體的基本 CRUD 作業實作了一套網頁。 在這個教學中,你會為學生索引頁面新增排序、篩選和分頁功能。 你還會建立一個用於簡單分組的頁面。

在這個練習中,你會為學生索引頁面新增排序、篩選和分頁功能。 你也會建立一個用於簡單分組的頁面。 以下圖示展示了完成後頁面的樣貌。 欄位標題是使用者可以選擇以該欄位排序的連結。 反覆選擇欄位標題會使排序順序在升降之間切換。

學生索引頁面的截圖,包含排序、篩選和分頁功能。

在本教學課程中,您已:

  • 新增支援欄位排序的連結
  • 新增搜尋框以支援搜尋
  • 為學生索引添加分頁功能
  • 新增連結以支援分頁動作
  • 為網站建立關於頁面

必要條件

要在 學生索引 頁面新增排序功能,你要更改 Index 學生控制器的方法,並在學生索引檢視中加入程式碼。

為索引方法新增排序功能

StudentsController.cs 檔案中,將方法替換 Index 為以下程式碼:

public async Task<IActionResult> Index(string sortOrder)
{
    ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
    ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";
    var students = from s in _context.Students
                   select s;
    switch (sortOrder)
    {
        case "name_desc":
            students = students.OrderByDescending(s => s.LastName);
            break;
        case "Date":
            students = students.OrderBy(s => s.EnrollmentDate);
            break;
        case "date_desc":
            students = students.OrderByDescending(s => s.EnrollmentDate);
            break;
        default:
            students = students.OrderBy(s => s.LastName);
            break;
    }
    return View(await students.AsNoTracking().ToListAsync());
}

此程式碼會從 URL 中的查詢字串接收 sortOrder 參數。 查詢字串值是由 ASP.NET Core MVC 提供,作為動作方法的參數。 參數是一個字串值:「Name」或「Date」。值後面可選擇性地加上底線和字串「desc」以指定降序。 預設排序順序為遞增。

第一次請求 索引頁面時 ,沒有查詢字串。 學生會依姓氏的遞增順序顯示,這是 switch 陳述式中失敗案例所建立的預設值。 當使用者選擇欄位標題超連結時,查詢字串中會提供相應 sortOrder 的值。

檢視用這兩個 ViewData 元素NameSortParm (和 DateSortParm)來配置欄位標題超連結,並設定適當的查詢字串值。

public async Task<IActionResult> Index(string sortOrder)
{
    ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
    ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";
    var students = from s in _context.Students
                   select s;
    switch (sortOrder)
    {
        case "name_desc":
            students = students.OrderByDescending(s => s.LastName);
            break;
        case "Date":
            students = students.OrderBy(s => s.EnrollmentDate);
            break;
        case "date_desc":
            students = students.OrderByDescending(s => s.EnrollmentDate);
            break;
        default:
            students = students.OrderBy(s => s.LastName);
            break;
    }
    return View(await students.AsNoTracking().ToListAsync());
}

這些程式碼變更包含元素的三進位陳述。 第一句話指定若 sortOrder 參數為空或空,該 NameSortParm 元素被設為「name_desc」。否則,該元素將被設定為空字串。 這兩個陳述式會啟動設定資料行標題超連結的檢視,如下所示:

目前排序次序 姓氏超連結 日期超連結
姓氏按字母升序排列 遞減 遞增
姓氏遞減 遞增 遞增
日期遞增 遞增 遞減
日期遞減 遞增 遞增

這個方法會使用 LINQ to Entities 來指定排序所依據的資料行(欄位)。 程式碼會在IQueryable陳述式之前建立變數,並在switch陳述式中修改該變數,之後在switch陳述式後呼叫ToListAsync方法。 當您建立和修改 IQueryable 變數時,沒有查詢會傳送至資料庫。 在您呼叫 IQueryable 等方法以將 ToListAsync 物件轉換成集合之前,不會執行查詢。 因此,這段程式碼會產生一個查詢,而該查詢直到執行return View陳述句時才會執行。

當你處理大量欄位時,這些程式碼可能會變得冗長。 本系列的最後一個教學《 學習進階情境 > 使用動態 LINQ 簡化程式碼》展示了如何撰寫能在字串變數中傳遞欄位名稱 OrderBy 的程式碼。

請將 Views/Students/Index.cshtml 檔案中的程式碼替換為以下新增欄目超連結的程式碼。 變更的行已突出顯示。

@model IEnumerable<ContosoUniversity.Models.Student>

@{
    ViewData["Title"] = "Index";
}

<h2>Index</h2>

<p>
    <a asp-action="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    <a asp-action="Index" asp-route-sortOrder="@ViewData["NameSortParm"]">@Html.DisplayNameFor(model => model.LastName)</a>
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.FirstMidName)
                </th>
                <th>
                    <a asp-action="Index" asp-route-sortOrder="@ViewData["DateSortParm"]">@Html.DisplayNameFor(model => model.EnrollmentDate)</a>
                </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.LastName)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.FirstMidName)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.EnrollmentDate)
            </td>
            <td>
                <a asp-action="Edit" asp-route-id="@item.ID">Edit</a> |
                <a asp-action="Details" asp-route-id="@item.ID">Details</a> |
                <a asp-action="Delete" asp-route-id="@item.ID">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

此程式碼利用屬性中的 ViewData 資訊來設定帶有適當查詢字串值的超連結。

啟動應用程式,選擇 學生 標籤,並選擇 姓氏入學日期 欄位標題以確認排序功能。

學生索引頁面的截圖,顯示學生姓氏依升至高排序。

要在 學生索引 頁面新增篩選功能,你可以在檢視中加入文字框和 提交 按鈕,並在方法中做出相應的調整 Index 。 在文字框中,你可以輸入字串,在學生的名字和姓氏欄位中搜尋。

為索引方法新增過濾支援

StudentsController.cs 檔案中,將方法替換 Index 為以下程式碼(變更部分會被標示)。

public async Task<IActionResult> Index(string sortOrder, string searchString)
{
    ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
    ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";
    ViewData["CurrentFilter"] = searchString;

    var students = from s in _context.Students
                   select s;
    if (!String.IsNullOrEmpty(searchString))
    {
        students = students.Where(s => s.LastName.Contains(searchString)
                               || s.FirstMidName.Contains(searchString));
    }
    switch (sortOrder)
    {
        case "name_desc":
            students = students.OrderByDescending(s => s.LastName);
            break;
        case "Date":
            students = students.OrderBy(s => s.EnrollmentDate);
            break;
        case "date_desc":
            students = students.OrderByDescending(s => s.EnrollmentDate);
            break;
        default:
            students = students.OrderBy(s => s.LastName);
            break;
    }
    return View(await students.AsNoTracking().ToListAsync());
}

你在searchString方法裡加了一個Index參數。 搜尋字串的值是從你加入索引檢視的文字框中獲得的。 你還在陳述中加入whereLINQ了一條條款,只選擇名字或姓氏與搜尋字串相符的學生。 加入子 where 句的陳述只有在存在可搜尋值時才會執行。

確定如何使用 Where 方法

在此情境中,在物件上呼叫方法,並在伺服器上處理過濾器。 在某些情況下,你可以在記憶體集合中呼叫該Where 方法作為延伸方法。 假設你將參考改為 _context.Students。 它不是呼叫 Entity Framework DbSet 方法,而是引用一個回傳 IEnumerable 集合的 repository 方法。 結果通常相同,但某些情況可能不同。

例如,.NET Framework 的 Contains 方法的預設行為是執行大小寫區分的比較。 在 SQL Server 中,判定是透過 SQL Server 實例的整合設定來完成。 設定預設為不區分大小寫。 你可以呼叫 ToUpper 方法來使檢測明確不區分大小寫:Where(s => s.LastName.ToUpper().Contains(searchString.ToUpper())。 這個選項確保即使你後來更改程式碼,使用回傳 IEnumerable 集合而非 IQueryable 物件的儲存庫,結果會保持不變。 (當你呼叫 Contains 集合中的 IEnumerable 方法時,你會得到 .NET Framework 的實作。當你呼叫 IQueryable 物件時,你會得到資料庫提供者的實作。)

不過,這個方案會帶來效能上的損失。 ToUpper 程式碼將函式放入WHERE子句中的TSQL SELECT陳述式。 此行為阻止優化器使用索引。 鑒於 SQL 大多安裝為不區分大小寫,因此最好在遷移到不區分大小寫的數據存儲之前避免使用 ToUpper 代碼。

在學生索引檢視中新增搜尋框

Views/Student/Index.cshtml 檔案中,請在開啟表格標籤 <table> 之前立即加入強調的程式碼,以便建立表格標題、一個文字框以及一個 搜尋 按鈕。

<p>
    <a asp-action="Create">Create New</a>
</p>

<form asp-action="Index" method="get">
    <div class="form-actions no-color">
        <p>
            <label>Find by name: <input type="text" name="SearchString" value="@ViewData["CurrentFilter"]" /></label>
            <input type="submit" value="Search" class="btn btn-default" /> |
            <a asp-action="Index">Back to Full List</a>
        </p>
    </div>
</form>

<table class="table">

此程式碼會使用 <form>標籤協助程式來新增搜尋文字方塊和按鈕。 預設情況下,<form> 標籤輔助器會使用 POST 來提交表單資料,這表示參數是透過 HTTP 訊息主體傳遞,而不是作為查詢字串形式傳送到 URL。 當你指定 HTTP GET時,表單資料會以查詢字串的形式傳入 URL,讓使用者可以將 URL 加入書籤。 W3C 指引建議在該動作沒有帶來更新時使用 GET

啟動應用程式,選擇 「學生 」標籤,輸入搜尋字串,然後選擇 「搜尋 」以確認過濾功能是否有效。

學生索引頁面的截圖,包含篩選功能。

請注意 URL 中包含了搜尋字串。

http://localhost:5813/Students?SearchString=an

如果你將此頁面加入書籤,使用書籤時會看到篩選後的清單。 將子句加入method="get"form標籤後,會產生查詢字串。

在開發的這個階段,如果你選擇欄位標題排序連結,你會失去 搜尋框中 輸入的篩選值。 你將在下一節了解如何處理這種情況。

將分頁功能加入學生索引

要在 學生索引 頁面新增分頁功能,你可以建立 PaginatedList 一個類別,使用 SkipTake 語句來過濾伺服器上的資料,而不是每次都檢索所有資料表列。 接著,你進一步修改 Index 方法,並在 Index 檢視中添加分頁按鈕。 下圖顯示了頁面按鈕。

學生索引頁面截圖,附有分頁連結。

在專案資料夾中建立 PaginatedList.cs 檔案。 將範本程式碼替換成以下程式碼。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace ContosoUniversity
{
    public class PaginatedList<T> : List<T>
    {
        public int PageIndex { get; private set; }
        public int TotalPages { get; private set; }

        public PaginatedList(List<T> items, int count, int pageIndex, int pageSize)
        {
            PageIndex = pageIndex;
            TotalPages = (int)Math.Ceiling(count / (double)pageSize);

            this.AddRange(items);
        }

        public bool HasPreviousPage => PageIndex > 1;

        public bool HasNextPage => PageIndex < TotalPages;

        public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source, int pageIndex, int pageSize)
        {
            var count = await source.CountAsync();
            var items = await source.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
            return new PaginatedList<T>(items, count, pageIndex, pageSize);
        }
    }
}

此程式碼中的方法 CreateAsync 會根據頁面大小與頁碼,並套用適當的 Skip and Take 語句給物件 IQueryable 。 當在 IQueryable 物件上呼叫 ToListAsync 方法時,該方法會回傳一個只包含請求頁面的 ListHasPreviousPageHasNextPage屬性可用來啟用或停用「上一頁」「下一頁」分頁按鈕。

CreateAsync 方法用來建立 PaginatedList<T> 物件而不是建構函式,因為建構函式無法執行非同步程式碼。

在索引方法中新增分頁

StudentsController.cs 檔案中,將方法替換 Index 為以下程式碼。

public async Task<IActionResult> Index(
    string sortOrder,
    string currentFilter,
    string searchString,
    int? pageNumber)
{
    ViewData["CurrentSort"] = sortOrder;
    ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
    ViewData["DateSortParm"] = sortOrder == "Date" ? "date_desc" : "Date";

    if (searchString != null)
    {
        pageNumber = 1;
    }
    else
    {
        searchString = currentFilter;
    }

    ViewData["CurrentFilter"] = searchString;

    var students = from s in _context.Students
                   select s;
    if (!String.IsNullOrEmpty(searchString))
    {
        students = students.Where(s => s.LastName.Contains(searchString)
                               || s.FirstMidName.Contains(searchString));
    }
    switch (sortOrder)
    {
        case "name_desc":
            students = students.OrderByDescending(s => s.LastName);
            break;
        case "Date":
            students = students.OrderBy(s => s.EnrollmentDate);
            break;
        case "date_desc":
            students = students.OrderByDescending(s => s.EnrollmentDate);
            break;
        default:
            students = students.OrderBy(s => s.LastName);
            break;
    }

    int pageSize = 3;
    return View(await PaginatedList<Student>.CreateAsync(students.AsNoTracking(), pageNumber ?? 1, pageSize));
}

此程式碼會將頁碼參數、目前排序次序參數和目前篩選參數新增至方法簽章。

public async Task<IActionResult> Index(
    string sortOrder,
    string currentFilter,
    string searchString,
    int? pageNumber)

頁面第一次顯示,或尚未選取分頁或排序連結時,所有參數都是空的。 若選擇分頁連結,頁面變數會包含要顯示的頁碼。

ViewData 元素(命名為 CurrentSort)提供當前的排序順序視圖。 這個值必須包含在分頁連結中,這樣分頁時排序順序才會保持一致。

名稱為CurrentFilterViewData元素為該視圖提供當前的過濾字串。 此值必須包含在分頁連結中,以維持分頁時的過濾器設定。 當頁面顯示時,必須將該值還原到文字框中。

若分頁時搜尋字串被更改,頁面必須重設為 1,因為新篩選器可能導致顯示不同資料。 當在文字框輸入一個值並選擇 「提交」時,搜尋字串會被更改。 在這種情況下,參數 searchString 並非空。

if (searchString != null)
{
    pageNumber = 1;
}
else
{
    searchString = currentFilter;
}

Index 方法的結尾處,PaginatedList.CreateAsync 方法會將學生查詢轉換成一種支援分頁的集合類型中的單一頁面。 學生的單一頁面會被交給檢視。

return View(await PaginatedList<Student>.CreateAsync(students.AsNoTracking(), pageNumber ?? 1, pageSize));

PaginatedList.CreateAsync 方法會採用頁面數。 兩個問號 ?? 代表零聚合算子。 空值合併運算子會針對可以為 Null 的型別定義一個預設值。 此表達 (pageNumber ?? 1) 式的意思是如果有值則回傳 的 pageNumber 值;若 pageNumber 為空,則回傳 1。

Views/Students/Index.cshtml 檔案中,將現有程式碼替換為以下程式碼。 變更已被標示。

@model PaginatedList<ContosoUniversity.Models.Student>

@{
    ViewData["Title"] = "Index";
}

<h2>Index</h2>

<p>
    <a asp-action="Create">Create New</a>
</p>

<form asp-action="Index" method="get">
    <div class="form-actions no-color">
        <p>
            <label>Find by name: <input type="text" name="SearchString" value="@ViewData["CurrentFilter"]" /></label>
            <input type="submit" value="Search" class="btn btn-default" /> |
            <a asp-action="Index">Back to Full List</a>
        </p>
    </div>
</form>

<table class="table">
    <thead>
        <tr>
            <th>
                <a asp-action="Index" asp-route-sortOrder="@ViewData["NameSortParm"]" asp-route-currentFilter="@ViewData["CurrentFilter"]">Last Name</a>
            </th>
            <th>
                First Name
            </th>
            <th>
                <a asp-action="Index" asp-route-sortOrder="@ViewData["DateSortParm"]" asp-route-currentFilter="@ViewData["CurrentFilter"]">Enrollment Date</a>
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.LastName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.FirstMidName)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.EnrollmentDate)
                </td>
                <td>
                    <a asp-action="Edit" asp-route-id="@item.ID">Edit</a> |
                    <a asp-action="Details" asp-route-id="@item.ID">Details</a> |
                    <a asp-action="Delete" asp-route-id="@item.ID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

@{
    var prevDisabled = !Model.HasPreviousPage ? "disabled" : "";
    var nextDisabled = !Model.HasNextPage ? "disabled" : "";
}

<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.PageIndex - 1)"
   asp-route-currentFilter="@ViewData["CurrentFilter"]"
   class="btn btn-default @prevDisabled">
    Previous
</a>
<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.PageIndex + 1)"
   asp-route-currentFilter="@ViewData["CurrentFilter"]"
   class="btn btn-default @nextDisabled">
    Next
</a>

頁面頂端的 @model 陳述式指定檢視現在會取得 PaginatedList<T> 物件,而不是 List<T> 物件。

欄位標頭連結利用查詢字串將當前搜尋字串傳給控制器,讓使用者能在篩選結果內進行排序:

<a asp-action="Index" asp-route-sortOrder="@ViewData["DateSortParm"]" asp-route-currentFilter ="@ViewData["CurrentFilter"]">Enrollment Date</a>

標籤輔助顯示分頁按鈕:

<a asp-action="Index"
   asp-route-sortOrder="@ViewData["CurrentSort"]"
   asp-route-pageNumber="@(Model.PageIndex - 1)"
   asp-route-currentFilter="@ViewData["CurrentFilter"]"
   class="btn btn-default @prevDisabled">
   Previous
</a>

啟動應用程式,然後前往 學生 頁面。

已更新的學生索引頁面截圖,附有分頁連結。

選擇不同排序順序的分頁連結,並確保分頁功能正常。 輸入搜尋字串,再試一次分頁,確認分頁是否也能正常進行排序和篩選。

建立 [關於] 頁面

在Contoso大學網站的 「關於」 頁面,你會顯示每個註冊日期的學生人數。 此功能需要分組並對分組進行簡單計算。 為了支持這種行為,你要完成以下任務:

  • 針對您需要傳遞至檢視的資料,建立檢視模型類別。
  • 在Home控制器中創建About方法。
  • 建立 About 視圖。

建立檢視模型

Models 資料夾中建立 SchoolViewModels 資料夾。

在新資料夾中新增一個 EnrollmentDateGroup.cs 類別檔案,並將範本程式碼替換為以下程式碼:

using System;
using System.ComponentModel.DataAnnotations;

namespace ContosoUniversity.Models.SchoolViewModels
{
    public class EnrollmentDateGroup
    {
        [DataType(DataType.Date)]
        public DateTime? EnrollmentDate { get; set; }

        public int StudentCount { get; set; }
    }
}

修改 Home 控制器

HomeController.cs 檔案中,請在檔案頂端新增以下 using 語句:

using Microsoft.EntityFrameworkCore;
using ContosoUniversity.Data;
using ContosoUniversity.Models.SchoolViewModels;
using Microsoft.Extensions.Logging;

在類別開頭的大括號 { 後立即新增一個針對資料庫上下文的類別變數,並從 ASP.NET Core DI 取得上下文的實例:

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly SchoolContext _context;

    public HomeController(ILogger<HomeController> logger, SchoolContext context)
    {
        _logger = logger;
        _context = context;
    }

使用下列程式碼來新增 About 方法:

public async Task<ActionResult> About()
{
    IQueryable<EnrollmentDateGroup> data = 
        from student in _context.Students
        group student by student.EnrollmentDate into dateGroup
        select new EnrollmentDateGroup()
        {
            EnrollmentDate = dateGroup.Key,
            StudentCount = dateGroup.Count()
        };
    return View(await data.AsNoTracking().ToListAsync());
}

LINQ 語句將學生實體按入學日期分組。 它會計算每個群組中的實體數量,並將結果儲存在一組 EnrollmentDateGroup 檢視模型物件中。

建立關於我檢視

新增一個包含以下程式碼的 Views/Home/About.cshtml 檔案:

@model IEnumerable<ContosoUniversity.Models.SchoolViewModels.EnrollmentDateGroup>

@{
    ViewData["Title"] = "Student Body Statistics";
}

<h2>Student Body Statistics</h2>

<table>
    <tr>
        <th>
            Enrollment Date
        </th>
        <th>
            Students
        </th>
    </tr>

    @foreach (var item in Model)
    {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.EnrollmentDate)
            </td>
            <td>
                @item.StudentCount
            </td>
        </tr>
    }
</table>

啟動應用程式,然後進入 關於頁面 。 每個註冊日期的學生人數將會顯示在資料表中。

取得程式碼

下載或查看完成的申請表

下一個步驟