添加视图数据模型 View Model

想要实现翻页,就要让视图知道数据总共有几页,当前在第几页。要实现这个目的,我们要创造一个 View Model 类,让它在 Controller 和 View 直接交互数据。前面的数据模型是应用和数据库服务器直接交互数据用的。
在 Models 文件夹里新建文件夹 ViewModels,在新文件夹里添加保存分页信息的类。
Listing 7-19 在 Models/viewModels 文件夹里新建 EBooksPaging.cs

namespace EBooksStore.Models.ViewModels
{
    public class EBooksPaging
    {
        public int TotalEBooks { get; set; }
        public int BooksPerPage { get; set; }
        public int CurrentPage { get; set; }

        public int TotalPages => (int)Math.Ceiling((decimal)TotalEBooks / BooksPerPage);
    }
}

为了逻辑清晰,我们再在项目文件夹里新建一个新文件夹 TagHelpers 。在这个文件夹里添加所有 Tag Helper 相关的类。 Listing 7-10 在 EBooksStore/TagHelpers 文件夹里新建 PagingTagHelper.cs 文件

using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace EBooksStore.TagHelpers;

[HtmlTargetElement("div", Attributes = "pagination")]
public class PagingTagHelper(IUrlHelperFactory urlHelperFactory) : TagHelper
{
    private readonly IUrlHelperFactory _urlHelperFactory = urlHelperFactory;

    [ViewContext]
    [HtmlAttributeNotBound]
    public ViewContext? ViewContext { get; set; }
    public PagingViewModel? Pagination { get; set; }
    public string? PageAction { get; set; } 

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if(ViewContext is null || Pagination is null)
        {
            return;
        }

        IUrlHelper? urlHelper = _urlHelperFactory.GetUrlHelper(ViewContext);

        TagBuilder? divTag = new("div");

        for (int i = 1; i <= Pagination!.TotalPages; i++)
        {
            TagBuilder? aTag = new("a");
            aTag.Attributes["href"] = urlHelper.Action(PageAction, new { pageNumber = i });
            aTag.InnerHtml.Append(i.ToString());

            divTag.InnerHtml.AppendHtml(aTag);
        }

        output.Content.AppendHtml(divTag);
    }
}

我们这里自定义了一个 Tag Helper 用来在视图里生成分页控制链接。helper类的限定属性是要求用在具有 pagination 属性的 div 上。
如果我们不给 Process 方法加上 [ViewContext] 限定属性,ASP.NET Core 就不能给 TagHelper 传送当前视图的数据,这些数据是作用域只是在当前的 HTTP 请求中。
[HtmlAttributeNotBound] 目的防止这个类的属性成员被 HTML 的同名属性修改。 在类的主构造器里注入 IUrlHelperFactory (Microsoft.AspNetCore.Mvc.Routing) 。重载的 Process 方法来生成 TagHelper 的 HTML 内容。
ViewContext 提供当前视图的上下文(用来生成 URL)
在确保 ViewContext 和 PagingViewModel 都不为空后,创建 urlHelper 实例来生成分页链接,在这里就是包含 http://localhost:5000 信息的一个对象实例。
用 TagBuilder 构建标签 div 当作所有分页链接标签 a 的容器,然后,取得在循环里给每个标签 a 添加 href 属性,这里的 PageAction 我们会设定为 Index 。接着,把代表页面的数字写到标签 a 的内部 HTML。最后把每个标签 a 都放到标签 div 里。
输出的 output 是 TagHelperOutput 类型。
这段代码会有很多目前超纲的内容,需要我们仔细琢磨,尽量懂个八九不离十。

接下来的工作是要把 EBooksPagingViewModel 引入到视图 Index 的 model 里,视图才能取得分页信息;换句话说,就是 Index 视图既要知道要显示什么数据,也要知道分页信息。所以,我们要构造一个给视图用的 View Model ,而它必须包含这两者信息。
Listing 7-11 在 Models/ViewModels 文件夹里新建 EBooksListViewModel.cs 文件

namespace EBooksStore.Models.ViewModels
{
    public class EBooksListViewModel
    {
        public IEnumerable<EBook> EBooks { get; set; }
            = [];
        public PagingViewModel PagingViewModel { get; set; } = new();
    }
}

