十、SportsStore:管理

在本章中,我将继续构建 SportsStore 应用,以便为站点管理员提供一种管理订单和产品的方法。在本章中,我使用 Blazor 来创建管理特性。Blazor 是 ASP.NET Core 的新成员,它结合了客户端 JavaScript 代码和由 ASP.NET Core 执行的服务器端代码,通过持久的 HTTP 连接进行连接。我在第3235章中详细描述了 Blazor,但是理解 Blazor 模型并不适用于所有项目是很重要的。(我在本章中使用的是 Blazor 服务器,它是 ASP.NET Core 平台支持的一部分。还有 Blazor WebAssembly,在撰写本文时,它还处于试验阶段,完全在浏览器中运行。我在第 36 章中描述了 Blazor WebAssembly。)

Tip

你可以从 https://github.com/apress/pro-asp.net-core-3 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第 1 章获取帮助。

正在准备 Blazor 服务器

第一步是为 Blazor 启用服务和中间件,如清单 10-1 所示。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using SportsStore.Models;

namespace SportsStore {
    public class Startup {

        public Startup(IConfiguration config) {
            Configuration = config;
        }

        private IConfiguration Configuration { get; set; }

        public void ConfigureServices(IServiceCollection services) {
            services.AddControllersWithViews();
            services.AddDbContext<StoreDbContext>(opts => {
                opts.UseSqlServer(
                    Configuration["ConnectionStrings:SportsStoreConnection"]);
            });
            services.AddScoped<IStoreRepository, EFStoreRepository>();
            services.AddScoped<IOrderRepository, EFOrderRepository>();
            services.AddRazorPages();
            services.AddDistributedMemoryCache();
            services.AddSession();
            services.AddScoped<Cart>(sp => SessionCart.GetCart(sp));
            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            services.AddServerSideBlazor();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseSession();
            app.UseRouting();

            app.UseEndpoints(endpoints => {
                endpoints.MapControllerRoute("catpage",
                    "{category}/Page{productPage:int}",
                    new { Controller = "Home", action = "Index" });

                endpoints.MapControllerRoute("page", "Page{productPage:int}",
                    new { Controller = "Home", action = "Index", productPage = 1 });

                endpoints.MapControllerRoute("category", "{category}",
                    new { Controller = "Home", action = "Index", productPage = 1 });

                endpoints.MapControllerRoute("pagination",
                    "Products/Page{productPage}",
                    new { Controller = "Home", action = "Index", productPage = 1 });
                endpoints.MapDefaultControllerRoute();
                endpoints.MapRazorPages();
                endpoints.MapBlazorHub();
                endpoints.MapFallbackToPage("/admin/{*catchall}", "/Admin/Index");
            });

            SeedData.EnsurePopulated(app);
        }
    }
}

Listing 10-1.Enabling Blazor in the Startup.cs File in the SportsStore Folder

AddServerSideBlazor方法创建 Blazor 使用的服务,MapBlazorHub方法注册 Blazor 中间件组件。最后一点是改进路由系统,确保 Blazor 与应用的其他部分无缝协作。

创建导入文件

Blazor 需要自己的导入文件来指定它使用的名称空间。创建Pages/Admin文件夹,添加一个名为_Imports.razor的文件,内容如清单 10-2 所示。(如果您使用的是 Visual Studio,您可以使用 Razor 组件模板来创建这个文件。)

Note

Blazor 文件的传统位置是在Pages文件夹中,但是 Blazor 文件可以在项目中的任何地方定义。例如,在第 4 部分,我使用了一个名为Blazor的文件夹来帮助强调哪些特性是由 Blazor 提供的,哪些是由 Razor Pages 提供的。

@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.EntityFrameworkCore
@using SportsStore.Models

Listing 10-2.The Contents of the _Imports.razor File in the SportsStore/Pages/Admin Folder

前四个@using表达式用于 Blazor 所需的名称空间。最后两个表达式是为了方便后面的例子,因为它们允许我使用实体框架核心和Models名称空间中的类。

