WPF-Books图书管理系统

前言

本手册旨在指导读者一步一步使用 C# 和 WPF 技术,并结合 MVVM (Model-View-ViewModel) 设计模式,构建一个功能完善的图书管理系统。通过本实训,您将不仅掌握 MVVM 模式的核心思想和实践方法,还能深入了解 WPF 数据绑定、命令、用户控件等关键技术,并学会如何运用 SQLite 进行本地数据存储。

本教程力求详尽,包含详细的代码解析和编程步骤,旨在帮助学生和开发者轻松重现并理解整个项目的构建过程。

##登录界面

主界面

章节一:项目介绍与环境搭建

1.1 项目概述

本实训项目——图书管理系统,将涵盖以下核心功能:

图书管理:支持添加新图书、浏览图书列表、编辑图书信息以及删除图书。
库存管理:能够记录图书的库存数量,并在录入相同 ISBN 的图书时自动累加数量。
借阅管理:实现用户借阅图书的功能,系统将记录借阅者的信息和借阅日期。支持多用户借阅同一本书的不同副本(前提是库存充足),借阅者信息将以逗号分隔的形式展示。
归还管理:用户可以方便地归还已借阅的图书。
用户管理:提供基础的用户登录及用户切换功能。
图书搜索:用户可以根据书名、作者或 ISBN 快速检索图书。

通过完成此项目,您将能够:

深刻理解 MVVM 设计模式的原理及其在实际项目中的应用。
熟练掌握 WPF 中的数据绑定、命令(Commanding)、通知机制(INotifyPropertyChanged)等核心概念。
学会使用 SQLite 作为轻量级本地数据库,并进行数据持久化操作。
提升 C# 编程能力和 WPF 应用开发技能。

1.2 技术选型

为了构建这个图书管理系统,我们选择以下技术栈:

编程语言:C# (我们将利用其强大的面向对象特性和 .NET 生态系统)
用户界面框架:WPF (Windows Presentation Foundation) – 用于创建具有丰富用户体验的桌面应用程序。
设计模式:MVVM (Model-View-ViewModel) – 旨在分离用户界面(View)与业务逻辑和数据(Model),提高代码的可测试性、可维护性和可重用性。
数据库:SQLite – 一个轻量级的、基于文件的关系型数据库,非常适合桌面应用程序的本地数据存储。
.NET 版本:本项目推荐使用 .NET Framework 4.7.2 或更高版本,或者 .NET Core 3.1 / .NET 5/6/7/8 及以上版本。请确保您的开发环境满足此要求。

1.3 开发环境准备

在开始项目之前,请确保您的开发环境中已安装以下软件:

Visual Studio: 推荐使用 Visual Studio 2019 或更高版本(Community, Professional, or Enterprise Edition均可)。请在安装时确保勾选了 “.NET桌面开发” 工作负载,这将包含 WPF 和 C# 开发所需的工具和 SDK。

下载地址:https://visualstudio.microsoft.com/zh-hans/downloads/

.NET SDK: 根据您选择的 .NET 版本(Framework 或 Core/5+),确保已安装相应的 SDK。

.NET Framework 通常随 Visual Studio 一同安装。
.NET Core/5+ SDK 下载地址:https://dotnet.microsoft.com/download

SQLite 支持: 为了在项目中使用 SQLite 数据库,我们需要相应的 NuGet 包。我们将在后续步骤中通过 NuGet 包管理器来安装它们,例如 Microsoft.EntityFrameworkCore.Sqlite

环境验证

打开 Visual Studio,尝试创建一个新的 “WPF 应用程序” 项目,如果能够成功创建并运行一个空白窗口,则表明您的 WPF 开发环境已基本就绪。

1.4 创建项目与基本结构

现在,让我们开始创建我们的图书管理系统项目。

启动 Visual Studio
在欢迎界面选择 “创建新项目”
在“创建新项目”对话框中,从模板列表中选择 “WPF 应用程序” (如果您使用的是 .NET Core/5+,则选择 “WPF Application”)。您可以使用顶部的搜索框输入 “WPF” 来快速筛选。

确保选择的是 C# 语言的 WPF 模板。

点击 “下一步”
配置新项目