Listing 7-12 修改 HomeController.cs 文件,让 Index 方法使用 EBooksListViewModel 参数

using EBooksStore.Models.Interfaces;
using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class HomeController(IStoreRepository repository, int PageSize = 4) : Controller
{
    private readonly IStoreRepository _repository = repository;
    public int PageSize { get; set; } = PageSize;
    public IActionResult Index(int pageNumber = 1)
        => View(new EBooksListViewModel
        {
            EBooks = _repository.EBooks
                .OrderBy(e => e.Id)
                .Skip((pageNumber - 1) * PageSize)
                .Take(PageSize),

            PagingViewModel = new PagingViewModel
            {
                CurrentPage = pageNumber,
                BooksPerPage = PageSize,
                TotalEBooks = _repository.EBooks.Count()
            }
        });
}

最后我们还要修改 Index 视图,引入正确的 View Model,并添加和设置 PagingTagHelper 指定的目标 HTML 元素 div。
Listing 7-13 在 Index.cshtml 里添加分页导航

@model EBooksListViewModel

<h2>欢迎光临电子图书网店!</h2>

@foreach (var item in Model.EBooks)
{
    <div>
        <h3>@item.Title</h3>
        <p>@item.Description</p>
        <p>价格:@item.Price</p>
        <p>作者:@item.Author</p>
    </div>
}

<div pagination="@Model.PagingViewModel" page-action="Index"></div>

因为 Tag Helper 是 Razor 视图渲染的,所以分页 div 里的 属性 pagination 是对应 PagingTagHelper 类里的 Pagination 成员,page-action 对应 PageAction 成员,这样的命名规则是框架的约定,遵守约定它才能正常工作。

防止空值引起网页崩溃

我们还需要给代码加一些保护,防止运行错误。比如,如果视图的数据模型的 model 有一个空的 Enumerable<T>,服务器在生成时遍历它,就会抛出异常,导致网页崩溃。
首先从数据模型开始,这里我们给每个字段都有了初始值 string.Empty ,不会有可能空值了。
再看看我们的仓库类,它的 IQueryable<EBook> 类型的 EBooks 就有可能取得空值,但是我们设置的 EBooksListViewModel 的 EBooks 成员也初始化了空的枚举类型 ,所以 Index 视图的 Model 中也不会出现空值。
看起来,暂时没什么问题。我们先运行下应用,保证它能正常工作。

给内容添加样式

现在数据显示正确了,就是版面既简陋又每页可读性。我们接下来就用 Bootstrap 框架快速地给页面元素添加样式。我们前面学过,安装这些工具,需要安装 LibMan 包管理工具。

dotnet tool uninstall --global Microsoft.Web.LibraryManager.Cli
dotnet tool install --global Microsoft.Web.LibraryManager.Cli

如果你确认没有安装 LibMan ,就只用在 PowerShell 输入第二条命令。
接着初始化,再安装 Bootstrap,这两个命令要确定 PowerShell 在 EBooksStore 文件夹里打开。

libman init -p cdnjs
libman install bootstrap -d wwwroot/lib/bootstrap

命令将新建 wwwroot/lib/bootstrap 文件夹,并下载 Bootstrap 到本地。
所有视图都会用到 _Layout.cshtml 页面布局这个马甲,我们先修改这个马甲。
Listing 7-14 修改 Views/Shared 文件夹里的 _Layout.cshtml 文件

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>逻辑的电子书店</title>
        <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        <div class="container">
            <div class="bg-secondary text-white p-2 mt-2">
                <span class="navbar-brand ml-2 display-6">逻辑的电子书店</span>
            </div>
            <div class="row m-3 p-3">
                <div class="col-3"></div>
                <div class="col-9">
                    @RenderBody()
                </div>
            </div>
        </div>
    </body>
</html>

再给主页面添加样式,并稍微更改下内容的位置。
Listing 7-15 修改 Index.cshtml