创建启动 Razor 页面

Blazor 依靠 Razor 页面向浏览器提供初始内容,其中包括连接到服务器并呈现 Blazor HTML 内容的 JavaScript 代码。在Pages/Admin文件夹中添加一个名为Index.cshtml的 Razor 页面,内容如清单 10-3 所示。

@page "/admin"
@{  Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <title>SportsStore Admin</title>
    <link href="/lib/twitter-bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <base href="/" />
</head>
<body>
    <component type="typeof(Routed)" render-mode="Server" />
    <script src="/_framework/blazor.server.js"></script>
</body>
</html>

Listing 10-3.The Contents of the Index.cshtml File in the SportsStore/Pages/Admin Folder

component元素用于在 Razor 页面的输出中插入 Razor 组件。Razor 组件是容易混淆的 Blazor 构建块,在清单 10-3 中应用的component元素被命名为Routed,不久将被创建。Razor 页面还包含一个script元素,告诉浏览器加载 Blazor 服务器使用的 JavaScript 文件。对该文件的请求被 Blazor 服务器中间件截获,您不需要显式地将 JavaScript 文件添加到项目中。

创建布线和布局组件

Pages/Admin文件夹中添加一个名为Routed.razor的 Razor 组件,并添加清单 10-4 中所示的内容。

<Router AppAssembly="typeof(Startup).Assembly">
    <Found>
        <RouteView RouteData="@context" DefaultLayout="typeof(AdminLayout)" />
    </Found>
    <NotFound>
        <h4 class="bg-danger text-white text-center p-2">
            No Matching Route Found
        </h4>
    </NotFound>
</Router>

Listing 10-4.The Contents of the Routed.razor File in the SportsStore/Pages/Admin Folder

这个组件的内容在本书的第 4 部分有详细的描述,但是,对于本章来说,知道这个组件将使用浏览器的当前 URL 来定位一个可以显示给用户的 Razor 组件就足够了。如果找不到匹配的组件,则会显示一条错误消息。

布拉佐有自己的布局系统。为了创建管理工具的布局,将一个名为AdminLayout.razor的 Razor 组件添加到Pages/Admin文件夹中,其内容如清单 10-5 所示。

@inherits LayoutComponentBase

<div class="bg-info text-white p-2">
    <span class="navbar-brand ml-2">SPORTS STORE Administration</span>
</div>
<div class="container-fluid">
    <div class="row p-2">
        <div class="col-3">
            <NavLink class="btn btn-outline-primary btn-block"
                        href="/admin/products"
                        ActiveClass="btn-primary text-white"
                        Match="NavLinkMatch.Prefix">
                Products
            </NavLink>
            <NavLink class="btn btn-outline-primary btn-block"
                        href="/admin/orders"
                        ActiveClass="btn-primary text-white"
                        Match="NavLinkMatch.Prefix">
                Orders
            </NavLink>
        </div>
        <div class="col">
            @Body
        </div>
    </div>
</div>

Listing 10-5.The Contents of the AdminLayout.razor File in the SportsStore/Pages/Admin Folder

Blazor 使用 Razor 语法生成 HTML,但引入了自己的指令和特性。这个布局呈现了一个带有ProductOrder导航按钮的两列显示,这些按钮是使用NavLink元素创建的。这些元素应用了一个内置的 Razor 组件,该组件在不触发新的 HTTP 请求的情况下更改 URL,这使得 Blazor 可以在不丢失应用状态的情况下响应用户交互。

创建剃须刀组件

为了完成初始设置,我需要添加将提供管理工具的组件,尽管它们最初将包含占位符消息。将名为Products.razor的 Razor 组件添加到Pages/Admin文件夹中,内容如清单 10-6 所示。

@page "/admin/products"
@page "/admin"

<h4>This is the products component</h4>

Listing 10-6.The Contents of the Products.razor File in the SportsStore/Pages/Admin Folder

@page指令指定了该组件将被显示的 URL,即/admin/products/admin。接下来,将一个名为Orders.razor的 Razor 组件添加到Pages/Admin文件夹中,其内容如清单 10-7 所示。

@page "/admin/orders"

<h4>This is the orders component</h4>

Listing 10-7.The Contents of the Orders.razor File in the SportsStore/Pages/Admin Folder

检查 Blazor 设置

为了确保 Blazor 正常工作,启动 ASP.NET Core 并请求http://localhost:5000/admin。这个请求将由Pages/Admin文件夹中的Index Razor 页面处理,它将在发送给浏览器的内容中包含 Blazor JavaScript 文件。JavaScript 代码将打开一个到 ASP.NET Core 服务器的持久 HTTP 连接,初始 Blazor 内容将被呈现,如图 10-1 所示。

Note

微软还没有发布测试 Razor 组件所需的工具,这也是本章没有单元测试例子的原因。

img/338050_8_En_10_Fig1_HTML.jpg

图 10-1。

Blazor 应用

点击订单按钮,将显示订单 Razor 组件生成的内容,如图 10-2 所示。与我在前面章节中使用的其他 ASP.NET Core 应用框架不同,即使浏览器显示的 URL 发生了变化,新内容也不会发送新的 HTTP 请求。

img/338050_8_En_10_Fig2_HTML.jpg

图 10-2。

在 Blazor 应用中导航

管理订单

既然 Blazor 已经安装并测试完毕,我将开始实现管理特性。在前一章中,我添加了对从客户那里接收订单并将它们存储在数据库中的支持。在本节中,我将创建一个简单的管理工具,让我查看已经收到的订单,并将它们标记为已发货。

增强模型

我需要做的第一个更改是增强数据模型,这样我就可以记录已经发货的订单。清单 10-8 展示了向Order类添加一个新属性,该属性在Models文件夹的Order.cs文件中定义。

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace SportsStore.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; }
        public string Line3 { 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; }

        public bool GiftWrap { get; set; }

        [BindNever]
        public bool Shipped { get; set; }
    }
}

