好的,构建一个图书管理系统是一个很棒的项目,结合Vue3、Spring Boot和MySQL可以实现一个功能完善、前后端分离的现代Web应用。下面我将为你提供一个详细的步骤和关键代码点指南,并为所有代码块添加中文注释。
好的,构建一个图书管理系统是一个很棒的项目,结合Vue3、Spring Boot和MySQL可以实现一个功能完善、前后端分离的现代Web应用。下面我将为你提供一个详细的步骤和关键代码点指南,并为所有代码块添加中文注释。
系统核心功能:
- 图书管理:
- 添加图书
- 删除图书
- 修改图书信息
- 查询图书(按书名、作者、ISBN等)
- 图书列表展示(分页)
- 用户管理 (可选,但推荐):
- 用户注册与登录
- 角色管理 (管理员 vs 普通用户)
- 借阅管理:
- 借书
- 还书
- 查看借阅记录
- 查看当前借阅(用户)
- 逾期提醒 (可选)
技术栈:
- 前端: 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)
-
项目初始化 (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 (或更高)
- Group:
- Dependencies:
- Spring Web (构建RESTful APIs)
- Spring Data JPA (数据持久化)
- MySQL Driver (连接MySQL)
- Lombok (简化POJO代码)
- Spring Security (可选,用于安全)
- Validation (用于数据校验)
- 点击 "GENERATE",下载并解压项目。
-
数据库设计 (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)。
-
-
配置
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小时) -
创建实体类 (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.java和BorrowRecord.java实体类,并使用@ManyToOne,@OneToMany等注解定义它们之间的关系。例如,一个BorrowRecord会@ManyToOne关联一个User和一个Book。
-
创建仓库接口 (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.java和BorrowRecordRepository.java。
-
创建服务层 (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.java和BorrowService.java(借阅服务会更复杂,需要处理库存、用户校验、记录生成等)。
-
创建控制器 (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.java和BorrowController.java。
-
自定义异常类:
位于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); } }
-
全局异常处理 (
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 } -
运行和测试后端:
- 运行 Spring Boot 应用 (通常是
LmsApplication.java中的main方法)。 - 使用 Postman、Insomnia 或浏览器的开发者工具(如Fetch/XHR)测试你的 API 端点。
GET http://localhost:8080/api/booksPOST http://localhost:8080/api/books(附带JSON body)GET http://localhost:8080/api/books/1PUT http://localhost:8080/api/books/1(附带JSON body)DELETE http://localhost:8080/api/books/1
- 运行 Spring Boot 应用 (通常是
二、前端 (Vue 3 + Vite)
-
项目初始化 (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 # 安装依赖 -
安装依赖:
npm install axios vue-router element-plus # (或你选择的UI库) # 如果使用 Element Plus,还需要安装图标 (如果按需引入的话) # npm install @element-plus/icons-vue -
配置
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' 的元素上 -
创建路由 (
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 // 导出路由实例 -
创建 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`); // } }; -
创建视图组件 (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>
-
-
更新
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> -
运行前端开发服务器:
npm run dev访问
http://localhost:5173(Vite 默认端口) 或http://localhost:8081(Vue CLI 默认端口)。
三、后续步骤与增强
-
用户认证与授权 (Spring Security + JWT):
- 后端:
- 添加
spring-boot-starter-security依赖。 - 配置
SecurityFilterChainBean 来定义哪些路径需要认证,哪些公开。 - 创建
UserDetailsServiceImpl实现UserDetailsService接口,用于从数据库加载用户信息。 - 创建
PasswordEncoderBean (例如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存储在localStorage或sessionStorage。 - 修改
apiClient的请求拦截器,在每个请求的Authorizationheader 中附带Bearer <JWT>。 - 实现登出功能 (清除
localStorage中的Token,重定向到登录页)。 - 使用Vue Router的导航守卫 (
router.beforeEach) 保护需要登录才能访问的路由。如果未登录,则重定向到登录页。
- 后端:
-
借阅管理:
- 后端:
- 设计
BorrowRecord实体及其与User和Book的关系。 - 创建
BorrowRecordRepository。 - 创建
BorrowService:borrowBook(userId, bookId): 检查用户是否存在,图书是否存在且availableQuantity > 0,然后创建借阅记录,并减少Book的availableQuantity。此操作应是事务性的。returnBook(borrowRecordId): 检查借阅记录是否存在且状态为已借出,更新借阅记录状态为已归还,记录归还日期,并增加对应Book的availableQuantity。此操作也应是事务性的。getUserBorrowHistory(userId, pageable): 查询某个用户的借阅历史。getAllBorrowRecords(pageable, statusFilter): 查询所有借阅记录,可按状态筛选。
- 设计
- 前端:
- 在图书列表或详情页添加“借阅”按钮 (需要用户登录)。
- 创建“我的借阅”页面,显示当前用户借阅的图书及借阅历史,提供“还书”按钮。
- (管理员) 创建借阅管理页面,显示所有借阅记录,可进行搜索、筛选。
- 后端:
-
更完善的错误处理和用户提示:
- 确保前端所有API调用都有
try...catch块,并使用ElMessage或ElNotification向用户显示清晰的成功或失败消息。 - 后端全局异常处理器返回统一的、包含有用信息的错误响应结构。
- 确保前端所有API调用都有
-
表单校验:
- 前端使用 Element Plus 表单组件自带的校验规则 (
rules) 和自定义校验器。 - 后端在 Controller 的方法参数上使用
@Valid注解,并在实体类的字段上使用jakarta.validation.constraints(如@NotBlank,@Size,@Email,@Pattern,@Min,@Max) 进行数据校验。全局异常处理器会捕获MethodArgumentNotValidException并返回详细的校验错误信息。
- 前端使用 Element Plus 表单组件自带的校验规则 (
-
状态管理 (Pinia):
- 当应用状态变得复杂时(例如,全局用户信息、权限列表、应用配置等),引入 Pinia 来集中管理这些状态会使代码更易于维护。
- 例如,可以创建一个
authStore来管理用户登录状态、用户信息和Token。
-
单元测试和集成测试:
- 后端: 使用 JUnit 5 和 Mockito 对 Service 层和 Controller 层编写单元测试。使用 Spring Boot 的
@SpringBootTest和 TestRestTemplate/MockMvc 进行集成测试。 - 前端: 使用 Vitest (Vite项目推荐) 或 Jest,配合 Vue Test Utils 对组件和业务逻辑进行单元测试。可以使用 Cypress 或 Playwright 进行端到端测试。
- 后端: 使用 JUnit 5 和 Mockito 对 Service 层和 Controller 层编写单元测试。使用 Spring Boot 的
-
部署:
- 后端 (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 可以编排前端、后端、数据库等多个容器,简化本地开发环境的搭建和多服务应用的部署。
- 后端 (Spring Boot):
这是一个相当庞大的项目,建议你分模块、分阶段逐步实现。从最核心的图书CRUD功能开始,确保其稳定可用,然后再逐步添加用户管理、借阅管理、安全认证等其他功能。在每个阶段完成后进行充分的测试。
祝你编码顺利,项目成功!
浙公网安备 33010602011771号