项目名称:输入 WPF-Books (或者您喜欢的其他名称)。
位置:选择一个合适的文件夹来存放您的项目(例如 d:0Learning5C#)。
解决方案名称:可以保持与项目名称一致,或者自定义。
框架:根据您的环境选择合适的 .NET Framework 版本或 .NET Core/5+ 版本。

点击 “创建”

Visual Studio 将会生成一个包含基本 WPF 应用程序结构的新项目。

推荐的项目文件夹结构

为了更好地组织代码和资源,我们建议在项目中创建以下文件夹结构。这有助于保持项目的清晰度和可维护性,尤其是在项目规模逐渐增大时。

WPF-Books/
├── App.xaml                # 应用程序定义文件,程序入口点
├── App.xaml.cs             # App.xaml 的后台代码
├── MainWindow.xaml         # 默认创建的主窗口 (我们稍后会重命名或替换为 BookView.xaml)
├── MainWindow.xaml.cs      # MainWindow.xaml 的后台代码
├── Models/                 # 存放数据模型类 (例如:Book.cs, User.cs)
├── ViewModels/             # 存放视图模型类 (例如:BookViewModel.cs, LoginViewModel.cs)
├── Views/                  # 存放视图 XAML 文件 (例如:BookView.xaml, LoginView.xaml, AddBookView.xaml)
├── Converters/             # (可选) 存放值转换器类 (例如:BooleanToVisibilityConverter.cs)
├── DataAccess/             # (可选, 如果数据库访问逻辑复杂) 存放数据库上下文和访问类
├── Resources/              # (可选) 存放共享资源,如样式、模板、图片等
├── WPF-Books.csproj        # 项目文件
└── WPF-Books.sln           # 解决方案文件

创建文件夹步骤

在 Visual Studio 的 “解决方案资源管理器” 中,右键点击 WPF-Books 项目。
选择 “添加” -> “新建文件夹”
依次创建 Models, ViewModels, Views, Converters 等文件夹。

在接下来的章节中,我们将逐步填充这些文件夹中的内容,构建起我们图书管理系统的各个模块。

章节二:MVVM 模式基础

2.1 什么是 MVVM?

MVVM (Model-View-ViewModel) 是一种专门为用户界面(UI)应用程序设计的软件架构模式。它是经典 MVC (Model-View-Controller) 和 MVP (Model-View-Presenter) 模式的演进,特别适用于像 WPF 和 UWP 这样的现代 UI 框架,因为这些框架通常提供了强大的数据绑定功能。

MVVM 模式的核心思想是将应用程序的三个主要部分进行解耦:

Model (模型):代表应用程序的数据和业务逻辑。它不关心数据如何显示或如何与用户交互。在我们的图书管理系统中,Book.csUser.cs 就是模型的例子,它们定义了图书和用户的属性。
View (视图):负责用户界面的展示和用户输入。在 WPF 中,View 通常由 XAML 文件定义(例如 BookView.xaml, LoginView.xaml)。View 的主要职责是显示 ViewModel 提供的数据,并将用户的操作(如点击按钮、输入文本)通知给 ViewModel。
ViewModel (视图模型):作为 View 和 Model 之间的桥梁。它从 Model 中获取数据,并将其转换为 View 可以直接绑定的格式(例如,将日期格式化为字符串,或者提供一个布尔值来控制某个控件的可见性)。ViewModel 还包含 View 的状态和行为逻辑,例如处理用户命令、执行数据验证等。ViewModel 不直接引用 View,而是通过数据绑定和命令与 View 通信。

图示 MVVM 结构:

+-----------------+      Data Binding      +-----------------+
|      View       | <--------------------> |    ViewModel    |
| (XAML: Buttons, | (Properties,          | (C#: Logic,      |
|  Lists, Text)   |  Commands)            |  State, Commands)|
+-----------------+                        +-----------------+
        |                                        |
        | User Actions (e.g., Click)             | Interacts with
        +----------------------------------------+ Model
                                                 |
                                                 v
                                         +-----------------+
                                         |      Model      |
                                         | (C#: Data,      |
                                         |  Business Logic)|
                                         +-----------------+

2.2 MVVM 的核心组件与通信

让我们更详细地了解 MVVM 的每个组件以及它们如何协同工作:

Model (模型)

职责:包含应用程序的数据(例如,图书列表、用户信息)和业务规则(例如,如何计算价格、验证数据有效性)。
特点:通常是普通的 C# 类 (POCO – Plain Old CLR Object)。它不依赖于 View 或 ViewModel。
示例:在我们的项目中,Book.cs 类定义了图书的属性(如标题、作者、ISBN),User.cs 类定义了用户的属性(如用户名、密码)。

View (视图)

职责:定义用户界面的结构、布局和外观。它负责向用户显示数据,并捕获用户的输入(如鼠标点击、键盘输入)。
特点:在 WPF 中,通常使用 XAML 声明式地定义 View。View 应该尽量保持“哑的 (dumb)”,即不包含复杂的逻辑。它通过数据绑定从 ViewModel 获取数据,并通过命令将用户操作传递给 ViewModel。
示例BookView.xaml 将用于显示图书列表和相关操作按钮;LoginView.xaml 将用于用户登录。

ViewModel (视图模型)

职责:这是 MVVM 模式的核心。它充当 View 和 Model 之间的中介。

暴露数据:ViewModel 从 Model 中获取数据,并将其转换为 View 可以轻松显示的格式。这可能涉及到数据转换、格式化或聚合。这些数据通常以公共属性的形式暴露给 View 进行绑定。
处理行为:ViewModel 暴露命令 (Commands),View 可以将用户的操作(如点击按钮)绑定到这些命令上。当命令被执行时,ViewModel 会执行相应的业务逻辑,可能会更新 Model 或自身的其他属性。
状态管理:ViewModel 维护 View 的状态(例如,当前选中的项、某个控件是否启用等)。
与 Model 交互:ViewModel 根据需要与 Model 交互,以读取或修改数据。

特点:ViewModel 不直接引用 View。它通过属性更改通知 (INotifyPropertyChanged) 来告知 View 数据的变化,View 则通过数据绑定自动更新。它通常包含对 Model 的引用或通过服务来访问 Model。
示例BookViewModel.cs 将包含图书列表 (ObservableCollection<Book>)、用于添加/删除/借阅/归还图书的命令,以及处理这些操作的逻辑。LoginViewModel.cs 将处理用户登录逻辑。

通信机制:

数据绑定 (Data Binding):这是 WPF 的一项核心功能,也是 MVVM 实现的关键。View 的元素(如 TextBoxText 属性,ListBoxItemsSource 属性)可以绑定到 ViewModel 的公共属性。当 ViewModel 的属性值发生变化时,如果 ViewModel 实现了 INotifyPropertyChanged 接口并正确触发了 PropertyChanged 事件,绑定的 View 元素会自动更新。反之,如果绑定是双向的 (TwoWay),View 中用户输入导致的变化也会更新到 ViewModel 的属性。

命令 (Commands):WPF 中的命令机制 (ICommand接口) 允许将用户界面操作(如按钮点击)与 ViewModel 中的方法解耦。View 将控件的 Command 属性绑定到 ViewModel 中实现了 ICommand 接口的属性。ViewModel 中的命令对象封装了要执行的逻辑 (Execute 方法) 以及该逻辑是否可以执行的条件 (CanExecute 方法)。这使得 ViewModel 可以控制 UI 元素的启用/禁用状态,而无需直接操作 View。

属性更改通知 (INotifyPropertyChanged):为了使数据绑定能够响应 ViewModel 中数据的变化,ViewModel 类通常需要实现 INotifyPropertyChanged 接口。该接口只有一个事件:PropertyChanged。当 ViewModel 的某个属性值发生变化时,它会触发此事件,并传递发生变化的属性的名称。监听此事件的 View(通过数据绑定机制)随后会更新其显示。

2.3 MVVM 的优势

采用 MVVM 模式可以带来诸多好处:

可测试性 (Testability):由于 ViewModel 不依赖于具体的 UI 元素 (View),因此可以轻松地对其进行单元测试。我们可以实例化 ViewModel,设置其属性,调用其命令,并验证其行为和状态,而无需创建和操作 UI。
已关注点分离 (Separation of Concerns):MVVM 将 UI (View)、UI 逻辑和状态 (ViewModel) 以及业务逻辑和数据 (Model) 清晰地分离开来。每个部分都有明确的职责,降低了代码的复杂性。
可维护性 (Maintainability):代码结构清晰,职责分明,使得修改和维护变得更加容易。例如,修改 UI 布局 (View) 通常不会影响 ViewModel 的逻辑,反之亦然。
可重用性 (Reusability):ViewModel 可以在不同的 View 中重用(如果它们需要展示相似的数据和行为)。Model 也可以在应用程序的不同部分甚至不同应用程序中重用。
并行开发 (Parallel Development):UI 设计师可以专注于 View (XAML) 的设计和实现,而开发人员可以专注于 ViewModel 和 Model 的逻辑开发,两者可以并行进行,提高了开发效率。
设计时数据 (Design-Time Data):WPF 设计器可以更好地支持 MVVM。通过在 ViewModel 中提供设计时数据,UI 设计师可以在不运行应用程序的情况下,在设计器中看到界面的大致外观和数据填充效果。

2.4 MVVM 的劣势与注意事项

尽管 MVVM 带来了很多好处,但在某些情况下也可能存在一些挑战:

学习曲线:对于初学者来说,理解数据绑定、命令、属性通知等概念可能需要一些时间。
代码量增加:为了实现 ViewModel 和必要的通知机制,可能会引入一些额外的代码(“胶水代码”)。
简单 UI 的过度设计:对于非常简单的 UI 或一次性的小工具,严格遵循 MVVM 可能显得有些过度设计。
调试数据绑定问题:有时数据绑定不按预期工作,调试起来可能比直接操作 UI 元素更复杂一些。

在我们的图书管理系统中,MVVM 模式的优势将远大于其潜在的复杂性,它将帮助我们构建一个结构良好、易于扩展和维护的应用程序。

章节三:模型 (Model) 的设计与实现

模型(Model)是 MVVM 模式中的核心组成部分之一,它代表了应用程序的数据结构和业务逻辑。在我们的图书管理系统中,我们需要定义清晰的数据模型来表示图书和用户信息。

3.1 定义图书模型 (Book.cs)

图书是本系统的核心实体。我们需要定义一个 Book 类来描述一本图书的各种属性。

解决方案资源管理器 中,右键点击 Models 文件夹。

选择 “添加” -> “类…”

将类命名为 Book.cs,然后点击 “添加”

编辑 Book.cs 文件,添加以下属性:

using System;
using System.ComponentModel.DataAnnotations; // 用于数据注解,例如 [Key]

namespace WPF_Books.Models
{

public class Book
{

[Key] // 将 Id 属性标记为主键,Entity Framework Core 会将其识别为数据库表的主键
public int Id { get; set; }

     [Required(ErrorMessage = "书名是必填项")] // 标记为必填项,并提供错误消息
     [StringLength(200, ErrorMessage = "书名长度不能超过200个字符")]
     public string Title { get; set; } = string.Empty;

     [Required(ErrorMessage = "作者是必填项")]
     [StringLength(100, ErrorMessage = "作者长度不能超过100个字符")]
     public string Author { get; set; } = string.Empty;

     [Required(ErrorMessage = "ISBN是必填项")]
     [StringLength(20, ErrorMessage = "ISBN长度不能超过20个字符")] // 例如:978-7-111-12579-1
     public string ISBN { get; set; } = string.Empty;

     [Range(0, int.MaxValue, ErrorMessage = "数量不能为负数")]
     public int Quantity { get; set; } // 库存数量

     public DateTime? PublishDate { get; set; } // 出版日期,可以为空

     // 借阅信息
     public string? Borrower { get; set; } // 借阅者用户名,可以为空,多个借阅者用逗号分隔
     public DateTime? BorrowDate { get; set; } // 借阅日期,可以为空

     public Book()
     {
         // 可以在构造函数中设置一些默认值,如果需要的话
         // Title = "未知书名";
         // Author = "未知作者";
         // ISBN = "000-0-000-00000-0";
     }
 }

}

代码解析

using System.ComponentModel.DataAnnotations;: 引入此命名空间是为了使用数据注解,如 [Key][Required]。这些注解不仅可以用于数据验证,Entity Framework Core 也会利用它们来配置数据库表结构。
public class Book: 定义了一个公共类 Book
[Key] public int Id { get; set; }: Id 属性是图书的唯一标识符。[Key] 注解告诉 Entity Framework Core 这个属性是数据库表的主键,并且通常会自动增长。
Title, Author, ISBN: 分别表示书名、作者和国际标准书号。它们都被声明为字符串类型,并初始化为 string.Empty 以避免空引用异常。

[Required(ErrorMessage = "...")]: 表明这些字段是必填的。如果用户未提供值,ErrorMessage 将用于数据验证提示。
[StringLength(maxLength, ErrorMessage = "...")]: 限制了字符串的最大长度。

Quantity: 表示图书的库存数量,为整数类型。[Range(0, int.MaxValue, ...)] 确保数量不会是负数。
PublishDate: 图书的出版日期,类型为 DateTime? (可空 DateTime),因为出版日期可能未知。
Borrower: 记录借阅者的用户名。类型为 string? (可空字符串),因为图书可能未被借出。如果允许多人借阅同一本书的不同副本,这里可以考虑存储一个借阅者列表或者用特定分隔符(如逗号)分隔的字符串。
BorrowDate: 记录借阅日期,类型为 DateTime?
构造函数 public Book(): 提供了一个默认的无参构造函数。在这里可以为属性设置初始默认值,但在这个例子中我们主要依赖属性初始化器。

3.2 定义用户模型 (User.cs)

接下来,我们需要一个用户模型来管理用户信息,主要用于登录和记录借阅者。

解决方案资源管理器 中,右键点击 Models 文件夹。

选择 “添加” -> “类…”

将类命名为 User.cs,然后点击 “添加”

编辑 User.cs 文件,添加以下属性:

using System.ComponentModel.DataAnnotations;

namespace WPF_Books.Models
{

public class User
{

[Key]
public int Id { get; set; }

     [Required(ErrorMessage = "用户名是必填项")]
     [StringLength(50, MinimumLength = 3, ErrorMessage = "用户名长度必须在3到50个字符之间")]
     public string Username { get; set; } = string.Empty;

     // 密码通常不直接存储明文,而是存储哈希值。这里为了简化,我们存储字符串。
     // 在实际应用中,务必使用安全的密码哈希和加盐机制。
     [Required(ErrorMessage = "密码是必填项")]
     [StringLength(100, MinimumLength = 6, ErrorMessage = "密码长度必须至少为6个字符")]
     public string PasswordHash { get; set; } = string.Empty; // 存储密码的哈希值

     // 可以添加其他用户属性,例如:
     // public string Email { get; set; }
     // public DateTime RegistrationDate { get; set; }
     // public UserRole Role { get; set; } // 例如:管理员,普通用户
 }

 // public enum UserRole
 // {
 //     Admin,
 //     Member
 // }

}

代码解析

Id: 用户唯一标识符,主键。
Username: 用户名,字符串类型,必填,并有长度限制。
PasswordHash: 用于存储用户密码的哈希值。非常重要:在实际生产应用中,绝不能存储明文密码。应该使用安全的哈希算法(如 Argon2, bcrypt, scrypt 或 PBKDF2)结合盐(salt)来存储密码的哈希值。本示例中为了简化,我们将其声明为字符串,但实际项目中需要实现密码哈希逻辑。
注释中提到了可以添加的其他用户属性,如 Email, RegistrationDate, Role 等,可以根据实际需求进行扩展。

3.3 数据持久化方案:SQLite 与 Entity Framework Core

为了存储我们的图书和用户信息,我们需要一个数据持久化机制。SQLite 是一个轻量级的、基于文件的数据库,非常适合桌面应用程序,因为它不需要单独的数据库服务器进程,数据库本身就是一个文件。

Entity Framework Core (EF Core) 是一个现代的对象关系映射器 (ORM),它使得 .NET 开发者可以使用 .NET 对象来处理数据库,而无需编写大量的 SQL 数据访问代码。EF Core 支持多种数据库,包括 SQLite。

安装必要的 NuGet 包

我们需要通过 NuGet 包管理器安装以下包到我们的 WPF-Books 项目中:

Microsoft.EntityFrameworkCore.Sqlite: EF Core 的 SQLite 数据库提供程序。
Microsoft.EntityFrameworkCore.Tools: EF Core 的命令行工具,用于数据库迁移等操作(主要在开发时使用)。

安装步骤

通过 NuGet 包管理器 UI:

解决方案资源管理器 中,右键点击 WPF-Books 项目或 “依赖项” 节点。
选择 “管理 NuGet 程序包…”
在打开的 NuGet 包管理器窗口中,切换到 “浏览” 选项卡。
搜索 Microsoft.EntityFrameworkCore.Sqlite,选择它,然后点击 “安装”
同样地,搜索并安装 Microsoft.EntityFrameworkCore.Tools

通过包管理器控制台:

打开 “工具” -> “NuGet 包管理器” -> “包管理器控制台”

在控制台中,确保 “默认项目” 下拉列表选择了 WPF-Books

执行以下命令:

Install-Package Microsoft.EntityFrameworkCore.Sqlite
Install-Package Microsoft.EntityFrameworkCore.Tools

3.4 创建数据库上下文 (AppDbContext.cs)

数据库上下文 (DbContext) 是 EF Core 与数据库交互的主要入口点。它代表了与数据库的一个会话,并允许我们查询和保存数据。我们需要创建一个继承自 DbContext 的类。

(可选,但推荐) 在项目中创建一个名为 DataAccess 的文件夹,用于存放与数据访问相关的类。

右键点击 WPF-Books 项目 -> “添加” -> “新建文件夹”,命名为 DataAccess

DataAccess (或项目根目录,如果未创建该文件夹) 文件夹下,添加一个新类 AppDbContext.cs

using Microsoft.EntityFrameworkCore;
using WPF_Books.Models; // 引入模型命名空间
using System.IO; // 用于 Path.Combine
using System; // 用于 Environment

namespace WPF_Books.DataAccess
{

public class AppDbContext : DbContext
{

public DbSet Books { get; set; }
public DbSet Users { get; set; }

     public string DbPath { get; }

     public AppDbContext()
     {
         // 获取应用程序的本地数据文件夹路径
         // Environment.SpecialFolder.LocalApplicationData 为当前用户的应用程序数据文件夹
         // string folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
         // DbPath = Path.Combine(folder, "library.db");

         // 为了简单起见,我们将数据库文件放在应用程序的执行目录下
         // 这种方式在开发和单用户场景下比较方便,但部署时可能需要考虑更合适的路径
         string basePath = AppDomain.CurrentDomain.BaseDirectory;
         DbPath = Path.Combine(basePath, "library.db");
     }

     // OnConfiguring 方法用于配置数据库连接和其他选项
     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
     {
         // 使用 SQLite 数据库,并指定连接字符串(即数据库文件的路径)
         optionsBuilder.UseSqlite($"Data Source={DbPath}");
     }

     // OnModelCreating 方法用于进一步配置模型和它们如何映射到数据库
     protected override void OnModelCreating(ModelBuilder modelBuilder)
     { 
         base.OnModelCreating(modelBuilder);

         // 可以在这里添加更复杂的模型配置,例如:
         // - 配置复合键
         // - 配置索引
         // - 配置表名或列名
         // - 设置数据种子 (Seed Data)

         // 示例:确保 ISBN 是唯一的 (如果需要)
         // modelBuilder.Entity<Book>()
         //     .HasIndex(b => b.ISBN)
         //     .IsUnique();

         // 示例:为 User 表的 Username 设置唯一索引
         modelBuilder.Entity<User>()
             .HasIndex(u => u.Username)
             .IsUnique();

         // 种子数据:在数据库创建时添加一些初始数据
         modelBuilder.Entity<User>().HasData(
             new User { Id = 1, Username = "admin", PasswordHash = "adminpass" }, // 实际项目中密码应为哈希值
             new User { Id = 2, Username = "user1", PasswordHash = "user1pass" }
         );

         modelBuilder.Entity<Book>().HasData(
             new Book { Id = 1, Title = "C# 从入门到实践", Author = "张三", ISBN = "978-7-121-00001-1", Quantity = 5, PublishDate = new DateTime(2023, 1, 15) },
             new Book { Id = 2, Title = "WPF 编程宝典", Author = "李四", ISBN = "978-7-121-00002-2", Quantity = 3, PublishDate = new DateTime(2022, 5, 20) },
             new Book { Id = 3, Title = "算法导论", Author = "Thomas H. Cormen", ISBN = "978-7-111-12806-9", Quantity = 2, Borrower = "admin", BorrowDate = DateTime.Now.AddDays(-10) },
             new Book { Id = 4, Title = "设计模式之禅", Author = "秦小波", ISBN = "978-7-111-2 设计模式", Quantity = 0, Borrower = "user1", BorrowDate = DateTime.Now.AddDays(-5) } // 假设这本书被借完了
         );
     }
 }

}

代码解析

public DbSet<Book> Books { get; set; }: DbSet<TEntity> 属性代表了数据库中的一个表。EF Core 会将 Books 属性映射到数据库中的 Books 表(表名默认与属性名相同)。
public DbSet<User> Users { get; set; }: 类似地,这代表了 Users 表。
DbPath: 一个只读属性,用于存储数据库文件的完整路径。
AppDbContext() 构造函数:

确定数据库文件的路径。这里我们选择将 library.db 文件放在应用程序的基目录(执行文件所在的目录)下。AppDomain.CurrentDomain.BaseDirectory 获取此路径。
Path.Combine 用于安全地组合路径字符串。

OnConfiguring(DbContextOptionsBuilder optionsBuilder):

这个方法在 DbContext 实例被创建时调用,用于配置数据库连接等。
optionsBuilder.UseSqlite($"Data Source={DbPath}"): 配置 EF Core 使用 SQLite,并将连接字符串设置为指向我们的 library.db 文件。

OnModelCreating(ModelBuilder modelBuilder):

此方法在 DbContext 首次初始化模型时调用,允许我们使用 Fluent API 进一步配置模型。这对于数据注解无法表达的复杂配置非常有用。
modelBuilder.Entity<User>().HasIndex(u => u.Username).IsUnique();: 配置 User 表的 Username 列具有唯一索引,确保用户名不重复。
modelBuilder.Entity<User>().HasData(...)modelBuilder.Entity<Book>().HasData(...): 这是 EF Core 的数据播种 (Data Seeding) 功能。它允许我们在数据库创建或迁移时,自动向表中插入初始数据。这对于提供一些默认用户、测试数据或基础配置非常有用。

注意:种子数据中的密码 adminpassuser1pass 是明文,仅用于演示。在实际应用中,应存储安全的哈希值。

3.5 数据库初始化与迁移 (Migrations)

EF Core 使用“迁移 (Migrations)”来管理数据库模式的演变。当我们更改了模型(例如,添加新属性、修改数据类型、添加新实体)后,我们可以创建一个新的迁移,EF Core 会生成相应的代码来更新数据库模式以匹配模型。

首次创建数据库和模式

打开包管理器控制台:在 Visual Studio 中,选择 “工具” -> “NuGet 包管理器” -> “包管理器控制台”

添加迁移:在包管理器控制台中,确保默认项目是 WPF-Books,然后运行以下命令来创建第一个迁移。我们将迁移命名为 InitialCreate

Add-Migration InitialCreate

执行此命令后,EF Core Tools 会检查您的 AppDbContext 和模型类,并生成一个新的迁移文件。这个文件通常位于项目中自动创建的 Migrations 文件夹下,其中包含了创建数据库表和应用种子数据的 C# 代码。

应用迁移到数据库:运行以下命令将迁移应用到数据库。如果数据库文件 (library.db) 尚不存在,EF Core 会创建它,并根据迁移文件中的指令创建表结构和插入种子数据。

Update-Database

执行完毕后,您应该能在项目的输出目录(例如 binDebug
etX.X
)下找到 library.db 文件。您可以使用 SQLite 浏览器(如 DB Browser for SQLite)打开它,查看表结构和种子数据是否已正确创建。

后续模型更改和迁移

如果将来您修改了 Book.csUser.cs 模型(例如,添加了一个新属性),您需要重复以下步骤:

添加新的迁移

Add-Migration GiveYourMigrationAName (例如:AddBookPublisher)

更新数据库

Update-Database

通过这种方式,EF Core 迁移可以帮助我们以受控和版本化的方式管理数据库模式的变更。

至此,我们已经完成了数据模型的设计、数据库上下文的创建以及数据库的初始化。在下一章节中,我们将开始设计和实现 ViewModel。

章节四:ViewModel 设计与实现

ViewModel 是 MVVM 模式的核心,它充当 View 和 Model 之间的桥梁。在本章节中,我们将设计并实现应用程序所需的 ViewModel,包括一个基础的 ObservableObject 类用于属性变更通知,一个 RelayCommand 类用于处理用户操作,以及具体的 LoginViewModelBookViewModel

4.1 实现 ObservableObject (属性变更通知基类)

为了让数据绑定能够正确工作,当 ViewModel 中的属性发生变化时,需要通知 View 进行更新。这通常通过实现 INotifyPropertyChanged 接口来完成。我们可以创建一个基类 ObservableObject 来封装这个逻辑,使得其他 ViewModel 可以继承它。

解决方案资源管理器 中,右键点击 ViewModels 文件夹。

选择 “添加” -> “类…”

将类命名为 ObservableObject.cs,然后点击 “添加”

编辑 ObservableObject.cs 文件,实现 INotifyPropertyChanged 接口:

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WPF_Books.ViewModels
{

public class ObservableObject : INotifyPropertyChanged
{

public event PropertyChangedEventHandler? PropertyChanged;

     protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
     {
         PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
     }

     protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
     {
         if (EqualityComparer<T>.Default.Equals(field, value)) return false;
         field = value;
         OnPropertyChanged(propertyName);
         return true;
     }
 }

}

代码解析

using System.ComponentModel;: 引入 INotifyPropertyChanged 接口所在的命名空间。
using System.Runtime.CompilerServices;: 引入 CallerMemberNameAttribute 特性所在的命名空间。
public class ObservableObject : INotifyPropertyChanged: ObservableObject 类实现了 INotifyPropertyChanged 接口。
public event PropertyChangedEventHandler? PropertyChanged;: 这是 INotifyPropertyChanged 接口要求的事件。当属性值改变时,将触发此事件。
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null):

这是一个受保护的虚方法,用于触发 PropertyChanged 事件。
[CallerMemberName] 特性:这是一个非常方便的特性。如果调用 OnPropertyChanged 方法时没有提供 propertyName 参数,编译器会自动将调用该方法的属性或方法的名称作为参数传入。例如,在一个名为 Username 的属性的 setter 中调用 OnPropertyChanged()propertyName 将自动设为 "Username"

protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null):

这是一个辅助方法,用于简化属性的 setter 逻辑。
它首先检查新值 value 是否与旧值 field 相同。如果相同,则不进行任何操作并返回 false
如果值不同,它会更新字段 field 的值,然后调用 OnPropertyChanged 来通知监听者属性已更改,并返回 true
泛型 T 使得此方法可以用于任何类型的属性。

4.2 实现 RelayCommand (命令基类)

WPF 中的命令机制允许我们将 UI 操作(如按钮点击)与 ViewModel 中的方法解耦。ICommand 接口定义了命令的行为。我们可以创建一个 RelayCommand 类(有时也叫 DelegateCommand)来实现 ICommand 接口,它允许我们将命令的执行逻辑和可执行条件逻辑委托给 ViewModel 中的方法。

解决方案资源管理器 中,右键点击 ViewModels 文件夹。

选择 “添加” -> “类…”

将类命名为 RelayCommand.cs,然后点击 “添加”

编辑 RelayCommand.cs 文件:

using System;
using System.Windows.Input;

namespace WPF_Books.ViewModels
{

public class RelayCommand : ICommand
{

private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;

     public event EventHandler? CanExecuteChanged
     {
         add { CommandManager.RequerySuggested += value; }
         remove { CommandManager.RequerySuggested -= value; }
     }

     public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
     {
         _execute = execute ?? throw new ArgumentNullException(nameof(execute));
         _canExecute = canExecute;
     }

     public bool CanExecute(object? parameter)
     {
         return _canExecute == null || _canExecute(parameter);
     }

     public void Execute(object? parameter)
     {
         _execute(parameter);
     }

     // 可选:一个触发 CanExecuteChanged 事件的方法,用于手动刷新命令状态
     public void RaiseCanExecuteChanged()
     {
         CommandManager.InvalidateRequerySuggested();
     }
 }

 // 泛型版本,用于需要强类型参数的命令
 public class RelayCommand<T> : ICommand
 {
     private readonly Action<T?> _execute;
     private readonly Predicate<T?>? _canExecute;

     public event EventHandler? CanExecuteChanged
     {
         add { CommandManager.RequerySuggested += value; }
         remove { CommandManager.RequerySuggested -= value; }
     }

     public RelayCommand(Action<T?> execute, Predicate<T?>? canExecute = null)
     {
         _execute = execute ?? throw new ArgumentNullException(nameof(execute));
         _canExecute = canExecute;
     }

     public bool CanExecute(object? parameter)
     {   
         if (parameter == null && typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) == null)
         {
             // 如果 T 是不可空值类型,而 parameter 是 null,则不能执行
             // 或者根据具体情况决定是否抛出异常或返回 false
             return _canExecute == null; // 如果 canExecute 为 null,则认为可以执行(或根据设计调整)
         }
         return _canExecute == null || _canExecute((T?)parameter);
     }

     public void Execute(object? parameter)
     {
         _execute((T?)parameter);
     }

     public void RaiseCanExecuteChanged()
     {
         CommandManager.InvalidateRequerySuggested();
     }
 }

}

代码解析 (RelayCommand)

