第 8 部分:购物车与 Ajax 更新

作者 :Jon Galloway

MVC 音乐存储是一个教程应用程序,它介绍并逐步说明如何使用 ASP.NET MVC 和 Visual Studio 进行 Web 开发。

MVC 音乐商店是一个轻量级的示例商店实现,用于在线销售音乐专辑,并实现基本的网站管理、用户登录和购物车功能。

本教程系列详细介绍了生成 ASP.NET MVC 音乐商店示例应用程序所执行的所有步骤。 第 8 部分介绍使用 Ajax 汇报的购物车。

我们将允许用户在不注册的情况下将相册放入购物车中,但他们需要注册为来宾才能完成结帐。 购物与结帐流程将分为两个控制器:一个是购物车控制器,一个是允许将商品匿名添加到购物车,另一个是用于处理结帐过程的结帐控制器。 我们将从本部分中的购物车开始,然后在下一部分中生成结帐过程。

添加 Cart、Order 和 OrderDetail 模型类

购物车和结帐流程将使用一些新类。 右键单击 Models 文件夹,并使用以下代码 (Cart.cs) 添加 Cart 类。

using System.ComponentModel.DataAnnotations;
 
namespace MvcMusicStore.Models
{
    public class Cart
    {
        [Key]
        public int      RecordId    { get; set; }
        public string   CartId      { get; set; }
        public int      AlbumId     { get; set; }
        public int      Count       { get; set; }
        public System.DateTime DateCreated { get; set; }
        public virtual Album Album  { get; set; }
    }
}

此类与到目前为止使用的其他类非常相似,RecordId 属性的 [Key] 属性除外。 购物车项将具有名为 CartID 的字符串标识符,以允许匿名购物,但该表包含名为 RecordId 的整数主键。 按照惯例,Entity Framework Code-First预期名为 Cart 的表的主键将是 CartId 或 ID,但如果需要,我们可以通过批注或代码轻松替代。 这是一个示例,说明如何在符合我们的条件时使用实体框架Code-First中的简单约定,但当它们不适合时,我们不受它们的约束。

接下来,使用以下代码 (Order.cs) 添加 Order 类。

using System.Collections.Generic;
 
namespace MvcMusicStore.Models
{
    public partial class Order
    {
        public int    OrderId    { get; set; }
        public string Username   { get; set; }
        public string FirstName  { get; set; }
        public string LastName   { get; set; }
        public string Address    { get; set; }
        public string City       { get; set; }
        public string State      { get; set; }
        public string PostalCode { get; set; }
        public string Country    { get; set; }
        public string Phone      { get; set; }
        public string Email      { get; set; }
        public decimal Total     { get; set; }
        public System.DateTime OrderDate      { get; set; }
        public List<OrderDetail> OrderDetails { get; set; }
    }
}

此类跟踪订单的摘要和交货信息。 它尚未编译,因为它具有一个 OrderDetails 导航属性,该属性依赖于我们尚未创建的类。 现在,让我们通过添加名为 OrderDetail.cs 的类来修复此问题,并添加以下代码。

namespace MvcMusicStore.Models
{
    public class OrderDetail
    {
        public int OrderDetailId { get; set; }
        public int OrderId { get; set; }
        public int AlbumId { get; set; }
        public int Quantity { get; set; }
        public decimal UnitPrice { get; set; }
        public virtual Album Album { get; set; }
        public virtual Order Order { get; set; }
    }
}

我们将对 MusicStoreEntities 类进行最后一次更新,以包含公开这些新模型类的 DbSet,还包括 DbSet<Artist>。 更新的 MusicStoreEntities 类如下所示。

using System.Data.Entity;
 
namespace MvcMusicStore.Models
{
    public class MusicStoreEntities : DbContext
    {
        public DbSet<Album>     Albums  { get; set; }
        public DbSet<Genre>     Genres  { get; set; }
        public DbSet<Artist>    Artists {
get; set; }
        public DbSet<Cart>     
Carts { get; set; }
        public DbSet<Order>     Orders
{ get; set; }
        public DbSet<OrderDetail>
OrderDetails { get; set; }
    }
}

管理购物车业务逻辑

接下来,我们将在 Models 文件夹中创建 ShoppingCart 类。 ShoppingCart 模型处理对 Cart 表的数据访问。 此外,它还会将业务逻辑处理为 ,以便从购物车中添加和删除项。