Listing 10-8.Adding a Property in the Order.cs File in the SportsStore/Models Folder

这种扩展和修改数据模型以支持不同特性的迭代方法是 ASP.NET Core 开发的典型特征。在理想的情况下,您将能够在项目开始时完全定义数据模型,并围绕它构建应用,但这仅发生在最简单的项目中,并且在实践中,随着对所需内容的理解的发展和演变,迭代开发是可以预期的。

实体框架核心迁移使这一过程变得更加容易,因为您不必通过编写自己的 SQL 命令来手动保持数据库模式与模型类同步。要更新数据库以反映向Order类添加了Shipped属性,请打开一个新的 PowerShell 窗口并运行 SportsStore 项目中清单 10-9 中所示的命令。

dotnet ef migrations add ShippedOrders

Listing 10-9.Creating a New Migration

当应用启动并且SeedData类调用实体框架核心提供的Migrate方法时,迁移将被自动应用。

向管理员显示订单

我将显示两个表,其中一个显示等待发货的订单,另一个显示已发货的订单。每个订单都有一个按钮,可以改变发货状态。这并不完全现实,因为订单处理通常比简单地更新数据库中的字段更复杂,但是与仓库和履行系统的集成远远超出了本书的范围。

为了避免重复的代码和内容,我将创建一个 Razor 组件来显示一个表,而不知道它正在处理哪一类订单。将名为OrderTable.razor的 Razor 组件添加到Pages/Admin文件夹中,内容如清单 10-10 所示。

<table class="table table-sm table-striped table-bordered">
    <thead>
        <tr><th colspan="5" class="text-center">@TableTitle</th></tr>
    </thead>
    <tbody>
        @if (Orders?.Count() > 0) {
            @foreach (Order o in Orders) {
                <tr>
                    <td>@o.Name</td><td>@o.Zip</td><th>Product</th><th>Quantity</th>
                <td>
                    <button class="btn btn-sm btn-danger"
                            @onclick="@(e => OrderSelected.InvokeAsync(o.OrderID))">
                        @ButtonLabel
                    </button>
                </td>
            </tr>
                @foreach (CartLine line in o.Lines) {
                    <tr>
                        <td colspan="2"></td>
                        <td>@line.Product.Name</td><td>@line.Quantity</td>
                        <td></td>
                    </tr>
                }
            }
        } else {
            <tr><td colspan="5" class="text-center">No Orders</td></tr>
        }
    </tbody>