private readonly Action<object?> _execute;: 一个委托,指向命令要执行的方法。这个方法接受一个 object? 类型的参数。
private readonly Predicate<object?>? _canExecute;: 一个可选的委托,指向判断命令是否可以执行的方法。这个方法也接受一个 object? 类型的参数,并返回一个布尔值。
public event EventHandler? CanExecuteChanged: ICommand 接口要求的事件。当命令的可执行状态可能发生变化时,应触发此事件。WPF 的 CommandManager 会监听这个事件,并在适当的时候(例如,UI 焦点改变、某些依赖属性变化时)重新查询命令的 CanExecute 状态,从而自动更新绑定到此命令的 UI 元素(如按钮)的启用/禁用状态。

CommandManager.RequerySuggested += value;CommandManager.RequerySuggested -= value;: 这是 RelayCommand 实现 CanExecuteChanged 事件的常用方式。它将事件的订阅和取消订阅委托给 CommandManager.RequerySuggested 事件。CommandManager 会在它认为命令状态可能需要刷新时触发 RequerySuggested 事件,从而间接触发所有订阅了 CanExecuteChanged 的处理程序。

public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null): 构造函数,接收执行逻辑和可选的条件逻辑。
public bool CanExecute(object? parameter): 实现 ICommandCanExecute 方法。如果 _canExecute 委托不为 null,则调用它来判断命令是否可执行;否则,默认命令可执行。
public void Execute(object? parameter): 实现 ICommandExecute 方法。调用 _execute 委托来执行命令的实际逻辑。
public void RaiseCanExecuteChanged(): 这是一个辅助方法,允许 ViewModel 在其内部逻辑发生变化并可能影响命令可执行状态时,主动通知 CommandManager 重新评估命令状态。例如,当某个属性值改变后,某个命令从不可执行变为可执行,就可以调用此方法。

泛型版本 RelayCommand<T>

提供了一个泛型版本的 RelayCommand<T>,它允许命令的 ExecuteCanExecute 方法接受强类型的参数 T,而不是 object?。这可以提高类型安全性和代码可读性。
CanExecute 方法中对 parameterT 的类型进行了更细致的处理,特别是当 T 是不可空值类型时。

4.3 设计 LoginViewModel

LoginViewModel 将负责处理用户登录逻辑。它需要包含用户名、密码属性,以及一个登录命令。

ViewModels 文件夹下创建 LoginViewModel.cs 类。

使其继承自 ObservableObject

using System.Windows.Input;
using WPF_Books.Models; // 如果需要引用 User 模型
using WPF_Books.DataAccess; // 如果需要直接访问 AppDbContext
using System.Linq; // 用于 LINQ 查询
using System.Windows; // For MessageBox

namespace WPF_Books.ViewModels
{

public class LoginViewModel : ObservableObject
{

private string _username = string.Empty;
public string Username
{

get => _username;
set
{

if (SetProperty(ref _username, value))
{

LoginCommand.RaiseCanExecuteChanged(); // 用户名变化时,可能影响登录按钮状态
}
}
}

     private string _password = string.Empty;
     public string Password // Password 通常通过 PasswordBox 获取,直接绑定可能不安全
     {
         get => _password;
         set
         {
             if (SetProperty(ref _password, value))
             {
                 LoginCommand.RaiseCanExecuteChanged(); // 密码变化时,可能影响登录按钮状态
             }
         }
     }

     private string _errorMessage = string.Empty;
     public string ErrorMessage
     {
         get => _errorMessage;
         private set => SetProperty(ref _errorMessage, value);
     }

     private bool _isLoginSuccessful;
     public bool IsLoginSuccessful
     {
         get => _isLoginSuccessful;
         private set => SetProperty(ref _isLoginSuccessful, value);
     }

     public RelayCommand LoginCommand { get; }

     // 用于存储当前登录的用户信息
     private User? _currentUser;
     public User? CurrentUser
     {
         get => _currentUser;
         private set => SetProperty(ref _currentUser, value);
     }

     public LoginViewModel()
     {
         LoginCommand = new RelayCommand(ExecuteLogin, CanExecuteLogin);
     }

     private bool CanExecuteLogin(object? parameter)
     {
         // 简单的验证:用户名和密码不能为空
         return !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password);
     }

     private void ExecuteLogin(object? parameter)
     {
         ErrorMessage = string.Empty; // 清除之前的错误信息
         IsLoginSuccessful = false;

         try
         {
             using (var context = new AppDbContext()) // 每次操作都创建一个新的上下文实例
             {
                 // 实际项目中,密码验证应该是比较哈希值
                 // 这里为了简化,我们直接比较字符串,这是不安全的!
                 var user = context.Users.FirstOrDefault(u => u.Username == Username);

                 if (user != null)
                 {
                     // !!重要提醒:实际应用中绝不能这样比较密码!!
                     // 应该使用安全的密码哈希比较函数,例如:
                     // if (PasswordHasher.VerifyHashedPassword(user.PasswordHash, Password))
                     if (user.PasswordHash == Password) // 仅为演示,极不安全
                     {
                         CurrentUser = user;
                         IsLoginSuccessful = true;
                         // 可以在这里触发一个事件或设置一个属性,通知View登录成功,然后关闭登录窗口并打开主窗口
                         MessageBox.Show($"用户 {Username} 登录成功!", "登录成功", MessageBoxButton.OK, MessageBoxImage.Information);
                     }
                     else
                     {
                         ErrorMessage = "密码错误。";
                     }
                 }
                 else
                 {
                     ErrorMessage = "用户名不存在。";
                 }
             }
         }
         catch (System.Exception ex)
         {
             ErrorMessage = $"登录过程中发生错误: {ex.Message}";
             // 记录更详细的日志 ex.ToString()
         }
     }
 }

}

代码解析 (LoginViewModel)

继承 ObservableObject 以获得属性变更通知能力。
UsernamePassword 属性:用于绑定到登录界面的输入框。它们的 setter 调用 SetProperty,并在值改变时调用 LoginCommand.RaiseCanExecuteChanged() 来更新登录按钮的可用状态。

关于密码:直接将 PasswordBoxPassword 属性绑定到 ViewModel 的字符串属性存在安全风险(密码可能在内存中以明文形式存在较长时间)。更安全的做法通常是:

在 View 的代码隐藏文件中处理 PasswordBox.PasswordChanged 事件,并将密码传递给 ViewModel 的一个方法或一个专门的 SecureString 属性。
使用附加行为 (Attached Behavior) 或自定义控件来更安全地处理密码。
对于本示例,为了简化,我们暂时采用直接绑定,但强烈建议在实际项目中采用更安全的方式

ErrorMessage 属性:用于向 View 显示登录过程中的错误信息。
IsLoginSuccessful 属性:一个布尔标志,指示登录是否成功。View 可以观察此属性来决定后续操作(例如关闭登录窗口)。
CurrentUser 属性:用于存储成功登录的用户对象。
LoginCommand: 一个 RelayCommand 实例,绑定到登录按钮。

ExecuteLogin 方法:包含实际的登录逻辑。

它创建一个 AppDbContext 实例来查询数据库。
重要安全提示:代码中直接比较 user.PasswordHash == Password极其不安全的,仅用于演示目的。在真实应用中,数据库中存储的应该是密码的哈希值,验证时应该将用户输入的密码进行同样的哈希运算(使用相同的盐),然后比较两个哈希值。
根据验证结果设置 CurrentUser, IsLoginSuccessfulErrorMessage
登录成功后,通过 MessageBox 显示提示。在实际应用中,通常会关闭登录窗口并导航到主应用程序界面。

CanExecuteLogin 方法:定义了登录命令何时可以执行(即用户名和密码都不为空时)。

4.4 初步设计 BookViewModel

BookViewModel 将是图书管理功能的核心 ViewModel。它将负责加载图书列表、处理图书的增删改查、借阅和归还等操作。

ViewModels 文件夹下创建 BookViewModel.cs 类。

使其继承自 ObservableObject

using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using WPF_Books.Models;
using WPF_Books.DataAccess;
using System.Windows; // For MessageBox
using Microsoft.EntityFrameworkCore; // For Include, etc.
using System;

namespace WPF_Books.ViewModels
{

public class BookViewModel : ObservableObject
{

private User? _currentUser; // 当前登录的用户
public User? CurrentUser
{

get => _currentUser;
set => SetProperty(ref _currentUser, value);
}

     private ObservableCollection<Book> _books = new ObservableCollection<Book>();
     public ObservableCollection<Book> Books
     {
         get => _books;
         set => SetProperty(ref _books, value);
     }

     private Book? _selectedBook;
     public Book? SelectedBook
     {
         get => _selectedBook;
         set
         {
             if (SetProperty(ref _selectedBook, value))
             {
                 // 当选中项改变时,更新编辑区域的图书信息
                 if (_selectedBook != null)
                 {
                     EditTitle = _selectedBook.Title;
                     EditAuthor = _selectedBook.Author;
                     EditISBN = _selectedBook.ISBN;
                     EditQuantity = _selectedBook.Quantity;
                     EditPublishDate = _selectedBook.PublishDate;
                     EditBorrower = _selectedBook.Borrower;
                 }
                 else
                 {
                     ClearEditFields();
                 }
                 // 更新命令的可执行状态
                 BorrowBookCommand.RaiseCanExecuteChanged();
                 ReturnBookCommand.RaiseCanExecuteChanged();
                 DeleteBookCommand.RaiseCanExecuteChanged();
                 UpdateBookCommand.RaiseCanExecuteChanged();
             }
         }
     }

     // 用于UI绑定的编辑字段
     private string _editTitle = string.Empty;
     public string EditTitle { get => _editTitle; set => SetProperty(ref _editTitle, value); }

     private string _editAuthor = string.Empty;
     public string EditAuthor { get => _editAuthor; set => SetProperty(ref _editAuthor, value); }

     private string _editISBN = string.Empty;
     public string EditISBN { get => _editISBN; set => SetProperty(ref _editISBN, value); }

     private int _editQuantity;
     public int EditQuantity { get => _editQuantity; set => SetProperty(ref _editQuantity, value); }

     private DateTime? _editPublishDate;
     public DateTime? EditPublishDate { get => _editPublishDate; set => SetProperty(ref _editPublishDate, value); }

     private string? _editBorrower;
     public string? EditBorrower { get => _editBorrower; set => SetProperty(ref _editBorrower, value); }


     // 搜索相关
     private string _searchText = string.Empty;
     public string SearchText
     {
         get => _searchText;
         set
         {
             if (SetProperty(ref _searchText, value))
             {
                 LoadBooks(); // 当搜索文本改变时,重新加载图书列表
             }
         }
     }

     // 命令
     public ICommand AddBookCommand { get; }
     public ICommand UpdateBookCommand { get; }
     public ICommand DeleteBookCommand { get; }
     public ICommand BorrowBookCommand { get; }
     public ICommand ReturnBookCommand { get; }
     public ICommand LoadBooksCommand { get; }
     public ICommand ClearSearchCommand { get; }


     public BookViewModel(User? loggedInUser) // 接收登录用户
     {
         CurrentUser = loggedInUser;

         LoadBooksCommand = new RelayCommand(_ => LoadBooks());
         AddBookCommand = new RelayCommand(_ => AddNewBook(), _ => CanAddBook());
         UpdateBookCommand = new RelayCommand(_ => UpdateSelectedBook(), _ => CanUpdateOrDeleteBook());
         DeleteBookCommand = new RelayCommand(_ => DeleteSelectedBook(), _ => CanUpdateOrDeleteBook());
         BorrowBookCommand = new RelayCommand(_ => BorrowSelectedBook(), _ => CanBorrowBook());
         ReturnBookCommand = new RelayCommand(_ => ReturnSelectedBook(), _ => CanReturnBook());
         ClearSearchCommand = new RelayCommand(_ => ClearSearchText());

         LoadBooks(); // ViewModel 初始化时加载图书
     }

     // 无参构造函数,主要用于XAML设计时数据或不需要立即传入用户的场景
     public BookViewModel() : this(null) 
     {
         // 设计时可以加载一些示例数据
         if (DesignerProperties.GetIsInDesignMode(new DependencyObject()))
         {
             LoadDesignTimeBooks();
         }
     }

     private void LoadDesignTimeBooks()
     {
         Books = new ObservableCollection<Book>
         {
             new Book { Title = "设计时图书1", Author = "作者A", ISBN = "111-111", Quantity = 5 },
             new Book { Title = "设计时图书2", Author = "作者B", ISBN = "222-222", Quantity = 3, Borrower = "testuser" },
             new Book { Title = "设计时图书3", Author = "作者C", ISBN = "333-333", Quantity = 0 }
         };
     }

     private void ClearEditFields()
     {
         EditTitle = string.Empty;
         EditAuthor = string.Empty;
         EditISBN = string.Empty;
         EditQuantity = 0;
         EditPublishDate = null;
         EditBorrower = string.Empty;
     }

