ASP.NET Core MVC 示例入门(第五部分)





5.00/5 (1投票)
在第四部分中,我们为购物车添加了基本支持,现在我们将增强和完善该功能。
引言
在第四部分中,我们为购物车添加了基本支持,现在我们将增强和完善该功能。
在本文中,我们将使用 ASP.NET Core 的核心功能——服务,来简化 Cart 对象的管理方式,从而使各个组件无需直接处理细节。
背景
本文是前 4 篇文章的延续。如果您尚未阅读,以下是各部分内容:
使用代码
创建支持存储的 Cart 类
组织 MyCart
类使用方式的第一步是创建一个专门的类,该类知道如何使用会话状态来存储自身。为了准备,我们为 MyCart
类应用了 virtual
关键字,以便可以重写其成员。在 **BooksStore/Models** 目录的 MyCart.cs
文件中应用 virtual
关键字。
public class MyCart
{
public List<CartLine> Lines { get; set; } = new List<CartLine>();
public virtual void AddItem(Book book, int quantity)
{
CartLine line = Lines
.Where(b => b.Book.BookID == book.BookID)
.FirstOrDefault();
if (line == null)
{
Lines.Add(new CartLine
{
Book = book,
Quantity = quantity
});
}
else
{
line.Quantity += quantity;
}
}
public virtual void RemoveLine(Book book) =>
Lines.RemoveAll(l => l.Book.BookID == book.BookID);
public decimal ComputeTotalValue() =>
Lines.Sum(e => e.Book.Price * e.Quantity);
public virtual void Clear() => Lines.Clear();
}
接下来,我们在 **Models** 目录中添加一个名为 MySessionCart.cs
的类文件,并使用它来定义类,如下面的代码所示:
using System;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using BooksStore.MyTagHelper;
namespace BooksStore.Models
{
public class MySessionCart : MyCart
{
public static MyCart GetCart(IServiceProvider services)
{
ISession session = services.GetRequiredService<IHttpContextAccessor>()?
.HttpContext.Session;
MySessionCart mycart = session?.GetJson<MySessionCart>("MyCart")
?? new MySessionCart();
mycart.Session = session;
return mycart;
}
[JsonIgnore]
public ISession Session { get; set; }
public override void AddItem(Book book, int quantity)
{
base.AddItem(book, quantity);
Session.SetJson("MyCart", this);
}
public override void RemoveLine(Book book)
{
base.RemoveLine(book);
Session.SetJson("MyCart", this);
}
public override void Clear()
{
base.Clear();
Session.Remove("MyCart");
}
}
}
MySessionCart
类继承自 MyCart
类,并重写了 AddItem
、RemoveLine
和 Clear
方法,以调用它们的基类实现,然后使用 ISession
接口上的扩展方法将更新后的状态存储在会话中。静态 GetCart
方法用于创建 MySessionCart
对象,并为它们提供 ISession
对象,以便它们可以存储自身。
访问 ISession
对象有些复杂。我们获取 IHttpContextAccessor
服务的实例,该服务使我们能够访问 HttpContext
对象,而 HttpContext
对象又使我们能够访问 ISession
。这种间接方法是必要的,因为会话不是作为常规服务提供的。
注册服务
下一步是为 MyCart
类创建一个服务。我们的目标是以 MySessionCart
对象来满足对 MyCart
对象的请求,这些对象可以无缝地自我存储。在 **BooksStore** 目录的 Startup.cs
文件中创建 MyCart
服务,如下所示:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddScoped<IBooksStoreRepository, EFBooksStoreRepository>();
services.AddRazorPages();
services.AddDistributedMemoryCache();
services.AddSession();
services.AddScoped<MyCart>(sp => MySessionCart.GetCart(sp));
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
}
AddScoped
方法指定将使用同一个对象来处理对 MyCart
类实例的相关请求。相关请求的作用域如何确定可以进行配置,但默认情况下,这意味着在同一 HTTP 请求的处理过程中,由组件请求的任何 MyCart
都将接收到同一个对象。
与为存储库提供类型映射不同,我们指定了一个 lambda 表达式,该表达式将在满足 MyCart
请求时被调用。该表达式接收已注册服务的集合,并将其传递给 MySessionCart
类的 GetCart
方法。因此,对 MyCart
服务的请求将通过创建 MySessionCart
对象来处理,这些对象在修改时会作为会话数据持久化。
我们还使用 AddSingleton
方法添加了一个服务,该方法指定将始终使用同一个对象。我们创建的服务要求 ASP.NET Core 在实现 IHttpContextAccessor
接口时使用 HttpContextAccessor
类。此服务对于我们在 MySessionCart
类中访问当前会话是必需的。
简化 Razor Cart 页面
创建这种类型的服务的优势在于,它使我们能够简化使用 MyCart
对象的地方的代码。我们将重构 MyCart
的页面模型类,以利用新服务。在 **BooksStore/Pages** 目录的 MyCart.cshtml.cs
文件中使用 MyCart
服务:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using BooksStore.MyTagHelper;
using BooksStore.Models;
using System.Linq;
namespace BooksStore.Pages
{
public class MyCartModel : PageModel
{
private IBooksStoreRepository repository;
public MyCartModel(IBooksStoreRepository repo, MyCart myCartService)
{
repository = repo;
myCart = myCartService;
}
public MyCart myCart { get; set; }
public string ReturnUrl { get; set; }
public void OnGet(string returnUrl)
{
ReturnUrl = returnUrl ?? "/";
}
public IActionResult OnPost(long bookId, string returnUrl)
{
Book book = repository.Books
.FirstOrDefault(b => b.BookID == bookId);
myCart.AddItem(book, 1);
return RedirectToPage(new { returnUrl = returnUrl });
}
}
}
页面模型类通过声明构造函数参数来指示它需要一个 MyCart
对象,这使得可以从处理方法中删除会话加载和存储命令。因此,一个更简单的页面模型类专注于其在应用程序中的角色,而不必担心 MyCart
对象是如何创建或存在的。而且,由于服务在整个应用程序中都可用,任何组件都可以使用相同的方法来持有用户的购物车。
完成购物车功能
我们已经引入了 MyCart
服务,现在是时候通过添加两个新功能来完成购物车功能了。第一个功能允许客户从购物车中移除商品,第二个功能将在页面顶部显示有关购物车的摘要信息。
从购物车中移除图书
要从购物车中移除图书,我们需要在 **Cart** 页面的内容中添加一个“移除”按钮,该按钮将发送一个 HTTP POST 请求。在 **BooksStore/Pages** 目录的 MyCart.cshtml
文件中移除 MyCart
项,如下所示:
@page
@model MyCartModel
<h2>Your cart</h2>
<table class="table table-bordered">
<thead class="thead-light">
<tr>
<th>Quantity</th>
<th>Item</th>
<th class="text-right">Price</th>
<th class="text-right">Subtotal</th>
</tr>
</thead>
<tbody>
@foreach (var line in Model.myCart.Lines)
{
<tr>
<td class="text-center">@line.Quantity</td>
<td class="text-left">@line.Book.Title</td>
<td class="text-right">@line.Book.Price.ToString("c")</td>
<td class="text-right">
@((line.Quantity * line.Book.Price).ToString("c"))
</td>
<td class="text-center">
<form asp-page-handler="Remove" method="post">
<input type="hidden" name="BookID"
value="@line.Book.BookID" />
<input type="hidden" name="returnUrl"
value="@Model.ReturnUrl" />
<button type="submit" class="btn btn-sm btn-danger">
Remove
</button>
</form>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-right">Total:</td>
<td class="text-right">
@Model.myCart.ComputeTotalValue().ToString("c")
</td>
</tr>
</tfoot>
</table>
<div class="text-center">
<a class="btn btn btn-info" href="@Model.ReturnUrl">Continue shopping</a>
</div>
该按钮要求在页面模型类中添加一个新的处理方法来接收请求并修改购物车。在 **BooksStore/Pages** 目录的 MyCart.cshtml.cs
文件中移除项,如下所示:
public class MyCartModel : PageModel
{
private IBooksStoreRepository repository;
public MyCartModel(IBooksStoreRepository repo, MyCart myCartService)
{
repository = repo;
myCart = myCartService;
}
public MyCart myCart { get; set; }
public string ReturnUrl { get; set; }
public void OnGet(string returnUrl)
{
ReturnUrl = returnUrl ?? "/";
}
public IActionResult OnPost(long bookId, string returnUrl)
{
Book book = repository.Books
.FirstOrDefault(b => b.BookID == bookId);
myCart.AddItem(book, 1);
return RedirectToPage(new { returnUrl = returnUrl });
}
public IActionResult OnPostRemove(long bookId, string returnUrl)
{
myCart.RemoveLine(myCart.Lines.First(cl =>
cl.Book.BookID == bookId).Book);
return RedirectToPage(new { returnUrl = returnUrl });
}
}
新的 HTML 内容定义了一个 HTML 表单。处理方法将通过 asp-page-handler
标记助手属性接收指定的请求,如下所示:
...
<form asp-page-handler="Remove" method="post">
...
提供的名称带有“On”前缀,并补充了与请求类型对应的适当后缀,因此“Remove”值会选择 OnRemovePost
处理方法。处理方法使用它接收到的值来查找购物车中的项并将其移除。
运行应用程序。点击“加入购物车”按钮添加图书到购物车,然后点击“移除”按钮。购物车将更新以移除您指定的项。
添加购物车摘要小部件
我们可能有一个可用的购物车,但其集成方式存在一个问题。客户只能通过查看购物车摘要屏幕来了解购物车中的内容。而且他们只能通过添加新商品到购物车来查看购物车摘要屏幕。
为了解决这个问题,我们将添加一个用于摘要购物车内容的窗口小部件,该窗口小部件可以点击以在整个应用程序中显示购物车内容。我们将以与添加导航窗口小部件类似的方式完成此操作——作为视图组件,其输出可包含在 Razor 布局中。
添加 Font Awesome 包
作为购物车摘要小部件的一部分,我们将显示一个允许用户继续结账的按钮。我们希望在按钮上显示一个购物车图标,而不是“结账”字样。您可以自己绘制一个购物车,但为了保持简单,我们将使用 **Font Awesome** 包——一个出色的开源图标集,作为字体集成到应用程序中,字体中的每个字符都是一个不同的图像。
要在 Visual Studio 2019 中安装 Font Awesome 包,请按照以下步骤操作:
右键单击 **BooksStore** 项目,然后选择“添加 > 客户端库...”
在“添加客户端库”窗口中,选择“cdnjs”作为“提供程序”,并在“库”字段中键入“font-awesome@6.1.1”(这是撰写本文时最新的版本。您可以在 https://fontawesome.com/ 访问它),然后按 Enter 键。
单击“安装”。**Font Awesome** 包将安装在 **wwwroot/lib** 目录中。
创建视图组件类和视图
我们将向 **ViewComponents** 目录添加一个名为 CartSummary.cs
的类文件,并使用它来定义视图组件。**BooksStore/ViewComponents** 目录中 CartSummary.cs
文件内容如下:
using Microsoft.AspNetCore.Mvc;
using BooksStore.Models;
namespace BooksStore.ViewComponents
{
public class CartSummary : ViewComponent
{
private MyCart cart;
public CartSummary(MyCart cartService)
{
cart = cartService;
}
public IViewComponentResult Invoke()
{
return View(cart);
}
}
}
此视图组件可以利用我们在本文前面创建的服务,将 Cart
对象作为构造函数参数接收。因此,一个简单的视图组件类将 Cart
传递给 View
方法,以生成要包含在布局中的 HTML 内容。要为该组件创建视图,我们创建一个名为 Views/Shared/Components/CartSummary 的目录,并在其中添加一个名为 Default.cshtml
的 **Razor 视图**。**Views/Shared/Components/CartSummary** 中的 Default.cshtml
文件内容如下:
@model MyCart
<div class="">
@if (Model.Lines.Count() > 0)
{
<small class="navbar-text">
<b>Your cart: </b>
@if (Model.Lines.Sum(x => x.Quantity) >= 2)
{
<text>@Model.Lines.Sum(x => x.Quantity) books</text>
}
else
{
<text>@Model.Lines.Sum(x => x.Quantity) book</text>
}
</small>
}
<a class="btn btn-sm btn-info navbar-btn" asp-page="/Cart"
asp-route-returnurl="@ViewContext.HttpContext.Request.PathAndQuery()">
<i class="fa fa-shopping-cart"></i>
</a>
</div>
该视图显示一个带有 Font Awesome 购物车图标的按钮,如果购物车中有商品,则会提供一个快速快照图像显示商品数量。现在我们有了视图组件和视图,我们可以修改布局,将购物车摘要小部件集成到 **Home** 控制器生成的响应中。在 **Views/Shared** 目录的 _Layout.cshtml
文件中添加 **Cart Summary**:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>BooksStore</title>
<link href="~/lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="~/lib/font-awesome/css/all.min.css" rel="stylesheet" />
</head>
<body>
<div>
<div class="bg-dark text-white p-2">
<div class="container-fluid">
<div class="row">
<div class="col navbar-brand">BOOKS STORE</div>
<div class="col-6 text-right">
<vc:cart-summary />
</div>
</div>
</div>
</div>
<div class="row m-1 p-1">
<div id="genres" class="col-3">
<p>The BooksStore homepage helps you explore Earth's Biggest Bookstore without ever leaving the comfort of your couch.</p>
<vc:genre-navigation />
</div>
<div class="col-9">
@RenderBody()
</div>
</div>
</div>
</body>
</html>
运行应用程序。您可以通过启动应用程序来查看购物车摘要。当购物车为空时,仅显示结账按钮。
如果您将一本书添加到购物车,然后显示了商品数量和购物车摘要。
如果有多个(复数)图书
提交订单
到目前为止,我们已经完成了 `BooksStore` 的最后一个客户功能:结账并完成订单。在接下来的部分中,我们将扩展数据模型,以支持从用户那里捕获送货详细信息,并添加应用程序支持来处理这些详细信息。
创建模型类
我们将向 **Models** 文件夹添加一个名为 Order.cs
的类文件,该类将用于表示客户的送货详细信息。该类的内容如下:
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace BooksStore.Models
{
public class Order
{
[BindNever]
public int OrderID { get; set; }
[BindNever]
public ICollection<CartLine> Lines { get; set; }
[Required(ErrorMessage = "Please enter a name")]
public string Name { get; set; }
[Required(ErrorMessage = "Please enter the first address line")]
public string Line1 { get; set; }
public string Line2 { get; set; }
[Required(ErrorMessage = "Please enter a city name")]
public string City { get; set; }
[Required(ErrorMessage = "Please enter a state name")]
public string State { get; set; }
public string Zip { get; set; }
[Required(ErrorMessage = "Please enter a country name")]
public string Country { get; set; }
}
}
添加结账流程
目标是允许用户输入其送货详细信息并提交订单。首先,我们需要在购物车视图中添加一个“立即结账”按钮,方法是在 **BooksStore/Pages** 目录的 MyCart.cshtml
文件中添加以下内容:
<div class="text-center">
<a class="btn btn-info" href="@Model.ReturnUrl">Continue shopping</a>
<a class="btn btn-info" asp-action="Checkout" asp-controller="Order">
Checkout Now
</a>
</div>
运行应用程序并点击“加入购物车”按钮。
创建控制器和视图
现在我们需要定义将处理订单的控制器。我们在 **Controllers** 文件夹中添加一个名为 OrderController.cs
的类文件,并使用该文件来定义具有以下内容的类:
using Microsoft.AspNetCore.Mvc;
using BooksStore.Models;
namespace BooksStore.Controllers
{
public class OrderController : Controller
{
public ViewResult Checkout() => View(new Order());
}
}
Checkout 方法返回默认视图,并传递一个新的 Order 对象作为视图模型。要创建视图,我们创建一个名为 Views/Order 的文件夹,并在其中添加一个名为 Checkout.cshtml
的 Razor 视图,内容如下:
@model Order
<p>Please enter your details:</p>
<form asp-action="Checkout" method="post">
<h3>Ship to</h3>
<div class="form-group">
<label>Name:</label><input asp-for="Name" class="form-control" />
</div>
<h3>Address</h3>
<div class="form-group">
<label>Line 1:</label><input asp-for="Line1" class="form-control" />
</div>
<div class="form-group">
<label>Line 2:</label><input asp-for="Line2" class="form-control" />
</div>
<div class="form-group">
<label>City:</label><input asp-for="City" class="form-control" />
</div>
<div class="form-group">
<label>State:</label><input asp-for="State" class="form-control" />
</div>
<div class="form-group">
<label>Zip:</label><input asp-for="Zip" class="form-control" />
</div>
<div class="form-group">
<label>Country:</label><input asp-for="Country" class="form-control" />
</div>
<div class="text-center">
<input class="btn btn-primary" type="submit" value="Order" />
</div>
</form>
对于模型中的每个属性,我们都创建了标签和输入元素来捕获用户输入,这些元素已用 **Bootstrap** 样式化,并配置了标记助手。输入元素上的 asp-for
属性由内置的标记助手处理,根据指定的模型属性生成 type
、id
、name
和值等属性。
运行应用程序,将商品添加到购物车,然后按“立即结账”按钮(或直接访问 https://:44333/order/checkout)。
实现订单处理
我们将通过将订单记录在数据库中来处理订单。当然,大多数电子商务网站不会止步于此,我们也不需要提供信用卡处理或其他支付方式的支持。但我们希望一切都专注于 ASP.NET Core,因此简单的数据库条目就足够了。
扩展数据库
由于我们在前面的部分中已经完成了初始设置,因此向数据库添加新的模型类型非常简单。首先,我们在 **BooksStore/Models** 目录的 BooksStoreDbContext.cs
文件中添加一个新属性到数据库上下文类:
using Microsoft.EntityFrameworkCore;
namespace BooksStore.Models
{
public class BooksStoreDbContext : DbContext
{
public BooksStoreDbContext(DbContextOptions<BooksStoreDbContext> options)
: base(options) { }
public DbSet<Book> Books { get; set; }
public DbSet<Order> Orders { get; set; }
}
}
此更改足以让 Entity Framework Core 生成数据库迁移,从而允许将 Order
对象存储在数据库中。要创建迁移,请转到 **工具**,选择 **NuGet 程序包管理器 > 程序包管理器控制台 (PMC)**。在 PMC 中,输入以下命令:
Add-Migration Orders Update-Database
此命令指示 Entity Framework Core 拍摄应用程序数据模型的新快照,找出它与先前数据库版本的区别,并创建一个名为“Orders”的新迁移。由于 SeedData
调用了 Entity Framework Core 提供的 Migrate
方法,因此在应用程序启动时将自动应用新迁移。
创建订单存储库
我们将遵循与产品存储库相同的模式来提供对 Order
对象的访问。我们在 **Models** 目录中添加了一个名为 IOrderRepository.cs
的新类文件,并用它来定义接口。**BooksStore/Models** 目录中 IOrderRepository.cs
文件内容如下:
using System.Linq;
namespace BooksStore.Models
{
public interface IOrderRepository
{
IQueryable<Order> Orders { get; }
void SaveOrder(Order order);
}
}
通过在 **Models** 目录中添加一个名为 EFOrderRepository.cs
的类文件并包含以下内容来实现该接口:
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace BooksStore.Models
{
public class EFOrderRepository : IOrderRepository
{
private BooksStoreDbContext context;
public EFOrderRepository(BooksStoreDbContext ctx)
{
context = ctx;
}
public IQueryable<Order> Orders => context.Orders
.Include(o => o.Lines)
.ThenInclude(l => l.Book);
public void SaveOrder(Order order)
{
context.AttachRange(order.Lines.Select(l => l.Book));
if (order.OrderID == 0)
{
context.Orders.Add(order);
}
context.SaveChanges();
}
}
}
此类使用 Entity Framework Core 实现 IOrderRepository
接口,允许访问存储的 Order
对象集合,并支持创建或修改订单。
我们在 Startup
类的 ConfigureServices
方法中将订单存储库注册为一个服务,如下所示:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddScoped<IBooksStoreRepository, EFBooksStoreRepository>();
services.AddScoped<IOrderRepository, EFOrderRepository>();
services.AddRazorPages();
...
}
完成订单控制器
要完成 OrderController
类,我们需要修改其构造函数,使其接收处理订单所需的服务,并添加一个操作方法来处理用户单击 **Order** 按钮时来自 HTTP 表单的 POST 请求。在 **BooksStore/Controllers** 目录的 OrderController.cs
文件中完成 **Controller**,如下所示:
using Microsoft.AspNetCore.Mvc;
using BooksStore.Models;
using System.Linq;
namespace BooksStore.Controllers
{
public class OrderController : Controller
{
private IOrderRepository repository;
private MyCart cart;
public OrderController(IOrderRepository repoService, MyCart cartService)
{
repository = repoService;
cart = cartService;
}
public ViewResult Checkout() => View(new Order());
[HttpPost]
public IActionResult Checkout(Order order)
{
if (cart.Lines.Count() == 0)
{
ModelState.AddModelError("", "Sorry, your cart is empty!");
}
if (ModelState.IsValid)
{
order.Lines = cart.Lines.ToArray();
repository.SaveOrder(order);
cart.Clear();
return RedirectToPage("/Completed", new { orderId = order.OrderID });
}
else
{
return View();
}
}
}
}
Checkout
操作方法用 HttpPost
属性标记,这意味着它将用于处理 POST 请求——在这种情况下,是当用户提交表单时。
在前面的部分中,我们使用了 ASP.NET Core 模型绑定功能从请求中接收简单数据值。在新操作方法中使用了类似的功能来接收已完成的 Order
对象。处理请求时,模型绑定系统会尝试为 Order
类定义的属性填充值。这是一种尽力而为的方法,这意味着如果请求中没有相应的数据项,我们可能会收到一个属性值缺失的 Order
对象。
为了确保我们拥有所需的数据,我们将对 Order
类应用验证属性。ASP.NET Core 会检查我们应用于 Order
类的验证约束,并通过 ModelState 属性提供有关结果的详细信息。我们可以通过检查 ModelState.IsValid
属性来检查是否存在任何问题。如果购物车中没有商品,我们还会调用 ModelState.AddModelError
方法来注册错误消息。
显示验证错误
ASP.NET Core 使用应用于 Order
类的验证属性来验证用户数据,但我们需要进行一项简单的更改来显示任何问题。这依赖于另一个内置标记助手来检查用户提供数据的验证状态,并为每个检测到的问题添加警告消息。在 **BooksStore/Views/Order** 目录的 Checkout.cshtml
文件中添加一个 **Validation Summary**:
@model Order
<p>Please enter your details:</p>
<div asp-validation-summary="All" class="text-danger"></div>
<form asp-action="Checkout" method="post">
<h3>Ship to</h3>
...
通过这个简单的更改,就可以将验证错误传达给用户。要查看效果,请重新启动 ASP.NET Core,访问 https://:44333/Order/Checkout,然后在不填写表单的情况下单击“Order”按钮。ASP.NET Core 将处理表单数据,检测到必需值缺失,并生成验证错误,如下所示:
显示摘要页面
为了完成结账流程,我们将创建一个 Razor 页面来显示感谢消息以及订单摘要。向 **Pages** 目录添加一个名为 Completed.cshtml
的 Razor 页面,内容如下:
@page
<div class="text-center">
<h3>Thanks for placing order #@OrderId</h3>
<p>We'll ship your goods as soon as possible.</p>
<a class="btn btn-info" asp-controller="Home">Return to Store</a>
</div>
@functions {
[BindProperty(SupportsGet = true)]
public string OrderId { get; set; }
}
尽管 Razor Pages 通常有页面模型类,但它们并非强制要求,简单的功能也可以在没有它们的情况下开发。在本例中,我们定义了一个名为 OrderId
的属性,并用 BindProperty
属性对其进行标记,表示此属性的值将从系统的模型绑定请求中检索。
现在,客户可以完成整个流程,从选择产品到结账。如果他们提供了有效的送货详细信息(并且购物车中有商品),当他们单击“Order”按钮时,他们将看到摘要页面,如下图所示:
关注点
我们已经完成了 `BooksStore` 应用程序面向客户部分的所有主要部分。我们拥有一个可以通过类别和分页浏览的产品目录,一个整洁的购物车,以及一个简单的结账流程。在下一篇文章中,我们将添加管理 `BooksStore` 应用程序所需的功能。
历史
在此处保持您所做的任何更改或改进的实时更新。