</table>

@code {

    [Parameter]
    public string TableTitle { get; set; } = "Orders";

    [Parameter]
    public IEnumerable<Order> Orders { get; set; }

    [Parameter]
    public string ButtonLabel { get; set; } = "Ship";

    [Parameter]
    public EventCallback<int> OrderSelected{ get; set; }
}

Listing 10-10.The Contents of the OrderTable.razor File in the SportsStore/Pages/Admin Folder

顾名思义,Razor 组件依赖 Razor 方法来处理带注释的 HTML 元素。组件的视图部分由@code部分的语句支持。该组件中的@code部分定义了四个用Parameter属性修饰的属性,这意味着这些值将在运行时由父组件提供,我将很快创建父组件。为参数提供的值用于组件的视图部分,以显示一系列Order对象的详细信息。

Blazor 向 Razor 语法添加了表达式。这个组件的视图部分包括这个button元素,它有一个@onclick属性。

...
<button class="btn btn-sm btn-danger"
        @onclick="@(e => OrderSelected.InvokeAsync(o.OrderID))">
    @ButtonLabel
</button>
...

这告诉 Blazor 当用户点击按钮时如何反应。在这种情况下,表达式告诉 Razor 调用OrderSelected属性的InvokeAsync方法。这就是该表如何与 Blazor 应用的其余部分进行通信,并且随着我构建更多的功能,它会变得更加清晰。

Tip

我在本书的第 4 部分中深入描述了 Blazor,所以如果本章中的 Razor 组件没有直接意义,请不要担心。SportsStore 示例的目的是展示整个开发过程,即使不了解个别功能。

下一步是创建一个组件,该组件将从数据库中获取Order数据,并使用OrderTable组件将其显示给用户。删除Orders组件中的占位符内容,并替换为清单 10-11 中所示的代码和内容。

@page "/admin/orders"
@inherits OwningComponentBase<IOrderRepository>

<OrderTable TableTitle="Unshipped Orders"
        Orders="UnshippedOrders" ButtonLabel="Ship" OrderSelected="ShipOrder" />
<OrderTable TableTitle="Shipped Orders"
       Orders="ShippedOrders" ButtonLabel="Reset" OrderSelected="ResetOrder" />
<button class="btn btn-info" @onclick="@(e => UpdateData())">Refresh Data</button>

@code {

    public IOrderRepository Repository => Service;

    public IEnumerable<Order> AllOrders { get; set; }
    public IEnumerable<Order> UnshippedOrders { get; set; }
    public IEnumerable<Order> ShippedOrders { get; set; }

    protected async override Task OnInitializedAsync() {
        await UpdateData();
    }

    public async Task UpdateData() {
        AllOrders = await Repository.Orders.ToListAsync();
        UnshippedOrders = AllOrders.Where(o => !o.Shipped);
        ShippedOrders = AllOrders.Where(o => o.Shipped);
    }

    public void ShipOrder(int id) => UpdateOrder(id, true);
    public void ResetOrder(int id) => UpdateOrder(id, false);

    private void UpdateOrder(int id, bool shipValue) {
        Order o = Repository.Orders.FirstOrDefault(o => o.OrderID == id);
        o.Shipped = shipValue;
        Repository.SaveOrder(o);
    }
}

Listing 10-11.The Revised Contents of the Orders.razor File in the SportsStore/Pages/Admin Folder

