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

在上一個教學課程中,您已針對學生實體的基本 CRUD 作業實作一組網頁。 在本教學課程中,您要將排序、篩選和分頁功能新增至 Students 的 [索引] 頁面。 此外,還要建立將執行簡易群組的頁面。

下圖顯示當您完成時的頁面外觀。 資料行標題是使用者可以按一下以依據該資料行排序的連結。 重覆按一下資料行標題,可切換遞增和遞減排序次序。

Students index page

在本教學課程中,您已:

  • 新增資料行排序連結
  • 新增 [搜尋] 方塊
  • 為 Students 索引新增分頁
  • 為 Index 方法新增分頁
  • 新增分頁連結
  • 建立 [關於] 頁面

必要條件

若要將排序新增至學生的 [索引] 頁面,您要變更 Students 控制器的 Index 方法,並將程式碼新增至學生的 [索引] 檢視。

將排序功能新增至 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 參數為 null 或是空的時,NameSortParm 應該設定為 "name_desc";否則它應該設定為空字串。 這兩個陳述式會啟動設定資料行標題超連結的檢視,如下所示:

目前排序次序 姓氏超連結 日期超連結
姓氏遞增 遞減 ascending
姓氏遞減 ascending ascending
日期遞增 ascending descending
日期遞減 ascending ascending

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

此程式碼可取得使用大量資料行數目的詳細資訊。 本系列中的最後一個教學課程示範如何撰寫程式碼,讓您以字串變數傳遞 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 屬性中的資訊,以適當的查詢字串值設定超連結。

執行應用程式,選取 [Students] 索引標籤,然後按一下 [姓氏] 和 [註冊日期] 資料行標題,以確認排序的運作正常。

Students index page in name order

若要將篩選新增至 Students 的 [索引] 頁面,您要將文字方塊和提交按鈕新增至檢視,並在 Index 方法中進行對應的變更。 文字方塊可讓您輸入要在名字和姓氏欄位中搜尋的字串。

將篩選功能新增至 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 方法。 從將新增至 [索引] 檢視的文字方塊中接收搜尋字串值。 您也已在 LINQ 陳述式中新增 where 子句,該子句只會選取其名字或姓氏包含搜尋字串的學生。 唯有當具有要搜尋的值時,才會執行新增 where 子句的陳述式。

注意

在這裡,您可以在 IQueryable 物件上呼叫 Where 方法,而篩選將會在伺服器上處理。 在某些情況下,您可能會呼叫 Where 方法在記憶體內部集合上作為擴充方法。 (例如,假設您變更了 _context.Students 的參考,以便它參考傳回 IEnumerable 集合的存放庫方法,而不是參考 EF DbSet)。結果通常都是一樣的,但在某些情況下可能會不同。