@model EBooksListViewModel
<div class="row">
@foreach (var item in Model.EBooks)
{
    <div class="col-4 p-2">
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">@item.Title</h5>
                <h6 class="card-subtitle mb-2 text-body-secondary">作者:@item.Author</h6>
                <p>类型:@item.Category</p> 
                <div>
                     简介:
                     <br />
                     <span class="text-body-secondary">@item.Description</span>
                </div>
                <p class="text-end mt-3">价格:@item.Price</p>
            </div>
        </div>
    </div>
}
</div>
<div pagination="@Model.PagingViewModel" page-action="Index"></div>

建议:在做 UI 客户端显示修改时,使用 dotnet watch 命令运行应用,可以实时更新浏览器页面,方便我们看到效果。
添加样式以后,页面显示如图:

简单的电子书卡片列表

Figure 7-3

对于分页导航的部分,因为我们又可能会重用到,所以最好把这些样式成为 PagingTagHelper 类的一部分,可以在不同的地方使用。
我们先修改视图里的分页 div ,添加必要的 Css 属性类:一个按钮类 button-class ,一个当前激活状态 button-active ,一个普通状态 button-normal 。

<div pagination="@Model.PagingViewModel" page-action="Index" class="d-flex justify-content-end my-2"
    button-class="btn" button-active="btn-primary" button-normal="btn-outline-primary"></div>

接下来我们给 PagingTagHelper 类添加属性,用来应用 Bootstrap 的类。
Listing 7-16 修改 PagingTagHelper.cs ,添加样式属性

using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace EBooksStore.TagHelpers;

[HtmlTargetElement("div", Attributes = "pagination")]
public class PagingTagHelper(IUrlHelperFactory urlHelperFactory) : TagHelper
{
    private readonly IUrlHelperFactory _urlHelperFactory = urlHelperFactory;

    [ViewContext]
    [HtmlAttributeNotBound]
    public ViewContext? ViewContext { get; set; }
    public PagingViewModel? Pagination { get; set; }
    public string? PageAction { get; set; } 

    public string ButtonClass { get; set; } = string.Empty;
    public string ButtonActive { get; set; } = string.Empty;
    public string ButtonNormal { get; set; } = string.Empty;

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if(ViewContext is null || Pagination is null)
        {
            return;
        }

        IUrlHelper? urlHelper = _urlHelperFactory.GetUrlHelper(ViewContext);

        TagBuilder? divTag = new("div");
        divTag.AddCssClass("btn-group");

        for (int i = 1; i <= Pagination!.TotalPages; i++)
        {
            TagBuilder? aTag = new("a");
            aTag.Attributes["href"] = urlHelper.Action(PageAction, new { pageNumber = i });

            aTag.AddCssClass(ButtonClass);
            aTag.AddCssClass(i == Pagination.CurrentPage ? ButtonActive : ButtonNormal);

            aTag.InnerHtml.Append(i.ToString());

            divTag.InnerHtml.AppendHtml(aTag);
        }

        output.Content.AppendHtml(divTag);
    }
}

我们对于 Bootstrap 设置可能都不同熟悉,也只能查查资料,做个像模像样就好。我们在试着给 Index 做多一些样式调整,让它看起来整洁些。
Listing 7-17 修改 Index.cshtml

@model EBooksListViewModel
<div class="row">
@foreach (var item in Model.EBooks)
{
    <div class="col-sm-6 col-md-4 p-2">
        <div class="card">
            <div class="card-body">
                <h5 class="card-title" style="height:3.2rem">@item.Title</h5>
                <h6 class="card-subtitle mb-2 text-body-secondary overflow-y-hidden" style="height:1.2rem">作者:@item.Author</h6>
                <p>类型:@item.Category</p> 
                <div class="overflow-y-auto" style="height: 180px;">
                     简介:
                     <br />
                     <span class="text-body-secondary">@item.Description</span>
                </div>
                <p class="text-end mt-3"><span class="badge text-bg-primary text-white p-2">价格:@item.Price</span></p>
            </div>
        </div>
    </div>
}
</div>
<div pagination="@Model.PagingViewModel" page-action="Index" class="d-flex justify-content-end my-2" 
    button-class="btn" button-active="btn-primary" button-normal="btn-outline-primary"></div>

运行后,页面如图:

添加样式的卡片列表


Figure 7-4

创建分类菜单