Blazor 组件不同于用于 SportsStore 应用面向用户部分的其他应用框架构建块。与处理单个请求不同,组件可以是长寿的,可以在更长的时间内处理多个用户交互。这需要不同的开发风格,尤其是在使用实体框架核心处理数据时。@inherits表达式确保该组件获得它自己的存储库对象,这确保它的操作与显示给同一用户的其他组件所执行的操作相分离。为了避免重复查询数据库——正如我在第 4 部分中解释的那样,这可能是 Blazor 中的一个严重问题——只有在组件初始化、Blazor 调用OnInitializedAsync方法或用户单击刷新数据按钮时才使用存储库。

为了向用户显示数据,使用了OrderTable组件,它作为 HTML 元素应用,如下所示:

...
<OrderTable TableTitle="Unshipped Orders"
        Orders="UnshippedOrders" ButtonLabel="Ship" OrderSelected="ShipOrder" />
...

分配给OrderTable元素属性的值用于设置清单 10-10 中用Parameter属性修饰的属性。这样,单个组件可以被配置为呈现两组不同的数据,而不需要复制代码和内容。

ShipOrderResetOrder方法被用作OrderSelected属性的值,这意味着当用户单击OrderTable组件提供的按钮之一时,它们被调用,通过存储库更新数据库中的数据。

要查看新功能,请重启 ASP.NET Core,请求http://localhost:5000,并创建一个订单。一旦您在数据库中至少有一个订单,请求http://localhost:5000/admin/orders,您将看到您创建的订单摘要显示在未发货订单表中。点击发货按钮,订单将被更新并移动到发货订单表,如图 10-3 所示。

img/338050_8_En_10_Fig3_HTML.jpg

图 10-3。

管理订单

添加目录管理

管理更复杂的项目集合的惯例是向用户呈现两个界面:一个列表界面和一个编辑界面,如图 10-4 所示。

img/338050_8_En_10_Fig4_HTML.jpg

图 10-4。

产品目录的 CRUD 用户界面草图

总之,这些接口允许用户创建、读取、更新和删除集合中的项目。这些行为统称为积垢。在本节中,我将使用 Blazor 实现这些接口。

Tip

开发人员经常需要实现 CRUD,以至于 Visual Studio 脚手架包括创建 CRUD 控制器或 Razor 页面的场景。但是,像所有的 Visual Studio 脚手架一样,我认为最好是学习如何直接创建这些功能,这就是为什么我在后面的章节中演示所有 ASP.NET Core 应用框架的 CRUD 操作。

扩展存储库

第一步是向存储库添加特性,允许创建、修改和删除Product对象。清单 10-12IStoreRepository接口添加了新方法。

using System.Linq;

namespace SportsStore.Models {
    public interface IStoreRepository {

        IQueryable<Product> Products { get; }

        void SaveProduct(Product p);
        void CreateProduct(Product p);
        void DeleteProduct(Product p);
    }
}

Listing 10-12.Adding Methods in the IStoreRepository.cs File in the SportsStore/Models Folder

清单 10-13 将这些方法的实现添加到实体框架核心存储库类中。

using System.Linq;

namespace SportsStore.Models {
    public class EFStoreRepository : IStoreRepository {
        private StoreDbContext context;

        public EFStoreRepository(StoreDbContext ctx) {
            context = ctx;
        }

        public IQueryable<Product> Products => context.Products;

        public void CreateProduct(Product p) {
            context.Add(p);
            context.SaveChanges();
        }

        public void DeleteProduct(Product p) {
            context.Remove(p);
            context.SaveChanges();
        }

        public void SaveProduct(Product p) {
            context.SaveChanges();
        }
    }
}

Listing 10-13.Implementing Methods in the EFStoreRepository.cs File in the SportsStore/Models Folder

将验证属性应用于数据模型

我想验证用户在编辑或创建Product对象时提供的值,就像我对客户结账过程所做的那样。在清单 10-14 中,我向Product数据模型类添加了验证属性。

using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;

namespace SportsStore.Models {

    public class Product {
        public long ProductID { get; set; }

        [Required(ErrorMessage = "Please enter a product name")]
        public string Name { get; set; }