例如,.NET Framework 實作的 Contains 方法預設會執行區分大小寫的比較,但在 SQL Server 中,這取決於 SQL Server 執行個體的定序設定。 該設定預設為不區分大小寫。 您可以呼叫 ToUpper 方法,使測試明確地不區分大小寫:Where(s => s.LastName.ToUpper().Contains(searchString.ToUpper())。 如果您稍後變更程式碼,以使用傳回 IEnumerable 集合 (而不是 IQueryable 物件) 的存放庫,這會確保結果保持不變。 (當您在 IEnumerable 集合上呼叫 Contains 方法時,將取得 .NET Framework 實作;當您在 IQueryable 物件上呼叫它時,則會取得資料庫提供者實作。)不過,此解決方案會對效能帶來負面影響。 ToUpper 程式碼會將一個函式置於 TSQL SELECT 陳述式的 WHERE 子句中。 這會防止最佳化工具使用索引。 假設 SQL 大部分安裝為不區分大小寫,最好避免使用 ToUpper 程式碼,直到您移轉至區分大小寫的資料存放區為止。

將搜尋方塊新增至學生的 [索引] 檢視

Views/Student/Index.cshtml 中,於開始表格標記之前立即新增反白顯示的程式碼,以建立標題、文字方塊及 [搜尋] 按鈕。

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

<form asp-action="Index" method="get">
    <div class="form-actions no-color">
        <p>
            Find by name: <input type="text" name="SearchString" value="@ViewData["CurrentFilter"]" />
            <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。

執行應用程式,選取 [Students] 索引標籤,輸入搜尋字串,然後按一下 [搜尋] 以確認篩選可以運作。

Students index page with filtering

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

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

如果您為此頁面加上書籤,則會在使用書籤時取得篩選的清單。 將 method="get" 新增至 form 標籤會導致查詢字串的產生。

在這個階段,如果您按一下資料行標題排序連結,將會遺失您在 [搜尋] 方塊中輸入的篩選值。 您將在下節修正該問題。

為 Students 索引新增分頁

若要將分頁新增至 Students 的 [索引] 頁面,您要建立使用 SkipTake 陳述式的 PaginatedList 類別來篩選伺服器上的資料,而不是一直擷取資料表的所有資料列。 然後,您要在 Index 方法中進行其他變更,並將分頁按鈕新增至 Index 檢視。 下圖顯示分頁按鈕。

Students index page with paging links

在專案資料夾中,建立 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 方法會採用頁面大小和頁面數,並會將適當的 SkipTake 陳述式套用至 IQueryable。 在 IQueryable 上呼叫 ToListAsync 時,會傳回僅包含所要求頁面的清單。 HasPreviousPageHasNextPage 屬性可用來啟用或停用 [上一頁] 和 [下一頁] 分頁按鈕。

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

為 Index 方法新增分頁

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)

第一次顯示頁面,或是使用者尚未按一下分頁或排序連結時,所有參數都會是 null。 如果按一下分頁連結,頁面變數將包含要顯示的頁碼。

名為 CurrentSort 的 ViewData 項目會提供使用目前排序次序的檢視,因為這必須包含在分頁連結中,才能保持與分頁時相同的排序順序。

名為 CurrentFilter 的 ViewData 項目則提供使用目前篩選字串的檢視。 此值必須包含在分頁連結中,才能維護分頁期間的篩選設定,而且它必須在頁面重新顯示時還原為文字方塊。

如果搜尋字串在分頁期間變更,頁面必須重設為 1,因為新的篩選可能會導致顯示不同的資料。 在文字方塊中輸入值並按下 [提交] 按鈕時,即會變更搜尋字串。 在此情況下,searchString 參數不是 null。

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 聯合運算子。 Null 聯合運算子將針對可為 Null 的型別定義預設值;(pageNumber ?? 1) 運算式表示在它含有值時會傳回值 pageNumber,或在 pageNumber 為 null 時傳回 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>
            Find by name: <input type="text" name="SearchString" value="@ViewData["CurrentFilter"]" />
            <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>

執行應用程式並移至 Students 頁面。

Students index page with paging links

以不同排序次序按一下分頁連結,以確定分頁運作正常。 然後輸入搜尋字串並再次嘗試分頁,以確認分頁的排序和篩選能正確運作。

建立 [關於] 頁面

對於 Contoso 大學網站的 About 頁面,您將顯示每個註冊日期已有多少學生註冊。 這需要對群組進行分組和簡單計算。 若要完成此工作,您需要執行下列作業:

  • 針對您需要傳遞至檢視的資料,建立檢視模型類別。
  • 在 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 檢視模型物件的集合中。

建立 About 檢視

使用下列程式碼新增 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>

執行應用程式並移至 About 頁面。 每個註冊日期的學生人數將會顯示在資料表中。

取得程式碼

下載或檢視已完成的應用程式。

下一步

在本教學課程中,您已:

  • 新增資料行排序連結
  • 新增 [搜尋] 方塊
  • 為 Students 索引新增分頁
  • 為 Index 方法新增分頁
  • 新增分頁連結
  • 建立 [關於] 頁面

若要了解如何使用移轉來處理資料模型變更,請前往下一個教學課程。