每个网站都需要菜单,方便用户按一定的逻辑分门别类地浏览内容。我们这里用视图组件 view component 根据我们数据表中的类别字段 category 来创建分类菜单。我们现在项目文件夹里新建文件夹 Components 用来存放组件类文件。
Listing 7-18 在 Components 文件夹里新建文件 NavMenuViewComponent.cs

using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Components
{
    public class NavMenuViewComponent : ViewComponent
    {
        public string Invoke() => "Hello from NavMenuViewComponent!";
    }
}

因为菜单需要在所有页面都能显示,所以把它放在 _Layout.cshtml 文件里是个不错的选择。
Listing 7-19 在 Views/Shared 文件夹的 _Layout.cshtml 文件里添加视图组件的 tag helper

<!DOCTYPE html>
<html>
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>逻辑的电子书店</title>
        <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        <div class="container">
            <div class="bg-secondary text-white p-2 mt-2">
                <span class="navbar-brand ml-2 display-6">逻辑的电子书店</span>
            </div>
            <div class="row m-3 p-3">
                <div class="col-3">
                    <vc:nav-menu /> <!-- 视图组件 tag helper -->
                </div>
                <div class="col-9">
                    @RenderBody()
                </div>
            </div>
        </div>
    </body>
</html>

请注意用到的 tag helper 约定的命名规则。类名是 NavMenuViewComponent ,cshtml 文件里的组件名是 nav-menu 。运行应用,主页左边列显示预定字符串内容。

添加目录菜单


Figure 7-5

我们要在组件类里生成分类菜单,因为它是组件,是依附于 Razor 页面容器的,所以组件类可以被注入依赖。 Listing 7-20 修改 Components 文件夹里的 NavMenuViewComponent.cs 文件

using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Components
{
    public class NavMenuViewComponent(IStoreRepository repository) : ViewComponent
    {
        private readonly IStoreRepository _repository = repository;

        public IViewComponentResult Invoke()
        {
            return View(_repository.EBooks
                .Select(e => e.Category)
                .Distinct()
                .OrderBy(c => c));
        }
    }
}

视图组件也需要视图显示数据,而 Razor 是用和 Controller 不同的约定来定位视图组件的视图的。它默认定位到 Views/Shared/Compoents/NavMenu/ 文件夹,并且该文件里要提供一个 Default.cshtml 文件。
Listing 7-21 新建一串文件夹 Views/Shared/Compoents/NavMenu/ ,在 NavMenu 文件夹新建 Default.cshtml

@model IEnumerable<string>

<nav class="nav flex-column nav-underline">
    <a class="nav-link fw-bold" asp-action="Index"
        asp-controller="Home" asp-route-category="">首页</a>
    @foreach (var category in Model ?? Enumerable.Empty<string>())
    {
        <a class="nav-link" asp-action="Index"
            asp-controller="Home" asp-route-category="@category">@category</a>
    }
</nav>

运行后,显示效果如图:

目录菜单加样式

Figure 7-6

我们有了初步的菜单,但是菜单项可以点击没有转到某页面的效果。我们要给菜单项添加点击到对应分类的功能。
用鼠标停留在某个菜单项,浏览器页面最下面会显示类似这样的链接 http://localhost:5000/?category=Fiction 。我们需要构造出符合这样路径的页面。
首先我们给 EBooksListViewModel 添加分类属性。
Listing 7-22 修改 Models/ViewModels/ 下面的 EBooksListViewModel.cs 文件:

namespace EBooksStore.Models.ViewModels
{
    public class EBooksListViewModel
    {
        public IEnumerable<EBook> EBooks { get; set; }
            = [];
        public PagingViewModel PagingViewModel { get; set; } = new();
        public string CurrentCategory { get; set; } = string.Empty;
    }
}

视图模型已经有了分类属性,那么 HomeController 类就需要生成按分类过滤的视图模型。
Listing 7-23 修改 HomeController.cs 文件,添加分类过滤

