好的,构建一个图书管理系统是一个很棒的项目,结合Vue3、Spring Boot和MySQL可以实现一个功能完善、前后端分离的现代Web应用。下面我将为你提供一个详细的步骤和关键代码点指南,并为所有代码块添加中文注释。

好的,构建一个图书管理系统是一个很棒的项目,结合Vue3、Spring Boot和MySQL可以实现一个功能完善、前后端分离的现代Web应用。下面我将为你提供一个详细的步骤和关键代码点指南,并为所有代码块添加中文注释。

系统核心功能:

  1. 图书管理:
    • 添加图书
    • 删除图书
    • 修改图书信息
    • 查询图书(按书名、作者、ISBN等)
    • 图书列表展示(分页)
  2. 用户管理 (可选,但推荐):
    • 用户注册与登录
    • 角色管理 (管理员 vs 普通用户)
  3. 借阅管理:
    • 借书
    • 还书
    • 查看借阅记录
    • 查看当前借阅(用户)
    • 逾期提醒 (可选)

技术栈:

  • 前端: Vue 3 (Composition API + Vite/Vue CLI) + Axios + Vue Router + UI库 (如 Element Plus, Naive UI)
  • 后端: Spring Boot + Spring MVC + Spring Data JPA + Spring Security (可选,用于认证授权)
  • 数据库: MySQL
  • 构建工具: Maven / Gradle
  • 开发工具: IntelliJ IDEA (后端) + VS Code (前端)