        [Required(ErrorMessage = "Please enter a description")]
        public string Description { get; set; }

        [Required]
        [Range(0.01, double.MaxValue,
            ErrorMessage = "Please enter a positive price")]
        [Column(TypeName = "decimal(8, 2)")]
        public decimal Price { get; set; }

        [Required(ErrorMessage = "Please specify a category")]
        public string Category { get; set; }
    }
}

Listing 10-14.Adding Validation Attributes in the Product.cs File in the SportsStore/Models Folder

Blazor 使用与 ASP.NET Core 的其他部分相同的验证方法,但是,正如您将看到的,它以不同的方式处理 Razor 组件的交互性。

创建列表组件

首先,我将创建一个表格,向用户展示一个产品表格和链接,允许用户查看和编辑这些产品。用清单 10-15 中显示的内容替换Products.razor文件的内容。

@page "/admin/products"
@page "/admin"
@inherits OwningComponentBase<IStoreRepository>

<table class="table table-sm table-striped table-bordered">
    <thead>
        <tr>
            <th>ID</th><th>Name</th>
            <th>Category</th><th>Price</th><td/>
        </tr>
    </thead>
    <tbody>
        @if (ProductData?.Count() > 0) {
            @foreach (Product p in ProductData) {
                <tr>
                    <td>@p.ProductID</td>
                    <td>@p.Name</td>
                    <td>@p.Category</td>
                    <td>@p.Price.ToString("c")</td>
                    <td>
                        <NavLink class="btn btn-info btn-sm"
                                 href="@GetDetailsUrl(p.ProductID)">
                            Details
                        </NavLink>
                        <NavLink class="btn btn-warning btn-sm"
                                 href="@GetEditUrl(p.ProductID)">
                            Edit
                        </NavLink>
                    </td>
                </tr>
            }
        } else {
            <tr>
                <td colspan="5" class="text-center">No Products</td>
            </tr>
        }
    </tbody>
</table>

<NavLink class="btn btn-primary" href="/admin/products/create">Create</NavLink>

@code {

    public IStoreRepository Repository => Service;

    public IEnumerable<Product> ProductData { get; set; }

    protected async override Task OnInitializedAsync() {
        await UpdateData();
    }

    public async Task UpdateData() {
        ProductData = await Repository.Products.ToListAsync();
    }

    public string GetDetailsUrl(long id) => $"/admin/products/details/{id}";
    public string GetEditUrl(long id) => $"/admin/products/edit/{id}";
}

Listing 10-15.The Revised Contents of the Products.razor File in the SportsStore/Pages/Admin Folder

该组件将存储库中的每个Product对象呈现在带有NavLink组件的表格行中,这些组件将导航到提供详细视图和编辑器的组件。还有一个按钮可以导航到组件,该组件允许创建新的Product对象并存储在数据库中。重启 ASP.NET Core 并请求http://localhost:5000/admin/products,您将会看到如图 10-5 所示的内容,尽管Products组件所显示的按钮目前都不起作用,因为我还没有创建它们的目标组件。

img/338050_8_En_10_Fig5_HTML.jpg

图 10-5。

展示产品列表

创建详图构件

细节组件的工作是显示单个Product对象的所有字段。将名为Details.razor的 Razor 组件添加到Pages/Admin文件夹中,内容如清单 10-16 所示。

@page "/admin/products/details/{id:long}"

<h3 class="bg-info text-white text-center p-1">Details</h3>

<table class="table table-sm table-bordered table-striped">
    <tbody>
        <tr><th>ID</th><td>@Product.ProductID</td></tr>
        <tr><th>Name</th><td>@Product.Name</td></tr>
        <tr><th>Description</th><td>@Product.Description</td></tr>
        <tr><th>Category</th><td>@Product.Category</td></tr>
        <tr><th>Price</th><td>@Product.Price.ToString("C")</td></tr>
    </tbody>
</table>

<NavLink class="btn btn-warning" href="@EditUrl">Edit</NavLink>
<NavLink class="btn btn-secondary" href="/admin/products">Back</NavLink>