using EBooksStore.Models.Interfaces;
using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class HomeController(IStoreRepository repository, int PageSize = 4) : Controller
{
    private readonly IStoreRepository _repository = repository;
    public int PageSize { get; set; } = PageSize;

    // 添加 category 参数
    public IActionResult Index(string category = "", int pageNumber = 1)
        => View(new EBooksListViewModel
        {
            EBooks = _repository.EBooks
                // 添加 category 过滤条件
                .Where(e => string.IsNullOrEmpty(category) || e.Category == category)
                .OrderBy(e => e.Id)
                .Skip((pageNumber - 1) * PageSize)
                .Take(PageSize),

            PagingViewModel = new PagingViewModel
            {
                CurrentPage = pageNumber,
                BooksPerPage = PageSize,
                TotalEBooks = _repository.EBooks.Count()
            },

            CurrentCategory = category // 赋值成员
        });
}

运行应用,现在我们可以点击菜单,切换分类页面了。但是,因为 PagingViewModel 里的总页面数没有相应修改,所以页面总是没有更新。我们只有更新分类的总数就可以自动更新总页面数。
Listing 7-24 *修改 HomeController.cs 更新 PagingViewModel 的 TotalEBooks 成员。

...
            PagingViewModel = new PagingViewModel
            {
                CurrentPage = pageNumber,
                BooksPerPage = PageSize,
                TotalEBooks = string.IsNullOrEmpty(category) ?
                    _repository.EBooks.Count() :
                    _repository.EBooks.Where(e => e.Category == category).Count()
            },
...

现在我们的页面总是正确了,运行应用,应该会看到如下页面:

正确按数量显示分页


Figure 7-7

现在为止,正常菜单的一个重要功能还没有实现,就是高亮选中的分类菜单项。这样做可以更好的和用户产生交互。在 ASP.NET Core 里,可以用 Request 对象访问 HTTP 请求的 URL 的路由信息。我们用 Request.Query[“category”] 去得到 URL 中 category 属性的值。(因为我们在视图组件的 Default.cshtml 文件中对菜单项的设定的请求 URL 中带有类似 ?category=Ficton 的字符)。
我们先在添加一个为视图组件添加视图模型而不是单纯的枚举 string 类型。
Listing 7-25 在 Models/View Models 文件夹里新建一个 NavMenuViewModel.cs 文件

namespace EBooksStore.Models.ViewModels
{
    public class NavMenuViewModel
    {
        public IEnumerable<string> Categories { get; set; } = [];
        public string CurrentCategory { get; set; } = string.Empty;
    }
}

我们用 CurrentCategory 保存当前选定的分类。接着修改 NavMenuViewComponent 类,在它的 Invoke 方法里生成 NavMenuViewModel 传给视图组件。
Listing 7-26 修改 NavMenuViewComponent.cs

using EBooksStore.Models.Interfaces;
using EBooksStore.Models.ViewModels;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Components
{
    public class NavMenuViewComponent(IStoreRepository repository) : ViewComponent
    {
        private readonly IStoreRepository _repository = repository;

        public IViewComponentResult Invoke()
        {
            // 创建新的视图模型
            NavMenuViewModel model = new()
            {
                //取得 URL 中的 category 查询的值
                CurrentCategory = Request.Query["category"].ToString() ?? string.Empty,
                Categories = _repository.EBooks
                    .Select(e => e.Category)
                    .Distinct()
                    .OrderBy(c => c)
            };
            return View(model);
        }
    }
}

再修改 Default.cshtml 视图组件的视图文件。
Listing 7-27 修改 Views/Shared/Components/NavMenu 文件夹里的 Default.cshtml 文件

@model NavMenuViewModel <!--引入视图模型-->

<nav class="nav flex-column nav-underline">
    <!-- 如果 category 不存在或者值为空,高亮首页 -->
    <a class="nav-link @(string.IsNullOrEmpty(Model.CurrentCategory) ? "active" : "")" asp-action="Index"
        asp-controller="Home" asp-route-category="">首页</a>
    @foreach (var category in Model.Categories)
    {
        <!-- 高亮 category 值对应的菜单项 -->
        <a class="nav-link @(category == Model.CurrentCategory ? "active" : "")" asp-action="Index"
            asp-controller="Home" asp-route-category="@category">@category</a>
    }
</nav>

运行应用,点击菜单后,页面类似下图,会高亮对应菜单项。

目录菜单高亮选中


Figure 7-8

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注