由于我们不想要求用户注册帐户只是为了将项目添加到购物车,因此我们会使用 GUID 向用户分配临时唯一标识符 (,或者在用户访问购物车时) 全局唯一标识符。 我们将使用 ASP.NET Session 类存储此 ID。

注意:ASP.NET 会话是存储用户特定信息(在用户离开网站后过期)的便利位置。 虽然滥用会话状态可能会对较大的网站产生性能影响,但对于演示目的,我们的轻量使用效果很好。

ShoppingCart 类公开以下方法:

AddToCart 将相册作为参数,并将其添加到用户的购物车。 由于 Cart 表跟踪每张相册的数量,因此它包括创建一个新行(如果需要)或仅增加数量(如果用户已订购了相册的一个副本)的逻辑。

RemoveFromCart 获取相册 ID 并将其从用户的购物车中删除。 如果用户的购物车中只有一个相册副本,则会删除该行。

EmptyCart 从用户的购物车中删除所有项目。

GetCartItems 检索用于显示或处理的 CartItems 列表。

GetCount 检索用户在购物车中拥有的相册总数。

GetTotal 计算购物车中所有项的总成本。

CreateOrder 在结帐阶段将购物车转换为订单。

GetCart 是一种静态方法,它允许控制器获取 cart 对象。 它使用 GetCartId 方法处理从用户会话读取 CartId。 GetCartId 方法需要 HttpContextBase,以便它可以从用户的会话中读取用户的 CartId。

下面是完整的 ShoppingCart 类

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
 
namespace MvcMusicStore.Models
{
    public partial class ShoppingCart
    {
        MusicStoreEntities storeDB = new MusicStoreEntities();
        string ShoppingCartId { get; set; }
        public const string CartSessionKey = "CartId";
        public static ShoppingCart GetCart(HttpContextBase context)
        {
            var cart = new ShoppingCart();
            cart.ShoppingCartId = cart.GetCartId(context);
            return cart;
        }
        // Helper method to simplify shopping cart calls
        public static ShoppingCart GetCart(Controller controller)
        {
            return GetCart(controller.HttpContext);
        }
        public void AddToCart(Album album)
        {
            // Get the matching cart and album instances
            var cartItem = storeDB.Carts.SingleOrDefault(
                c => c.CartId == ShoppingCartId 
                && c.AlbumId == album.AlbumId);
 
            if (cartItem == null)
            {
                // Create a new cart item if no cart item exists
                cartItem = new Cart
                {
                    AlbumId = album.AlbumId,
                    CartId = ShoppingCartId,
                    Count = 1,
                    DateCreated = DateTime.Now
                };
                storeDB.Carts.Add(cartItem);
            }
            else
            {
                // If the item does exist in the cart, 
                // then add one to the quantity
                cartItem.Count++;
            }
            // Save changes
            storeDB.SaveChanges();
        }
        public int RemoveFromCart(int id)
        {
            // Get the cart
            var cartItem = storeDB.Carts.Single(
                cart => cart.CartId == ShoppingCartId 
                && cart.RecordId == id);
 
            int itemCount = 0;
 
            if (cartItem != null)
            {
                if (cartItem.Count > 1)
                {
                    cartItem.Count--;
                    itemCount = cartItem.Count;
                }
                else
                {
                    storeDB.Carts.Remove(cartItem);
                }
                // Save changes
                storeDB.SaveChanges();
            }
            return itemCount;
        }
        public void EmptyCart()
        {
            var cartItems = storeDB.Carts.Where(
                cart => cart.CartId == ShoppingCartId);
 
            foreach (var cartItem in cartItems)
            {
                storeDB.Carts.Remove(cartItem);
            }
            // Save changes
            storeDB.SaveChanges();
        }
        public List<Cart> GetCartItems()
        {
            return storeDB.Carts.Where(
                cart => cart.CartId == ShoppingCartId).ToList();
        }
        public int GetCount()
        {
            // Get the count of each item in the cart and sum them up
            int? count = (from cartItems in storeDB.Carts
                          where cartItems.CartId == ShoppingCartId
                          select (int?)cartItems.Count).Sum();
            // Return 0 if all entries are null
            return count ?? 0;
        }
        public decimal GetTotal()
        {
            // Multiply album price by count of that album to get 
            // the current price for each of those albums in the cart
            // sum all album price totals to get the cart total
            decimal? total = (from cartItems in storeDB.Carts
                              where cartItems.CartId == ShoppingCartId
                              select (int?)cartItems.Count *
                              cartItems.Album.Price).Sum();

            return total ?? decimal.Zero;
        }
        public int CreateOrder(Order order)
        {
            decimal orderTotal = 0;
 
            var cartItems = GetCartItems();
            // Iterate over the items in the cart, 
            // adding the order details for each
            foreach (var item in cartItems)
            {
                var orderDetail = new OrderDetail
                {
                    AlbumId = item.AlbumId,
                    OrderId = order.OrderId,
                    UnitPrice = item.Album.Price,
                    Quantity = item.Count
                };
                // Set the order total of the shopping cart
                orderTotal += (item.Count * item.Album.Price);
 
                storeDB.OrderDetails.Add(orderDetail);
 
            }
            // Set the order's total to the orderTotal count
            order.Total = orderTotal;
 
            // Save the order
            storeDB.SaveChanges();
            // Empty the shopping cart
            EmptyCart();
            // Return the OrderId as the confirmation number
            return order.OrderId;
        }
        // We're using HttpContextBase to allow access to cookies.
        public string GetCartId(HttpContextBase context)
        {
            if (context.Session[CartSessionKey] == null)
            {
                if (!string.IsNullOrWhiteSpace(context.User.Identity.Name))
                {
                    context.Session[CartSessionKey] =
                        context.User.Identity.Name;
                }
                else
                {
                    // Generate a new random GUID using System.Guid class
                    Guid tempCartId = Guid.NewGuid();
                    // Send tempCartId back to client as a cookie
                    context.Session[CartSessionKey] = tempCartId.ToString();
                }
            }
            return context.Session[CartSessionKey].ToString();
        }
        // When a user has logged in, migrate their shopping cart to
        // be associated with their username
        public void MigrateCart(string userName)
        {
            var shoppingCart = storeDB.Carts.Where(
                c => c.CartId == ShoppingCartId);
 
            foreach (Cart item in shoppingCart)
            {
                item.CartId = userName;
            }
            storeDB.SaveChanges();
        }
    }
}

