设计购物车
现在我们回过头来让前面的购物车概念实体化。所有 OOP 编程都从设计类开始,我们必须构建一个购物车类。我现在还是觉得这样不符合人类的思维习惯。哦,应该是我的思维习惯。
Listing 8-9 在 Models 文件夹新建一个模型文件 Cart.cs
namespace EBooksStore.Models;
public class Cart
{
public IEnumerable<CartLine> Lines { get; set; } = [];
public decimal ComputeTotalValue()
=> Lines.Sum(e => e.EBook.Price * e.Quantity);
private List<CartLine> _lines = [];
}
public class CartLine
{
public EBook EBook { get; set; } = new();
public int Quantity { get; set; }
}
购物车的数据是一行行的购物列表,有一个计算总价格的方法。
有了 Cart 类就可以处理购物车数据。前面只添加了购物车的视图文件 Index.cshtml,现在我们更新这个视图文件的内容,让它能绑定 Cart 和显示数据和合计。
Listing 8-10 修改 Views/Cart 文件夹里的Index.cshtml
@model Cart
@{
ViewData["Title"] = "购物车";
Layout = "../Shared/_CartLayout";
}
<h2>购物车</h2>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>书名</th>
<th>作者</th>
<th>价格</th>
<th>数量</th>
<th>小计</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Lines)
{
<tr>
<td>@item.EBook.Title</td>
<td>@item.EBook.Author</td>
<td>@item.EBook.Price</td>
<td>@item.Quantity</td>
<td>@(item.EBook.Price * item.Quantity)</td>
<td>
<a href="#">删除</a>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-end">总计:</td>
<td>@Model.ComputeTotalValue()</td>
<td></td>
</tr>
</tfoot>
</table>
这个购物车视图除了每行显示书名,作者等商品信息外,最后有一个删除操作,可以让用户点击取消购买。
接下来我们添加视图后面的控制类 CartController 。
Listing 8-11 在 Controllers 文件夹里新建一个文件 CartController.cs
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Controllers;
public class CartController : Controller
{
public IActionResult Index() => View();
}
运行应用后,现在如果我们在浏览器里键入 localhost:5000/cart 发送转到购物车页面的请求,系统会抛出异常,因为我们没有把数据模型 Cart.cs 实例传给视图。 我们的应用里的购物逻辑是用户点击按钮加入购物车,而购物车的数据要在 HomeController 和 CartController 之间共享。幸运的是,我们前面已经把已经点击购买的电子书的 Id 都保存在了会话中,而会话是在服务端的内存里的,所以 CartController 就可以访问这些数据了。
Listing 8-12 修改 Controllers/CartController.cs 文件
using EBooksStore.Extensions;
using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Controllers;
public class CartController(IStoreRepository storeRepository) : Controller
{
private readonly IStoreRepository _repository = storeRepository;
public IActionResult Index()
{
Cart cart = new()
{
Lines = GetCartLines()
}
return View(Cart);
}
private List<CartLine> GetCartLines()
{
List<CartLine> lines = [];
var AddedEbookIds = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
if(AddedEbookIds.Count != 0)
{
foreach (var id in AddedEbookIds)
{
var eBook = _repository.EBooks.FirstOrDefault(e => e.Id == id);
if (eBook != null)
{
lines.Add(new CartLine
{
Ebook = eBook,
Quantity = 1
})
}
}
}
}
}
在 CartController 类里,首先要依赖注入 IStoreRepository 以便按 Id 找到相应的电子书信息;取得按会话保存的点击加入过购物车的商品 Id 列表,生成购物车的行信息;再生成购物车实例作为参数再传给视图。
运行应用,点击加入购物车后,浏览器在列表和购物车页面类似下图:
Figure 8-2
Figure 8-3
现在的这个处理方法,每次跳转到购物车时,CartController 都要初始化一个购物车。接着,服务端都要从头开始从数据库取数据填充购物车,这样做不仅对性能会有影响,而且编码也不优雅。我们可以用会话来保存购物车,或者用依赖注入来提供购物车的服务。因为我们的购物车看来比较小,用会话保存应该没问题。而事实上,我们只要保存 CartLine 就行。(注:真实应用中用依赖注入 ID 结合会话会更好) CartController 里的GetCartLines 方法要增加一个处理已加入购物车行的条件判断,只是对未加入的电子书进行数据库操作。
Listing 8-13 修改 Controllers 文件夹里的 CartController.cs 文件
using EBooksStore.Extensions;
using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Controllers;
public class CartController(IStoreRepository storeRepository) : Controller
{
private readonly IStoreRepository _repository = storeRepository;
public IActionResult Index()
{
//构建新的 Cart 实例
var cartLines = GetCartLines();
Cart cart = new();
cart.AddLines(cartLines);
return View(cart);
}
private List<CartLine> GetCartLines()
{
var cartLinesInSession = HttpContext.Session.Get<List<CartLine>>("CartLines") ?? [];
var AddedEbookIds = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
if(AddedEbookIds.Count != 0)
{
//如果会话里的 cartLines 有数据,那么检查并加入新的行
if(cartLinesInSession.Count != 0)
{
var eBookIds = cartLinesInSession.Select(e => e.EBook.Id).ToList();
foreach (var id in AddedEbookIds)
{
if (!eBookIds.Contains(id))
{
var eBook = _repository.EBooks.FirstOrDefault(e => e.Id == id);
if (eBook != null)
{
cartLinesInSession.Add(new CartLine
{
EBook = eBook,
Quantity = 1
});
}
}
}
}
//cartLines 是空的,添加所有用户选定的电子书行
else
{
foreach (var id in AddedEbookIds)
{
var eBook = _repository.EBooks.FirstOrDefault(e => e.Id == id);
if (eBook != null)
{
cartLinesInSession.Add(new CartLine
{
EBook = eBook,
Quantity = 1
});
}
}
}
//把对象序列化存回会话
HttpContext.Session.Set<List<CartLine>>("CartLines", cartLinesInSession);
}
return cartLinesInSession;
}
}
一般说来,用户的购物车数据都会用会话来保存,主要因为会话是依赖用户的,每个用户有自己独立的会话;但是在大型的系统会涉及分布式内存管理,要确保会话状态在服务器之间共享(比如使用分布式缓存 Redis)。现在除了新加入购物车的商品会从数据库取数据,老的商品都是在内存通过序列化会话来生成购物车行了。
完善购物车
我们接下来完善购物车的功能。首先是取消购买某一本电子书,就是删除一行。我们先把购物车视图的每个删除按钮改为表单按钮,以便提交删除。
Listing 8-14 修改 Views/Cart 文件夹里的 Index.cshtml 文件
@model Cart
@{
ViewData["Title"] = "购物车";
Layout = "../Shared/_CartLayout";
}
<h2>购物车</h2>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>书名</th>
<th>作者</th>
<th>价格</th>
<th>数量</th>
<th>小计</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Lines)
{
<tr>
<td>@item.EBook.Title</td>
<td>@item.EBook.Author</td>
<td>@item.EBook.Price</td>
<td>@item.Quantity</td>
<td>@(item.EBook.Price * item.Quantity)</td>
<td>
<form asp-action="RemoveFromCart" method="post">
<input type="hidden" name="eBookId" value="@item.EBook.Id" />
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
<!-- 修改成表单,提交删除 -->
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-end">总计:</td>
<td>@Model.ComputeTotalValue()</td>
<td></td>
</tr>
</tfoot>
</table>
Listing 8-15 在 CartController 类里添加删除操作的方法。
using EBooksStore.Extensions;
using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Controllers;
public class CartController(IStoreRepository storeRepository) : Controller
{
private readonly IStoreRepository _repository = storeRepository;
public IActionResult Index()
{
Cart cart = new()
{
Lines = GetCartLines()
};
return View(cart);
}
private List<CartLine> GetCartLines()
{
var cartLinesInSession = HttpContext.Session.Get<List<CartLine>>("CartLines") ?? [];
var AddedEbookIds = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
if (AddedEbookIds.Count != 0)
{
if (cartLinesInSession.Count != 0)
{
var eBookIds = cartLinesInSession.Select(e => e.EBook.Id).ToList();
foreach (var id in AddedEbookIds)
{
if (!eBookIds.Contains(id))
{
var eBook = _repository.EBooks.FirstOrDefault(e => e.Id == id);
if (eBook != null)
{
cartLinesInSession.Add(new CartLine
{
EBook = eBook,
Quantity = 1
});
}
}
}
}
else
{
foreach (var id in AddedEbookIds)
{
var eBook = _repository.EBooks.FirstOrDefault(e => e.Id == id);
if (eBook != null)
{
cartLinesInSession.Add(new CartLine
{
EBook = eBook,
Quantity = 1
});
}
}
}
HttpContext.Session.Set<List<CartLine>>("CartLines", cartLinesInSession);
}
return cartLinesInSession;
}
// 添加处理删除表单的方法
public IActionResult RemoveFromCart(int eBookId)
{
var cartLines = GetCartLines();
var line = cartLines.FirstOrDefault(e => e.EBook.Id == eBookId);
if (line != null)
{
cartLines.Remove(line);
HttpContext.Session.Set<List<CartLine>>("CartLines", cartLines);
var disabledCartItems = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
disabledCartItems.Remove(eBookId);
HttpContext.Session.Set("DisabledCartItems", disabledCartItems);
}
return RedirectToAction("Index");
}
}
我们先要从会话里取得购物车行,在 List 里找到对应的行,并进行删除 Remove 后,还需要删除会话里 CartLines 和 DisabledCartItems 里对应 eBookId 的项。最后返回购物车视图的语句将更新并显示 Index 视图。
为了方便,我们再加一个清空购物车的按钮,省得一行一行地删除。
再添加一个继续购物按钮,和结算按钮。
Listing 8-16 修改 Views/Cart 文件夹里的 Index.cshtml,添加三个按钮
...
<tfoot>
<tr>
<td colspan="4" class="text-end">总计:</td>
<td>@Model.ComputeTotalValue()</td>
<td></td>
</tr>
</tfoot>
</table>
<div class="row">
<div class="col-sm-6">
<a asp-controller="Home" asp-action="Index" class="btn btn-primary">继续购物</a>
<a asp-action="Checkout" class="btn btn-primary">结算</a>
</div>
<form class="col-sm-6 text-end" asp-action="ClearCart" method="post">
<button type="submit" class="btn btn-danger">清空购物车</button>
</form>
</div>
Listing 8-17 CartController 添加 ClearCart 方法
...
public IActionResult ClearCart()
{
HttpContext.Session.Remove("CartLines");
HttpContext.Session.Remove("DisabledCartItems");
return RedirectToAction("Index");
}
...
继续购物按钮里我们把 Controller 指向 Home,Action 为 Index,就可以了,简单直接。完成后,购物车页面如图:
Figure 8-4
还有一个购物车行的功能在真实应用中必须实现,就是每件商品的数量最好是一个可以调整的 Input,可以用类型是数字的input来做,但是最好用其他更直观的 UI 插件做。
说到 UI ,我们还需要一个购物车的图标和所购品种数量显示在菜单栏上。我们接下来利用 Font Awesome 来实现它。先用 Libman 安装 Font Awesome 包。
Listing 8-18
libman install font-awesome@6.7.1 -d wwwroot/lib/font-awesome
我们要把图标显示在菜单栏上,而菜单栏是在 _Layout 里的,所以把购物车做成视图组件 View Component 是最好的选择。我们在 Components 文件夹增加一个 CartViewComponent 类。
Listing 8-18 在 Components 文件夹里新建一个文件 CartViewComponent.cs
using EBooksStore.Extensions;
using EBooksStore.Models;
using Microsoft.AspNetCore.Mvc;
namespace EBooksStore.Components;
public class CartViewComponent : ViewComponent
{
public IViewComponentResult Invoke()
{
var disabledItems = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
return View(disabledItems.Count);
}
}
我们从会话里面取得 cartLines 列表,把列表的项目数量作为参数传给视图 Default.cshtml 。 Listing 8-19 新建 Views/Shared/Component/Cart 文件夹,在其中新建 Default.cshtml 文件
<div class="nav-link">
<a class="text-white" href="/Cart"><i class="fas fa-shopping-cart"></i></a>
@if (Model >= 0)
{
<small>
<span class="badge bg-danger">@Model</span>
</small>
}
</div>
我们只要把这个视图组件放在 _Layout.cshtml 里就行了,但是还要让_CartLayout.cshtml 里的店名变为超链接,点击可以回到主页。
Listing 8-20 修改 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" />
<link href="/lib/font-awesome/css/all.min.css" rel="stylesheet" />
</head>
<body>
<div class="container">
<div class="bg-secondary mt-2 p-2 d-flex justify-content-between">
<div class="nav-link">
<a class="text-white" href="/">逻辑的电子书店</a>
</div>
<vc:cart />
</div>
<div class="row m-3 p-3">
<div class="col-3">
<vc:nav-menu />
</div>
<div class="col-9">
@RenderBody()
</div>
</div>
</div>
</body>
</html>
Listing 8-21 修改 Views/Shared 文件夹里的 _CartLayout.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 mt-2 p-2 d-flex justify-content-between">
<div class="nav-link">
<a class="text-white" href="/">逻辑的电子书店</a>
</div>
</div>
<div class="row m-3 p-3">
<div class="col-12">
@RenderBody()
</div>
</div>
</div>
</body>
</html>
当用户点击加入购物车时,菜单栏里购物车的数量不会动态更新,所以我们要在 AJAX 调用返回的 Promise 里动态更新购物车图标后面红色的品种数量。
Listing 8-22 修改 Views/Home 文件夹里的 Index.cshtml 里的 JavaScript fetch()
...
if (response.ok) {
response.json().then(data => {
if(data.success) {
document.getElementById(`btn-${ebookId}`).textContent = '已加入购物车';
document.getElementById(`btn-${ebookId}`).disabled = true;
document.querySelector('.badge').textContent = data.numberOfItems;
}
});
}
...
同时,还要更新 HomeController 里的 AddToCart 方法,在成功返回的匿名类里增加数量成员。
Listing 8-23 修改 Controllers 文件夹里的 HomeController.cs 文件
...
public JsonResult AddToCart(int eBookId)
{
// Logic to add the ebook to the cart
// For example: _cartService.AddToCart(id);
// retrive existing disabled cart items fro session
var disabledCartItems = HttpContext.Session.Get<List<int>>("DisabledCartItems") ?? [];
var numberOfItems = disabledCartItems.Count; //声明一个本地变量存储品种数量
try
{
// add the new item to the list
if (!disabledCartItems.Contains(eBookId))
{
disabledCartItems.Add(eBookId);
numberOfItems++; //新加一个
HttpContext.Session.Set("DisabledCartItems", disabledCartItems);
}
return Json(new { success = true , numberOfItems }); //成功,返回数量
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
...
运行应用后,页面类似如下:
Figure 8-5
图中显示了动态数量的变化,当打开主页时购物车内品种数量为 0 ;添加 2 个电子书后,数量为 2 ;然后,我们转到购物车页面,删除一个商品,再返回继续购物,数量为 1 。
发表回复