一、后端 (Spring Boot + MySQL)

  1. 项目初始化 (Spring Initializr):

    • 访问 https://start.spring.io/
    • Project: Maven Project
    • Language: Java
    • Spring Boot: 最新稳定版 (例如 3.x.x)
    • Project Metadata:
      • Group: com.example (你的包名)
      • Artifact: library-management-system
      • Name: library-management-system
      • Description: Library Management System
      • Package name: com.example.lms
      • Packaging: Jar
      • Java: 17 (或更高)
    • Dependencies:
      • Spring Web (构建RESTful APIs)
      • Spring Data JPA (数据持久化)
      • MySQL Driver (连接MySQL)
      • Lombok (简化POJO代码)
      • Spring Security (可选,用于安全)
      • Validation (用于数据校验)
    • 点击 "GENERATE",下载并解压项目。
  2. 数据库设计 (MySQL):

    • books 表:

      CREATE TABLE books (
          id BIGINT AUTO_INCREMENT PRIMARY KEY,          -- 图书ID,主键,自增
          title VARCHAR(255) NOT NULL,                  -- 书名,不能为空
          author VARCHAR(255) NOT NULL,                 -- 作者,不能为空
          isbn VARCHAR(50) UNIQUE NOT NULL,             -- ISBN号,唯一,不能为空
          publisher VARCHAR(255),                       -- 出版社
          publication_date DATE,                        -- 出版日期
          quantity INT NOT NULL DEFAULT 0,              -- 总数量,不能为空,默认为0
          available_quantity INT NOT NULL DEFAULT 0,    -- 可借数量,不能为空,默认为0
          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间,默认为当前时间戳
          updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 更新时间,默认为当前时间戳,并在更新时自动更新
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
      
    • users 表 (如果需要用户管理):

      CREATE TABLE users (
          id BIGINT AUTO_INCREMENT PRIMARY KEY,
          username VARCHAR(100) UNIQUE NOT NULL,       -- 用户名,唯一,不能为空
          password VARCHAR(255) NOT NULL,              -- 密码 (存储哈希后的密码),不能为空
          email VARCHAR(255) UNIQUE,                   -- 邮箱,唯一
          role VARCHAR(50) DEFAULT 'USER',             -- 角色 (例如 'USER', 'ADMIN'),默认为 'USER'
          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 创建时间
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
      
    • borrow_records 表:

      CREATE TABLE borrow_records (
          id BIGINT AUTO_INCREMENT PRIMARY KEY,
          user_id BIGINT NOT NULL,                     -- 用户ID,外键关联 users 表
          book_id BIGINT NOT NULL,                     -- 图书ID,外键关联 books 表
          borrow_date TIMESTAMP NOT NULL,              -- 借阅日期,不能为空
          due_date TIMESTAMP NOT NULL,                 -- 应还日期,不能为空
          return_date TIMESTAMP NULL,                  -- 归还日期,可以为空
          status VARCHAR(20) NOT NULL DEFAULT 'BORROWED', -- 状态 (例如 'BORROWED', 'RETURNED', 'OVERDUE'),默认为 'BORROWED'
          FOREIGN KEY (user_id) REFERENCES users(id),
          FOREIGN KEY (book_id) REFERENCES books(id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
      
    • 确保你已经创建了相应的数据库 (例如 library_db)。

  3. 配置 application.properties (或 application.yml):
    位于 src/main/resources/application.properties

    # 服务器端口
    server.port=8080
    
    # MySQL 数据源配置
    spring.datasource.url=jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    spring.datasource.username=your_mysql_user       # 你的MySQL用户名
    spring.datasource.password=your_mysql_password   # 你的MySQL密码
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # MySQL驱动类
    
    # JPA / Hibernate 配置
    # ddl-auto:
    #   create: 每次启动时删除并重新创建表结构 (开发时常用)
    #   create-drop: 启动时创建,关闭时删除
    #   update: 启动时检查并更新表结构 (可能不安全,不会删除列)
    #   validate: 启动时校验表结构与实体是否匹配
    #   none: 不做任何操作
    spring.jpa.hibernate.ddl-auto=update
    spring.jpa.show-sql=true # 在控制台显示执行的SQL语句
    spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect # 指定Hibernate方言
    
    # (可选) 如果使用Spring Security和JWT进行认证
    # jwt.secret=yourVeryLongAndSecureSecretKeyForJWTGeneration # JWT签名密钥
    # jwt.expirationMs=86400000 # JWT过期时间 (毫秒,例如24小时)
    
  4. 创建实体类 (Entities):
    位于 com.example.lms.model (或 com.example.lms.entity) 包下。

    • Book.java:
      package com.example.lms.model;
      
      import jakarta.persistence.*; // JPA注解包
      import jakarta.validation.constraints.NotBlank; // 校验注解:非空字符串
      import jakarta.validation.constraints.NotNull;  // 校验注解:非null
      import jakarta.validation.constraints.PositiveOrZero; // 校验注解:正数或零
      import lombok.Data; // Lombok注解:自动生成getter, setter, toString等
      import lombok.NoArgsConstructor; // Lombok注解:自动生成无参构造函数
      import lombok.AllArgsConstructor; // Lombok注解:自动生成全参构造函数
      import java.time.LocalDate; // Java 8 日期类
      import java.time.LocalDateTime; // Java 8 日期时间类
      
      @Entity // 声明这是一个JPA实体类
      @Table(name = "books") // 映射到数据库中的 "books" 表
      @Data // Lombok: 自动生成getter, setter, equals, hashCode, toString
      @NoArgsConstructor // Lombok: 生成无参构造函数
      @AllArgsConstructor // Lombok: 生成包含所有字段的构造函数
      public class Book {
          @Id // 声明这是主键
          @GeneratedValue(strategy = GenerationType.IDENTITY) // 主键生成策略:数据库自增
          private Long id; // 图书ID
      
          @NotBlank(message = "书名不能为空") // 校验规则:书名不能为空字符串
          @Column(nullable = false) // 数据库列约束:不能为空
          private String title; // 书名
      
          @NotBlank(message = "作者不能为空")
          @Column(nullable = false)
          private String author; // 作者
      
          @NotBlank(message = "ISBN不能为空")
          @Column(unique = true, nullable = false) // 数据库列约束:唯一且不能为空
          private String isbn; // ISBN号
      
          private String publisher; // 出版社
      
          @Column(name = "publication_date") // 映射到 "publication_date" 列
          private LocalDate publicationDate; // 出版日期
      
          @NotNull(message = "总数量不能为空")
          @PositiveOrZero(message = "总数量必须为零或正数")
          private Integer quantity; // 总数量
      
          @NotNull(message = "可借数量不能为空")
          @PositiveOrZero(message = "可借数量必须为零或正数")
          @Column(name = "available_quantity") // 映射到 "available_quantity" 列
          private Integer availableQuantity; // 可借数量
      
          @Column(name = "created_at", updatable = false) // 创建时间,不允许更新
          @Temporal(TemporalType.TIMESTAMP) // 指定日期时间类型映射
          private LocalDateTime createdAt; // 创建时间
      
          @Column(name = "updated_at")
          @Temporal(TemporalType.TIMESTAMP)
          private LocalDateTime updatedAt; // 更新时间
      
          @PrePersist // JPA回调:在实体持久化之前执行
          protected void onCreate() {
              LocalDateTime now = LocalDateTime.now();
              createdAt = now;
              updatedAt = now;
              // 如果可借数量未设置,则默认为总数量
              if (availableQuantity == null && quantity != null) {
                  availableQuantity = quantity;
              } else if (availableQuantity == null) {
                  availableQuantity = 0; // 默认可借为0
              }
          }
      
          @PreUpdate // JPA回调:在实体更新之前执行
          protected void onUpdate() {
              updatedAt = LocalDateTime.now();
          }
      }
      
    • 类似地创建 User.javaBorrowRecord.java 实体类,并使用 @ManyToOne, @OneToMany 等注解定义它们之间的关系。例如,一个 BorrowRecord@ManyToOne 关联一个 User 和一个 Book
  5. 创建仓库接口 (Repositories):
    位于 com.example.lms.repository 包下。

    • BookRepository.java:
      package com.example.lms.repository;
      
      import com.example.lms.model.Book;
      import org.springframework.data.domain.Page; // Spring Data 分页支持
      import org.springframework.data.domain.Pageable; // Spring Data 分页参数
      import org.springframework.data.jpa.repository.JpaRepository; // Spring Data JPA核心接口
      import org.springframework.stereotype.Repository; // 声明这是一个仓库Bean
      
      import java.util.Optional; // Java 8 Optional
      
      @Repository // 将该接口声明为Spring管理的Bean
      public interface BookRepository extends JpaRepository<Book, Long> { // 继承JpaRepository,获得CRUD和分页等功能
          // JpaRepository<实体类型, 主键类型>
      
          // 根据ISBN查找图书 (Spring Data JPA会根据方法名自动生成查询)
          Optional<Book> findByIsbn(String isbn);
      
          // 根据书名或作者模糊查询并分页 (忽略大小写)
          // Spring Data JPA会解析方法名生成SQL: SELECT * FROM books WHERE lower(title) LIKE lower(?) OR lower(author) LIKE lower(?)
          Page<Book> findByTitleContainingIgnoreCaseOrAuthorContainingIgnoreCase(String titleKeyword, String authorKeyword, Pageable pageable);
      }
      
    • 创建 UserRepository.javaBorrowRecordRepository.java
  6. 创建服务层 (Services):
    位于 com.example.lms.service 包下。封装业务逻辑。

    • BookService.java:
      package com.example.lms.service;
      
      import com.example.lms.exception.DuplicateIsbnException; // 自定义异常
      import com.example.lms.exception.ResourceNotFoundException; // 自定义异常
      import com.example.lms.model.Book;
      import com.example.lms.repository.BookRepository;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.data.domain.Page;
      import org.springframework.data.domain.Pageable;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional; // 事务管理
      
      import java.util.Optional;
      
      @Service // 声明这是一个服务Bean
      public class BookService {
      
          @Autowired // 自动注入BookRepository实例
          private BookRepository bookRepository;
      
          // 获取所有图书(分页)
          @Transactional(readOnly = true) // 只读事务,提高性能
          public Page<Book> getAllBooks(Pageable pageable) {
              return bookRepository.findAll(pageable);
          }
      
          // 根据ID获取图书
          @Transactional(readOnly = true)
          public Optional<Book> getBookById(Long id) {
              return bookRepository.findById(id);
          }
      
          // 根据ISBN获取图书
          @Transactional(readOnly = true)
          public Optional<Book> getBookByIsbn(String isbn) {
              return bookRepository.findByIsbn(isbn);
          }
      
          // 保存图书 (创建或更新)
          @Transactional // 读写事务
          public Book saveBook(Book book) {
              // 如果是新书 (ID为null),检查ISBN是否已存在
              if (book.getId() == null && bookRepository.findByIsbn(book.getIsbn()).isPresent()) {
                  throw new DuplicateIsbnException("ISBN: " + book.getIsbn() + " 已存在");
              }
              // 如果是新书且可借数量未设置,则默认为总数量
              if (book.getId() == null && book.getAvailableQuantity() == null && book.getQuantity() != null) {
                  book.setAvailableQuantity(book.getQuantity());
              } else if (book.getId() == null && book.getAvailableQuantity() == null){
                   book.setAvailableQuantity(0); // 确保不为null
              }
              return bookRepository.save(book);
          }
      
          // 更新图书信息
          @Transactional
          public Book updateBook(Long id, Book bookDetails) {
              // 首先查找图书是否存在
              Book existingBook = bookRepository.findById(id)
                      .orElseThrow(() -> new ResourceNotFoundException("未找到ID为 " + id + " 的图书"));
      
              // 检查更新后的ISBN是否与其它图书冲突 (如果ISBN允许修改且修改了)
              if (bookDetails.getIsbn() != null && !bookDetails.getIsbn().equals(existingBook.getIsbn())) {
                  bookRepository.findByIsbn(bookDetails.getIsbn()).ifPresent(b -> {
                      if (!b.getId().equals(id)) { // 如果找到的ISBN属于另一本书
                          throw new DuplicateIsbnException("更新失败,ISBN: " + bookDetails.getIsbn() + " 已被其他图书使用");
                      }
                  });
                  existingBook.setIsbn(bookDetails.getIsbn());
              }
      
              // 更新图书属性
              existingBook.setTitle(bookDetails.getTitle());
              existingBook.setAuthor(bookDetails.getAuthor());
              existingBook.setPublisher(bookDetails.getPublisher());
              existingBook.setPublicationDate(bookDetails.getPublicationDate());
      
              // 处理数量更新:
              // 当总数量变化时,可借数量应该相应调整。
              // 注意:更复杂的场景下,需要考虑当前已借出的数量。
              // 此处简化处理:可借数量的变化量 = 总数量的变化量
              if (bookDetails.getQuantity() != null) {
                  int quantityDiff = bookDetails.getQuantity() - existingBook.getQuantity();
                  existingBook.setQuantity(bookDetails.getQuantity());
                  existingBook.setAvailableQuantity(existingBook.getAvailableQuantity() + quantityDiff);
      
                  // 确保可借数量不为负数且不超过总数量
                  if (existingBook.getAvailableQuantity() < 0) {
                      existingBook.setAvailableQuantity(0);
                  }
                  if (existingBook.getAvailableQuantity() > existingBook.getQuantity()) {
                      existingBook.setAvailableQuantity(existingBook.getQuantity());
                  }
              }
              // 如果前端直接传递了 availableQuantity,也允许更新,但仍需校验
              if (bookDetails.getAvailableQuantity() != null) {
                  existingBook.setAvailableQuantity(bookDetails.getAvailableQuantity());
                   if (existingBook.getAvailableQuantity() < 0) {
                      existingBook.setAvailableQuantity(0);
                  }
                  if (existingBook.getAvailableQuantity() > existingBook.getQuantity()) {
                      existingBook.setAvailableQuantity(existingBook.getQuantity());
                  }
              }
      
      
              return bookRepository.save(existingBook);
          }
      
          // 删除图书
          @Transactional
          public void deleteBook(Long id) {
              // 检查图书是否存在
              if (!bookRepository.existsById(id)) {
                  throw new ResourceNotFoundException("未找到ID为 " + id + " 的图书,无法删除");
              }
              // TODO: 在实际应用中,删除前应检查该图书是否有未归还的借阅记录
              bookRepository.deleteById(id);
          }
      
          // 搜索图书(根据关键词匹配书名或作者)
          @Transactional(readOnly = true)
          public Page<Book> searchBooks(String keyword, Pageable pageable) {
              if (keyword == null || keyword.trim().isEmpty()) {
                  return getAllBooks(pageable); // 如果关键词为空,返回所有图书
              }
              return bookRepository.findByTitleContainingIgnoreCaseOrAuthorContainingIgnoreCase(keyword, keyword, pageable);
          }
      }
      
    • 创建 UserService.javaBorrowService.java (借阅服务会更复杂,需要处理库存、用户校验、记录生成等)。
  7. 创建控制器 (Controllers - REST APIs):
    位于 com.example.lms.controller 包下。

    • BookController.java:
      package com.example.lms.controller;
      
      import com.example.lms.model.Book;
      import com.example.lms.service.BookService;
      import jakarta.validation.Valid; // JSR 303 Bean Validation: 启用对方法参数的校验
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.data.domain.Page;
      import org.springframework.data.domain.PageRequest;
      import org.springframework.data.domain.Pageable;
      import org.springframework.data.domain.Sort;
      import org.springframework.http.HttpStatus;
      import org.springframework.http.ResponseEntity; // Spring MVC 响应实体类
      import org.springframework.web.bind.annotation.*; // Spring MVC 注解
      
      import java.util.Arrays;
      import java.util.stream.Collectors;
      
      @RestController // 声明这是一个RESTful控制器,@Controller + @ResponseBody的组合
      @RequestMapping("/api/books") // 所有请求路径以 "/api/books" 开头
      // 允许来自 http://localhost:5173 (Vite默认端口) 的跨域请求
      // 生产环境中通常通过网关或Nginx/Apache等反向代理配置CORS
      @CrossOrigin(origins = {"http://localhost:5173/", "http://localhost:8081/"}) // 8081是Vue CLI的默认端口
      public class BookController {
      
          @Autowired // 自动注入BookService实例
          private BookService bookService;
      
          // 获取图书列表 (支持分页、排序、搜索)
          @GetMapping
          public ResponseEntity<Page<Book>> getAllBooks(
                  // @RequestParam 用于获取查询参数
                  @RequestParam(defaultValue = "0") int page,         // 当前页码,默认为0 (Spring Data Pageable是0-based)
                  @RequestParam(defaultValue = "10") int size,        // 每页大小,默认为10
                  @RequestParam(defaultValue = "id,asc") String[] sort, // 排序字段和方向,格式: "field,direction", e.g., "title,desc"
                  @RequestParam(required = false) String keyword) {    // 搜索关键词,可选
      
              // 解析排序参数
              Sort.Order[] orders = Arrays.stream(sort)
                  .map(s -> {
                      String[] parts = s.split(",");
                      return new Sort.Order(parts.length > 1 && parts[1].equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC, parts[0]);
                  })
                  .toArray(Sort.Order[]::new);
      
              Pageable pageable = PageRequest.of(page, size, Sort.by(orders)); // 创建分页和排序对象
      
              Page<Book> booksPage;
              if (keyword != null && !keyword.trim().isEmpty()) {
                  booksPage = bookService.searchBooks(keyword.trim(), pageable); // 调用服务层搜索方法
              } else {
                  booksPage = bookService.getAllBooks(pageable); // 调用服务层获取所有图书方法
              }
              return ResponseEntity.ok(booksPage); // 返回200 OK 和图书分页数据
          }
      
          // 根据ID获取单本图书
          @GetMapping("/{id}")
          public ResponseEntity<Book> getBookById(@PathVariable Long id) { // @PathVariable 从路径中获取参数
              return bookService.getBookById(id)
                      .map(ResponseEntity::ok) // 如果找到,返回200 OK 和图书数据
                      .orElse(ResponseEntity.notFound().build()); // 如果未找到,返回404 Not Found
          }
      
          // 创建一本新书
          @PostMapping
          public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) { // @Valid 启用对Book对象的校验, @RequestBody 将请求体JSON转为Book对象
              // ISBN唯一性校验已移至Service层
              Book savedBook = bookService.saveBook(book);
              return ResponseEntity.status(HttpStatus.CREATED).body(savedBook); // 返回201 Created 和创建的图书数据
          }
      
          // 更新一本现有图书
          @PutMapping("/{id}")
          public ResponseEntity<Book> updateBook(@PathVariable Long id, @Valid @RequestBody Book bookDetails) {
              Book updatedBook = bookService.updateBook(id, bookDetails); // Service层会处理未找到的情况
              return ResponseEntity.ok(updatedBook); // 返回200 OK 和更新后的图书数据
          }
      
          // 删除一本图书
          @DeleteMapping("/{id}")
          public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
              bookService.deleteBook(id); // Service层会处理未找到的情况
              return ResponseEntity.noContent().build(); // 返回204 No Content,表示成功删除,无返回体
          }
      }
      
    • 创建 UserController.javaBorrowController.java
  8. 自定义异常类:
    位于 com.example.lms.exception 包下。

    • ResourceNotFoundException.java:
      package com.example.lms.exception;
      
      import org.springframework.http.HttpStatus;
      import org.springframework.web.bind.annotation.ResponseStatus;
      
      // 当请求的资源未找到时抛出此异常
      // @ResponseStatus(HttpStatus.NOT_FOUND) // 可以直接在这里指定HTTP状态码,但通常在全局异常处理器中处理更灵活
      public class ResourceNotFoundException extends RuntimeException {
          public ResourceNotFoundException(String message) {
              super(message); // 调用父类构造函数,设置异常信息
          }
      }
      
    • DuplicateIsbnException.java:
      package com.example.lms.exception;
      
      // 当尝试创建或更新图书导致ISBN重复时抛出此异常
      public class DuplicateIsbnException extends RuntimeException {
          public DuplicateIsbnException(String message) {
              super(message);
          }
      }
      
  9. 全局异常处理 (GlobalExceptionHandler.java):
    位于 com.example.lms.exception (或专门的 com.example.lms.config 包下)。

    package com.example.lms.exception;
    
    import org.springframework.dao.DataIntegrityViolationException;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.validation.FieldError;
    import org.springframework.web.bind.MethodArgumentNotValidException;
    import org.springframework.web.bind.annotation.ControllerAdvice; // 声明这是一个控制器建言,用于全局处理
    import org.springframework.web.bind.annotation.ExceptionHandler; // 声明这是一个异常处理器方法
    import org.springframework.web.context.request.WebRequest; // Web请求对象
    
    import java.util.Date;
    import java.util.HashMap;
    import java.util.Map;
    import java.util.stream.Collectors;
    
    @ControllerAdvice // 声明这个类是全局异常处理的核心组件
    public class GlobalExceptionHandler {
    
        // 处理 ResourceNotFoundException 异常
        @ExceptionHandler(ResourceNotFoundException.class)
        public ResponseEntity<ErrorDetails> resourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
            ErrorDetails errorDetails = new ErrorDetails(
                    new Date(), // 时间戳
                    ex.getMessage(), // 异常消息
                    request.getDescription(false) // 请求的描述信息 (不包含客户端信息)
            );
            return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND); // 返回404状态码
        }
    
        // 处理 DuplicateIsbnException 异常
        @ExceptionHandler(DuplicateIsbnException.class)
        public ResponseEntity<ErrorDetails> duplicateIsbnException(DuplicateIsbnException ex, WebRequest request) {
            ErrorDetails errorDetails = new ErrorDetails(new Date(), ex.getMessage(), request.getDescription(false));
            return new ResponseEntity<>(errorDetails, HttpStatus.CONFLICT); // 返回409状态码 (冲突)
        }
    
        // 处理 @Valid 注解校验失败时抛出的 MethodArgumentNotValidException 异常
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ResponseEntity<ValidationErrorDetails> handleValidationExceptions(MethodArgumentNotValidException ex, WebRequest request) {
            // 从异常中提取所有字段的校验错误信息
            Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
                    .collect(Collectors.toMap(
                            FieldError::getField, // 错误字段名
                            fieldError -> fieldError.getDefaultMessage() == null ? "无效值" : fieldError.getDefaultMessage() // 错误消息
                    ));
            ValidationErrorDetails errorDetails = new ValidationErrorDetails(
                    new Date(),
                    "校验失败",
                    request.getDescription(false),
                    errors // 具体的校验错误
            );
            return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); // 返回400状态码
        }
    
        // 处理数据库层面的数据完整性违规异常 (例如 unique constraint violation)
        @ExceptionHandler(DataIntegrityViolationException.class)
        public ResponseEntity<ErrorDetails> handleDataIntegrityViolation(DataIntegrityViolationException ex, WebRequest request) {
            // 可以根据ex.getCause()或ex.getMostSpecificCause()来获取更具体的错误原因
            String message = "数据完整性冲突,请检查输入。";
            if (ex.getCause() != null && ex.getCause().getMessage().contains("Duplicate entry")) {
                message = "数据已存在,无法重复添加。"; // 更友好的提示
            }
            ErrorDetails errorDetails = new ErrorDetails(new Date(), message, request.getDescription(false));
            return new ResponseEntity<>(errorDetails, HttpStatus.CONFLICT); // 通常是409 Conflict
        }
    
    
        // 处理所有其他未被捕获的异常 (作为最后的保障)
        @ExceptionHandler(Exception.class)
        public ResponseEntity<ErrorDetails> globalExceptionHandler(Exception ex, WebRequest request) {
            ErrorDetails errorDetails = new ErrorDetails(
                    new Date(),
                    "服务器内部发生错误: " + ex.getMessage(), // 可以隐藏具体ex.getMessage(),只返回通用错误
                    request.getDescription(false)
            );
            ex.printStackTrace(); // 在服务器日志中打印堆栈信息,便于排查
            return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR); // 返回500状态码
        }
    }
    
    // 用于封装通用错误信息的辅助类
    class ErrorDetails {
        public Date timestamp; // 错误发生时间
        public String message;   // 错误消息
        public String details;   // 错误详情 (通常是请求路径)
    
        public ErrorDetails(Date timestamp, String message, String details) {
            this.timestamp = timestamp;
            this.message = message;
            this.details = details;
        }
        // Getters (Lombok @Data 也会生成, 但这里显式写出或让IDE生成)
    }
    
    // 用于封装校验错误信息的辅助类,继承自ErrorDetails
    class ValidationErrorDetails extends ErrorDetails {
        public Map<String, String> validationErrors; // 字段名 -> 错误消息 的映射
    
        public ValidationErrorDetails(Date timestamp, String message, String details, Map<String, String> validationErrors) {
            super(timestamp, message, details);
            this.validationErrors = validationErrors;
        }
        // Getters
    }
    
  10. 运行和测试后端:

    • 运行 Spring Boot 应用 (通常是 LmsApplication.java 中的 main 方法)。
    • 使用 Postman、Insomnia 或浏览器的开发者工具(如Fetch/XHR)测试你的 API 端点。
      • GET http://localhost:8080/api/books
      • POST http://localhost:8080/api/books (附带JSON body)
      • GET http://localhost:8080/api/books/1
      • PUT http://localhost:8080/api/books/1 (附带JSON body)
      • DELETE http://localhost:8080/api/books/1