@code {

    [Inject]
    public IStoreRepository Repository { get; set; }

    [Parameter]
    public long Id { get; set; }

    public Product Product { get; set; }

    protected override void OnParametersSet() {
        Product = Repository.Products.FirstOrDefault(p => p.ProductID == Id);
    }

    public string EditUrl => $"/admin/products/edit/{Product.ProductID}";
}

Listing 10-16.The Contents of the Details.razor File in the SportsStore/Pages/Admin Folder

组件使用Inject属性来声明它需要一个IStoreRepository接口的实现,这是 Blazor 提供访问应用服务的方法之一。Id属性的值将由用于导航到组件的 URL 填充,该组件用于从数据库中检索Product对象。要查看细节视图,重启 ASP.NET Core,请求http://localhost:5000/admin/products,点击其中一个细节按钮,如图 10-6 所示。

img/338050_8_En_10_Fig6_HTML.jpg

图 10-6。

展示产品的详细信息

创建编辑器组件

创建和编辑数据的操作将由同一个组件处理。将名为Editor.razor的 Razor 组件添加到Pages/Admin文件夹中,内容如清单 10-17 所示。

@page "/admin/products/edit/{id:long}"
@page "/admin/products/create"
@inherits OwningComponentBase<IStoreRepository>

<style>
    div.validation-message { color: rgb(220, 53, 69); font-weight: 500 }
</style>

<h3 class="bg-@ThemeColor text-white text-center p-1">@TitleText a Product</h3>
<EditForm Model="Product" OnValidSubmit="SaveProduct">
    <DataAnnotationsValidator />
    @if(Product.ProductID != 0) {
        <div class="form-group">
            <label>ID</label>
            <input class="form-control" disabled value="@Product.ProductID" />
        </div>
    }
    <div class="form-group">
        <label>Name</label>
        <ValidationMessage For="@(() => Product.Name)" />
        <InputText class="form-control" @bind-Value="Product.Name" />
    </div>
    <div class="form-group">
        <label>Description</label>
        <ValidationMessage For="@(() => Product.Description)" />
        <InputText class="form-control" @bind-Value="Product.Description" />
    </div>
    <div class="form-group">
        <label>Category</label>
        <ValidationMessage For="@(() => Product.Category)" />
        <InputText class="form-control" @bind-Value="Product.Category" />
    </div>
    <div class="form-group">
        <label>Price</label>
        <ValidationMessage For="@(() => Product.Price)" />
        <InputNumber class="form-control" @bind-Value="Product.Price" />
    </div>
    <button type="submit" class="btn btn-@ThemeColor">Save</button>
    <NavLink class="btn btn-secondary" href="/admin/products">Cancel</NavLink>
</EditForm>

@code {

    public IStoreRepository Repository => Service;

    [Inject]
    public NavigationManager NavManager { get; set; }

    [Parameter]
    public long Id { get; set; } = 0;

    public Product Product { get; set; } = new Product();

    protected override void OnParametersSet() {
        if (Id != 0) {
            Product = Repository.Products.FirstOrDefault(p => p.ProductID == Id);
        }
    }

    public void SaveProduct() {
        if (Id == 0) {
            Repository.CreateProduct(Product);
        } else {
            Repository.SaveProduct(Product);
        }
        NavManager.NavigateTo("/admin/products");
    }

    public string ThemeColor => Id == 0 ? "primary" : "warning";
    public string TitleText => Id == 0 ? "Create" : "Edit";
}

Listing 10-17.The Contents of the Editor.razor File in the SportsStore/Pages/Admin Folder

Blazor 提供了一组用于显示和验证表单的内置 Razor 组件,这很重要,因为浏览器不能在 Blazor 组件中使用POST请求提交数据。EditForm组件用于呈现 Blazor 友好的表单,InputTextInputNumber组件呈现接受字符串和数字值的input元素,当用户做出更改时,这些元素会自动更新模型属性。