ViewModels

购物车控制器需要向其视图传达一些复杂信息,这些视图不会清晰映射到模型对象。 我们不希望修改模型以适应我们的观点;模型类应表示我们的域,而不是用户界面。 一种解决方案是使用 ViewBag 类将信息传递给视图,就像我们处理应用商店管理器下拉列表信息一样,但通过 ViewBag 传递大量信息很难管理。

解决方法是使用 ViewModel 模式。 使用此模式时,我们将创建针对特定视图方案进行优化的强类型类,并公开视图模板所需的动态值/内容的属性。 然后,我们的控制器类可以填充这些视图优化类并将其传递给视图模板以使用。 这将在视图模板中启用类型安全、编译时检查和编辑器 IntelliSense。

我们将创建两个在购物车控制器中使用的视图模型:ShoppingCartViewModel 将保存用户购物车的内容,而 ShoppingCartRemoveViewModel 将用于显示确认信息时,用户从购物车中删除某些内容。

让我们在项目的根目录中创建新的 ViewModels 文件夹,使内容保持井然有序。 右键单击项目,选择“添加/新建文件夹”。

项目窗口的屏幕截图,其中右键菜单以黄色突出显示了“添加”和“新建文件夹”选项。

将文件夹命名为 ViewModels。

解决方案资源管理器的屏幕截图,其中显示了新创建的文件夹和新命名的文件夹“视图模型”,其中突出显示了一个黑框。

接下来,在 ViewModels 文件夹中添加 ShoppingCartViewModel 类。 它具有两个属性:Cart 项目列表和用于保存购物车中所有商品的总价的十进制值。

using System.Collections.Generic;
using MvcMusicStore.Models;
 
namespace MvcMusicStore.ViewModels
{
    public class ShoppingCartViewModel
    {
        public List<Cart> CartItems { get; set; }
        public decimal CartTotal { get; set; }
    }
}

现在,将 ShoppingCartRemoveViewModel 添加到 ViewModels 文件夹,其中包含以下四个属性。

namespace MvcMusicStore.ViewModels
{
    public class ShoppingCartRemoveViewModel
    {
        public string Message { get; set; }
        public decimal CartTotal { get; set; }
        public int CartCount { get; set; }
        public int ItemCount { get; set; }
        public int DeleteId { get; set; }
    }
}