二、前端 (Vue 3 + Vite)

  1. 项目初始化 (Vite):

    # 使用 npm
    npm create vite@latest library-frontend -- --template vue-ts # (推荐 TypeScript)
    # 或者 JavaScript 版本
    # npm create vite@latest library-frontend -- --template vue
    
    # 使用 yarn
    # yarn create vite library-frontend --template vue-ts
    
    cd library-frontend # 进入项目目录
    npm install         # 安装依赖
    
  2. 安装依赖:

    npm install axios vue-router element-plus # (或你选择的UI库)
    # 如果使用 Element Plus,还需要安装图标 (如果按需引入的话)
    # npm install @element-plus/icons-vue
    
  3. 配置 main.ts (或 main.js):
    位于 src/main.ts

    // src/main.ts
    import { createApp } from 'vue' // 从 'vue' 导入 createApp 函数
    import App from './App.vue'     // 导入根组件 App.vue
    import router from './router'   // 导入路由配置
    
    // 引入 Element Plus (全局引入示例)
    import ElementPlus from 'element-plus'       // 导入 Element Plus 插件
    import 'element-plus/dist/index.css'      // 导入 Element Plus 的 CSS 样式
    import * as ElementPlusIconsVue from '@element-plus/icons-vue' // 导入所有 Element Plus 图标
    
    const app = createApp(App) // 创建 Vue 应用实例
    
    // 注册 Element Plus 图标 (如果全局使用)
    for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
      app.component(key, component)
    }
    
    app.use(router) // 使用路由插件
    app.use(ElementPlus) // 使用 Element Plus 插件
    
    app.mount('#app') // 将 Vue 应用实例挂载到 HTML 页面中 ID 为 'app' 的元素上
    
  4. 创建路由 (src/router/index.ts):

    // src/router/index.ts
    import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' // 导入 Vue Router 相关函数和类型
    // 导入视图组件
    import BookList from '../views/BookList.vue'
    import BookForm from '../views/BookForm.vue'
    // import BookDetail from '../views/BookDetail.vue'; // 图书详情页 (可选)
    // import LoginView from '../views/LoginView.vue'; // 登录页 (可选)
    // import RegisterView from '../views/RegisterView.vue'; // 注册页 (可选)
    // import BorrowHistory from '../views/BorrowHistory.vue'; // 借阅历史页 (可选)
    
    // 定义路由规则数组,类型为 RouteRecordRaw[]
    const routes: Array<RouteRecordRaw> = [
      {
        path: '/', // 路由路径
        name: 'BookList', // 路由名称 (唯一)
        component: BookList // 该路径对应的组件
      },
      {
        path: '/books/new',
        name: 'BookCreate',
        component: BookForm,
        props: { isEdit: false } // 通过 props 传递参数给 BookForm 组件,表明是创建模式
      },
      {
        path: '/books/edit/:id', // 动态路由参数 :id
        name: 'BookEdit',
        component: BookForm,
        // props 函数,将路由参数转换为组件的 props
        props: route => ({ isEdit: true, bookId: route.params.id }) // 传递 isEdit=true 和 bookId
      },
      // { path: '/books/:id', name: 'BookDetail', component: BookDetail, props: true }, // 示例:图书详情页
      // { path: '/login', name: 'Login', component: LoginView },
      // { path: '/register', name: 'Register', component: RegisterView },
      // {
      //   path: '/borrow-history',
      //   name: 'BorrowHistory',
      //   component: BorrowHistory,
      //   meta: { requiresAuth: true } // meta 字段,可用于路由守卫判断是否需要认证
      // }
      // 在此添加更多路由
    ]
    
    // 创建路由实例
    const router = createRouter({
      history: createWebHistory(import.meta.env.BASE_URL), // 使用 HTML5 History 模式
      routes // 应用路由规则
    })
    
    // (可选) 全局前置守卫,用于认证检查
    // router.beforeEach((to, from, next) => {
    //   const isAuthenticated = !!localStorage.getItem('user-token'); // 示例:从 localStorage 获取 token 判断是否登录
    //   if (to.meta.requiresAuth && !isAuthenticated) {
    //     // 如果目标路由需要认证且用户未认证
    //     next({ name: 'Login' }); // 跳转到登录页
    //   } else {
    //     next(); // 允许导航
    //   }
    // });
    
    export default router // 导出路由实例
    
  5. 创建 API 服务 (src/services/apiService.ts):

    // src/services/apiService.ts
    import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios'; // 导入 axios 及相关类型
    
    // 定义后端API的基础URL
    const baseURL: string = 'http://localhost:8080/api';
    
    // 创建 axios 实例
    const apiClient: AxiosInstance = axios.create({
      baseURL: baseURL,
      headers: {
        'Content-Type': 'application/json',
        // 如果有登录功能,可以在这里统一添加 Authorization header
        // 'Authorization': `Bearer ${localStorage.getItem('user-token')}`
      }
    });
    
    // (可选) 请求拦截器:在每个请求发送前执行
    // apiClient.interceptors.request.use(config => {
    //   const token = localStorage.getItem('user-token'); // 获取 token
    //   if (token) {
    //     config.headers.Authorization = `Bearer ${token}`; // 如果 token 存在,添加到请求头
    //   }
    //   return config; // 返回配置,继续请求
    // }, error => {
    //   return Promise.reject(error); // 请求错误处理
    // });
    
    // (可选) 响应拦截器:在接收到响应后执行
    // apiClient.interceptors.response.use(
    //   (response: AxiosResponse) => response, // 成功响应直接返回
    //   (error: AxiosError) => {
    //     // 统一处理错误,例如 token 过期跳转到登录页
    //     if (error.response?.status === 401) {
    //       // localStorage.removeItem('user-token');
    //       // router.push('/login'); // 假设 router 实例已导入或可访问
    //       console.error('认证失败或Token过期,请重新登录');
    //     }
    //     return Promise.reject(error); // 继续抛出错误,让调用方处理
    //   }
    // );
    
    
    // 定义图书相关的接口类型 (可选,但推荐用于强类型)
    interface Book {
        id?: number | null;
        title: string;
        author: string;
        isbn: string;
        publisher?: string | null;
        publicationDate?: string | null; // 格式 YYYY-MM-DD
        quantity: number;
        availableQuantity?: number;
        createdAt?: string;
        updatedAt?: string;
    }
    
    interface Page<T> {
        content: T[];
        totalPages: number;
        totalElements: number;
        size: number;
        number: number; // 当前页码 (0-based)
    }
    
    
    // 导出 API 服务对象
    export default {
      // 图书服务
      getBooks(page: number = 0, size: number = 10, sort: string = 'id,asc', keyword: string = ''): Promise<AxiosResponse<Page<Book>>> {
        let params = `?page=${page}&size=${size}&sort=${sort}`;
        if (keyword) {
            params += `&keyword=${encodeURIComponent(keyword)}`; // URL编码关键词
        }
        return apiClient.get<Page<Book>>(`/books${params}`);
      },
      getBook(id: number | string): Promise<AxiosResponse<Book>> {
        return apiClient.get<Book>(`/books/${id}`);
      },
      createBook(book: Book): Promise<AxiosResponse<Book>> {
        return apiClient.post<Book>('/books', book);
      },
      updateBook(id: number | string, book: Book): Promise<AxiosResponse<Book>> {
        return apiClient.put<Book>(`/books/${id}`, book);
      },
      deleteBook(id: number | string): Promise<AxiosResponse<void>> {
        return apiClient.delete<void>(`/books/${id}`);
      },
    
      // 用户服务 (示例)
      // login(credentials: object): Promise<AxiosResponse<any>> {
      //   return apiClient.post('/auth/login', credentials);
      // },
      // register(userData: object): Promise<AxiosResponse<any>> {
      //   return apiClient.post('/auth/register', userData);
      // }
    
      // 借阅服务 (示例)
      // borrowBook(userId: number, bookId: number): Promise<AxiosResponse<any>> {
      //   return apiClient.post('/borrows', { userId, bookId });
      // },
      // returnBook(borrowId: number): Promise<AxiosResponse<any>> {
      //   return apiClient.put(`/borrows/${borrowId}/return`);
      // }
    };
    
  6. 创建视图组件 (Views/Pages - src/views/):

    • BookList.vue:

      <template>
        <div class="book-list-container">
          <h1>图书列表</h1>
      
          <!-- 搜索和操作区域 -->
          <el-row :gutter="20" style="margin-bottom: 20px;">
            <el-col :span="16" :xs="24" :sm="16" :md="18">
              <el-input
                v-model="searchKeyword"
                placeholder="输入书名或作者进行搜索..."
                clearable
                @keyup.enter="handleSearch"
                @clear="handleSearch"
              >
                <template #append>
                  <el-button :icon="Search" @click="handleSearch" />
                </template>
              </el-input>
            </el-col>
            <el-col :span="8" :xs="24" :sm="8" :md="6" style="text-align: right;">
              <el-button type="primary" :icon="Plus" @click="goToCreateBook">添加图书</el-button>
            </el-col>
          </el-row>
      
          <!-- 图书表格 -->
          <el-table :data="books" v-loading="loading" style="width: 100%" border stripe>
            <el-table-column prop="id" label="ID" width="80" sortable />
            <el-table-column prop="title" label="书名" min-width="180" sortable show-overflow-tooltip />
            <el-table-column prop="author" label="作者" min-width="120" sortable show-overflow-tooltip />
            <el-table-column prop="isbn" label="ISBN" min-width="150" sortable />
            <el-table-column prop="publisher" label="出版社" min-width="120" show-overflow-tooltip />
            <el-table-column prop="publicationDate" label="出版日期" width="120" sortable>
              <template #default="scope">
                {{ formatDate(scope.row.publicationDate) }}
              </template>
            </el-table-column>
            <el-table-column prop="quantity" label="总数" width="90" align="center" sortable />
            <el-table-column prop="availableQuantity" label="可借" width="90" align="center" sortable />
            <el-table-column label="操作" width="180" fixed="right" align="center">
              <template #default="scope">
                <el-button size="small" :icon="Edit" @click="editBook(scope.row.id)">编辑</el-button>
                <el-button size="small" type="danger" :icon="Delete" @click="confirmDeleteBook(scope.row.id)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
      
          <!-- 分页组件 -->
          <el-pagination
            v-if="totalElements > 0"
            style="margin-top: 20px; justify-content: flex-end;"
            background
            layout="total, sizes, prev, pager, next, jumper"
            :total="totalElements"
            :page-sizes="[5, 10, 20, 50, 100]"
            v-model:current-page="currentPage"
            v-model:page-size="pageSize"
            @size-change="handleSizeChange"
            @current-change="handleCurrentChange"
          />
        </div>
      </template>
      
      <script setup lang="ts">
      import { ref, onMounted, watch } from 'vue'; // 从 'vue' 导入 ref, onMounted, watch
      import { useRouter } from 'vue-router'; // 从 'vue-router' 导入 useRouter
      import apiService from '../services/apiService'; // 导入 API 服务
      import { ElMessage, ElMessageBox } from 'element-plus'; // 导入 Element Plus 的消息和确认框组件
      import { Search, Plus, Edit, Delete } from '@element-plus/icons-vue'; // 导入 Element Plus 图标
      
      // 定义图书接口 (与apiService中的一致或更详细)
      interface BookItem {
        id: number;
        title: string;
        author: string;
        isbn: string;
        publisher?: string;
        publicationDate?: string;
        quantity: number;
        availableQuantity: number;
      }
      
      const router = useRouter(); // 获取路由实例
      const books = ref<BookItem[]>([]); // 存储图书列表的响应式引用,初始为空数组
      const loading = ref(false); // 控制加载状态的响应式引用
      const searchKeyword = ref(''); // 搜索关键词的响应式引用
      
      // 分页相关状态
      const currentPage = ref(1); // 当前页码 (Element Plus 分页从1开始)
      const pageSize = ref(10);   // 每页显示条数
      const totalElements = ref(0); // 总条目数
      
      // 获取图书列表的异步函数
      const fetchBooks = async () => {
        loading.value = true; // 开始加载,设置 loading 为 true
        try {
          // 调用 API 服务获取图书数据
          // 注意:API 的 page 参数是 0-based, Element Plus 的 currentPage 是 1-based
          const response = await apiService.getBooks(currentPage.value - 1, pageSize.value, 'id,asc', searchKeyword.value);
          books.value = response.data.content; // 更新图书列表
          totalElements.value = response.data.totalElements; // 更新总条目数
        } catch (error: any) {
          console.error('加载图书列表失败:', error);
          ElMessage.error(error.response?.data?.message || '加载图书列表失败,请稍后重试');
        } finally {
          loading.value = false; // 加载结束,设置 loading 为 false
        }
      };
      
      // 格式化日期 (简单示例,可使用更专业的日期库如 dayjs)
      const formatDate = (dateString: string | undefined | null): string => {
        if (!dateString) return '';
        const date = new Date(dateString);
        return date.toLocaleDateString(); // 根据本地设置格式化
      };
      
      // 处理搜索事件
      const handleSearch = () => {
        currentPage.value = 1; // 搜索时重置到第一页
        fetchBooks();
      };
      
      // 每页显示条数变化时的处理函数
      const handleSizeChange = (val: number) => {
        pageSize.value = val;
        fetchBooks(); // 重新获取数据
      };
      
      // 当前页码变化时的处理函数
      const handleCurrentChange = (val: number) => {
        currentPage.value = val;
        fetchBooks(); // 重新获取数据
      };
      
      // 跳转到创建图书页面
      const goToCreateBook = () => {
        router.push({ name: 'BookCreate' });
      };
      
      // 跳转到编辑图书页面
      const editBook = (id: number) => {
        router.push({ name: 'BookEdit', params: { id } });
      };
      
      // 确认删除图书的处理函数
      const confirmDeleteBook = (id: number) => {
        ElMessageBox.confirm(
          '确定要删除这本书吗? 此操作不可撤销。', // 提示消息
          '警告', // 标题
          {
            confirmButtonText: '确定删除',
            cancelButtonText: '取消',
            type: 'warning', // 消息类型
            draggable: true, // 可拖拽
          }
        ).then(async () => { // 用户点击确定
          try {
            await apiService.deleteBook(id); // 调用 API 删除图书
            ElMessage.success('图书删除成功'); // 显示成功消息
            fetchBooks(); // 刷新图书列表
          } catch (error: any) {
            console.error('删除图书失败:', error);
            ElMessage.error(error.response?.data?.message || '删除图书失败');
          }
        }).catch(() => { // 用户点击取消或关闭对话框
          // ElMessage.info('已取消删除');
        });
      };
      
      // 组件挂载后执行获取图书列表
      onMounted(fetchBooks);
      
      // (可选) 监听搜索关键词变化,实现 debounce 效果(延迟搜索)
      // let debounceTimer: number;
      // watch(searchKeyword, (newValue) => {
      //   clearTimeout(debounceTimer);
      //   debounceTimer = window.setTimeout(() => {
      //     handleSearch();
      //   }, 500); // 延迟500毫秒
      // });
      
      </script>
      
      <style scoped>
      .book-list-container {
        padding: 20px;
      }
      .el-table {
        margin-top: 15px;
      }
      .el-pagination {
        margin-top: 20px;
        display: flex; /* Element Plus v2.x 后默认是 flex */
        justify-content: flex-end; /* 将分页组件靠右对齐 */
      }
      /* 针对小屏幕优化搜索和按钮的布局 */
      @media (max-width: 768px) {
        .el-row .el-col {
          margin-bottom: 10px;
        }
        .el-row .el-col[style*="text-align: right"] {
          text-align: left !important;
        }
      }
      </style>
      
    • BookForm.vue (用于创建和编辑):

      <template>
        <div class="book-form-container">
          <el-page-header @back="goBack" :content="isEdit ? '编辑图书' : '添加新书'" style="margin-bottom: 20px;" />
      
          <el-card shadow="never">
            <el-form
              :model="bookForm"
              :rules="rules"
              ref="bookFormRef"
              label-width="120px"
              label-position="right"
              v-loading="formLoading"
            >
              <el-form-item label="书名" prop="title">
                <el-input v-model="bookForm.title" placeholder="请输入书名" />
              </el-form-item>
              <el-form-item label="作者" prop="author">
                <el-input v-model="bookForm.author" placeholder="请输入作者名" />
              </el-form-item>
              <el-form-item label="ISBN" prop="isbn">
                <el-input v-model="bookForm.isbn" placeholder="请输入ISBN号" :disabled="isEdit" />
                <small v-if="isEdit" class="form-item-tip">(ISBN在编辑模式下不可修改)</small>
              </el-form-item>
              <el-form-item label="出版社" prop="publisher">
                <el-input v-model="bookForm.publisher" placeholder="请输入出版社名称" />
              </el-form-item>
              <el-form-item label="出版日期" prop="publicationDate">
                <el-date-picker
                  v-model="bookForm.publicationDate"
                  type="date"
                  placeholder="选择出版日期"
                  format="YYYY-MM-DD"
                  value-format="YYYY-MM-DD"
                  style="width: 100%;"
                />
              </el-form-item>
              <el-form-item label="总数量" prop="quantity">
                <el-input-number v-model="bookForm.quantity" :min="0" controls-position="right" style="width: 100%;" />
              </el-form-item>
              <el-form-item v-if="isEdit" label="可借数量" prop="availableQuantity">
                <el-input-number
                  v-model="bookForm.availableQuantity"
                  :min="0"
                  :max="bookForm.quantity"
                  controls-position="right"
                  style="width: 100%;"
                />
                <small class="form-item-tip">(可借数量不能超过总数量)</small>
              </el-form-item>
              <el-form-item>
                <el-button type="primary" @click="submitForm" :loading="submitLoading">
                  {{ isEdit ? '保存更改' : '立即创建' }}
                </el-button>
                <el-button @click="resetForm" :disabled="submitLoading">重置</el-button>
                <el-button @click="goBack" :disabled="submitLoading">取消</el-button>
              </el-form-item>
            </el-form>
          </el-card>
        </div>
      </template>
      
      <script setup lang="ts">
      import { ref, onMounted, reactive, computed } from 'vue'; // 导入 Vue Composition API
      import { useRouter, useRoute } from 'vue-router'; // 导入 Vue Router
      import apiService from '../services/apiService'; // 导入 API 服务
      import { ElMessage, FormInstance, FormRules } from 'element-plus'; // 导入 Element Plus 组件和类型
      
      // 定义props,接收来自路由的参数
      const props = defineProps({
        isEdit: { // 是否为编辑模式
          type: Boolean,
          default: false
        },
        bookId: { // 编辑模式下的图书ID
          type: [String, Number],
          default: null
        }
      });
      
      const router = useRouter(); // 获取路由实例
      // const route = useRoute(); // (如果不用props,可以通过route.params.id获取)
      
      const bookFormRef = ref<FormInstance>(); // 表单实例的引用,用于校验等操作
      const formLoading = ref(false); // 控制表单加载状态 (主要用于编辑时加载数据)
      const submitLoading = ref(false); // 控制提交按钮加载状态
      
      // 图书表单数据模型 (响应式)
      const bookForm = reactive({
        id: null as number | null, // 图书ID
        title: '',
        author: '',
        isbn: '',
        publisher: '',
        publicationDate: null as string | null, // 日期格式 YYYY-MM-DD
        quantity: 1,
        availableQuantity: 1, // 可借数量
      });
      
      // 表单校验规则
      const rules = reactive<FormRules>({
        title: [{ required: true, message: '书名不能为空', trigger: 'blur' }],
        author: [{ required: true, message: '作者不能为空', trigger: 'blur' }],
        isbn: [
          { required: true, message: 'ISBN不能为空', trigger: 'blur' },
          // (可选) 添加ISBN格式校验,例如10位或13位数字
          // { pattern: /^(?:\d{9}[\dXx]|\d{13})$/, message: '请输入有效的ISBN号', trigger: 'blur' }
        ],
        quantity: [
          { required: true, message: '总数量不能为空', trigger: 'blur' },
          { type: 'number', message: '总数量必须为数字', trigger: 'blur' }
        ],
        availableQuantity: [ // 仅在编辑模式下显示和校验
          { type: 'number', message: '可借数量必须为数字', trigger: 'blur' },
          { validator: (rule, value, callback) => { // 自定义校验器
              if (props.isEdit && value > bookForm.quantity) {
                callback(new Error('可借数量不能大于总数量'));
              } else {
                callback();
              }
            }, trigger: 'blur'
          }
        ],
        publicationDate: [{ type: 'date', message: '请选择有效的出版日期', trigger: 'change' }]
      });
      
      // 如果是编辑模式,组件挂载后获取图书详情
      onMounted(async () => {
        if (props.isEdit && props.bookId) {
          formLoading.value = true;
          try {
            const response = await apiService.getBook(props.bookId);
            // 将获取到的数据填充到表单中
            // Object.assign(bookForm, response.data); // 这种方式类型检查可能不完美
            bookForm.id = response.data.id;
            bookForm.title = response.data.title;
            bookForm.author = response.data.author;
            bookForm.isbn = response.data.isbn;
            bookForm.publisher = response.data.publisher || ''; // 处理null值
            bookForm.publicationDate = response.data.publicationDate || null;
            bookForm.quantity = response.data.quantity;
            bookForm.availableQuantity = response.data.availableQuantity ?? response.data.quantity; // 如果没有则默认为总数
          } catch (error: any) {
            console.error('加载图书详情失败:', error);
            ElMessage.error(error.response?.data?.message || '加载图书详情失败,请返回列表重试');
            router.push({ name: 'BookList' }); // 出错则返回列表页
          } finally {
            formLoading.value = false;
          }
        } else {
          // 新建模式下,如果需要,可以设置 availableQuantity 初始值等于 quantity
           bookForm.availableQuantity = bookForm.quantity;
        }
      });
      
      // 提交表单的处理函数
      const submitForm = async () => {
        if (!bookFormRef.value) return; // 确保表单实例存在
        submitLoading.value = true;
      
        bookFormRef.value.validate(async (valid) => {
          if (valid) { // 表单校验通过
            try {
              const payload = { ...bookForm };
              // 新建时,availableQuantity 如果用户没动,可以默认等于 quantity
              if (!props.isEdit && payload.availableQuantity === undefined) {
                  payload.availableQuantity = payload.quantity;
              }
      
              if (props.isEdit && props.bookId) { // 编辑模式
                await apiService.updateBook(props.bookId, payload);
                ElMessage.success('图书信息更新成功');
              } else { // 创建模式
                await apiService.createBook(payload);
                ElMessage.success('新图书添加成功');
              }
              router.push({ name: 'BookList' }); // 操作成功后返回列表页
            } catch (error: any) {
              console.error('表单提交失败:', error);
              // 根据后端返回的错误类型显示不同消息
              if (error.response) {
                  const errData = error.response.data;
                  if (errData.validationErrors) { // 处理后端校验错误
                      let errorMsg = "提交失败,请检查:";
                      for (const field in errData.validationErrors) {
                          errorMsg += `\n- ${errData.validationErrors[field]}`;
                      }
                      ElMessage.error({ message: errorMsg, duration: 5000, showClose: true });
                  } else {
                      ElMessage.error(errData.message || (props.isEdit ? '更新失败' : '添加失败'));
                  }
              } else {
                  ElMessage.error('操作失败,请检查网络或联系管理员');
              }
            } finally {
              submitLoading.value = false;
            }
          } else { // 表单校验失败
            ElMessage.error('表单信息有误,请检查并修正');
            submitLoading.value = false;
            return false;
          }
        });
      };
      
      // 重置表单
      const resetForm = () => {
        if (bookFormRef.value) {
          bookFormRef.value.resetFields(); // 重置表单到初始值
          if (!props.isEdit) { // 如果是新建模式,可以额外清空一些内容
              bookForm.publisher = '';
              bookForm.publicationDate = null;
              bookForm.quantity = 1;
              bookForm.availableQuantity = 1;
          } else {
              // 编辑模式重置时,应该重新加载原始数据,或者提供一个“撤销更改”的功能
              // 简单起见,这里仅重置到上次加载的数据(如果表单被修改过)
              // 若要完全重置到服务器数据,需再次调用 fetchBookDetails
          }
        }
      };
      
      // 返回上一页
      const goBack = () => {
        router.back(); // 或 router.push({ name: 'BookList' });
      };
      
      // 监听总数量变化,自动调整可借数量 (仅在新建模式或编辑模式下手动调整总数时)
      watch(() => bookForm.quantity, (newQuantity) => {
          if (!props.isEdit) { // 新建模式下,可借数量总是等于总数量
              bookForm.availableQuantity = newQuantity;
          } else {
              // 编辑模式下,如果可借数量超过新的总数量,则调整可借数量
              if (bookForm.availableQuantity > newQuantity) {
                  bookForm.availableQuantity = newQuantity;
              }
          }
      });
      
      </script>
      
      <style scoped>
      .book-form-container {
        max-width: 700px; /* 调整表单最大宽度 */
        margin: 20px auto;
        padding: 20px;
      }
      .el-card {
        border: none; /* Element Plus 风格统一 */
      }
      .form-item-tip {
        color: #909399;
        font-size: 12px;
        line-height: 1.5;
        margin-left: 10px;
      }
      .el-form-item {
          margin-bottom: 22px; /* 统一表单项间距 */
      }
      </style>
      
  7. 更新 App.vue (根组件,用于布局):

    <template>
      <el-config-provider :locale="zhCn"> <!-- Element Plus 国际化配置 -->
        <div id="app-layout">
          <!-- 顶部导航栏 -->
          <el-header class="app-header">
            <div class="logo-area" @click="goToHome">
              <img src="https://www.cnblogs.com/favicon.ico" alt="Logo" class="logo-img" /> <!-- 假设 public 目录下有 favicon.ico -->
              <span>图书管理系统</span>
            </div>
            <el-menu
              :default-active="activeMenu"
              class="app-menu"
              mode="horizontal"
              :ellipsis="false"
              router
            >
              <el-menu-item index="/">图书列表</el-menu-item>
              <el-menu-item index="/books/new">添加图书</el-menu-item>
              <!-- <el-menu-item index="/borrow-history">借阅记录</el-menu-item> -->
              <!-- 用户相关菜单 (可选) -->
              <!--
              <el-sub-menu index="user-menu" style="margin-left: auto;">
                <template #title>
                  <el-icon><User /></el-icon> 用户
                </template>
                <el-menu-item index="/profile">个人中心</el-menu-item>
                <el-menu-item index="/login" v-if="!isLoggedIn">登录</el-menu-item>
                <el-menu-item @click="logout" v-if="isLoggedIn">退出登录</el-menu-item>
              </el-sub-menu>
              -->
            </el-menu>
          </el-header>
    
          <!-- 主内容区域 -->
          <el-main class="app-main">
            <router-view v-slot="{ Component }">
              <transition name="fade" mode="out-in"> <!-- 页面切换过渡效果 -->
                <component :is="Component" />
              </transition>
            </router-view>
          </el-main>
    
          <!-- 底部信息栏 -->
          <el-footer class="app-footer" height="40px">
            © {{ new Date().getFullYear() }} 图书管理系统 - Vue3 + SpringBoot 版
          </el-footer>
        </div>
      </el-config-provider>
    </template>
    
    <script setup lang="ts">
    import { computed } from 'vue';
    import { useRoute, useRouter } from 'vue-router';
    import { ElConfigProvider } from 'element-plus'; // Element Plus 配置组件
    import zhCn from 'element-plus/dist/locale/zh-cn.mjs'; // Element Plus 中文语言包
    // import { User } from '@element-plus/icons-vue'; // 如果使用用户菜单
    
    const route = useRoute(); // 获取当前路由信息
    const router = useRouter(); // 获取路由实例
    
    // 计算当前激活的菜单项 (基于路由路径)
    const activeMenu = computed(() => {
      // 对于 /books/edit/:id 这样的路径,我们希望高亮父级菜单 "/"
      if (route.path.startsWith('/books/edit') || route.path.startsWith('/books/new')) {
        return '/'; // 或者特定的父菜单项 index
      }
      return route.path;
    });
    
    // (示例) 判断用户是否登录的状态
    // const isLoggedIn = computed(() => !!localStorage.getItem('user-token'));
    
    // (示例) 登出方法
    // const logout = () => {
    //   localStorage.removeItem('user-token');
    //   router.push('/login');
    //   // ElMessage.success('已成功退出登录');
    // };
    
    const goToHome = () => {
      router.push('/');
    }
    </script>
    
    <style>
    /* 全局基础样式 */
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
                   'Microsoft YaHei', Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
    }
    
    #app { /* Vite 项目的根元素通常是 #app */
      height: 100%;
    }
    
    #app-layout {
      display: flex;
      flex-direction: column;
      min-height: 100vh; /* 确保布局至少占满整个视口高度 */
    }
    
    .app-header {
      background-color: #3A8EE6; /* Element Plus 蓝色主题的深色变体 */
      color: white;
      display: flex;
      align-items: center;
      padding: 0 20px;
      border-bottom: 1px solid #e0e0e0;
      box-shadow: 0 2px 4px rgba(0,0,0,0.05);
    }
    
    .logo-area {
      display: flex;
      align-items: center;
      font-size: 20px;
      font-weight: bold;
      margin-right: 40px;
      cursor: pointer;
    }
    .logo-img {
      width: 32px;
      height: 32px;
      margin-right: 10px;
    }
    
    .app-menu {
      flex-grow: 1; /* 让菜单占据剩余空间 */
      background-color: transparent !important; /* 使菜单背景透明以显示header背景 */
      border-bottom: none !important; /* 移除 Element Plus 菜单的默认下边框 */
    }
    
    /* 修改 Element Plus 菜单项颜色 */
    .app-header .el-menu--horizontal > .el-menu-item,
    .app-header .el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
      color: white !important; /* 菜单文字颜色 */
      border-bottom: 2px solid transparent; /* 默认无下划线 */
    }
    .app-header .el-menu--horizontal > .el-menu-item:not(.is-disabled):hover,
    .app-header .el-menu--horizontal > .el-sub-menu .el-sub-menu__title:hover {
      background-color: rgba(255,255,255,0.15) !important; /* 鼠标悬停背景色 */
      color: #f0f0f0 !important;
    }
    .app-header .el-menu--horizontal > .el-menu-item.is-active {
      color: #ffd04b !important; /* 激活的菜单项文字颜色 (Element Plus 默认黄色) */
      border-bottom: 2px solid #ffd04b !important; /* 激活的菜单项下划线颜色 */
      background-color: transparent !important;
    }
    
    
    .app-main {
      flex-grow: 1; /* 主内容区域占据剩余垂直空间 */
      padding: 20px;
      background-color: #f4f6f8; /* 淡灰色背景,使内容区域更突出 */
    }
    
    .app-footer {
      text-align: center;
      padding: 10px 0;
      background-color: #e9eef3; /* 页脚背景色 */
      color: #606266;          /* 页脚文字颜色 */
      font-size: 0.85em;
      border-top: 1px solid #dcdfe6;
    }
    
    /* 页面切换过渡效果 */
    .fade-enter-active, .fade-leave-active {
      transition: opacity 0.3s ease;
    }
    .fade-enter-from, .fade-leave-to {
      opacity: 0;
    }
    
    /* Element Plus 表格和表单的一些通用微调 (可选) */
    .el-table th {
      background-color: #fafafa !important; /* 表头背景色 */
    }
    .el-form label {
        font-weight: 500; /* 表单标签加粗一点 */
    }
    </style>
    
  8. 运行前端开发服务器:

    npm run dev
    

    访问 http://localhost:5173 (Vite 默认端口) 或 http://localhost:8081 (Vue CLI 默认端口)。