数据验证被集成到这些内置组件中,EditForm组件上的OnValidSubmit属性用于指定一个方法,只有当输入到表单中的数据符合验证属性定义的规则时,才会调用该方法。

Blazor 还提供了NavigationManager类,用于以编程方式在组件之间导航,而不会触发新的 HTTP 请求。在数据库更新后,Editor组件使用作为服务获得的NavigationManager返回到Products组件。

要查看编辑器,重启 ASP.NET Core,请求http://localhost:5000/admin,并点击创建按钮。在没有填写表单字段的情况下点击保存按钮,你会看到 Blazor 自动产生的验证错误,如图 10-7 所示。填写表格,再次点击保存,您将看到您创建的产品显示在表格中,也如图 10-7 所示。

img/338050_8_En_10_Fig7_HTML.jpg

图 10-7。

使用编辑器组件

单击其中一个产品的编辑按钮,相同的组件将用于编辑所选Product对象的属性。点击保存按钮,您所做的任何更改——如果通过验证——将被保存在数据库中,如图 10-8 所示。

img/338050_8_En_10_Fig8_HTML.jpg

图 10-8。

编辑产品

删除产品

最后一个 CRUD 特性是删除产品,这很容易在Products组件中实现,如清单 10-18 所示。

@page "/admin/products"
@page "/admin"
@inherits OwningComponentBase<IStoreRepository>

<table class="table table-sm table-striped table-bordered">
    <thead>
        <tr>
            <th>ID</th><th>Name</th>
            <th>Category</th><th>Price</th><td/>
        </tr>
    </thead>
    <tbody>
        @if (ProductData?.Count() > 0) {
            @foreach (Product p in ProductData) {
                <tr>
                    <td>@p.ProductID</td>
                    <td>@p.Name</td>
                    <td>@p.Category</td>
                    <td>@p.Price.ToString("c")</td>
                    <td>
                        <NavLink class="btn btn-info btn-sm"
                                 href="@GetDetailsUrl(p.ProductID)">
                            Details
                        </NavLink>
                        <NavLink class="btn btn-warning btn-sm"
                                 href="@GetEditUrl(p.ProductID)">
                            Edit
                        </NavLink>
                        <button class="btn btn-danger btn-sm"
                                 @onclick="@(e => DeleteProduct(p))">
                            Delete
                        </button>
                    </td>
                </tr>
            }
        } else {
            <tr>
                <td colspan="5" class="text-center">No Products</td>
            </tr>
        }
    </tbody>
</table>

<NavLink class="btn btn-primary" href="/admin/products/create">Create</NavLink>

@code {

    public IStoreRepository Repository => Service;

    public IEnumerable<Product> ProductData { get; set; }

    protected async override Task OnInitializedAsync() {
        await UpdateData();
    }

    public async Task UpdateData() {
        ProductData = await Repository.Products.ToListAsync();
    }

    public async Task DeleteProduct(Product p) {
        Repository.DeleteProduct(p);
        await UpdateData();
    }

    public string GetDetailsUrl(long id) => $"/admin/products/details/{id}";
    public string GetEditUrl(long id) => $"/admin/products/edit/{id}";
}

Listing 10-18.Adding Delete Support in the Products.razor File in the SportsStore/Pages/Admin Folder

新的button元素配置有@onclick属性,该属性调用DeleteProduct方法。选中的Product对象从数据库中删除,组件显示的数据被更新。重启 ASP.NET Core,请求http://localhost:5000/admin/products,点击删除按钮从数据库中删除一个对象,如图 10-9 所示。

img/338050_8_En_10_Fig9_HTML.jpg

图 10-9。

从数据库中删除对象

摘要

在本章中,我介绍了管理功能,并向您展示了如何使用 Blazor Server 来实现 CRUD 操作,这些操作允许管理员从存储库中创建、读取、更新和删除产品,并将订单标记为已发货。在下一章中,我将向您展示如何保护管理功能,使它们对所有用户都不可用,并且我将准备将 SportsStore 应用部署到生产环境中。