购物车控制器

购物车控制器有三个main目的:将商品添加到购物车、从购物车中删除商品以及查看购物车中的项目。 它将使用我们刚刚创建的三个类:ShoppingCartViewModel、ShoppingCartRemoveViewModel 和 ShoppingCart。 与 StoreController 和 StoreManagerController 一样,我们将添加一个字段来保存 MusicStoreEntities 的实例。

使用空控制器模板向项目添加新的购物车控制器。

“添加控制器”窗口的屏幕截图,其中“控制器名称”字段中的“购物车控制器”以蓝色突出显示。

下面是完整的 ShoppingCart 控制器。 索引和添加控制器操作看起来应该非常熟悉。 Remove 和 CartSummary 控制器操作处理两种特殊情况,我们将在下一节中讨论这些情况。

using System.Linq;
using System.Web.Mvc;
using MvcMusicStore.Models;
using MvcMusicStore.ViewModels;
 
namespace MvcMusicStore.Controllers
{
    public class ShoppingCartController : Controller
    {
        MusicStoreEntities storeDB = new MusicStoreEntities();
        //
        // GET: /ShoppingCart/
        public ActionResult Index()
        {
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            // Set up our ViewModel
            var viewModel = new ShoppingCartViewModel
            {
                CartItems = cart.GetCartItems(),
                CartTotal = cart.GetTotal()
            };
            // Return the view
            return View(viewModel);
        }
        //
        // GET: /Store/AddToCart/5
        public ActionResult AddToCart(int id)
        {
            // Retrieve the album from the database
            var addedAlbum = storeDB.Albums
                .Single(album => album.AlbumId == id);
 
            // Add it to the shopping cart
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            cart.AddToCart(addedAlbum);
 
            // Go back to the main store page for more shopping
            return RedirectToAction("Index");
        }
        //
        // AJAX: /ShoppingCart/RemoveFromCart/5
        [HttpPost]
        public ActionResult RemoveFromCart(int id)
        {
            // Remove the item from the cart
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            // Get the name of the album to display confirmation
            string albumName = storeDB.Carts
                .Single(item => item.RecordId == id).Album.Title;
 
            // Remove from cart
            int itemCount = cart.RemoveFromCart(id);
 
            // Display the confirmation message
            var results = new ShoppingCartRemoveViewModel
            {
                Message = Server.HtmlEncode(albumName) +
                    " has been removed from your shopping cart.",
                CartTotal = cart.GetTotal(),
                CartCount = cart.GetCount(),
                ItemCount = itemCount,
                DeleteId = id
            };
            return Json(results);
        }
        //
        // GET: /ShoppingCart/CartSummary
        [ChildActionOnly]
        public ActionResult CartSummary()
        {
            var cart = ShoppingCart.GetCart(this.HttpContext);
 
            ViewData["CartCount"] = cart.GetCount();
            return PartialView("CartSummary");
        }
    }
}

使用 jQuery 的 Ajax 汇报

接下来,我们将创建一个“购物车索引”页,该页强类型化为 ShoppingCartViewModel,并使用与之前相同的方法使用列表视图模板。

“添加视图”窗口的屏幕截图,其中显示了“视图名称”字段、“视图引擎”、“模型类”和“基架”下拉列表以及“使用布局文件选取器”。

但是,我们将使用 jQuery 为此视图中具有 HTML 类 RemoveLink 的所有链接“连接”单击事件,而不是使用 Html.ActionLink 从购物车中删除项目。 此单击事件处理程序不会发布表单,而只是对我们的 RemoveFromCart 控制器操作进行 AJAX 回调。 RemoveFromCart 返回 JSON 序列化结果,jQuery 回调随后使用 jQuery 分析页面并执行四个快速更新:

    1. 从列表中删除已删除的相册
    1. 汇报标题中的购物车计数
    1. 向用户显示更新消息
    1. 汇报购物车的总价

由于删除方案由索引视图中的 Ajax 回调处理,因此我们不需要为 RemoveFromCart 操作提供其他视图。 下面是 /ShoppingCart/Index 视图的完整代码:

@model MvcMusicStore.ViewModels.ShoppingCartViewModel
@{
    ViewBag.Title = "Shopping Cart";
}
<script src="/Scripts/jquery-1.4.4.min.js"
type="text/javascript"></script>
<script type="text/javascript">
    $(function () {
        // Document.ready -> link up remove event handler
        $(".RemoveLink").click(function () {
            // Get the id from the link
            var recordToDelete = $(this).attr("data-id");
            if (recordToDelete != '') {
                // Perform the ajax post
                $.post("/ShoppingCart/RemoveFromCart", {"id": recordToDelete },
                    function (data) {
                        // Successful requests get here
                        // Update the page elements
                        if (data.ItemCount == 0) {
                            $('#row-' + data.DeleteId).fadeOut('slow');
                        } else {
                            $('#item-count-' + data.DeleteId).text(data.ItemCount);
                        }
                        $('#cart-total').text(data.CartTotal);
                        $('#update-message').text(data.Message);
                        $('#cart-status').text('Cart (' + data.CartCount + ')');
                    });
            }
        });
    });
</script>
<h3>
    <em>Review</em> your cart:
 </h3>
<p class="button">
    @Html.ActionLink("Checkout
>>", "AddressAndPayment", "Checkout")
</p>
<div id="update-message">
</div>
<table>
    <tr>
        <th>
            Album Name
        </th>
        <th>
            Price (each)
        </th>
        <th>
            Quantity
        </th>
        <th></th>
    </tr>
    @foreach (var item in
Model.CartItems)
    {
        <tr id="row-@item.RecordId">
            <td>
                @Html.ActionLink(item.Album.Title,
"Details", "Store", new { id = item.AlbumId }, null)
            </td>
            <td>
                @item.Album.Price
            </td>
            <td id="item-count-@item.RecordId">
                @item.Count
            </td>
            <td>
                <a href="#" class="RemoveLink"
data-id="@item.RecordId">Remove
from cart</a>
            </td>
        </tr>
    }
    <tr>
        <td>
            Total
        </td>
        <td>
        </td>
        <td>
        </td>
        <td id="cart-total">
            @Model.CartTotal
        </td>
    </tr>
</table>

为了测试这一点,我们需要能够将商品添加到购物车。 我们将更新 “应用商店详细信息” 视图,以包含“添加到购物车”按钮。 当我们使用时,我们可以包括自上次更新此视图以来添加的一些专辑附加信息:流派、艺术家、价格和专辑艺术。 更新的“应用商店详细信息”视图代码如下所示。

@model MvcMusicStore.Models.Album
@{
    ViewBag.Title = "Album - " + Model.Title;
 }
<h2>@Model.Title</h2>
<p>
    <img alt="@Model.Title"
src="@Model.AlbumArtUrl" />
</p>
<div id="album-details">
    <p>
        <em>Genre:</em>
        @Model.Genre.Name
    </p>
    <p>
        <em>Artist:</em>
        @Model.Artist.Name
    </p>
    <p>
        <em>Price:</em>
        @String.Format("{0:F}",
Model.Price)
    </p>
    <p class="button">
        @Html.ActionLink("Add to
cart", "AddToCart", 
        "ShoppingCart", new { id = Model.AlbumId }, "")
    </p>
</div>

现在,我们可以单击商店并测试在购物车中添加和删除相册。 运行应用程序并浏览到应用商店索引。

“音乐存储”窗口的屏幕截图,其中显示了从输入到数据库的所有专辑数据中定义的流派详细信息。

接下来,单击“流派”以查看专辑列表。

“音乐商店”窗口的屏幕截图,其中显示了与专辑数据库中的迪斯科流派关联的专辑列表。

单击“相册”标题现在会显示更新的“专辑详细信息”视图,包括“添加到购物车”按钮。

“音乐商店”窗口的屏幕截图,其中显示了更新的“专辑详细信息”视图和“添加到购物车”按钮。

单击“添加到购物车”按钮将显示包含购物车摘要列表的“购物车索引”视图。

“音乐商店”窗口的屏幕截图,其中显示了“购物车”视图,其中包含购物车中所有项目的摘要列表。

加载购物车后,可以单击“从购物车中删除”链接,查看购物车的 Ajax 更新。

“音乐商店”窗口的屏幕截图,其中“购物车”视图从摘要列表中删除了专辑。

我们构建了一个工作购物车,允许未注册的用户将项目添加到购物车。 在下一部分中,我们将允许他们注册并完成结帐过程。