三、后续步骤与增强

  1. 用户认证与授权 (Spring Security + JWT):

    • 后端:
      • 添加 spring-boot-starter-security 依赖。
      • 配置 SecurityFilterChain Bean 来定义哪些路径需要认证,哪些公开。
      • 创建 UserDetailsServiceImpl 实现 UserDetailsService 接口,用于从数据库加载用户信息。
      • 创建 PasswordEncoder Bean (例如 BCryptPasswordEncoder) 用于密码加密和验证。
      • 实现登录接口 (/api/auth/login),成功后生成JWT并返回给前端。
      • 实现注册接口 (/api/auth/register),密码需加密存储。
      • 创建JWT工具类 (用于生成、解析、校验Token)。
      • 创建JWT认证过滤器 (继承 OncePerRequestFilter),在每个需要认证的请求中从Header提取并校验JWT,然后设置Spring Security的认证上下文。
      • 使用 @PreAuthorize("hasRole('ADMIN')").requestMatchers(...).hasAuthority("ROLE_ADMIN") 等方式在方法或配置层面进行权限控制。
    • 前端:
      • 创建登录、注册页面。
      • apiService.ts 中,登录成功后将JWT存储在 localStoragesessionStorage
      • 修改 apiClient 的请求拦截器,在每个请求的 Authorization header 中附带 Bearer <JWT>
      • 实现登出功能 (清除 localStorage 中的Token,重定向到登录页)。
      • 使用Vue Router的导航守卫 (router.beforeEach) 保护需要登录才能访问的路由。如果未登录,则重定向到登录页。
  2. 借阅管理:

    • 后端:
      • 设计 BorrowRecord 实体及其与 UserBook 的关系。
      • 创建 BorrowRecordRepository
      • 创建 BorrowService
        • borrowBook(userId, bookId): 检查用户是否存在,图书是否存在且 availableQuantity > 0,然后创建借阅记录,并减少 BookavailableQuantity。此操作应是事务性的。
        • returnBook(borrowRecordId): 检查借阅记录是否存在且状态为已借出,更新借阅记录状态为已归还,记录归还日期,并增加对应 BookavailableQuantity。此操作也应是事务性的。
        • getUserBorrowHistory(userId, pageable): 查询某个用户的借阅历史。
        • getAllBorrowRecords(pageable, statusFilter): 查询所有借阅记录,可按状态筛选。
    • 前端:
      • 在图书列表或详情页添加“借阅”按钮 (需要用户登录)。
      • 创建“我的借阅”页面,显示当前用户借阅的图书及借阅历史,提供“还书”按钮。
      • (管理员) 创建借阅管理页面,显示所有借阅记录,可进行搜索、筛选。
  3. 更完善的错误处理和用户提示:

    • 确保前端所有API调用都有 try...catch 块,并使用 ElMessageElNotification 向用户显示清晰的成功或失败消息。
    • 后端全局异常处理器返回统一的、包含有用信息的错误响应结构。
  4. 表单校验:

    • 前端使用 Element Plus 表单组件自带的校验规则 (rules) 和自定义校验器。
    • 后端在 Controller 的方法参数上使用 @Valid 注解,并在实体类的字段上使用 jakarta.validation.constraints (如 @NotBlank, @Size, @Email, @Pattern, @Min, @Max) 进行数据校验。全局异常处理器会捕获 MethodArgumentNotValidException 并返回详细的校验错误信息。
  5. 状态管理 (Pinia):

    • 当应用状态变得复杂时(例如,全局用户信息、权限列表、应用配置等),引入 Pinia 来集中管理这些状态会使代码更易于维护。
    • 例如,可以创建一个 authStore 来管理用户登录状态、用户信息和Token。
  6. 单元测试和集成测试:

    • 后端: 使用 JUnit 5 和 Mockito 对 Service 层和 Controller 层编写单元测试。使用 Spring Boot 的 @SpringBootTest 和 TestRestTemplate/MockMvc 进行集成测试。
    • 前端: 使用 Vitest (Vite项目推荐) 或 Jest,配合 Vue Test Utils 对组件和业务逻辑进行单元测试。可以使用 Cypress 或 Playwright 进行端到端测试。
  7. 部署:

    • 后端 (Spring Boot):
      • 使用 mvn package (或 gradle build) 打包成可执行的 Jar 文件。
      • 部署到服务器 (例如 Linux 服务器上使用 java -jar your-app.jar)。
      • 使用 systemd 或其他进程管理工具使其作为服务运行。
      • 使用 Docker 将应用容器化,便于部署和管理。
    • 前端 (Vue):
      • 运行 npm run build (或 yarn build) 生成静态文件 (通常在 dist 目录)。
      • dist 目录下的静态文件部署到 Web 服务器 (如 Nginx, Apache) 或静态站点托管服务 (如 Netlify, Vercel, GitHub Pages)。
      • 配置 Nginx/Apache 作为反向代理,将前端请求和后端API请求路由到正确的服务,并处理CORS问题、HTTPS等。
    • 数据库 (MySQL): 可以是本地安装、云数据库服务 (如 AWS RDS, Google Cloud SQL, Azure Database for MySQL) 或 Docker容器。
    • Docker Compose: 使用 Docker Compose 可以编排前端、后端、数据库等多个容器,简化本地开发环境的搭建和多服务应用的部署。

这是一个相当庞大的项目,建议你分模块、分阶段逐步实现。从最核心的图书CRUD功能开始,确保其稳定可用,然后再逐步添加用户管理、借阅管理、安全认证等其他功能。在每个阶段完成后进行充分的测试。

祝你编码顺利,项目成功!

posted on 2025-05-18 12:51  winrar_z  阅读(401)  评论(0)    收藏  举报

导航