设计购物车

现在我们回过头来让前面的购物车概念实体化。所有 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,就可以了,简单直接。完成后,购物车页面如图:

增加3个按钮

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 。

发表回复

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