     private void LoadBooks()
     {
         try
         {
             using (var context = new AppDbContext())
             {
                 IQueryable<Book> query = context.Books;
                 if (!string.IsNullOrWhiteSpace(SearchText))
                 {
                     string searchTerm = SearchText.ToLower();
                     query = query.Where(b => 
                         (b.Title != null && b.Title.ToLower().Contains(searchTerm)) ||
                         (b.Author != null && b.Author.ToLower().Contains(searchTerm)) ||
                         (b.ISBN != null && b.ISBN.ToLower().Contains(searchTerm))
                     );
                 }
                 Books = new ObservableCollection<Book>(query.ToList());
             }
         }
         catch (Exception ex)
         {
             MessageBox.Show($"加载图书列表失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
         }
     }

     private bool CanAddBook()
     {
         // 简单示例:当前用户必须存在才能添加图书
         return CurrentUser != null && 
                !string.IsNullOrWhiteSpace(EditTitle) && 
                !string.IsNullOrWhiteSpace(EditAuthor) && 
                !string.IsNullOrWhiteSpace(EditISBN) &&
                EditQuantity >= 0;
     }

     private void AddNewBook()
     {
         // 数据验证 (更复杂的验证可以用 DataAnnotations 或 IDataErrorInfo)
         if (string.IsNullOrWhiteSpace(EditTitle) || string.IsNullOrWhiteSpace(EditAuthor) || string.IsNullOrWhiteSpace(EditISBN) || EditQuantity < 0)
         {
             MessageBox.Show("请输入完整的图书信息 (书名、作者、ISBN、数量)。", "信息不完整", MessageBoxButton.OK, MessageBoxImage.Warning);
             return;
         }

         try
         {
             using (var context = new AppDbContext())
             {
                 // 检查ISBN是否已存在,如果存在则增加数量,否则添加新书
                 var existingBook = context.Books.FirstOrDefault(b => b.ISBN == EditISBN);
                 if (existingBook != null)
                 {
                     existingBook.Quantity += EditQuantity;
                     MessageBox.Show($"图书 '{EditTitle}' 已存在,数量已增加 {EditQuantity}。当前总数: {existingBook.Quantity}", "数量更新", MessageBoxButton.OK, MessageBoxImage.Information);
                 }
                 else
                 {
                     var newBook = new Book
                     {
                         Title = EditTitle,
                         Author = EditAuthor,
                         ISBN = EditISBN,
                         Quantity = EditQuantity,
                         PublishDate = EditPublishDate
                     };
                     context.Books.Add(newBook);
                     MessageBox.Show($"图书 '{EditTitle}' 添加成功!", "添加成功", MessageBoxButton.OK, MessageBoxImage.Information);
                 }
                 context.SaveChanges();
             }
             LoadBooks(); // 重新加载列表
             ClearEditFields(); // 清空编辑字段
         }
         catch (Exception ex)
         {
             MessageBox.Show($"添加图书失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
         }
     }

     private bool CanUpdateOrDeleteBook()
     {
         return SelectedBook != null && CurrentUser != null;
     }

     private void UpdateSelectedBook()
     {
         if (SelectedBook == null) return;

         if (string.IsNullOrWhiteSpace(EditTitle) || string.IsNullOrWhiteSpace(EditAuthor) || string.IsNullOrWhiteSpace(EditISBN) || EditQuantity < 0)
         {
             MessageBox.Show("请输入完整的图书信息 (书名、作者、ISBN、数量)。", "信息不完整", MessageBoxButton.OK, MessageBoxImage.Warning);
             return;
         }

         try
         {
             using (var context = new AppDbContext())
             {
                 var bookToUpdate = context.Books.Find(SelectedBook.Id);
                 if (bookToUpdate != null)
                 {
                     bookToUpdate.Title = EditTitle;
                     bookToUpdate.Author = EditAuthor;
                     bookToUpdate.ISBN = EditISBN; // 通常不建议修改ISBN,除非非常确定
                     bookToUpdate.Quantity = EditQuantity;
                     bookToUpdate.PublishDate = EditPublishDate;
                     // Borrower 和 BorrowDate 通常通过借阅/归还操作更新,不在此处直接修改

                     context.SaveChanges();
                     MessageBox.Show("图书信息更新成功!", "更新成功", MessageBoxButton.OK, MessageBoxImage.Information);
                 }
             }
             LoadBooks();
         }
         catch (Exception ex)
         {
             MessageBox.Show($"更新图书失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
         }
     }

     private void DeleteSelectedBook()
     {
         if (SelectedBook == null) return;

         var result = MessageBox.Show($"确定要删除图书 '{SelectedBook.Title}' 吗?", "确认删除", MessageBoxButton.YesNo, MessageBoxImage.Warning);
         if (result == MessageBoxResult.Yes)
         {
             try
             {
                 using (var context = new AppDbContext())
                 {
                     var bookToDelete = context.Books.Find(SelectedBook.Id);
                     if (bookToDelete != null)
                     {
                         if (!string.IsNullOrEmpty(bookToDelete.Borrower))
                         {
                             MessageBox.Show("该图书已被借阅,不能删除!请先归还。", "操作失败", MessageBoxButton.OK, MessageBoxImage.Error);
                             return;
                         }
                         context.Books.Remove(bookToDelete);
                         context.SaveChanges();
                         MessageBox.Show("图书删除成功!", "删除成功", MessageBoxButton.OK, MessageBoxImage.Information);
                     }
                 }
                 LoadBooks();
                 ClearEditFields();
             }
             catch (Exception ex)
             {
                 MessageBox.Show($"删除图书失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
             }
         }
     }

     private bool CanBorrowBook()
     {
         return SelectedBook != null && SelectedBook.Quantity > 0 && CurrentUser != null && 
                (string.IsNullOrEmpty(SelectedBook.Borrower) || !SelectedBook.Borrower.Split(',').Select(s => s.Trim()).Contains(CurrentUser.Username));
     }

     private void BorrowSelectedBook()
     {
         if (SelectedBook == null || CurrentUser == null) return;

         try
         {
             using (var context = new AppDbContext())
             {
                 var bookToBorrow = context.Books.Find(SelectedBook.Id);
                 if (bookToBorrow != null && bookToBorrow.Quantity > 0)
                 {
                     // 检查当前用户是否已借阅此书
                     var borrowers = string.IsNullOrEmpty(bookToBorrow.Borrower) ? new List<string>() : bookToBorrow.Borrower.Split(',').Select(s => s.Trim()).ToList();
                     if (borrowers.Contains(CurrentUser.Username))
                     {
                         MessageBox.Show("您已经借阅了这本书。", "操作失败", MessageBoxButton.OK, MessageBoxImage.Warning);
                         return;
                     }

                     bookToBorrow.Quantity--;
                     if (string.IsNullOrEmpty(bookToBorrow.Borrower))
                     {
                         bookToBorrow.Borrower = CurrentUser.Username;
                     }
                     else
                     {
                         bookToBorrow.Borrower += $", {CurrentUser.Username}";
                     }
                     bookToBorrow.BorrowDate = DateTime.Now; // 可以考虑为每个借阅者记录单独的借阅日期,如果需求复杂

                     context.SaveChanges();
                     MessageBox.Show($"图书 '{bookToBorrow.Title}' 借阅成功!", "借阅成功", MessageBoxButton.OK, MessageBoxImage.Information);
                     LoadBooks();
                     // 更新选中项的显示,因为 SelectedBook 实例可能还是旧的
                     var updatedSelectedBook = Books.FirstOrDefault(b => b.Id == SelectedBook.Id);
                     SelectedBook = updatedSelectedBook; 
                 }
                 else
                 {
                     MessageBox.Show("图书库存不足或不存在!", "借阅失败", MessageBoxButton.OK, MessageBoxImage.Warning);
                 }
             }
         }
         catch (Exception ex)
         {
             MessageBox.Show($"借阅图书失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
         }
     }

     private bool CanReturnBook()
     {
         return SelectedBook != null && CurrentUser != null && 
                !string.IsNullOrEmpty(SelectedBook.Borrower) && 
                SelectedBook.Borrower.Split(',').Select(s => s.Trim()).Contains(CurrentUser.Username);
     }

     private void ReturnSelectedBook()
     {
         if (SelectedBook == null || CurrentUser == null) return;

         try
         {
             using (var context = new AppDbContext())
             {
                 var bookToReturn = context.Books.Find(SelectedBook.Id);
                 if (bookToReturn != null && !string.IsNullOrEmpty(bookToReturn.Borrower))
                 {
                     var borrowers = bookToReturn.Borrower.Split(',').Select(s => s.Trim()).ToList();
                     if (borrowers.Contains(CurrentUser.Username))
                     {
                         bookToReturn.Quantity++;
                         borrowers.Remove(CurrentUser.Username);
                         bookToReturn.Borrower = string.Join(", ", borrowers);

                         if (!borrowers.Any()) // 如果没有其他借阅者了
                         {
                             bookToReturn.BorrowDate = null;
                         }
                         // 否则,BorrowDate 保持不变,或根据需求更新为最近一次借阅的日期

                         context.SaveChanges();
                         MessageBox.Show($"图书 '{bookToReturn.Title}' 归还成功!", "归还成功", MessageBoxButton.OK, MessageBoxImage.Information);
                         LoadBooks();
                         var updatedSelectedBook = Books.FirstOrDefault(b => b.Id == SelectedBook.Id);
                         SelectedBook = updatedSelectedBook;
                     }
                     else
                     {
                         MessageBox.Show("您没有借阅这本书,或图书信息已过时。", "归还失败", MessageBoxButton.OK, MessageBoxImage.Warning);
                     }
                 }
                 else
                 {
                     MessageBox.Show("该图书未被借出或不存在!", "归还失败", MessageBoxButton.OK, MessageBoxImage.Warning);
                 }
             }
         }
         catch (Exception ex)
         {
             MessageBox.Show($"归还图书失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
         }
     }

     private void ClearSearchText()
     {
         SearchText = string.Empty;
         // LoadBooks(); // SearchText的setter会自动调用LoadBooks
     }
 }

}

代码解析 (BookViewModel 初步)

CurrentUser: 存储当前登录的用户信息,从构造函数传入。这对于权限控制和记录借阅者很重要。
Books: 一个 ObservableCollection<Book>,用于存储从数据库加载的图书列表。使用 ObservableCollection 是因为当集合内容发生变化(添加、删除项)时,它会自动通知绑定的 UI 元素(如 ListBoxDataGrid)进行更新。
SelectedBook: 存储用户在列表中选择的图书。当 SelectedBook 改变时:

其 setter 会将选中图书的属性值填充到 EditTitle, EditAuthor 等编辑字段中,方便用户查看和修改。
调用各个命令的 RaiseCanExecuteChanged() 方法,因为选中项的变化可能会影响这些命令(如借阅、归还、删除、更新)的可执行状态。

EditTitle, EditAuthor, EditISBN, EditQuantity, EditPublishDate, EditBorrower: 这些是用于 UI 中编辑区域的属性,双向绑定到输入控件。用户可以通过这些控件添加新书或修改选中图书的信息。
SearchText: 用于绑定到搜索框,当其值改变时,会触发 LoadBooks 方法以根据搜索条件过滤图书列表。
命令

LoadBooksCommand: 加载/重新加载图书列表。
AddBookCommand: 添加新图书。
UpdateBookCommand: 更新选中图书的信息。
DeleteBookCommand: 删除选中的图书。
BorrowBookCommand: 借阅选中的图书。
ReturnBookCommand: 归还选中的图书。
ClearSearchCommand: 清空搜索框内容。

构造函数

接收一个 User 对象作为参数,表示当前登录的用户。
初始化所有命令,将它们与相应的执行方法和条件方法关联起来。
调用 LoadBooks() 来在 ViewModel 初始化时加载初始的图书数据。
提供了一个无参构造函数,并调用了带参构造函数 this(null)。这主要用于支持 XAML 设计器中的设计时数据。通过 DesignerProperties.GetIsInDesignMode 判断是否处于设计模式,如果是,则加载一些示例数据 LoadDesignTimeBooks(),方便在设计器中预览界面。

LoadDesignTimeBooks(): 为 XAML 设计器提供示例数据。
ClearEditFields(): 清空编辑区域的输入字段,通常在添加或删除操作后调用。
LoadBooks(): 从数据库加载图书。如果 SearchText 不为空,则根据书名、作者或 ISBN 进行模糊搜索(不区分大小写)。
CanAddBook(): 添加图书命令的可执行条件。这里简单地要求当前用户存在,并且必要的编辑字段不为空。
AddNewBook(): 添加新图书的逻辑。

进行基本的数据验证。
检查具有相同 ISBN 的图书是否已存在。如果存在,则增加其数量;否则,创建一本新书并添加到数据库。
保存更改,重新加载图书列表,并清空编辑字段。

CanUpdateOrDeleteBook(): 更新或删除图书命令的可执行条件,要求有选中的图书且当前用户已登录。
UpdateSelectedBook(): 更新选中图书的逻辑。

将编辑字段中的值赋给从数据库中找到的对应图书实体。
保存更改并重新加载列表。

DeleteSelectedBook(): 删除选中图书的逻辑。

弹出确认对话框。
检查图书是否已被借阅,如果已被借阅则不允许删除。
从数据库中移除图书,保存更改,重新加载列表,并清空编辑字段。

CanBorrowBook(): 借阅图书命令的可执行条件。要求有选中的图书,该图书有库存 (Quantity > 0),当前用户已登录,并且当前用户尚未借阅此书。
BorrowSelectedBook(): 借阅选中图书的逻辑。

检查库存和用户是否已借阅。
减少库存量,并将当前用户名添加到 Borrower 字段(如果是多人借阅,则用逗号分隔)。
记录借阅日期。
保存更改,重新加载列表,并尝试更新 SelectedBook 的引用以反映更改。

CanReturnBook(): 归还图书命令的可执行条件。要求有选中的图书,当前用户已登录,并且 Borrower 字段中包含当前用户名。
ReturnSelectedBook(): 归还选中图书的逻辑。

检查用户是否确实借阅了此书。
增加库存量,从 Borrower 字段中移除当前用户名。
如果归还后没有其他借阅者,则清空借阅日期。
保存更改,重新加载列表,并更新 SelectedBook

ClearSearchText(): 清空搜索文本框。

这只是 BookViewModel 的初步实现。在后续章节中,我们可能会根据 UI 设计和交互需求对其进行调整和完善,例如添加更复杂的验证逻辑、错误处理、用户反馈等。

至此,我们已经为登录和图书管理功能设计了基本的 ViewModel 结构。下一章节我们将开始创建应用程序的视图 (View)。

章节五:视图 (View) 设计与实现

视图 (View) 是用户直接与之交互的界面。在 WPF 中,视图通常使用 XAML (Extensible Application Markup Language) 来定义。本章节我们将创建登录窗口和图书管理主窗口,并将它们与之前创建的 ViewModel 进行数据绑定。

5.1 创建登录窗口 (LoginView.xaml)

登录窗口将提供用户名和密码输入框,以及一个登录按钮。

解决方案资源管理器 中,右键点击 Views 文件夹。

选择 “添加” -> “窗口 (WPF)…”

将窗口命名为 LoginView.xaml,然后点击 “添加”

修改 LoginView.xaml 的 XAML 代码,设计登录界面并进行数据绑定:

<Window.DataContext>
vm:LoginViewModel/
</Window.DataContext>

<Grid.RowDefinitions>

</Grid.RowDefinitions>
<Grid.ColumnDefinitions>

</Grid.ColumnDefinitions>

     <TextBlock Text="用户登录" FontSize="20" FontWeight="Bold" Grid.ColumnSpan="2" HorizontalAlignment="Center" Margin="0,0,0,20"/>

     <Label Content="用户名:" Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
     <TextBox Grid.Row="1" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
              Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}"/>

     <Label Content="密  码:" Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
     <!-- PasswordBox 的 Password 属性不能直接双向绑定,需要特殊处理 -->
     <!-- 简单起见,这里我们暂时用一个普通 TextBox 代替,或在 ViewModel 中直接处理 PasswordBox -->
     <!-- 更推荐的做法是使用附加行为或在 code-behind 中传递密码 -->
     <PasswordBox x:Name="PasswordBox" Grid.Row="2" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"/>
     <!-- <TextBox Grid.Row="2" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
              Text="{Binding Password, UpdateSourceTrigger=PropertyChanged}"/> -->

     <Button Content="登录" Grid.Row="3" Grid.ColumnSpan="2" Margin="10,20,10,10" Padding="10,5"
             Command="{Binding LoginCommand}" Click="LoginButton_Click"/>

     <TextBlock Text="{Binding ErrorMessage}" Grid.Row="4" Grid.ColumnSpan="2" 
                Foreground="Red" TextWrapping="Wrap" VerticalAlignment="Top" Margin="5"/>

 </Grid>

XAML 代码解析 (LoginView.xaml)

xmlns:vm="clr-namespace:WPF_Books.ViewModels": 引入 ViewModels 命名空间,以便在 XAML 中引用 LoginViewModel

<Window.DataContext>: 这是设置 LoginView 数据上下文的关键部分。我们将其实例化为一个 LoginViewModel 对象。这样,LoginView 中的所有绑定都将默认相对于 LoginViewModel 的属性和命令。

Grid: 用于布局界面元素。

TextBlock (用户登录标题): 显示窗口标题。

LabelTextBox (用户名): 用户名输入。

Text="{Binding Username, UpdateSourceTrigger=PropertyChanged}": 将 TextBoxText 属性双向绑定到 LoginViewModelUsername 属性。UpdateSourceTrigger=PropertyChanged 表示每当 TextBox 中的文本发生变化时,源属性(Username)就会立即更新。

LabelPasswordBox (密码):

重要PasswordBoxPassword 属性出于安全原因不是一个依赖属性,因此不能直接像 TextBox.Text 那样进行双向数据绑定。在上面的 XAML 中,我们给 PasswordBox 命名为 PasswordBox
我们将在代码隐藏文件 LoginView.xaml.cs 中处理密码的传递。

Button (登录):

Command="{Binding LoginCommand}": 将按钮的 Command 属性绑定到 LoginViewModelLoginCommand。当按钮被点击时,LoginCommandExecute 方法将被调用。按钮的启用/禁用状态将由 LoginCommandCanExecute 方法控制。
Click="LoginButton_Click": 我们添加了一个 Click 事件处理器,用于在代码隐藏中获取 PasswordBox 的值并传递给 ViewModel。

TextBlock (错误信息): Text="{Binding ErrorMessage}",用于显示来自 LoginViewModel 的登录错误信息。

修改 LoginView.xaml.cs (代码隐藏文件) 来处理密码和登录成功后的逻辑:

using System.Windows;
using WPF_Books.ViewModels;

namespace WPF_Books.Views
{

public partial class LoginView : Window
{

public LoginView()
{

InitializeComponent();
// DataContext 已在 XAML 中设置
// var viewModel = DataContext as LoginViewModel;
// if (viewModel != null)
// {

// viewModel.IsLoginSuccessfulChanged += OnLoginSuccessfulChanged;
// }
}

     private void LoginButton_Click(object sender, RoutedEventArgs e)
     {
         if (DataContext is LoginViewModel viewModel)
         {
             // 从 PasswordBox 获取密码并设置到 ViewModel
             viewModel.Password = PasswordBox.Password; 
             // 手动调用命令的 Execute,因为我们可能需要先设置 Password
             // 如果 LoginCommand 的 CanExecute 依赖 Password,确保在设置 Password 后它能正确评估
             if (viewModel.LoginCommand.CanExecute(null))
             {
                 viewModel.LoginCommand.Execute(null);

                 if (viewModel.IsLoginSuccessful)
                 {
                     // 登录成功,打开主窗口并关闭登录窗口
                     BookView mainView = new BookView(viewModel.CurrentUser); // 传递当前用户
                     mainView.Show();
                     this.Close();
                 }
             }
         }
     }

     // 如果通过事件来处理登录成功,可以取消注释以下代码
     // private void OnLoginSuccessfulChanged(object? sender, bool isSuccess)
     // {
     //     if (isSuccess && DataContext is LoginViewModel viewModel)
     //     {
     //         BookView mainView = new BookView(viewModel.CurrentUser);
     //         mainView.Show();
     //         this.Close();
     //     }
     // }

     // 在窗口关闭时,如果 ViewModel 实现了 IDisposable,可以考虑释放资源
     // protected override void OnClosed(EventArgs e)
     // {
     //     base.OnClosed(e);
     //     if (DataContext is LoginViewModel viewModel && viewModel is IDisposable disposable)
     //     {
     //         disposable.Dispose();
     //     }
     // }
 }

}

代码解析 (LoginView.xaml.cs)

LoginButton_Click 事件处理器:

当登录按钮被点击时,此方法被调用。
它首先获取 DataContext (即 LoginViewModel 实例)。
viewModel.Password = PasswordBox.Password;: 关键步骤。这里我们从 PasswordBox 控件中读取密码,并将其赋值给 LoginViewModelPassword 属性。这是处理 PasswordBox 的一种简单方式。
然后,它检查 LoginCommand 是否可以执行,如果可以,则执行该命令。
if (viewModel.IsLoginSuccessful): 在命令执行后,检查 LoginViewModelIsLoginSuccessful 属性。如果为 true

创建一个新的 BookView (图书管理主窗口) 实例,并将成功登录的 CurrentUser 对象传递给它。
显示 BookView
关闭当前的 LoginView 窗口。

替代方案 (事件): 注释掉的代码块展示了另一种处理登录成功的方式:LoginViewModel 可以定义一个事件 (例如 IsLoginSuccessfulChanged),当登录状态改变时触发。LoginView 可以订阅这个事件,并在事件处理程序中执行打开主窗口和关闭登录窗口的逻辑。这种方式更符合 MVVM 的已关注点分离原则,因为 View 不直接依赖 ViewModel 的某个特定属性值来做判断,而是响应 ViewModel 发出的信号。

5.2 创建图书管理主窗口 (BookView.xaml)

BookView 将是应用程序的主要界面,用于展示图书列表、搜索图书、添加、编辑、删除、借阅和归还图书。

解决方案资源管理器 中,右键点击 Views 文件夹。

选择 “添加” -> “窗口 (WPF)…”

将窗口命名为 BookView.xaml,然后点击 “添加”

修改 BookView.xaml 的 XAML 代码:

<Grid.RowDefinitions>

</Grid.RowDefinitions>

     <!-- 搜索和用户信息区域 -->
     <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
         <Label Content="搜索图书:" VerticalAlignment="Center"/>
         <TextBox Width="200" Margin="5,0" VerticalContentAlignment="Center"
                  Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"/>
         <Button Content="清空搜索" Margin="5,0" Command="{Binding ClearSearchCommand}"/>
         <TextBlock Text="|" Margin="10,0" VerticalAlignment="Center"/>
         <Label Content="当前用户:" VerticalAlignment="Center"/>
         <TextBlock Text="{Binding CurrentUser.Username, FallbackValue='未登录'}" VerticalAlignment="Center" FontWeight="Bold"/>
     </StackPanel>

     <!-- 图书列表 -->
     <DataGrid Grid.Row="1" ItemsSource="{Binding Books}" SelectedItem="{Binding SelectedBook}"
               AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False"
               SelectionMode="Single" Margin="0,0,0,10">
         <DataGrid.Columns>
             <DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="50"/>
             <DataGridTextColumn Header="书名" Binding="{Binding Title}" Width="*"/>
             <DataGridTextColumn Header="作者" Binding="{Binding Author}" Width="2*"/>
             <DataGridTextColumn Header="ISBN" Binding="{Binding ISBN}" Width="1.5*"/>
             <DataGridTextColumn Header="数量" Binding="{Binding Quantity}" Width="80"/>
             <DataGridTextColumn Header="出版社" Binding="{Binding Publisher}" Width="*"/>
             <DataGridTextColumn Header="出版日期" Binding="{Binding PublishDate, StringFormat='yyyy-MM-dd'}" Width="1.2*"/>
             <DataGridTextColumn Header="借阅者" Binding="{Binding Borrower, FallbackValue='无'}" Width="1.5*"/>
             <DataGridTextColumn Header="借阅日期" Binding="{Binding BorrowDate, StringFormat='yyyy-MM-dd HH:mm', FallbackValue='-'}" Width="1.5*"/>
         </DataGrid.Columns>
     </DataGrid>

     <!-- 编辑/添加区域 -->
     <GroupBox Grid.Row="2" Header="图书信息编辑/添加" Margin="0,5,0,5" Padding="10">
         <Grid>
             <Grid.RowDefinitions>
                 <RowDefinition Height="Auto"/>
                 <RowDefinition Height="Auto"/>
                 <RowDefinition Height="Auto"/>
                 <RowDefinition Height="Auto"/>
             </Grid.RowDefinitions>
             <Grid.ColumnDefinitions>
                 <ColumnDefinition Width="Auto"/>
                 <ColumnDefinition Width="*"/>
                 <ColumnDefinition Width="Auto"/>
                 <ColumnDefinition Width="*"/>
             </Grid.ColumnDefinitions>

             <Label Content="书名:" Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
             <TextBox Grid.Row="0" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
                      Text="{Binding EditTitle, UpdateSourceTrigger=PropertyChanged}"/>

             <Label Content="作者:" Grid.Row="0" Grid.Column="2" VerticalAlignment="Center" Margin="5"/>
             <TextBox Grid.Row="0" Grid.Column="3" Margin="5" VerticalContentAlignment="Center"
                      Text="{Binding EditAuthor, UpdateSourceTrigger=PropertyChanged}"/>

             <Label Content="ISBN:" Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
             <TextBox Grid.Row="1" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
                      Text="{Binding EditISBN, UpdateSourceTrigger=PropertyChanged}"/>

             <Label Content="数量:" Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Margin="5"/>
             <TextBox Grid.Row="1" Grid.Column="3" Margin="5" VerticalContentAlignment="Center"
                      Text="{Binding EditQuantity, UpdateSourceTrigger=PropertyChanged}"/>

             <Label Content="出版日期:" Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
             <DatePicker Grid.Row="2" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
                         SelectedDate="{Binding EditPublishDate, UpdateSourceTrigger=PropertyChanged}"/>

             <!-- 借阅者信息通常是只读的,通过借阅/归还操作更新 -->
             <Label Content="借阅者:" Grid.Row="2" Grid.Column="2" VerticalAlignment="Center" Margin="5"/>
             <TextBox Grid.Row="2" Grid.Column="3" Margin="5" VerticalContentAlignment="Center" IsReadOnly="True"
                      Text="{Binding EditBorrower, Mode=OneWay, FallbackValue='无'}"/>

             <!-- 可以添加一个清空编辑区按钮 -->
              <Button Content="清空编辑区" Grid.Row="3" Grid.Column="3" HorizontalAlignment="Right" Margin="5,10,5,0" 
                     Command="{Binding ClearEditFieldsCommand}"/> 
                     <!-- Assume ClearEditFieldsCommand exists in ViewModel -->
         </Grid>
     </GroupBox>

     <!-- 操作按钮 -->
     <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,10,0,0">
         <Button Content="添加图书" Width="100" Margin="5" Command="{Binding AddBookCommand}"/>
         <Button Content="更新图书" Width="100" Margin="5" Command="{Binding UpdateBookCommand}"/>
         <Button Content="删除图书" Width="100" Margin="5" Command="{Binding DeleteBookCommand}"/>
         <Button Content="借阅图书" Width="100" Margin="5" Command="{Binding BorrowBookCommand}"/>
         <Button Content="归还图书" Width="100" Margin="5" Command="{Binding ReturnBookCommand}"/>
         <Button Content="刷新列表" Width="100" Margin="5" Command="{Binding LoadBooksCommand}"/>
     </StackPanel>
 </Grid>

XAML 代码解析 (BookView.xaml)

xmlns:vm="clr-namespace:WPF_Books.ViewModels"xmlns:models="clr-namespace:WPF_Books.Models": 引入 ViewModel 和 Model 的命名空间。

DataContext: 与 LoginView 不同,BookViewDataContext (即 BookViewModel) 将在代码隐藏文件 BookView.xaml.cs 的构造函数中设置,因为我们需要将登录成功的 User 对象传递给 BookViewModel

整体布局: 使用 Grid 分为四个主要区域:搜索和用户信息、图书列表、编辑/添加区域、操作按钮。

搜索和用户信息区域 (StackPanel):

TextBox 绑定到 BookViewModelSearchText 属性,用于输入搜索关键字。
Button (清空搜索) 绑定到 ClearSearchCommand
TextBlock 显示当前登录用户的用户名,绑定到 CurrentUser.UsernameFallbackValue='未登录' 用于在 CurrentUsernull 时显示默认文本。

图书列表 (DataGrid):

ItemsSource="{Binding Books}": 将 DataGrid 的数据源绑定到 BookViewModelBooks (一个 ObservableCollection<Book>)。
SelectedItem="{Binding SelectedBook}": 将 DataGrid 中选中的行双向绑定到 BookViewModelSelectedBook 属性。
AutoGenerateColumns="False": 我们手动定义列,而不是让 DataGrid 自动生成。
IsReadOnly="True": 通常列表本身是只读的,编辑通过专门的编辑区域进行。
<DataGrid.Columns>: 定义了 DataGrid 的每一列。

DataGridTextColumn: 用于显示文本数据。
Header: 列的标题。
Binding="{Binding PropertyName}": 将列的内容绑定到 Book 对象中相应的属性 (例如 Title, Author, ISBN 等)。
StringFormat='yyyy-MM-dd' 用于格式化日期显示。
FallbackValue 用于在绑定属性为 null 或无效时显示默认值。

编辑/添加区域 (GroupBox 内嵌 Grid):

包含多个 LabelTextBox (或 DatePicker) 用于显示和编辑选中图书的信息,或输入新图书的信息。
这些输入控件的 Text (或 SelectedDate) 属性双向绑定到 BookViewModelEditTitle, EditAuthor, EditISBN, EditQuantity, EditPublishDate 属性。
EditBorrowerTextBox 设置为 IsReadOnly="True"Mode=OneWay,因为借阅者信息通常通过借阅/归还操作来管理,而不是直接编辑。
添加了一个“清空编辑区”按钮,假设 ViewModel 中有对应的 ClearEditFieldsCommand (我们在 BookViewModel 中已经创建了 ClearEditFields 方法,可以包装成命令)。

操作按钮 (StackPanel):

包含“添加图书”、“更新图书”、“删除图书”、“借阅图书”、“归还图书”、“刷新列表”等按钮。
每个按钮的 Command 属性都绑定到 BookViewModel 中相应的命令 (AddBookCommand, UpdateBookCommand 等)。

修改 BookView.xaml.cs (代码隐藏文件) 来设置 DataContext

using System.Windows;
using WPF_Books.Models; // For User model
using WPF_Books.ViewModels; // For BookViewModel

namespace WPF_Books.Views
{

public partial class BookView : Window
{

// 主构造函数,接收登录的用户信息
public BookView(User? loggedInUser)
{

InitializeComponent();
DataContext = new BookViewModel(loggedInUser);
}

     // 无参数构造函数,主要用于 XAML 设计器预览
     // 如果 BookViewModel 有一个无参构造函数用于设计时数据,这将很有用
     public BookView()
     {
         InitializeComponent();
         // 设计时可以考虑加载一个带有示例数据的 BookViewModel
         // if (System.ComponentModel.DesignerProperties.GetIsInDesignMode(this))
         // {
         //     DataContext = new BookViewModel(); // 假设 BookViewModel 有一个处理设计时数据的无参构造
         // }
         // 或者,如果 BookViewModel 的无参构造函数已经处理了设计时数据,则可以直接:
         DataContext = new BookViewModel(); 
     }
 }

}

代码解析 (BookView.xaml.cs)

构造函数 BookView(User? loggedInUser):

这是主要的构造函数,它接收一个 User 对象 (loggedInUser) 作为参数,这个对象是从 LoginView 成功登录后传递过来的。
DataContext = new BookViewModel(loggedInUser);: 关键步骤。它创建了一个 BookViewModel 的实例,并将 loggedInUser 传递给 BookViewModel 的构造函数。然后,将这个 BookViewModel 实例设置为 BookViewDataContext。这样,BookView.xaml 中的所有数据绑定就能够正确地连接到 BookViewModel 的属性和命令。

无参数构造函数 BookView():

这个构造函数主要用于支持 XAML 设计器。当你在 Visual Studio 中打开 BookView.xaml 进行设计时,设计器会尝试实例化 BookView。如果 BookView 只有一个带参数的构造函数,设计器可能无法正确显示预览。
DataContext = new BookViewModel();: 这里我们假设 BookViewModel 也有一个无参数的构造函数(我们在 BookViewModel 中已经添加了这样的构造函数,并且它会加载设计时数据)。这样,在设计时,BookView 也能有一个 DataContext,使得绑定能够被解析,从而在设计器中看到界面的大致样子。

5.3 修改 App.xaml.cs 以启动登录窗口

默认情况下,WPF 应用程序会启动 MainWindow.xaml (如果存在)。我们需要修改 App.xamlApp.xaml.cs 来首先显示我们的 LoginView

修改 App.xaml:移除或注释掉 StartupUri="MainWindow.xaml" (如果你的项目模板自动生成了 MainWindow.xaml 并将其设置为启动 URI)。

<!– StartupUri=“MainWindow.xaml” <– 注释或删除这一行 –>
<Application.Resources>

   </Application.Resources>

修改 App.xaml.cs:重写 OnStartup 方法来手动创建并显示 LoginView

using System.Windows;
using WPF_Books.Views; // 需要引入 Views 命名空间

namespace WPF_Books
{

public partial class App : Application
{

protected override void OnStartup(StartupEventArgs e)
{

base.OnStartup(e);

           // 创建并显示登录窗口
           LoginView loginView = new LoginView();
           // loginView.Show(); // Show() 会立即返回,允许其他代码执行

           // 通常,登录窗口应该是模态的,或者在它关闭后决定是否启动主应用
           // 如果 LoginView 设置 DialogResult,我们可以这样处理:
           bool? dialogResult = loginView.ShowDialog();

           // 在 LoginView 内部,当登录成功并打开 BookView 后,LoginView 会自行关闭。
           // ShowDialog() 会阻塞,直到 loginView 关闭。
           // 如果 LoginView 是通过 this.Close() 关闭的,dialogResult 可能是 null。
           // 如果是通过设置 DialogResult = true/false 关闭的,这里可以根据结果做不同处理。
           // 但由于我们的逻辑是 LoginView 成功后自己开 BookView 并关闭自己,
           // 所以这里的 ShowDialog() 主要是为了确保在 LoginView 完成其使命前,App 不会提前退出。

           // 如果 LoginView 关闭后,没有其他窗口打开,应用程序会退出。
           // 我们的 BookView 是在 LoginView 内部打开的,所以这通常没问题。
       }
   }

}

代码解析 (App.xaml.cs)

移除 StartupUri:确保应用程序不会自动打开一个默认的 MainWindow
OnStartup 方法:

base.OnStartup(e); 调用基类的实现。
LoginView loginView = new LoginView();: 创建 LoginView 的实例。
loginView.ShowDialog();: 以模态对话框的形式显示登录窗口。这意味着在 LoginView 关闭之前,用户不能与应用程序的其他部分(如果有的话)交互,并且 ShowDialog() 方法会阻塞,直到对话框关闭。在我们的流程中,LoginView 在登录成功后会打开 BookView 并关闭自身。ShowDialog() 的使用确保了应用程序在登录流程完成前不会意外退出。

至此,我们已经创建了基本的登录视图和图书管理主视图,并将它们与相应的 ViewModel 进行了数据绑定。应用程序现在应该能够从登录界面启动,并在成功登录后显示图书管理界面。

在下一章节,我们将讨论如何进一步完善应用程序,例如添加数据验证、用户反馈、样式美化以及可能的错误处理和日志记录机制。

章节六:数据验证、用户反馈与界面美化

一个健壮且用户友好的应用程序不仅需要实现核心功能,还需要提供有效的数据验证、清晰的用户反馈以及美观的界面。本章将探讨如何在我们的图书管理系统中实现这些方面。

6.1 数据验证

数据验证确保用户输入的数据符合预期的格式和约束,从而保证数据的完整性和一致性。在 MVVM 模式中,数据验证通常在 ViewModel 层实现。

6.1.1 使用 IDataErrorInfo 接口

WPF 支持通过 IDataErrorInfo 接口进行数据验证。ViewModel 可以实现此接口,并在属性的 setter 中执行验证逻辑。

修改 BookViewModel.cs 以实现 IDataErrorInfo 并添加验证逻辑到编辑相关的属性 (例如 EditTitle, EditAuthor, EditISBN, EditQuantity, EditPublishDate)。

using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using Microsoft.EntityFrameworkCore;
using WPF_Books.Models;
using WPF_Books.Data;

namespace WPF_Books.ViewModels
{

public class BookViewModel : ObservableObject, IDataErrorInfo // 实现 IDataErrorInfo
{

private readonly AppDbContext _context;
private User? _currentUser;

     // ... (其他属性和构造函数保持不变)

     private string _editTitle = string.Empty;
     public string EditTitle
     {
         get => _editTitle;
         set => SetProperty(ref _editTitle, value);
     }

     private string _editAuthor = string.Empty;
     public string EditAuthor
     {
         get => _editAuthor;
         set => SetProperty(ref _editAuthor, value);
     }

     private string _editISBN = string.Empty;
     public string EditISBN
     {
         get => _editISBN;
         set => SetProperty(ref _editISBN, value);
     }

     private int _editQuantity;
     public int EditQuantity
     {
         get => _editQuantity;
         set => SetProperty(ref _editQuantity, value);
     }

     private DateTime? _editPublishDate = DateTime.Now;
     public DateTime? EditPublishDate
     {
         get => _editPublishDate;
         set => SetProperty(ref _editPublishDate, value);
     }

     // ... (SelectedBook, EditBorrower, Commands 等保持不变)

     // IDataErrorInfo 实现
     public string Error => string.Empty; // 通常不使用,返回 null 或 string.Empty

     public string this[string columnName]
     {
         get
         {
             string? result = null;
             switch (columnName)
             {
                 case nameof(EditTitle):
                     if (string.IsNullOrWhiteSpace(EditTitle))
                         result = "书名不能为空";
                     else if (EditTitle.Length > 100)
                         result = "书名长度不能超过100个字符";
                     break;
                 case nameof(EditAuthor):
                     if (string.IsNullOrWhiteSpace(EditAuthor))
                         result = "作者不能为空";
                     else if (EditAuthor.Length > 100)
                         result = "作者长度不能超过100个字符";
                     break;
                 case nameof(EditISBN):
                     if (string.IsNullOrWhiteSpace(EditISBN))
                         result = "ISBN 不能为空";
                     // 简单的 ISBN 格式校验 (例如,10位或13位数字,可能包含连字符)
                     // 更复杂的校验可以使用正则表达式
                     else if (!System.Text.RegularExpressions.Regex.IsMatch(EditISBN.Replace("-", ""), @"^(d{10}|d{13})$"))
                         result = "ISBN 格式不正确 (应为10或13位数字)";
                     break;
                 case nameof(EditQuantity):
                     if (EditQuantity < 0)
                         result = "数量不能为负数";
                     break;
                 case nameof(EditPublishDate):
                     if (EditPublishDate == null)
                         result = "出版日期不能为空";
                     else if (EditPublishDate > DateTime.Now)
                         result = "出版日期不能晚于当前日期";
                     break;
             }
             // 当验证通过时,清除之前可能存在的错误信息
             // 这需要与UI绑定配合,确保错误提示能被移除
             // OnPropertyChanged(nameof(HasErrors)); // 如果有 HasErrors 属性
             return result ?? string.Empty; // 返回 string.Empty 而不是 null,以便WPF正确处理
         }
     }

     // 可以添加一个 HasErrors 属性来控制命令的 CanExecute
     public bool HasErrors
     {
         get
         {
             // 检查所有需要验证的属性
             if (!string.IsNullOrEmpty(this[nameof(EditTitle)])) return true;
             if (!string.IsNullOrEmpty(this[nameof(EditAuthor)])) return true;
             if (!string.IsNullOrEmpty(this[nameof(EditISBN)])) return true;
             if (!string.IsNullOrEmpty(this[nameof(EditQuantity)])) return true;
             if (!string.IsNullOrEmpty(this[nameof(EditPublishDate)])) return true;
             return false;
         }
     }

     // 修改 AddBookCommand 和 UpdateBookCommand 的 CanExecute 方法
     private bool CanAddOrUpdateBook()
     {
         return !HasErrors && 
                !string.IsNullOrWhiteSpace(EditTitle) && 
                !string.IsNullOrWhiteSpace(EditAuthor) && 
                !string.IsNullOrWhiteSpace(EditISBN);
     }

     // 在构造函数或初始化时,确保命令的 CanExecute 状态被正确评估
     public BookViewModel(User? currentUser)
     {
         _context = new AppDbContext();
         _currentUser = currentUser;
         Books = new ObservableCollection<Book>();
         LoadBooksCommand = new RelayCommand(LoadBooks);
         AddBookCommand = new RelayCommand(AddBook, CanAddOrUpdateBook);
         UpdateBookCommand = new RelayCommand(UpdateBook, CanAddOrUpdateBook);
         DeleteBookCommand = new RelayCommand(DeleteBook, CanSelectBook);
         BorrowBookCommand = new RelayCommand(BorrowBook, CanBorrowBook);
         ReturnBookCommand = new RelayCommand(ReturnBook, CanReturnBook);
         ClearSearchCommand = new RelayCommand(() => SearchText = string.Empty);
         ClearEditFieldsCommand = new RelayCommand(ClearEditFields);

         LoadBooks();
         // 当编辑字段变化时,重新评估命令状态
         PropertyChanged += (s, e) => 
         {
             if (e.PropertyName == nameof(EditTitle) || 
                 e.PropertyName == nameof(EditAuthor) || 
                 e.PropertyName == nameof(EditISBN) || 
                 e.PropertyName == nameof(EditQuantity) || 
                 e.PropertyName == nameof(EditPublishDate) ||
                 e.PropertyName == nameof(SelectedBook))
             {
                 (AddBookCommand as RelayCommand)?.RaiseCanExecuteChanged();
                 (UpdateBookCommand as RelayCommand)?.RaiseCanExecuteChanged();
                 (DeleteBookCommand as RelayCommand)?.RaiseCanExecuteChanged();
                 (BorrowBookCommand as RelayCommand)?.RaiseCanExecuteChanged();
                 (ReturnBookCommand as RelayCommand)?.RaiseCanExecuteChanged();
                 OnPropertyChanged(nameof(HasErrors)); // 通知 HasErrors 可能已更改
             }
         };
     }
     // ... (其他方法保持不变)
 }

}

代码解析 (BookViewModel.cs IDataErrorInfo 实现):

public class BookViewModel : ObservableObject, IDataErrorInfo: ViewModel 类实现 IDataErrorInfo 接口。

public string Error => string.Empty;: IDataErrorInfo 接口的 Error 属性,通常用于对象级别的错误信息。我们这里不使用它,返回 string.Empty

public string this[string columnName]: 这是 IDataErrorInfo 接口的索引器属性。WPF 的绑定引擎会在属性值改变时调用这个索引器,传入发生改变的属性名 (columnName)。

switch (columnName): 根据属性名执行相应的验证逻辑。
如果验证失败,返回错误信息字符串。
如果验证通过,返回 string.Empty (注意:返回 null 可能导致某些情况下错误提示不消失,推荐返回 string.Empty)。

public bool HasErrors: 一个辅助属性,用于判断当前 ViewModel 是否存在任何验证错误。这可以用于禁用“保存”或“更新”按钮。

CanAddOrUpdateBook(): 修改了添加和更新命令的 CanExecute 逻辑,使其依赖于 HasErrors 属性以及关键字段是否为空。

构造函数中的 PropertyChanged 订阅:

当编辑相关的属性 (EditTitle, EditAuthor 等) 或 SelectedBook 发生变化时,会触发 PropertyChanged 事件。
在事件处理中,我们调用命令的 RaiseCanExecuteChanged() 方法来通知 UI 重新评估这些命令的 CanExecute 状态 (例如,如果之前有错误导致按钮禁用,现在错误修复了,按钮应该重新启用)。
同时调用 OnPropertyChanged(nameof(HasErrors)) 来通知 UI HasErrors 属性也可能已更改。

BookView.xaml 中显示验证错误:WPF 的 TextBox (以及其他一些输入控件) 可以通过设置 Validation.ErrorTemplate 来定义验证错误的可视化样式。当绑定属性的 IDataErrorInfo 索引器返回错误信息时,这个模板会被应用。修改 BookView.xaml 中编辑区域的 TextBoxDatePicker,添加 ValidatesOnDataErrors=True 并可以定义一个全局的错误模板。首先,在 <Window.Resources><Application.Resources> 中定义一个错误模板:

<Window …>
<Window.Resources>

</Window.Resources>

然后,在编辑区域的输入控件上应用它:

  <!-- 编辑/添加区域 -->
  <GroupBox Grid.Row="2" Header="图书信息编辑/添加" Margin="0,5,0,5" Padding="10">
      <Grid>
          <!-- ... Grid.RowDefinitions, Grid.ColumnDefinitions ... -->
  
          <Label Content="书名:" Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
          <TextBox Grid.Row="0" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
                   Text="{Binding EditTitle, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                   Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"/>
  
          <Label Content="作者:" Grid.Row="0" Grid.Column="2" VerticalAlignment="Center" Margin="5"/>
          <TextBox Grid.Row="0" Grid.Column="3" Margin="5" VerticalContentAlignment="Center"
                   Text="{Binding EditAuthor, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                   Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"/>
  
          <Label Content="ISBN:" Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
          <TextBox Grid.Row="1" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
                   Text="{Binding EditISBN, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                   Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"/>
  
          <Label Content="数量:" Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Margin="5"/>
          <TextBox Grid.Row="1" Grid.Column="3" Margin="5" VerticalContentAlignment="Center"
                   Text="{Binding EditQuantity, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                   Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"/>
  
          <Label Content="出版日期:" Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="5"/>
          <DatePicker Grid.Row="2" Grid.Column="1" Margin="5" VerticalContentAlignment="Center"
                      SelectedDate="{Binding EditPublishDate, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
                      Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}"/>
  
          <!-- ... 其他控件 ... -->
      </Grid>
  </GroupBox>

XAML 代码解析 (验证错误显示):

ValidationErrorTemplate: 这是一个 ControlTemplate,定义了当验证错误发生时,控件应该如何显示。

AdornedElementPlaceholder: 这是一个占位符,代表原始的被装饰的控件 (例如 TextBox)。
TextBlock: 用于显示错误信息。{Binding AdornedElement.(Validation.Errors)[0].ErrorContent} 会获取附加到被装饰元素的第一个验证错误的 ErrorContent (即 IDataErrorInfo 索引器返回的字符串)。

TextBoxDatePicker 的绑定中:

ValidatesOnDataErrors=True: 告诉绑定引擎要检查数据源对象 (ViewModel) 是否实现了 IDataErrorInfo,并在属性更改时查询错误信息。
Validation.ErrorTemplate="{StaticResource ValidationErrorTemplate}": 将我们定义的错误模板应用于该控件。

现在,当用户在编辑区域输入不符合验证规则的数据时,相应的输入框下方会显示红色的错误提示信息,并且“添加图书”和“更新图书”按钮会根据 HasErrors 状态自动启用或禁用。

6.1.2 使用 Data Annotations (可选)

除了 IDataErrorInfo,还可以使用数据注解 (Data Annotations) 来声明验证规则。这需要 ViewModel 属性上添加如 [Required], [StringLength], [Range] 等特性,并通常需要一个辅助类或库来将这些注解转换为 IDataErrorInfo 的行为或直接与 WPF 验证机制集成。

例如,可以修改 BookViewModel 的属性:

// 需要 using System.ComponentModel.DataAnnotations;
private string _editTitle = string.Empty;
[Required(ErrorMessage = "书名不能为空")]
[StringLength(100, ErrorMessage = "书名长度不能超过100个字符")]
public string EditTitle
{
    get => _editTitle;
    set => SetProperty(ref _editTitle, value, true); // 第三个参数 true 表示进行验证
}

如果使用数据注解,SetProperty 方法需要被修改以触发验证,或者使用一个基类 (如很多 MVVM 框架提供的 ViewModelBase) 来处理注解验证并实现 IDataErrorInfo

对于本项目,我们主要使用 IDataErrorInfo 的直接实现,因为它提供了更灵活的自定义验证逻辑。

6.2 用户反馈

良好的用户反馈能让用户了解应用程序当前的状态和操作结果。

6.2.1 状态栏/消息区域

可以在主窗口 (BookView) 底部添加一个状态栏或消息区域,用于显示操作成功、失败或进行中的信息。

修改 BookViewModel.cs 添加一个 StatusMessage 属性:

// … (在 BookViewModel 中)
private string _statusMessage = string.Empty;
public string StatusMessage
{

get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}

// 在执行命令的方法中更新 StatusMessage
private async void AddBook()
{

// … (之前的逻辑)
try
{

// … (添加图书到数据库)
await _context.SaveChangesAsync();
Books.Add(newBook);
StatusMessage = $“图书 ‘{newBook.Title}’ 添加成功!”;
ClearEditFields();
}
catch (Exception ex)
{

StatusMessage = $“添加图书失败: {ex.Message}”;
// 可以考虑记录日志
}
}

// 对 UpdateBook, DeleteBook, BorrowBook, ReturnBook 等方法也做类似修改
// 例如,在 LoadBooks 方法中:
private async void LoadBooks()
{

StatusMessage = “正在加载图书列表…”;
try
{

var booksFromDb = await _context.Books.AsNoTracking().ToListAsync();
Books.Clear();
foreach (var book in booksFromDb)
{

Books.Add(book);
}
StatusMessage = $“图书列表加载完成,共 {Books.Count} 本。”;
}
catch (Exception ex)
{

StatusMessage = $“加载图书列表失败: {ex.Message}”;
}
}

修改 BookView.xaml 添加一个 TextBlock 来显示 StatusMessage

6.2.2 模态对话框 (MessageBox)

对于重要的操作结果或需要用户确认的操作,可以使用 MessageBox

例如,在删除图书前进行确认:

// ... (在 BookViewModel 中)
private async void DeleteBook()
{
    if (SelectedBook == null) return;

    var result = MessageBox.Show($"确定要删除图书 '{SelectedBook.Title}' 吗?此操作不可恢复。", 
                                 "确认删除", 
                                 MessageBoxButton.YesNo, 
                                 MessageBoxImage.Warning);

    if (result == MessageBoxResult.Yes)
    {
        try
        {
            _context.Books.Remove(SelectedBook);
            await _context.SaveChangesAsync();
            Books.Remove(SelectedBook);
            StatusMessage = $"图书 '{SelectedBook.Title}' 删除成功!";
            ClearEditFields(); // 清空编辑区,因为选中的书已删除
        }
        catch (Exception ex)
        {
            StatusMessage = $"删除图书失败: {ex.Message}";
            MessageBox.Show($"删除图书 '{SelectedBook.Title}' 失败:
{ex.Message}", "删除错误", MessageBoxButton.OK, MessageBoxImage.Error);
        }
    }
    else
    {
        StatusMessage = "删除操作已取消。";
    }
}

注意:直接在 ViewModel 中调用 System.Windows.MessageBox.Show() 会使 ViewModel 依赖于 PresentationFramework.dll (WPF 的一部分),这在严格的 MVVM 模式中被认为是不理想的,因为它破坏了 ViewModel 的可测试性和平台无关性。更纯粹的做法是通过服务接口 (例如 IMessageBoxService) 来抽象消息框的显示,并在 View 层或应用引导程序中提供具体实现。但对于中小型项目,直接使用 MessageBox 也是一种常见的简化做法。

6.3 界面美化 (Styling and Theming)

WPF 提供了强大的样式和模板系统,可以完全自定义控件的外观。

6.3.1 使用资源字典 (Resource Dictionaries)

可以将样式、模板、画刷等资源组织在资源字典中,以便在整个应用程序或特定窗口/控件中重用。

创建资源字典文件:在项目中创建一个新的文件夹,例如 ResourcesStyles。右键点击该文件夹 -> “添加” -> “资源字典 (WPF)…”。命名例如 SharedStyles.xaml

SharedStyles.xaml 中定义样式:

   <!-- 统一样式:所有 Button -->
   <Style TargetType="Button">
       <Setter Property="Padding" Value="10,5"/>
       <Setter Property="Margin" Value="5"/>
       <Setter Property="MinWidth" Value="80"/>
       <Setter Property="Background" Value="LightBlue"/>
       <Setter Property="BorderBrush" Value="DarkBlue"/>
       <Setter Property="BorderThickness" Value="1"/>
       <Style.Triggers>
           <Trigger Property="IsMouseOver" Value="True">
               <Setter Property="Background" Value="CornflowerBlue"/>
               <Setter Property="Foreground" Value="White"/>
           </Trigger>
           <Trigger Property="IsEnabled" Value="False">
               <Setter Property="Opacity" Value="0.5"/>
           </Trigger>
       </Style.Triggers>
   </Style>

   <!-- 特定 Key 的样式:例如主操作按钮 -->
   <Style x:Key="PrimaryActionButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
       <Setter Property="Background" Value="SteelBlue"/>
       <Setter Property="Foreground" Value="White"/>
       <Setter Property="FontWeight" Value="Bold"/>
   </Style>

   <Style TargetType="TextBox">
       <Setter Property="Padding" Value="3"/>
       <Setter Property="Margin" Value="5"/>
       <Setter Property="VerticalContentAlignment" Value="Center"/>
       <Setter Property="BorderBrush" Value="Gray"/>
       <Setter Property="BorderThickness" Value="1"/>
   </Style>

   <Style TargetType="Label">
       <Setter Property="VerticalAlignment" Value="Center"/>
       <Setter Property="Margin" Value="5"/>
   </Style>

   <Style TargetType="PasswordBox">
       <Setter Property="Padding" Value="3"/>
       <Setter Property="Margin" Value="5"/>
       <Setter Property="VerticalContentAlignment" Value="Center"/>
       <Setter Property="BorderBrush" Value="Gray"/>
       <Setter Property="BorderThickness" Value="1"/>
   </Style>

   <Style TargetType="DatePicker">
       <Setter Property="Padding" Value="3"/>
       <Setter Property="Margin" Value="5"/>
       <Setter Property="VerticalContentAlignment" Value="Center"/>
   </Style>

   <Style TargetType="DataGrid">
       <Setter Property="BorderBrush" Value="LightGray"/>
       <Setter Property="BorderThickness" Value="1"/>
       <Setter Property="HorizontalGridLinesBrush" Value="LightGray"/>
       <Setter Property="VerticalGridLinesBrush" Value="LightGray"/>
   </Style>

   <Style TargetType="GroupBox">
       <Setter Property="BorderBrush" Value="CornflowerBlue"/>
       <Setter Property="BorderThickness" Value="1.5"/>
       <Setter Property="Padding" Value="10"/>
       <Setter Property="Margin" Value="0,5,0,5"/>
   </Style>

App.xaml 中合并资源字典:这样,SharedStyles.xaml 中定义的样式将对整个应用程序可用。

<Application.Resources>

<ResourceDictionary.MergedDictionaries>

</ResourceDictionary.MergedDictionaries>

</Application.Resources>

现在,应用程序中的 Button, TextBox 等控件将自动应用这些默认样式。如果需要应用带 x:Key 的样式,可以在控件上显式设置 Style="{StaticResource PrimaryActionButtonStyle}"。例如,在 BookView.xaml 中,可以移除一些局部的 MarginPadding 设置,因为它们现在由全局样式控制。对于希望使用特定样式的按钮,可以这样设置:

  <Button Content="添加图书" Command="{Binding AddBookCommand}" Style="{StaticResource PrimaryActionButtonStyle}"/>
6.3.2 使用第三方 UI 库 (可选)

有许多优秀的第三方 WPF UI 库,如 MahApps.Metro, Material Design In XAML Toolkit, HandyControl 等,它们提供了预设的现代化主题和丰富的自定义控件,可以快速提升应用程序的视觉效果。

示例:集成 MahApps.Metro (简要步骤)

安装 NuGet 包: MahApps.Metro

App.xaml 中配置: 需要合并 MahApps 的资源字典。

<Application.Resources>

<ResourceDictionary.MergedDictionaries>

               <!-- 你的自定义样式,可以放在 MahApps 之后以覆盖或扩展 -->
               <ResourceDictionary Source="Resources/SharedStyles.xaml"/>
           </ResourceDictionary.MergedDictionaries>
       </ResourceDictionary>
   </Application.Resources>

修改窗口类型: 将 Window 修改为 mah:MetroWindow。在 LoginView.xamlBookView.xaml 的根元素 <Window ...> 中:

添加命名空间: xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"

修改窗口标签: <mah:MetroWindow ...> (同样修改结束标签)。

在代码隐藏文件 (.xaml.cs) 中,将继承的类从 System.Windows.Window 修改为 MahApps.Metro.Controls.MetroWindow

// LoginView.xaml.cs 和 BookView.xaml.cs
// using System.Windows; // 移除或注释
using MahApps.Metro.Controls; // 添加

// public partial class LoginView : Window // 修改前
public partial class LoginView : MetroWindow // 修改后
{

// …
}

集成第三方库可以显著改变应用外观,但也会增加项目的复杂性和依赖。对于本实训项目,我们将主要依赖 WPF 内建的样式系统和自定义资源字典。

通过本章的改进,我们的图书管理系统在数据输入的健壮性、用户操作的反馈以及界面的专业性方面都有了提升。下一章节我们将探讨如何对应用程序进行打包和部署。

章节七:错误处理与日志记录

在应用程序开发中,妥善处理可能发生的错误并记录相关信息对于应用的稳定性、可维护性和问题排查至关重要。本章将介绍如何在我们的WPF图书管理系统中实现基本的错误处理和日志记录机制。

7.1 错误处理策略

错误处理的目标是优雅地捕获和响应运行时可能发生的异常,防止应用程序崩溃,并向用户提供有意义的反馈或指导。

7.1.1 try-catch

在可能抛出异常的代码段(如数据库操作、文件操作、网络请求等)周围使用 try-catch 块是最基本的错误处理方式。

示例:在 BookViewModel.cs 中改进数据库操作的错误处理

我们已经在之前的章节中部分使用了 try-catch,例如在 AddBookLoadBooks 等方法中。这里我们回顾并强调其重要性。

// ... (在 BookViewModel 中)
private async void LoadBooks()
{
    StatusMessage = "正在加载图书列表...";
    try
    {
        // 模拟可能发生的数据库连接问题或其他IO异常
        // if (new Random().Next(0, 5) == 0) throw new Exception("模拟数据库连接超时"); 

        var booksFromDb = await _context.Books.AsNoTracking().ToListAsync();
        Books.Clear();
        foreach (var book in booksFromDb)
        {
            Books.Add(book);
        }
        StatusMessage = $"图书列表加载完成,共 {Books.Count} 本。";
    }
    catch (DbUpdateException dbEx) // 特定类型的异常优先捕获
    {
        StatusMessage = $"数据库更新错误: {dbEx.InnerException?.Message ?? dbEx.Message}";
        LogError(dbEx, "加载图书列表时发生数据库更新错误");
        MessageBox.Show($"加载图书列表时发生数据库更新错误。
详情: {dbEx.InnerException?.Message ?? dbEx.Message}", "数据库错误", MessageBoxButton.OK, MessageBoxImage.Error);
    }
    catch (System.Data.Common.DbException dbCommEx) // 更通用的数据库操作异常
    {
        StatusMessage = $"数据库操作错误: {dbCommEx.Message}";
        LogError(dbCommEx, "加载图书列表时发生数据库操作错误");
        MessageBox.Show($"加载图书列表时发生数据库操作错误。
详情: {dbCommEx.Message}", "数据库错误", MessageBoxButton.OK, MessageBoxImage.Error);
    }
    catch (Exception ex) // 捕获其他所有未预料到的异常
    {
        StatusMessage = $"加载图书列表失败: {ex.Message}";
        LogError(ex, "加载图书列表时发生未知错误");
        MessageBox.Show($"加载图书列表时发生未知错误。
详情: {ex.Message}", "加载错误", MessageBoxButton.OK, MessageBoxImage.Error);
    }
}

// 类似的 try-catch 结构应用于 AddBook, UpdateBook, DeleteBook, BorrowBook, ReturnBook
// 例如 DeleteBook:
private async void DeleteBook()
{
    if (SelectedBook == null) return;

    var result = MessageBox.Show($"确定要删除图书 '{SelectedBook.Title}' 吗?此操作不可恢复。",
                                 "确认删除",
                                 MessageBoxButton.YesNo,
                                 MessageBoxImage.Warning);

    if (result == MessageBoxResult.Yes)
    {
        try
        {
            var bookToDelete = await _context.Books.FindAsync(SelectedBook.Id);
            if (bookToDelete != null)
            {
                _context.Books.Remove(bookToDelete);
                await _context.SaveChangesAsync();
                Books.Remove(SelectedBook); // 从UI集合中移除
                StatusMessage = $"图书 '{SelectedBook.Title}' 删除成功!";
                ClearEditFields();
            }
            else
            {
                StatusMessage = "错误:尝试删除的图书未在数据库中找到。";
                MessageBox.Show("尝试删除的图书未在数据库中找到,可能已被其他用户删除。请刷新列表重试。", "删除错误", MessageBoxButton.OK, MessageBoxImage.Error);
                LoadBooks(); // 刷新列表
            }
        }
        catch (DbUpdateConcurrencyException ex) // 并发冲突
        {
            StatusMessage = "删除图书失败:数据可能已被修改或删除。";
            LogError(ex, $"删除图书 '{SelectedBook.Title}' 时发生并发冲突");
            MessageBox.Show("删除图书失败,数据可能已被其他用户修改或删除。请刷新列表后重试。", "并发错误", MessageBoxButton.OK, MessageBoxImage.Error);
            LoadBooks(); // 刷新列表以获取最新数据
        }
        catch (Exception ex)
        {
            StatusMessage = $"删除图书失败: {ex.Message}";
            LogError(ex, $"删除图书 '{SelectedBook.Title}' 时发生未知错误");
            MessageBox.Show($"删除图书 '{SelectedBook.Title}' 失败:
{ex.Message}", "删除错误", MessageBoxButton.OK, MessageBoxImage.Error);
        }
    }
    else
    {
        StatusMessage = "删除操作已取消。";
    }
}

// LogError 方法将在日志记录部分实现
private void LogError(Exception ex, string contextMessage)
{
    // 具体的日志记录逻辑将在后面添加
    System.Diagnostics.Debug.WriteLine($"错误: {contextMessage} - {ex.ToString()}");
    // 实际项目中会写入日志文件或发送到日志服务
}

代码解析:

特定异常优先:首先捕获更具体的异常类型,如 DbUpdateException (EF Core 更新数据库时可能抛出) 或 DbUpdateConcurrencyException (并发冲突时抛出)。这样可以进行更针对性的处理。
通用异常:然后捕获更通用的数据库异常 System.Data.Common.DbException
Exception:最后捕获所有其他类型的 Exception,作为最后的防线,确保没有未处理的异常导致程序崩溃。
用户反馈:在 catch 块中,更新 StatusMessage 并在必要时通过 MessageBox 向用户显示错误信息。
日志记录:调用 LogError 方法(我们稍后会详细实现)来记录异常的详细信息,以便开发者进行分析和调试。
并发处理:在 DeleteBook 中,演示了如何捕获 DbUpdateConcurrencyException。当多个用户同时操作同一条数据时可能发生这种情况。处理方式通常是通知用户,并建议刷新数据后重试。
数据一致性:在删除操作中,先从数据库查找实体再删除,确保操作的是最新的数据状态。如果实体未找到,也应给予用户提示。

7.1.2 全局异常处理

除了在特定代码块中使用 try-catch,WPF 应用程序还可以设置全局异常处理器来捕获那些在 UI 线程中未被处理的异常,以及来自非 UI 线程的未处理异常。

1. UI 线程未处理异常 (Application.DispatcherUnhandledException)

App.xaml.cs 中,可以订阅 DispatcherUnhandledException 事件:

using System.Windows;
using System.Windows.Threading; // 需要此 using

namespace WPF_Books
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            // 订阅 UI 线程未处理异常事件
            this.DispatcherUnhandledException += App_DispatcherUnhandledException;

            // ... (原有的启动登录窗口逻辑)
            var loginView = new LoginView();
            if (loginView.ShowDialog() == true)
            {
                var bookView = new BookView(loginView.LoggedInUser);
                bookView.Show();
            }
            else
            {
                Shutdown();
            }
        }

        private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
        {
            // 标记异常已处理,防止应用程序默认行为(通常是崩溃)
            e.Handled = true; 

            string errorMessage = $"发生了一个未处理的UI线程异常:
{e.Exception.Message}";

            // 记录日志 (我们将在后面实现一个简单的日志服务)
            SimpleFileLogger.Log($"UI Dispatcher Unhandled Exception: {e.Exception.ToString()}");

            // 向用户显示错误信息
            MessageBox.Show(errorMessage, "应用程序错误", MessageBoxButton.OK, MessageBoxImage.Error);

            // 根据情况决定是否关闭应用程序
            // if (e.Exception is CriticalException) // 假设有一个 CriticalException 类型
            // {
            //     Shutdown(-1); 
            // }
        }
    }
}

代码解析 (App_DispatcherUnhandledException):

e.Handled = true;: 这是关键。将此属性设置为 true 告诉 WPF 框架我们已经处理了这个异常,从而阻止应用程序因未处理异常而终止。
日志记录:调用日志记录方法(这里假设有一个 SimpleFileLogger.Log 方法)记录完整的异常信息,包括堆栈跟踪。
用户通知:通过 MessageBox 向用户显示一个友好的错误消息。
可选的关闭逻辑:在某些严重错误发生后,可能需要关闭应用程序。可以根据异常类型来决定。

2. 非 UI 线程未处理异常 (AppDomain.CurrentDomain.UnhandledException)

对于在后台线程(例如通过 Task.Run 启动的任务)中发生的未处理异常,DispatcherUnhandledException 不会捕获它们。需要使用 AppDomain.CurrentDomain.UnhandledException

App.xaml.csOnStartup 中添加订阅:

// ... (在 App.xaml.cs 的 OnStartup 方法中)
protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);

    this.DispatcherUnhandledException += App_DispatcherUnhandledException;

    // 订阅非 UI 线程未处理异常事件
    AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;

    // ... (启动窗口逻辑)
}

private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    Exception? ex = e.ExceptionObject as Exception;
    string errorMessage = "发生了一个未处理的后台线程异常。应用程序可能变得不稳定。";
    if (ex != null)
    {
        errorMessage += $"
详情: {ex.Message}";
        SimpleFileLogger.Log($"AppDomain Unhandled Exception: {ex.ToString()}");
    }
    else
    {
        SimpleFileLogger.Log($"AppDomain Unhandled Exception: {e.ExceptionObject.ToString()}");
    }

    // 注意:在这个事件处理器中,通常无法安全地进行复杂的UI操作,
    // 因为它可能不在UI线程上执行。主要目的是记录日志。
    // 如果需要通知用户,应考虑如何安全地切换到UI线程,或仅记录日志并允许程序终止(如果错误严重)。

    // 如果 e.IsTerminating 为 true,表示CLR即将终止应用程序。
    if (e.IsTerminating)
    {
        SimpleFileLogger.Log("应用程序即将因未处理的后台异常而终止。");
        // 可以在这里尝试做最后的清理工作,但时间非常有限。
    }
    else
    {
        // 理论上,如果 IsTerminating 是 false,程序可能继续运行,但这通常不推荐,
        // 因为未处理的后台异常可能已损坏应用程序状态。
        // 对于WPF应用,更安全的做法通常是记录日志并让程序按预期(或非预期)结束,
        // 或者尝试优雅关闭。
        // MessageBox.Show(errorMessage, "后台错误", MessageBoxButton.OK, MessageBoxImage.Error); // 不推荐直接调用
    }
}

代码解析 (CurrentDomain_UnhandledException):

e.ExceptionObject: 包含未处理的异常对象。需要将其转换为 Exception 类型。
日志记录:首要任务是记录异常信息。
UI 操作限制:此事件处理器可能不在 UI 线程上执行,因此直接进行 UI 操作 (如显示 MessageBox) 是不安全的,可能导致跨线程访问异常。如果确实需要通知用户,应使用 Dispatcher.Invoke 或类似机制切换到 UI 线程,但这会增加复杂性。通常,对于后台线程的致命错误,记录日志后让程序终止是可接受的。
e.IsTerminating: 指示 CLR 是否因为此异常而决定终止应用程序。

7.2 日志记录

日志记录是将应用程序运行时的重要事件、错误信息、调试信息等写入持久化存储(如文件、数据库、事件查看器或远程日志服务)的过程。

7.2.1 选择日志框架

虽然可以手动实现简单的日志记录,但使用成熟的日志框架通常更高效、功能更强大。流行的 .NET 日志框架包括:

Serilog: 功能丰富,高度可配置,支持结构化日志。
NLog: 另一个流行的、灵活的日志框架。
log4net: 一个历史悠久且广泛使用的日志框架。
Microsoft.Extensions.Logging: .NET Core 和 ASP.NET Core 中内置的日志抽象,也可以在 WPF 中使用,通常与其他日志提供程序(如 Serilog, NLog)结合。

对于本项目,为了简单起见,我们将实现一个非常基础的文本文件日志记录器。在实际项目中,强烈建议使用上述成熟框架之一。

7.2.2 实现简单的文件日志记录器 (SimpleFileLogger.cs)

创建 SimpleFileLogger.cs 文件:在项目根目录或一个 Services / Logging 文件夹下创建该类。

using System;
using System.IO;
using System.Text;

namespace WPF_Books
{

public static class SimpleFileLogger
{

private static readonly string LogFilePath;
private static readonly object LockObj = new object();

       static SimpleFileLogger()
       {
           try
           {
               string logDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
               if (!Directory.Exists(logDirectory))
               {
                   Directory.CreateDirectory(logDirectory);
               }
               // 日志文件名可以包含日期,例如:AppLog_2023-10-27.log
               LogFilePath = Path.Combine(logDirectory, $"AppLog_{DateTime.Now:yyyy-MM-dd}.log");
           }
           catch (Exception ex)
           {
               // 如果创建日志文件路径失败,则记录到调试输出,并禁用日志文件记录
               System.Diagnostics.Debug.WriteLine($"无法初始化日志文件路径: {ex.Message}");
               LogFilePath = string.Empty; // 表示日志记录到文件已禁用
           }
       }

       public static void Log(string message, LogLevel level = LogLevel.Info)
       {
           if (string.IsNullOrEmpty(LogFilePath)) return; // 如果路径无效,则不记录

           try
           {
               string logEntry = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] - {message}{Environment.NewLine}";

               lock (LockObj) // 简单的线程安全,防止多个线程同时写入文件
               {
                   File.AppendAllText(LogFilePath, logEntry, Encoding.UTF8);
               }
           }
           catch (Exception ex)
           {
               // 如果写入日志失败,记录到调试输出
               System.Diagnostics.Debug.WriteLine($"写入日志文件失败: {ex.Message}
原始消息: {message}");
           }
       }

       public static void LogError(Exception ex, string contextMessage = "")
       {
           string message = string.IsNullOrEmpty(contextMessage) ? ex.ToString() : $"{contextMessage}
{ex.ToString()}";
           Log(message, LogLevel.Error);
       }
   }

   public enum LogLevel
   {
       Debug,
       Info,
       Warning,
       Error,
       Fatal
   }

}

代码解析 (SimpleFileLogger.cs):

LogFilePath: 静态只读字段,存储日志文件的完整路径。在静态构造函数中初始化。
LockObj: 用于 lock 语句,确保对日志文件的写入是线程安全的,防止并发写入导致文件损坏或数据丢失。
静态构造函数 static SimpleFileLogger():

在类首次被访问时执行。
创建 Logs 子目录(如果不存在)。
构造日志文件名,包含当前日期,这样每天会生成一个新的日志文件。
如果初始化日志路径失败(例如由于权限问题),则将 LogFilePath 设为空字符串,并禁止后续的文件日志记录。

Log(string message, LogLevel level = LogLevel.Info) 方法:

核心日志记录方法。
如果 LogFilePath 无效,则直接返回。
格式化日志条目,包含时间戳、日志级别和消息内容。
使用 lock (LockObj) 来同步对 File.AppendAllText 的调用。
File.AppendAllText 用于将文本追加到文件末尾,如果文件不存在则创建它。使用 Encoding.UTF8 以支持多种字符。
如果在写入日志时发生异常,则将错误信息输出到调试控制台。

LogError(Exception ex, string contextMessage = "") 方法:

一个辅助方法,专门用于记录异常信息。它会格式化异常的详细信息 (包括堆栈跟踪) 和可选的上下文消息。

LogLevel 枚举: 定义了不同的日志级别,可以用于过滤或分类日志条目。

7.2.3 在应用程序中使用日志记录器

现在可以在应用程序的各个部分调用 SimpleFileLogger

BookViewModel.csLogError 方法中使用:

// … (在 BookViewModel 中)
private void LogError(Exception ex, string contextMessage)
{

SimpleFileLogger.LogError(ex, contextMessage);
// System.Diagnostics.Debug.WriteLine($“错误: {contextMessage} – {ex.ToString()}”); // 保留原有调试输出,或按需移除
}

在全局异常处理器中使用:我们已经在 App.xaml.csApp_DispatcherUnhandledExceptionCurrentDomain_UnhandledException 方法中添加了对 SimpleFileLogger.LogSimpleFileLogger.LogError 的调用。

记录其他重要事件 (可选):除了错误,还可以记录其他信息,如用户登录、重要操作的开始和结束等。

// … (在 LoginViewModel 的 Login 方法中)
private async Task Login()
{

// … (验证用户)
if (user != null)
{

IsLoggedIn = true;
LoggedInUser = user;
SimpleFileLogger.Log(KaTeX parse error: Expected 'EOF', got '}' at position 83: …voke(); }̲ else …“用户 ‘{Username}’ 登录失败:用户名或密码错误。”, LogLevel.Warning);
}
}

// … (在 BookViewModel 的构造函数或初始化方法中)
public BookViewModel(User? currentUser, AppDbContext context) // 确保 AppDbContext 被传入
{

_context = context;
_currentUser = currentUser;
SimpleFileLogger.Log($“BookView 初始化,当前用户: {_currentUser?.Username ?? “未知”}”, LogLevel.Info);
// …
LoadBooksCommand = new RelayCommand(async () => await LoadBooksAsync());
// …
Task.Run(async () => await LoadBooksAsync()); // 初始加载
}

运行应用程序并执行一些操作(包括故意触发一些错误,例如输入无效数据后尝试保存,或模拟数据库连接失败),然后检查项目输出目录下的 Logs 文件夹(通常在 binDebug
etX.X-windowsLogs
binRelease
etX.X-windowsLogs
),应该能看到生成的 AppLog_YYYY-MM-DD.log 文件以及其中记录的日志信息。

通过本章的实现,我们的应用程序具备了更强的健壮性,能够更好地应对运行时错误,并通过日志文件为开发者提供了问题追踪的手段。在实际的生产环境中,错误处理和日志记录是非常关键的组成部分。

章节八:应用打包与部署

当应用程序开发完成后,下一步是将其打包并分发给最终用户。本章将介绍几种常见的 WPF 应用程序打包和部署方法。

8.1 打包与部署概述

部署WPF应用程序意味着将所有必要的文件(可执行文件、DLL、资源文件、配置文件等)以一种用户可以轻松安装和运行的方式组织起来。

常见的部署方式包括:

XCopy 部署 (或称文件夹部署):最简单的方式,直接将编译输出的文件夹(通常是 binReleasebinDebug 下的 netX.X-windows 文件夹)复制到目标机器上。用户直接运行其中的 .exe 文件。
ClickOnce 部署:.NET 提供的一种部署技术,允许用户通过 Web 浏览器或网络共享轻松安装和更新 Windows 应用程序。它处理依赖项、版本控制和自动更新。
MSIX 打包 (推荐):现代的 Windows 应用程序打包格式,提供了可靠的安装、卸载和更新体验,支持容器化,并且可以发布到 Microsoft Store 或进行旁加载 (sideloading)。
安装程序 (Installer):使用如 WiX Toolset、InstallShield 等工具创建传统的 .msi.exe 安装包。

对于我们的图书管理系统,我们将重点介绍 XCopy 部署和 MSIX 打包,因为它们是相对常用且现代化的方式。

8.2 XCopy 部署

XCopy 部署非常直接,适用于简单的内部应用或用户具备一定技术能力的情况。

步骤

选择发布配置

在 Visual Studio 中,将解决方案配置设置为 Release。Release 配置通常会进行优化,移除调试信息,生成更小的可执行文件。

生成应用程序

右键单击项目 (WPF-Books),选择“生成”或“重新生成”。

定位输出文件夹

在项目的输出目录中找到发布的文件。对于 .NET 6/7/8 等现代 .NET 版本,通常路径是:[项目文件夹]inRelease
et[版本号]-windows
例如:d:0Learning5C#WPF-BooksWPF-BooksinRelease
et8.0-windows

这个文件夹包含了运行应用程序所需的所有文件,包括:

WPF-Books.exe (主可执行文件)
WPF-Books.dll (如果项目类型是类库,或者某些代码被编译到单独的DLL中)
依赖的 NuGet 包的 DLL (如 Microsoft.EntityFrameworkCore.dll, Microsoft.Data.Sqlite.dll 等)
WPF-Books.deps.json (描述应用程序依赖项)
WPF-Books.runtimeconfig.json (运行时配置信息)
SQLite 数据库文件 (library.db)。重要: 对于 XCopy 部署,数据库文件需要与可执行文件在同一目录下,或者应用程序需要知道如何找到它(例如通过相对路径或配置)。在我们的 AppDbContext 中,我们使用了相对路径 DataSource=library.db (在修改为可写路径之前),这意味着它期望 library.dbWPF-Books.exe 在同一目录。
Logs 文件夹 (如果已运行并生成了日志)。

复制到目标机器

将整个 net[版本号]-windows 文件夹(或其内容)复制到目标用户的计算机上。

运行应用程序

用户双击 WPF-Books.exe 即可运行。

优点

简单快捷,无需复杂的安装过程。
易于理解和控制文件。

缺点

依赖项管理:用户机器必须安装了正确的 .NET 运行时。如果应用程序是框架依赖的 (Framework-Dependent Deployment – FDD),用户需要预先安装 .NET Desktop Runtime。如果打包为自包含部署 (Self-Contained Deployment – SCD),则会将 .NET 运行时一同打包,文件会大很多,但用户无需预装。
无自动更新:更新应用程序需要手动替换文件。
无卸载程序:用户需要手动删除文件夹。
快捷方式和开始菜单:不会自动创建桌面快捷方式或开始菜单项。
注册表和文件关联:无法进行注册表操作或设置文件关联。

关于数据库文件 library.db (XCopy):

在 XCopy 部署时,如果 AppDbContext 使用的是如 DataSource=library.db 这样的相对路径,并且没有实现将数据库复制到用户可写目录的逻辑,那么 library.db 文件会和 .exe 一起被复制和使用。这意味着每个用户都会有自己的一份数据库副本。如果需要共享数据库,则需要将数据库放在网络共享位置,并修改连接字符串。

8.3 使用 MSIX 打包 (推荐)

MSIX 是微软推荐的现代 Windows 应用程序打包格式。它提供了更可靠的安装、卸载和更新体验,并且可以更好地与现代 Windows 功能集成。

前提条件

Visual Studio 2019 16.3 或更高版本 (建议使用最新版)。
Windows 10, version 1809 (build 17763) 或更高版本用于构建 MSIX 包。目标用户也需要兼容的 Windows 版本来安装。
确保已安装 Visual Studio 的 “MSIX Packaging Tools” 组件 (通过 Visual Studio Installer 修改安装,通常包含在“通用 Windows 平台开发”负载中)。

步骤

添加 Windows Application Packaging Project:

在解决方案资源管理器中,右键单击解决方案,选择 “添加” -> “新建项目”。
搜索 “Windows Application Packaging Project”。
给打包项目命名,例如 WPF-Books.Package
选择目标版本和最低版本。通常保持默认即可。

设置应用程序引用:

在新的打包项目 (WPF-Books.Package) 中,右键单击 “应用程序” 文件夹,选择 “添加引用…”。
在弹出的对话框中,勾选你的 WPF 项目 (WPF-Books),然后单击 “确定”。

配置包清单 (Package.appxmanifest):

双击打包项目中的 Package.appxmanifest 文件以打开清单设计器。
应用程序选项卡:

显示名称: 用户在开始菜单中看到的名称 (例如, 图书管理系统 WPF)。
入口点: 应已自动设置为你的 WPF 项目。
徽标和磁贴: 配置应用程序的图标。你需要提供不同尺寸的图像资源。Visual Studio 可以帮助生成一些占位符,但建议替换为高质量的自定义图标。

打包选项卡:

包名称: 唯一标识符,通常是 GUID
包显示名称: 同上。
发布者显示名称: 你的公司或开发者名称 (例如 CN=YourName)。这在创建自签名证书时很重要。
版本: Major.Minor.Build.Revision (例如 1.0.0.0)。每次更新时应增加版本号。

功能选项卡:

对于我们的应用,如果 library.db 要写入用户目录,通常不需要特殊功能。但如果需要访问更广泛的文件系统位置,可能需要 broadFileSystemAccess (需要用户在设置中额外授权)。由于我们将数据库复制到 LocalAppData,这通常不需要特殊功能。

处理数据库文件 (library.db) 和日志目录 (Logs):

确保 library.dbWPF-Books 项目中的属性设置为:

生成操作 (Build Action): Content
复制到输出目录 (Copy to Output Directory): 始终复制 (Copy always)如果较新则复制 (Copy if newer)

修改 AppDbContext.cs 以支持 MSIX 环境下的可写数据库位置 (已在第七章中完成):回顾第七章中对 AppDbContext 的修改,确保数据库文件在首次运行时从包安装位置复制到用户可写的 LocalAppData 文件夹下的特定子目录。连接字符串应指向这个可写位置的数据库副本。

// (回顾第七章 AppDbContext.GetDatabasePath() 的实现)
// string appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
// string companyName = "MyCompany"; // 替换为你的公司名或应用名
// string appName = "WPF-Books";
// string dbDirectory = Path.Combine(appDataPath, companyName, appName);
// ...
// string packagedDbPath = Path.Combine(AppContext.BaseDirectory, "library.db");
// ...
// if (!File.Exists(dbPath) && File.Exists(packagedDbPath))
// {
//     File.Copy(packagedDbPath, dbPath, false);
// }

日志文件: SimpleFileLogger 将日志存储在 AppContext.BaseDirectory 下的 Logs 目录。在 MSIX 环境中,AppContext.BaseDirectory 指向包的安装根目录,这是只读的。因此,日志记录也会失败。解决方案: 修改 SimpleFileLogger.cs,将日志也存储到用户可写的 LocalAppData 目录中,与数据库文件类似。修改 SimpleFileLogger.cs:

// ... (using 语句)
public static class SimpleFileLogger
{
    private static readonly string LogFilePath;
    private static readonly object LockObj = new object();

