添加视图数据模型 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
发表回复