    static SimpleFileLogger()
    {
        try
        {
            // 将日志目录设置到 LocalApplicationData
            string appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
            string companyName = "MyCompany"; // 与 AppDbContext 中保持一致
            string appName = "WPF-Books";      // 与 AppDbContext 中保持一致
            string logDirectoryRoot = Path.Combine(appDataPath, companyName, appName, "Logs");

            if (!Directory.Exists(logDirectoryRoot))
            {
                Directory.CreateDirectory(logDirectoryRoot);
            }
            LogFilePath = Path.Combine(logDirectoryRoot, $"AppLog_{DateTime.Now:yyyy-MM-dd}.log");
        }
        catch (Exception ex)
        {
            System.Diagnostics.Debug.WriteLine($"无法初始化日志文件路径: {ex.Message}");
            LogFilePath = string.Empty;
        }
    }
    // ... (Log 和 LogError 方法保持不变)
}
// ... (LogLevel 枚举)

重要: 确保 SimpleFileLoggerAppDbContext 中用于构建路径的 companyNameappName 字符串是相同的,以便数据和日志存储在同一应用专属目录下。

创建应用程序包:

右键单击打包项目 (WPF-Books.Package),选择 “发布” -> “创建应用程序包…”。
分发方法: 选择 “旁加载 (Sideloading)”。
自动更新: 可以暂时不勾选以简化首次打包。
签名方法: MSIX 包必须签名。

选择 “创建…” (自签名证书) 进行测试。输入发布者名称 (例如 CN=YourName,与清单中的发布者匹配) 和密码。或者,如果你有代码签名证书,选择它。

选择并配置包:

输出位置: 指定生成包的文件夹。
版本号: 确认或修改。
体系结构: 选择 x64 (或根据需要选择其他)。
生成捆绑包: 通常选择 始终 (Always)
确保 “解决方案配置” 为 Release

单击 “创建”。

分发和安装包:

构建完成后,在指定的输出位置,你会找到一个 *_Test 文件夹,其中包含 .msixbundle (或 .msix) 文件、证书 (.cer) 和一个 Add-AppDevPackage.ps1 PowerShell 脚本。
安装:

信任证书 (如果使用自签名): 在目标机器上,右键单击 .cer 文件,选择 “安装证书”。选择 “本地计算机”,然后将证书放入 “受信任的根证书颁发机构” 或 “受信任的发布者” 存储区。
安装应用: 双击 .msixbundle (或 .msix) 文件。Windows App Installer 将引导完成安装。
或者,以管理员身份运行 Add-AppDevPackage.ps1 脚本,它会尝试安装证书和包。

优点 (MSIX):

可靠的安装和卸载。
支持自动更新。
增强的安全性 (容器化)。
可发布到 Microsoft Store。

缺点 (MSIX):

比 XCopy 复杂。
旁加载需要签名和证书信任步骤。
对文件系统写入的严格限制要求仔细规划数据存储。

8.4 ClickOnce 部署

ClickOnce 是 .NET 提供的另一种部署技术,允许用户从 Web 服务器、文件共享或 CD/DVD 安装和运行 Windows 应用程序。它也支持自动更新。

步骤 (简要):

在 WPF 项目属性中配置 ClickOnce:

右键单击 WPF-Books 项目,选择 “属性”。
转到 “发布” 选项卡 (如果找不到,可能需要安装 Visual Studio 的 ClickOnce 发布工具组件)。
发布文件夹位置: 指定应用程序文件将发布到的位置。
安装文件夹 URL (可选): 如果应用程序将从与发布位置不同的 URL 安装。
安装模式和设置: 联机或脱机可用。
应用程序文件: 确保 library.db 包含在内,并设置为 Data File,且复制行为正确。
系统必备: 配置 .NET Desktop Runtime 等依赖。
更新: 配置更新检查。
签名: ClickOnce 应用程序也需要签名。

发布应用程序: 单击 “立即发布”。

分发给用户: 用户运行 setup.exe 或通过部署 URL 安装。

数据库和日志处理: 类似于 MSIX,如果 library.db 和日志文件需要写入,应在首次运行时复制/创建到用户可写的位置 (如 LocalAppDataApplicationData)。

8.5 总结与选择

XCopy: 最简单,适合内部或临时分发,无自动更新。
ClickOnce: 较易于设置自动更新,适合通过 Web 或网络共享分发。
MSIX: 现代、可靠、安全,是微软推荐的方式,尤其适合商店发布或企业旁加载,但对数据存储有严格要求。

对于本项目,MSIX 是推荐的长期解决方案,因为它提供了最佳的用户体验和管理特性。XCopy 可用于快速测试。ClickOnce 也是一个可行的选择。

无论选择哪种方法,确保正确处理用户数据(如 library.db)和日志文件的存储位置及读写权限至关重要,通常意味着将这些文件放在用户配置文件目录下的应用专属文件夹中。

至此,我们已经完成了WPF图书管理系统实训手册的所有主要章节。希望本手册能帮助你理解和掌握使用WPF和MVVM模式开发桌面应用程序的基本流程和关键技术点。祝你学习愉快,编码顺利!


后续步骤建议

实际操作:亲自动手按照手册步骤实现整个项目,并尝试不同的部署方法。
扩展功能:尝试为项目添加新功能,例如:

更高级的搜索和筛选图书功能(多条件组合)。
用户角色管理 (管理员 vs 普通用户,不同权限)。
图书封面图片显示和上传。
借阅/归还历史记录,超期提醒。
导出图书列表到 CSV 或 Excel 文件。
实现单元测试和集成测试。

深入学习:研究本手册中涉及的各个技术点,如 XAML 高级布局、数据绑定模式、EF Core 性能优化、异步编程最佳实践、依赖注入容器的使用 (如 Microsoft.Extensions.DependencyInjection) 等。
探索其他 UI 框架:了解 Avalonia UI 或 Uno Platform 等跨平台 .NET UI 框架,它们允许你将类似的 MVVM 技能应用于更广泛的平台。

感谢阅读!

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容