Week 1-8: Spring Boot 学习计划完整项目
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
106
week7/backend/pom.xml
Normal file
106
week7/backend/pom.xml
Normal file
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.learn</groupId>
|
||||
<artifactId>week5-security</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Week 5: Spring 全家桶核心组件</name>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<mybatis-plus.version>3.5.6</mybatis-plus.version>
|
||||
<jjwt.version>0.12.5</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cache + Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Actuator 监控 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
56
week7/backend/sql/init.sql
Normal file
56
week7/backend/sql/init.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- =============================================
|
||||
-- Week 5:学生管理系统 v2 数据库初始化
|
||||
-- 新增:users 表(认证)+ 逻辑删除 + RBAC
|
||||
-- =============================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS week5_student
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE week5_student;
|
||||
|
||||
-- 学生表(增加 deleted 字段用于逻辑删除,第 7 天)
|
||||
DROP TABLE IF EXISTS student;
|
||||
CREATE TABLE student (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
name VARCHAR(20) NOT NULL COMMENT '姓名',
|
||||
age INT NOT NULL COMMENT '年龄',
|
||||
email VARCHAR(50) NOT NULL COMMENT '邮箱',
|
||||
score INT NOT NULL DEFAULT 0 COMMENT '成绩 0-100',
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0-未删 1-已删',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_name (name),
|
||||
INDEX idx_score (score)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';
|
||||
|
||||
-- 用户表(Spring Security 认证用,第 2-3 天)
|
||||
DROP TABLE IF EXISTS users;
|
||||
CREATE TABLE users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||
password VARCHAR(200) NOT NULL COMMENT 'BCrypt 加密后的密码',
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:ADMIN / USER',
|
||||
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 预置学生数据
|
||||
INSERT INTO student (name, age, email, score) VALUES
|
||||
('张三', 20, 'zhangsan@mail.com', 85),
|
||||
('李四', 22, 'lisi@mail.com', 92),
|
||||
('王五', 19, 'wangwu@mail.com', 78),
|
||||
('赵六', 21, 'zhaoliu@mail.com', 88),
|
||||
('孙七', 23, 'sunqi@mail.com', 95),
|
||||
('周八', 20, 'zhouba@mail.com', 73),
|
||||
('吴九', 22, 'wujiu@mail.com', 81),
|
||||
('郑十', 21, 'zhengshi@mail.com',90);
|
||||
|
||||
-- 用户数据由应用启动时通过 Java 代码自动初始化(保证 BCrypt 哈希正确)
|
||||
-- 默认账号:admin / 123456(ADMIN)、user / 123456(USER)
|
||||
|
||||
SELECT 'student' AS tbl, COUNT(*) AS cnt FROM student
|
||||
UNION ALL
|
||||
SELECT 'users', COUNT(*) FROM users;
|
||||
13
week7/backend/src/main/java/com/learn/Week5Application.java
Normal file
13
week7/backend/src/main/java/com/learn/Week5Application.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.learn;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableCaching // 开启 Spring Cache(第 4 天)
|
||||
public class Week5Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Week5Application.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.learn.annotation;
|
||||
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
/**
|
||||
* @ValidScore 的校验逻辑实现
|
||||
*
|
||||
* isValid 方法返回 true 表示校验通过,false 表示校验失败。
|
||||
* ConstraintValidatorContext 用于自定义错误消息。
|
||||
*/
|
||||
public class ScoreValidator implements ConstraintValidator<ValidScore, Integer> {
|
||||
|
||||
@Override
|
||||
public boolean isValid(Integer value, ConstraintValidatorContext context) {
|
||||
if (value == null) return true; // null 由 @NotNull 处理
|
||||
return value >= 0 && value <= 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.learn.annotation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 自定义校验注解(第 6 天)
|
||||
*
|
||||
* 功能等价于 @Min(0) @Max(100),但演示了自定义注解的完整流程:
|
||||
* 1. 定义注解 @ValidScore
|
||||
* 2. 编写校验器 ScoreValidator(实现 ConstraintValidator)
|
||||
* 3. 在实体类字段上使用 @ValidScore
|
||||
*
|
||||
* @Constraint(validatedBy = ...) 将注解和校验器绑定
|
||||
*/
|
||||
@Documented
|
||||
@Constraint(validatedBy = ScoreValidator.class)
|
||||
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ValidScore {
|
||||
|
||||
// 校验失败时的默认错误消息
|
||||
String message() default "成绩必须在 0-100 之间";
|
||||
|
||||
// 分组校验(后续学习,此处用默认)
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
// Payload(扩展用,此处用默认)
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.learn.entity.User;
|
||||
import com.learn.repository.jpa.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||
private final UserRepository userRepo;
|
||||
private final PasswordEncoder encoder;
|
||||
|
||||
public DataInitializer(UserRepository userRepo, PasswordEncoder encoder) {
|
||||
this.userRepo = userRepo;
|
||||
this.encoder = encoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (userRepo.count() > 0) return; // 已有数据则跳过
|
||||
|
||||
User admin = new User();
|
||||
admin.setUsername("admin");
|
||||
admin.setPassword(encoder.encode("123456"));
|
||||
admin.setRole("ADMIN");
|
||||
userRepo.save(admin);
|
||||
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
user.setPassword(encoder.encode("123456"));
|
||||
user.setRole("USER");
|
||||
userRepo.save(user);
|
||||
|
||||
log.info("默认用户已创建: admin/123456 (ADMIN), user/123456 (USER)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MyBatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
PaginationInnerInterceptor p = new PaginationInnerInterceptor(DbType.MYSQL);
|
||||
p.setOverflow(true);
|
||||
p.setMaxLimit(100L);
|
||||
interceptor.addInnerInterceptor(p);
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.learn.security.JwtAuthFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Security 配置(第 1-3 天)
|
||||
*
|
||||
* 核心设计:
|
||||
* 1. 无状态会话(JWT,不依赖 Cookie/Session)
|
||||
* 2. 自定义 JWT 过滤器在 UsernamePasswordAuthenticationFilter 之前执行
|
||||
* 3. RBAC:ADMIN 可增删改,USER 只能查
|
||||
* 4. 公开 /api/auth/** 和 Actuator 端点
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity // 开启方法级安全注解(第 3 天)
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
}
|
||||
|
||||
/** 跨域配置:允许 Vue 前端(5173)访问 */
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf.disable()) // 前后端分离,禁用 CSRF
|
||||
.sessionManagement(sm -> sm
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// 公开接口
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/css/**", "/js/**").permitAll()
|
||||
// 查询:USER 及以上
|
||||
.requestMatchers(HttpMethod.GET, "/api/students/**").hasAnyRole("ADMIN", "USER")
|
||||
// 增删改:仅 ADMIN(第 3 天 RBAC)
|
||||
.requestMatchers(HttpMethod.POST, "/api/students/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.PUT, "/api/students/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/students/**").hasRole("ADMIN")
|
||||
// 其他请求需认证
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
// 在用户名密码过滤器之前插入 JWT 过滤器
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(); // BCrypt 加密,不可逆
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import com.learn.dto.LoginRequest;
|
||||
import com.learn.service.AuthService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 认证接口(第 2 天)
|
||||
*
|
||||
* POST /api/auth/register —— 注册
|
||||
* POST /api/auth/login —— 登录,返回 JWT
|
||||
*
|
||||
* 这些接口在 SecurityConfig 中设置了 permitAll(),无需认证即可访问。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> register(@RequestBody Map<String, String> body) {
|
||||
try {
|
||||
Map<String, Object> result = authService.register(
|
||||
body.get("username"), body.get("password"));
|
||||
return ResponseEntity.ok(ApiResponse.success("注册成功", result));
|
||||
} catch (RuntimeException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> login(@Valid @RequestBody LoginRequest req) {
|
||||
try {
|
||||
Map<String, Object> result = authService.login(req);
|
||||
return ResponseEntity.ok(ApiResponse.success("登录成功", result));
|
||||
} catch (RuntimeException e) {
|
||||
return ResponseEntity.status(401).body(ApiResponse.error(401, e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import com.learn.entity.Student;
|
||||
import com.learn.service.StudentServiceSelector;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/students")
|
||||
public class StudentController {
|
||||
|
||||
private final StudentServiceSelector service;
|
||||
|
||||
public StudentController(StudentServiceSelector service) { this.service = service; }
|
||||
|
||||
// ---- 查询(USER + ADMIN 均可)----
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<java.util.List<Student>> list(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||
java.util.List<Student> students = service.search(keyword);
|
||||
return ApiResponse.success(students)
|
||||
.put("total", students.size())
|
||||
.put("orm", service.getActiveMode());
|
||||
}
|
||||
var result = service.listPage(page, size);
|
||||
return ApiResponse.success(result.getContent())
|
||||
.put("total", result.getTotalElements())
|
||||
.put("pages", result.getTotalPages())
|
||||
.put("orm", service.getActiveMode());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Student>> getById(@PathVariable Long id) {
|
||||
Student s = service.getById(id);
|
||||
if (s != null) return ResponseEntity.ok(ApiResponse.success(s));
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound("学生不存在"));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/excellent")
|
||||
public ApiResponse<Long> statsExcellent(@RequestParam(defaultValue = "85") int min) {
|
||||
return ApiResponse.success(service.countExcellent(min));
|
||||
}
|
||||
|
||||
// ---- 新增(仅 ADMIN)----
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Student>> add(@Valid @RequestBody Student student) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.created(service.add(student)));
|
||||
}
|
||||
|
||||
// ---- 更新(仅 ADMIN)----
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Student>> update(
|
||||
@PathVariable Long id, @Valid @RequestBody Student student) {
|
||||
return service.update(id, student)
|
||||
.map(u -> ResponseEntity.ok(ApiResponse.success("更新成功", u)))
|
||||
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound("学生不存在")));
|
||||
}
|
||||
|
||||
// ---- 删除(仅 ADMIN,MP 模式下为逻辑删除)----
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')") // 方法级权限控制(第 3 天)
|
||||
public ResponseEntity<ApiResponse<?>> delete(@PathVariable Long id) {
|
||||
return service.delete(id)
|
||||
? ResponseEntity.ok(ApiResponse.success("删除成功", null))
|
||||
: ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound("学生不存在"));
|
||||
}
|
||||
|
||||
// ---- 当前用户信息 ----
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<String> me(Principal principal) {
|
||||
return ApiResponse.success("当前用户: " + principal.getName()
|
||||
+ " | ORM: " + service.getActiveMode());
|
||||
}
|
||||
}
|
||||
33
week7/backend/src/main/java/com/learn/dto/ApiResponse.java
Normal file
33
week7/backend/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Map<String, Object> extra;
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) { return build(200, "success", data); }
|
||||
public static <T> ApiResponse<T> success(String msg, T data) { return build(200, msg, data); }
|
||||
public static <T> ApiResponse<T> created(T data) { return build(201, "创建成功", data); }
|
||||
public static <T> ApiResponse<T> error(int code, String msg) { return build(code, msg, null); }
|
||||
public static <T> ApiResponse<T> notFound(String msg) { return error(404, msg); }
|
||||
public static <T> ApiResponse<T> badRequest(String msg) { return error(400, msg); }
|
||||
|
||||
public ApiResponse<T> put(String key, Object value) {
|
||||
if (extra == null) extra = new HashMap<>();
|
||||
extra.put(key, value); return this;
|
||||
}
|
||||
|
||||
private static <T> ApiResponse<T> build(int code, String msg, T data) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.code = code; r.message = msg; r.data = data; return r;
|
||||
}
|
||||
|
||||
public int getCode() { return code; }
|
||||
public String getMessage() { return message; }
|
||||
public T getData() { return data; }
|
||||
public Map<String, Object> getExtra() { return extra; }
|
||||
}
|
||||
15
week7/backend/src/main/java/com/learn/dto/LoginRequest.java
Normal file
15
week7/backend/src/main/java/com/learn/dto/LoginRequest.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class LoginRequest {
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
private String username;
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
}
|
||||
77
week7/backend/src/main/java/com/learn/entity/Student.java
Normal file
77
week7/backend/src/main/java/com/learn/entity/Student.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.learn.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "student")
|
||||
@TableName("student")
|
||||
public class Student implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "姓名不能为空")
|
||||
@Size(min = 1, max = 20)
|
||||
@Column(name = "name", length = 20, nullable = false)
|
||||
@TableField("name")
|
||||
private String name;
|
||||
|
||||
@Min(1) @Max(150)
|
||||
@Column(name = "age", nullable = false)
|
||||
@TableField("age")
|
||||
private int age;
|
||||
|
||||
@NotBlank @Email
|
||||
@Column(name = "email", length = 50, nullable = false)
|
||||
@TableField("email")
|
||||
private String email;
|
||||
|
||||
@com.learn.annotation.ValidScore // 第 6 天:自定义校验注解
|
||||
@Column(name = "score", nullable = false)
|
||||
@TableField("score")
|
||||
private int score;
|
||||
|
||||
/** 逻辑删除标记(第 7 天): 0=未删, 1=已删 */
|
||||
@TableLogic // MyBatis-Plus 逻辑删除
|
||||
@Column(name = "deleted")
|
||||
@TableField("deleted")
|
||||
private Integer deleted = 0;
|
||||
|
||||
@Column(name = "create_time", insertable = false, updatable = false)
|
||||
@TableField("create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time", insertable = false, updatable = false)
|
||||
@TableField("update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
public Student() {}
|
||||
|
||||
public Student(Long id, String name, int age, String email, int score) {
|
||||
this.id = id; this.name = name; this.age = age; this.email = email; this.score = score;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public int getAge() { return age; }
|
||||
public void setAge(int age) { this.age = age; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
public int getScore() { return score; }
|
||||
public void setScore(int score) { this.score = score; }
|
||||
public Integer getDeleted() { return deleted; }
|
||||
public void setDeleted(Integer deleted) { this.deleted = deleted; }
|
||||
public LocalDateTime getCreateTime() { return createTime; }
|
||||
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
|
||||
public LocalDateTime getUpdateTime() { return updateTime; }
|
||||
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }
|
||||
}
|
||||
44
week7/backend/src/main/java/com/learn/entity/User.java
Normal file
44
week7/backend/src/main/java/com/learn/entity/User.java
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.learn.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String password; // BCrypt 加密后的密文
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
private String role; // ADMIN / USER(第 3 天 RBAC)
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer enabled = 1;
|
||||
|
||||
@Column(name = "create_time", insertable = false, updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
public User() {}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
public Integer getEnabled() { return enabled; }
|
||||
public void setEnabled(Integer enabled) { this.enabled = enabled; }
|
||||
public LocalDateTime getCreateTime() { return createTime; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.learn.exception;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/** 参数校验失败 */
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String detail = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest().body(ApiResponse.badRequest("参数校验失败: " + detail));
|
||||
}
|
||||
|
||||
/** 权限不足(第 3 天) */
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleAccessDenied(AccessDeniedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(403, "权限不足: 只有 ADMIN 可以执行此操作"));
|
||||
}
|
||||
|
||||
/** 兜底 */
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleAll(Exception ex) {
|
||||
log.error("未处理异常", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(500, "服务器错误: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.learn.repository.jpa;
|
||||
|
||||
import com.learn.entity.Student;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
|
||||
|
||||
List<Student> findByNameContaining(String keyword);
|
||||
|
||||
@Query("SELECT s FROM Student s WHERE (s.name LIKE %:kw% OR s.email LIKE %:kw%) AND s.deleted = 0")
|
||||
List<Student> search(@Param("kw") String keyword);
|
||||
|
||||
@Query("SELECT s FROM Student s WHERE s.score >= :min AND s.deleted = 0 ORDER BY s.score DESC")
|
||||
List<Student> excellent(@Param("min") int minScore);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.learn.repository.jpa;
|
||||
|
||||
import com.learn.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
boolean existsByUsername(String username);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.learn.repository.mp;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.learn.entity.Student;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface StudentMapper extends BaseMapper<Student> {
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.learn.security;
|
||||
|
||||
import com.learn.entity.User;
|
||||
import com.learn.repository.jpa.UserRepository;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* JWT 认证过滤器(第 2 天)
|
||||
*
|
||||
* 每个请求到达时,从 Authorization 头提取 JWT Token,
|
||||
* 解析出用户名和角色,设置到 Spring Security 上下文中。
|
||||
*
|
||||
* OncePerRequestFilter:保证每个请求只过滤一次。
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final UserRepository userRepo;
|
||||
|
||||
public JwtAuthFilter(JwtUtil jwtUtil, UserRepository userRepo) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.userRepo = userRepo;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
// 1. 提取 Token
|
||||
String token = extractToken(request);
|
||||
if (token == null || !jwtUtil.validateToken(token)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 解析用户名
|
||||
String username = jwtUtil.getUsername(token);
|
||||
|
||||
// 3. 查数据库确认用户仍存在且启用
|
||||
Optional<User> userOpt = userRepo.findByUsername(username);
|
||||
if (userOpt.isEmpty() || userOpt.get().getEnabled() == 0) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 将认证信息设置到 Spring Security 上下文
|
||||
String role = jwtUtil.getRole(token);
|
||||
List<SimpleGrantedAuthority> authorities = List.of(
|
||||
new SimpleGrantedAuthority("ROLE_" + role)
|
||||
);
|
||||
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(username, null, authorities);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
return header.substring(7).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
81
week7/backend/src/main/java/com/learn/security/JwtUtil.java
Normal file
81
week7/backend/src/main/java/com/learn/security/JwtUtil.java
Normal file
@@ -0,0 +1,81 @@
|
||||
package com.learn.security;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 工具类(第 2 天)
|
||||
*
|
||||
* 三个核心方法:
|
||||
* generateToken(username, role) → 生成 Token
|
||||
* getUsername(token) → 从 Token 解析用户名
|
||||
* validateToken(token) → 验证 Token 是否有效
|
||||
*
|
||||
* JWT 结构:header.payload.signature
|
||||
* header: {"alg": "HS256"}
|
||||
* payload: {"sub": "admin", "role": "ADMIN", "exp": 1234567890}
|
||||
* signature: HMAC-SHA256(header + "." + payload, secret)
|
||||
*/
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expiration;
|
||||
|
||||
public JwtUtil(@Value("${jwt.secret}") String secret,
|
||||
@Value("${jwt.expiration}") long expiration) {
|
||||
// 确保密钥至少 256 位(32 字节)
|
||||
String paddedSecret = secret;
|
||||
if (paddedSecret.length() < 32) {
|
||||
paddedSecret = String.format("%-32s", secret).replace(' ', '0');
|
||||
}
|
||||
this.key = Keys.hmacShaKeyFor(paddedSecret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expiration = expiration;
|
||||
}
|
||||
|
||||
/** 生成 Token */
|
||||
public String generateToken(String username, String role) {
|
||||
Date now = new Date();
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("role", role)
|
||||
.issuedAt(now)
|
||||
.expiration(new Date(now.getTime() + expiration))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** 从 Token 中提取用户名 */
|
||||
public String getUsername(String token) {
|
||||
return parse(token).getSubject();
|
||||
}
|
||||
|
||||
/** 从 Token 中提取角色 */
|
||||
public String getRole(String token) {
|
||||
return parse(token).get("role", String.class);
|
||||
}
|
||||
|
||||
/** 验证 Token 是否有效 */
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parse(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Claims parse(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.learn.service;
|
||||
|
||||
import com.learn.dto.LoginRequest;
|
||||
import com.learn.entity.User;
|
||||
import com.learn.repository.jpa.UserRepository;
|
||||
import com.learn.security.JwtUtil;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 认证服务(第 2-3 天)
|
||||
*
|
||||
* 注册:创建用户(BCrypt 加密密码)
|
||||
* 登录:验证密码 → 签发 JWT
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepo;
|
||||
private final PasswordEncoder encoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public AuthService(UserRepository userRepo, PasswordEncoder encoder, JwtUtil jwtUtil) {
|
||||
this.userRepo = userRepo;
|
||||
this.encoder = encoder;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
/** 注册(默认 USER 角色) */
|
||||
public Map<String, Object> register(String username, String password) {
|
||||
if (userRepo.existsByUsername(username)) {
|
||||
throw new RuntimeException("用户名已存在");
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPassword(encoder.encode(password)); // BCrypt 加密
|
||||
user.setRole("USER");
|
||||
userRepo.save(user);
|
||||
|
||||
String token = jwtUtil.generateToken(username, "USER");
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", token);
|
||||
result.put("role", "USER");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 登录 */
|
||||
public Map<String, Object> login(LoginRequest req) {
|
||||
User user = userRepo.findByUsername(req.getUsername())
|
||||
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
|
||||
|
||||
if (!encoder.matches(req.getPassword(), user.getPassword())) {
|
||||
throw new RuntimeException("用户名或密码错误");
|
||||
}
|
||||
|
||||
if (user.getEnabled() == 0) {
|
||||
throw new RuntimeException("账号已被禁用");
|
||||
}
|
||||
|
||||
String token = jwtUtil.generateToken(user.getUsername(), user.getRole());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", token);
|
||||
result.put("role", user.getRole());
|
||||
result.put("username", user.getUsername());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.learn.service;
|
||||
|
||||
import com.learn.entity.Student;
|
||||
import com.learn.service.jpa.StudentJpaService;
|
||||
import com.learn.service.mp.StudentMpService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class StudentServiceSelector {
|
||||
|
||||
private final StudentJpaService jpa;
|
||||
private final StudentMpService mp;
|
||||
|
||||
@Value("${orm.mode:jpa}")
|
||||
private String mode;
|
||||
|
||||
public StudentServiceSelector(StudentJpaService jpa, StudentMpService mp) {
|
||||
this.jpa = jpa; this.mp = mp;
|
||||
}
|
||||
|
||||
private boolean useJpa() { return !"mp".equalsIgnoreCase(mode); }
|
||||
|
||||
public Student add(Student s) { return useJpa() ? jpa.add(s) : mp.add(s); }
|
||||
|
||||
public List<Student> list() { return useJpa() ? jpa.list() : mp.list(); }
|
||||
|
||||
public Page<Student> listPage(int page, int size) {
|
||||
return useJpa() ? jpa.listPage(page, size) : mp.listPage(page, size);
|
||||
}
|
||||
|
||||
public Student getById(Long id) { return useJpa() ? jpa.getById(id) : mp.getById(id); }
|
||||
|
||||
public List<Student> search(String kw) { return useJpa() ? jpa.search(kw) : mp.search(kw); }
|
||||
|
||||
public Optional<Student> update(Long id, Student s) { return useJpa() ? jpa.update(id, s) : mp.update(id, s); }
|
||||
|
||||
public boolean delete(Long id) { return useJpa() ? jpa.delete(id) : mp.delete(id); }
|
||||
|
||||
public long count() { return useJpa() ? jpa.count() : mp.count(); }
|
||||
|
||||
public long countExcellent(int min) { return useJpa() ? jpa.countExcellent(min) : mp.countExcellent(min); }
|
||||
|
||||
public String getActiveMode() { return useJpa() ? "JPA" : "MyBatis-Plus"; }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.learn.service.jpa;
|
||||
|
||||
import com.learn.entity.Student;
|
||||
import com.learn.repository.jpa.StudentJpaRepository;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class StudentJpaService {
|
||||
|
||||
private final StudentJpaRepository repo;
|
||||
|
||||
public StudentJpaService(StudentJpaRepository repo) { this.repo = repo; }
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "students", allEntries = true) // 新增时清空缓存
|
||||
public Student add(Student s) { return repo.save(s); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(value = "students", key = "'list'") // 缓存查询结果
|
||||
public List<Student> list() { return repo.findAll(Sort.by(Sort.Direction.DESC, "score")); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<Student> listPage(int page, int size) {
|
||||
return repo.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "score")));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(value = "students", key = "#id")
|
||||
public Student getById(Long id) { return repo.findById(id).orElse(null); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Student> search(String kw) { return repo.search(kw); }
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "students", allEntries = true)
|
||||
public Optional<Student> update(Long id, Student updated) {
|
||||
return repo.findById(id).map(existing -> {
|
||||
if (updated.getName() != null) existing.setName(updated.getName());
|
||||
if (updated.getAge() > 0) existing.setAge(updated.getAge());
|
||||
if (updated.getEmail() != null) existing.setEmail(updated.getEmail());
|
||||
if (updated.getScore() >= 0) existing.setScore(updated.getScore());
|
||||
return repo.save(existing);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "students", allEntries = true)
|
||||
public boolean delete(Long id) {
|
||||
if (repo.existsById(id)) { repo.deleteById(id); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long count() { return repo.count(); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countExcellent(int min) { return repo.excellent(min).size(); }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.learn.service.mp;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.learn.entity.Student;
|
||||
import com.learn.repository.mp.StudentMapper;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class StudentMpService {
|
||||
|
||||
private final StudentMapper mapper;
|
||||
|
||||
public StudentMpService(StudentMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "students", allEntries = true)
|
||||
public Student add(Student s) { mapper.insert(s); return s; }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(value = "students", key = "'list'")
|
||||
public List<Student> list() {
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.orderByDesc(Student::getScore);
|
||||
return mapper.selectList(w);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public org.springframework.data.domain.Page<Student> listPage(int page, int size) {
|
||||
Page<Student> mpPage = new Page<>(page + 1, size); // MP 页码从 1 开始
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.orderByDesc(Student::getScore);
|
||||
Page<Student> result = mapper.selectPage(mpPage, w);
|
||||
return new PageImpl<>(result.getRecords(),
|
||||
PageRequest.of(page, size), result.getTotal());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(value = "students", key = "#id")
|
||||
public Student getById(Long id) {
|
||||
return mapper.selectById(id);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Student> search(String kw) {
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.like(Student::getName, kw).or().like(Student::getEmail, kw);
|
||||
w.orderByDesc(Student::getScore);
|
||||
return mapper.selectList(w);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "students", allEntries = true)
|
||||
public Optional<Student> update(Long id, Student updated) {
|
||||
Student existing = mapper.selectById(id);
|
||||
if (existing == null) return Optional.empty();
|
||||
if (updated.getName() != null) existing.setName(updated.getName());
|
||||
if (updated.getAge() > 0) existing.setAge(updated.getAge());
|
||||
if (updated.getEmail() != null) existing.setEmail(updated.getEmail());
|
||||
if (updated.getScore() >= 0) existing.setScore(updated.getScore());
|
||||
mapper.updateById(existing);
|
||||
return Optional.of(existing);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = "students", allEntries = true)
|
||||
public boolean delete(Long id) {
|
||||
return mapper.deleteById(id) > 0; // 逻辑删除:MP 自动将 deleted 设为 1
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long count() {
|
||||
return mapper.selectCount(null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countExcellent(int min) {
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.ge(Student::getScore, min);
|
||||
return mapper.selectCount(w);
|
||||
}
|
||||
}
|
||||
69
week7/backend/src/main/resources/application.yml
Normal file
69
week7/backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
spring:
|
||||
application:
|
||||
name: week5-security
|
||||
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/week5_student?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: 1365957941@Wfj
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.MySQLDialect
|
||||
|
||||
# Redis(第 4 天,本地未安装 Redis 时可注释掉)
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: 1365957941@Wfj
|
||||
timeout: 3000ms
|
||||
|
||||
cache:
|
||||
type: redis
|
||||
redis:
|
||||
time-to-live: 60000 # 缓存有效期 60 秒
|
||||
cache-null-values: false
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
map-underscore-to-camel-case: true
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
logic-delete-field: deleted # 逻辑删除字段(第 7 天)
|
||||
logic-delete-value: 1 # 已删除 = 1
|
||||
logic-not-delete-value: 0 # 未删除 = 0
|
||||
|
||||
# JWT 配置
|
||||
jwt:
|
||||
secret: week5-jwt-secret-key-for-student-management-system
|
||||
expiration: 86400000 # 24 小时(毫秒)
|
||||
|
||||
# Actuator(第 5 天)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,beans,env,mappings
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
# ORM 模式
|
||||
orm:
|
||||
mode: jpa
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.learn: DEBUG
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
149
week7/backend/src/main/resources/static/css/style.css
Normal file
149
week7/backend/src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,149 @@
|
||||
/* ========== 基础重置 ========== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; padding: 20px; color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ========== 头部 ========== */
|
||||
header {
|
||||
text-align: center; padding: 30px 20px 20px;
|
||||
}
|
||||
header h1 { font-size: 2em; color: #fff; margin-bottom: 6px; }
|
||||
.subtitle { color: rgba(255,255,255,0.75); font-size: 0.95em; }
|
||||
|
||||
.main-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20px; background: #fff; border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.main-header h1 { font-size: 1.4em; color: #333; margin-bottom: 0; }
|
||||
.main-header .subtitle { color: #999; margin-top: 2px; }
|
||||
|
||||
/* ========== 登录/注册卡片 ========== */
|
||||
.login-card {
|
||||
background: #fff; border-radius: 12px; padding: 36px 30px;
|
||||
max-width: 420px; margin: 0 auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||
}
|
||||
.login-card h2 { font-size: 1.3em; margin-bottom: 18px; color: #333; }
|
||||
.hint { color: #999; font-size: 0.85em; margin-bottom: 14px; }
|
||||
.hint.small { font-size: 0.8em; text-align: center; }
|
||||
.hint a { color: #667eea; text-decoration: none; }
|
||||
|
||||
/* ========== 表单 ========== */
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label { display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: 500; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px 12px; border: 1.5px solid #e0e0e0;
|
||||
border-radius: 8px; font-size: 0.95em; transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
|
||||
}
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
/* ========== 按钮 ========== */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 10px 20px; border: none; border-radius: 8px; font-size: 0.93em;
|
||||
cursor: pointer; transition: all 0.2s; text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: #667eea; color: #fff; }
|
||||
.btn-primary:hover { background: #5a6fd6; }
|
||||
.btn-outline { background: #fff; color: #667eea; border: 1.5px solid #667eea; }
|
||||
.btn-outline:hover { background: #f0f2ff; }
|
||||
.btn-danger { background: #e74c3c; color: #fff; padding: 5px 12px; font-size: 0.82em; }
|
||||
.btn-danger:hover { background: #c0392b; }
|
||||
.btn-edit { background: #2ecc71; color: #fff; padding: 5px 12px; font-size: 0.82em; margin-right: 6px; }
|
||||
.btn-edit:hover { background: #27ae60; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 0.82em; }
|
||||
.btn-block { display: block; width: 100%; }
|
||||
|
||||
/* ========== 工具栏 ========== */
|
||||
.toolbar {
|
||||
background: #fff; padding: 14px 20px; display: flex; gap: 10px;
|
||||
border-top: 1px solid #f0f0f0; align-items: center;
|
||||
}
|
||||
.toolbar input {
|
||||
flex: 1; padding: 9px 12px; border: 1.5px solid #e0e0e0; border-radius: 8px;
|
||||
font-size: 0.93em;
|
||||
}
|
||||
.toolbar input:focus { outline: none; border-color: #667eea; }
|
||||
|
||||
/* ========== 表格 ========== */
|
||||
.table-wrap {
|
||||
background: #fff; padding: 0 20px 20px; overflow-x: auto;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.93em; }
|
||||
thead th {
|
||||
text-align: left; padding: 12px 10px; border-bottom: 2px solid #667eea;
|
||||
color: #667eea; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 12px 10px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
tbody tr:hover { background: #f8f9ff; }
|
||||
.loading, .empty { text-align: center; color: #999; padding: 40px 0 !important; }
|
||||
|
||||
/* ========== 成绩徽章 ========== */
|
||||
.badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 0.82em; font-weight: 600; color: #fff;
|
||||
}
|
||||
.badge-excellent { background: #2ecc71; }
|
||||
.badge-good { background: #3498db; }
|
||||
.badge-normal { background: #f39c12; }
|
||||
.badge-poor { background: #e74c3c; }
|
||||
|
||||
/* ========== 统计卡片 ========== */
|
||||
.stats {
|
||||
background: #fff; padding: 16px 20px; border-radius: 0 0 12px 12px;
|
||||
display: flex; gap: 14px; border-top: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1; text-align: center; padding: 14px 10px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.stat-value { display: block; font-size: 1.6em; font-weight: 700; color: #fff; }
|
||||
.stat-label { display: block; font-size: 0.78em; color: rgba(255,255,255,0.8); margin-top: 4px; }
|
||||
|
||||
/* ========== 错误消息 ========== */
|
||||
.error-msg { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; min-height: 1.2em; }
|
||||
.error-bar {
|
||||
background: #fff3cd; color: #856404; padding: 10px 20px; border-left: 4px solid #ffc107;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
/* ========== 弹窗 ========== */
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.45); display: flex; justify-content: center;
|
||||
align-items: center; z-index: 1000; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.modal-overlay.show { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; padding: 28px 30px; width: 90%; max-width: 480px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal h2 { margin-bottom: 18px; font-size: 1.2em; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 10px; }
|
||||
.login-card { padding: 24px 18px; }
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
.main-header { flex-direction: column; gap: 10px; }
|
||||
.toolbar { flex-wrap: wrap; }
|
||||
.stats { flex-direction: column; }
|
||||
}
|
||||
105
week7/backend/src/main/resources/static/index.html
Normal file
105
week7/backend/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>学生管理系统 v2</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 登录面板 -->
|
||||
<div class="container" id="login-panel">
|
||||
<header>
|
||||
<h1>📚 学生管理系统 v2</h1>
|
||||
<p class="subtitle">Spring Security + JWT + Redis</p>
|
||||
</header>
|
||||
<div class="login-card">
|
||||
<h2>登录</h2>
|
||||
<p class="hint">默认账号:admin/123456 (管理员) 或 user/123456 (普通用户)</p>
|
||||
<div class="form-group">
|
||||
<input type="text" id="login-username" placeholder="用户名" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="login-password" placeholder="密码">
|
||||
</div>
|
||||
<div class="error-msg" id="login-error"></div>
|
||||
<button class="btn btn-primary btn-block" onclick="doLogin()">登录</button>
|
||||
<p class="hint small" style="margin-top:12px">
|
||||
没有账号?<a href="#" onclick="showRegister()">注册</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册面板 -->
|
||||
<div class="container" id="register-panel" style="display:none">
|
||||
<header><h1>📚 注册</h1></header>
|
||||
<div class="login-card">
|
||||
<h2>注册新账号</h2>
|
||||
<div class="form-group"><input type="text" id="reg-username" placeholder="用户名"></div>
|
||||
<div class="form-group"><input type="password" id="reg-password" placeholder="密码"></div>
|
||||
<div class="error-msg" id="reg-error"></div>
|
||||
<button class="btn btn-primary btn-block" onclick="doRegister()">注册</button>
|
||||
<p class="hint small" style="margin-top:12px"><a href="#" onclick="showLogin()">返回登录</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主面板 -->
|
||||
<div class="container" id="main-panel" style="display:none">
|
||||
<header class="main-header">
|
||||
<div>
|
||||
<h1>📚 学生管理系统 v2</h1>
|
||||
<span id="user-info"></span>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="logout()">退出</button>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<input type="text" id="keyword" placeholder="搜索..." onkeyup="if(event.key==='Enter')search()">
|
||||
<button class="btn btn-primary" id="btn-add" onclick="showForm(null)">+ 新增</button>
|
||||
<button class="btn btn-outline" onclick="loadStudents()">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="error-bar" id="error-bar" style="display:none"></div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>姓名</th><th>年龄</th><th>邮箱</th><th>成绩</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats-bar">
|
||||
<div class="stat-card"><span class="stat-value" id="stat-total">-</span><span class="stat-label">总数</span></div>
|
||||
<div class="stat-card"><span class="stat-value" id="stat-excellent">-</span><span class="stat-label">优秀(≥85)</span></div>
|
||||
<div class="stat-card"><span class="stat-value" id="stat-orm">-</span><span class="stat-label">ORM 引擎</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">新增学生</h2>
|
||||
<form id="student-form" onsubmit="submitForm(event)">
|
||||
<input type="hidden" id="form-id">
|
||||
<div class="form-group"><label>姓名 *</label><input type="text" id="form-name" required maxlength="20"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>年龄 *</label><input type="number" id="form-age" required min="1" max="150"></div>
|
||||
<div class="form-group"><label>成绩 *</label><input type="number" id="form-score" required min="0" max="100"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>邮箱 *</label><input type="email" id="form-email" required></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
210
week7/backend/src/main/resources/static/js/app.js
Normal file
210
week7/backend/src/main/resources/static/js/app.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const BASE = '/api';
|
||||
let token = localStorage.getItem('wk5_token') || '';
|
||||
let role = localStorage.getItem('wk5_role') || '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (token) { showMain(); loadStudents(); checkRoleUI(); }
|
||||
});
|
||||
|
||||
// ==================== 认证 ====================
|
||||
|
||||
async function doLogin() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value.trim();
|
||||
if (!username || !password) { showLoginError('请输入用户名和密码'); return; }
|
||||
|
||||
try {
|
||||
const resp = await fetch(BASE + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 200) {
|
||||
token = result.data.token;
|
||||
role = result.data.role;
|
||||
localStorage.setItem('wk5_token', token);
|
||||
localStorage.setItem('wk5_role', role);
|
||||
showMain(); loadStudents(); checkRoleUI();
|
||||
} else {
|
||||
showLoginError(result.message || '登录失败');
|
||||
}
|
||||
} catch (e) { showLoginError('连接失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const username = document.getElementById('reg-username').value.trim();
|
||||
const password = document.getElementById('reg-password').value.trim();
|
||||
if (!username || !password) { document.getElementById('reg-error').textContent = '请输入用户名和密码'; return; }
|
||||
|
||||
try {
|
||||
const resp = await fetch(BASE + '/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 200) {
|
||||
token = result.data.token;
|
||||
role = result.data.role;
|
||||
localStorage.setItem('wk5_token', token);
|
||||
localStorage.setItem('wk5_role', role);
|
||||
showMain(); loadStudents(); checkRoleUI();
|
||||
} else {
|
||||
document.getElementById('reg-error').textContent = result.message || '注册失败';
|
||||
}
|
||||
} catch (e) { document.getElementById('reg-error').textContent = '连接失败'; }
|
||||
}
|
||||
|
||||
function showLoginError(msg) { document.getElementById('login-error').textContent = msg; }
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('login-panel').style.display = 'block';
|
||||
document.getElementById('register-panel').style.display = 'none';
|
||||
document.getElementById('main-panel').style.display = 'none';
|
||||
}
|
||||
function showRegister() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('register-panel').style.display = 'block';
|
||||
}
|
||||
function showMain() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('register-panel').style.display = 'none';
|
||||
document.getElementById('main-panel').style.display = 'block';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('wk5_token'); localStorage.removeItem('wk5_role');
|
||||
token = ''; role = ''; showLogin();
|
||||
}
|
||||
|
||||
/** 根据角色显示/隐藏操作按钮 */
|
||||
function checkRoleUI() {
|
||||
document.getElementById('user-info').textContent = '当前: ' + (role || '-') + ' | ';
|
||||
document.getElementById('btn-add').style.display = (role === 'ADMIN') ? '' : 'none';
|
||||
}
|
||||
|
||||
// ==================== 请求封装 ====================
|
||||
|
||||
async function api(url, options) {
|
||||
options = options || {};
|
||||
options.headers = options.headers || {};
|
||||
options.headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
const resp = await fetch(url, options);
|
||||
const result = await resp.json();
|
||||
|
||||
if (resp.status === 403) {
|
||||
showError('权限不足:需要 ADMIN 角色'); return null;
|
||||
}
|
||||
if ((resp.status === 401 || resp.status === 403) && result.code !== 200) {
|
||||
logout(); showError('认证失败,请重新登录'); return null;
|
||||
}
|
||||
if (result.extra && result.extra.orm) {
|
||||
document.getElementById('stat-orm').textContent = result.extra.orm;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const bar = document.getElementById('error-bar');
|
||||
bar.textContent = '⚠ ' + msg; bar.style.display = 'block';
|
||||
setTimeout(() => bar.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
// ==================== 数据 ====================
|
||||
|
||||
async function loadStudents(keyword) {
|
||||
const tbody = document.getElementById('table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading">加载中...</td></tr>';
|
||||
|
||||
let url = BASE + '/students';
|
||||
if (keyword) url += '?keyword=' + encodeURIComponent(keyword);
|
||||
|
||||
const result = await api(url);
|
||||
if (!result) { tbody.innerHTML = '<tr><td colspan="6" class="empty">加载失败</td></tr>'; return; }
|
||||
|
||||
const students = result.data;
|
||||
if (!students || students.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = students.map(s => {
|
||||
let cls = 'badge-poor';
|
||||
if (s.score >= 90) cls = 'badge-excellent';
|
||||
else if (s.score >= 80) cls = 'badge-good';
|
||||
else if (s.score >= 60) cls = 'badge-normal';
|
||||
const actions = role === 'ADMIN'
|
||||
? `<button class="btn-edit" onclick="showForm(${s.id})">编辑</button>
|
||||
<button class="btn-danger" onclick="deleteStudent(${s.id})">删除</button>`
|
||||
: '<span style="color:#ccc">--</span>';
|
||||
return `<tr>
|
||||
<td>${s.id}</td><td><strong>${escapeHtml(s.name)}</strong></td>
|
||||
<td>${s.age}</td><td>${escapeHtml(s.email)}</td>
|
||||
<td><span class="badge ${cls}">${s.score}</span></td>
|
||||
<td>${actions}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('stat-total').textContent = result.extra?.total ?? students.length;
|
||||
|
||||
const statResp = await api(BASE + '/students/stats/excellent?min=85');
|
||||
if (statResp) document.getElementById('stat-excellent').textContent = statResp.data;
|
||||
}
|
||||
|
||||
function search() { loadStudents(document.getElementById('keyword').value.trim() || undefined); }
|
||||
|
||||
// ==================== 表单 ====================
|
||||
|
||||
async function showForm(id) {
|
||||
document.getElementById('form-id').value = ''; document.getElementById('student-form').reset();
|
||||
if (id) {
|
||||
document.getElementById('modal-title').textContent = '编辑学生';
|
||||
const result = await api(BASE + '/students/' + id);
|
||||
if (!result) return;
|
||||
const s = result.data;
|
||||
document.getElementById('form-id').value = s.id;
|
||||
document.getElementById('form-name').value = s.name;
|
||||
document.getElementById('form-age').value = s.age;
|
||||
document.getElementById('form-email').value = s.email;
|
||||
document.getElementById('form-score').value = s.score;
|
||||
} else {
|
||||
document.getElementById('modal-title').textContent = '新增学生';
|
||||
}
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
}
|
||||
|
||||
async function submitForm(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('form-id').value;
|
||||
const body = {
|
||||
name: document.getElementById('form-name').value.trim(),
|
||||
age: parseInt(document.getElementById('form-age').value),
|
||||
email: document.getElementById('form-email').value.trim(),
|
||||
score: parseInt(document.getElementById('form-score').value)
|
||||
};
|
||||
|
||||
let result;
|
||||
if (id) {
|
||||
result = await api(BASE + '/students/' + id, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
result = await api(BASE + '/students', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
if (result) { closeModal(); loadStudents(); }
|
||||
}
|
||||
|
||||
async function deleteStudent(id) {
|
||||
if (!confirm('确认删除?(MP 模式下为逻辑删除)')) return;
|
||||
const result = await api(BASE + '/students/' + id, { method: 'DELETE' });
|
||||
if (result) loadStudents();
|
||||
}
|
||||
|
||||
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); }
|
||||
document.addEventListener('click', e => { if (e.target.id === 'modal-overlay') closeModal(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && document.getElementById('login-panel').style.display !== 'none') doLogin();
|
||||
});
|
||||
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
24
week7/frontend/.gitignore
vendored
Normal file
24
week7/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
13
week7/frontend/index.html
Normal file
13
week7/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>学生管理系统 v3</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1950
week7/frontend/package-lock.json
generated
Normal file
1950
week7/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
week7/frontend/package.json
Normal file
21
week7/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
1
week7/frontend/public/favicon.svg
Normal file
1
week7/frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
week7/frontend/public/icons.svg
Normal file
24
week7/frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
3
week7/frontend/src/App.vue
Normal file
3
week7/frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
10
week7/frontend/src/api/auth.js
Normal file
10
week7/frontend/src/api/auth.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// 认证 API(Day 2)
|
||||
import http from './http'
|
||||
|
||||
export function loginApi(username, password) {
|
||||
return http.post('/auth/login', { username, password })
|
||||
}
|
||||
|
||||
export function registerApi(username, password) {
|
||||
return http.post('/auth/register', { username, password })
|
||||
}
|
||||
30
week7/frontend/src/api/http.js
Normal file
30
week7/frontend/src/api/http.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Axios 封装 — 统一 JWT 请求头 + 错误处理(Day 7)
|
||||
import axios from 'axios'
|
||||
|
||||
const http = axios.create({ baseURL: '/api', timeout: 10000 })
|
||||
|
||||
http.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('wk7_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
response => {
|
||||
const body = response.data
|
||||
if (body && body.code === 200) return body
|
||||
return Promise.reject(new Error(body?.message || '未知错误'))
|
||||
},
|
||||
error => {
|
||||
const resp = error.response
|
||||
if (!resp) return Promise.reject(new Error('网络错误,请检查后端是否启动'))
|
||||
const body = resp.data
|
||||
if (resp.status === 401) {
|
||||
localStorage.removeItem('wk7_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(new Error(body?.message || `请求失败 (${resp.status})`))
|
||||
}
|
||||
)
|
||||
|
||||
export default http
|
||||
29
week7/frontend/src/api/student.js
Normal file
29
week7/frontend/src/api/student.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// 学生 CRUD API(Day 3-5)
|
||||
import http from './http'
|
||||
|
||||
export function fetchStudents(keyword, page, size) {
|
||||
let url = '/students?'
|
||||
if (keyword) url += 'keyword=' + encodeURIComponent(keyword) + '&'
|
||||
if (page != null) url += 'page=' + page + '&size=' + (size || 10)
|
||||
return http.get(url)
|
||||
}
|
||||
|
||||
export function fetchStudent(id) {
|
||||
return http.get('/students/' + id)
|
||||
}
|
||||
|
||||
export function createStudent(data) {
|
||||
return http.post('/students', data)
|
||||
}
|
||||
|
||||
export function updateStudent(id, data) {
|
||||
return http.put('/students/' + id, data)
|
||||
}
|
||||
|
||||
export function deleteStudent(id) {
|
||||
return http.delete('/students/' + id)
|
||||
}
|
||||
|
||||
export function fetchExcellent(min) {
|
||||
return http.get('/students/stats/excellent?min=' + (min || 85))
|
||||
}
|
||||
47
week7/frontend/src/components/NavBar.vue
Normal file
47
week7/frontend/src/components/NavBar.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<!-- 顶部导航栏(Day 4 + Day 6:组件通信 + Store) -->
|
||||
<template>
|
||||
<nav>
|
||||
<div class="nav-left">
|
||||
<router-link to="/" class="logo">学生管理 v3</router-link>
|
||||
<router-link to="/students">数据管理</router-link>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span class="user-tag">{{ auth.username }} <em>({{ auth.role }})</em></span>
|
||||
<button @click="handleLogout">退出</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
height: 56px; padding: 0 24px;
|
||||
background: rgba(255,255,255,0.95); box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.nav-left { display: flex; gap: 20px; align-items: center; }
|
||||
.logo { font-weight: 700; font-size: 1.1em; color: #667eea; }
|
||||
.nav-left a { color: #555; text-decoration: none; font-size: 0.93em; }
|
||||
.nav-left a.router-link-active { color: #667eea; font-weight: 600; }
|
||||
.nav-right { display: flex; align-items: center; gap: 14px; }
|
||||
.user-tag { font-size: 0.88em; color: #666; }
|
||||
.user-tag em { font-style: normal; color: #667eea; }
|
||||
.nav-right button {
|
||||
padding: 6px 14px; border: 1.5px solid #e74c3c; background: #fff;
|
||||
color: #e74c3c; border-radius: 6px; cursor: pointer; font-size: 0.82em;
|
||||
}
|
||||
.nav-right button:hover { background: #e74c3c; color: #fff; }
|
||||
</style>
|
||||
46
week7/frontend/src/components/Pagination.vue
Normal file
46
week7/frontend/src/components/Pagination.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<!-- 分页组件(Day 4:分页) -->
|
||||
<template>
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="current === 0" @click="$emit('change', 0)">首页</button>
|
||||
<button :disabled="current === 0" @click="$emit('change', current - 1)">上一页</button>
|
||||
<span v-for="p in pages" :key="p">
|
||||
<button v-if="p !== '...'" :class="{ active: p - 1 === current }" @click="$emit('change', p - 1)">{{ p }}</button>
|
||||
<span v-else class="dots">...</span>
|
||||
</span>
|
||||
<button :disabled="current >= totalPages - 1" @click="$emit('change', current + 1)">下一页</button>
|
||||
<button :disabled="current >= totalPages - 1" @click="$emit('change', totalPages - 1)">末页</button>
|
||||
<span class="info">{{ current + 1 }} / {{ totalPages }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({ current: Number, totalPages: Number })
|
||||
defineEmits(['change'])
|
||||
|
||||
const pages = computed(() => {
|
||||
const total = props.totalPages
|
||||
const cur = props.current
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
const result = [1]
|
||||
if (cur > 3) result.push('...')
|
||||
for (let i = Math.max(2, cur - 1); i <= Math.min(total - 1, cur + 3); i++) result.push(i)
|
||||
if (cur < total - 4) result.push('...')
|
||||
result.push(total)
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 20px; }
|
||||
.pagination button {
|
||||
padding: 6px 12px; border: 1.5px solid #ddd; background: #fff; border-radius: 6px;
|
||||
cursor: pointer; font-size: 0.85em; transition: all 0.15s;
|
||||
}
|
||||
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.pagination button:not(:disabled):hover { border-color: #667eea; color: #667eea; }
|
||||
.pagination button.active { background: #667eea; color: #fff; border-color: #667eea; }
|
||||
.dots { padding: 0 4px; color: #999; }
|
||||
.info { margin-left: 8px; font-size: 0.85em; color: #888; }
|
||||
</style>
|
||||
79
week7/frontend/src/components/StudentForm.vue
Normal file
79
week7/frontend/src/components/StudentForm.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- 学生表单弹窗(Day 3 + Day 4:props/emits + 表单校验) -->
|
||||
<template>
|
||||
<div class="overlay" :class="{ show: visible }" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<h2>{{ isEdit ? '编辑学生' : '新增学生' }}</h2>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="field"><label>姓名 *</label><input v-model="form.name" required maxlength="20" placeholder="请输入姓名"></div>
|
||||
<div class="row">
|
||||
<div class="field"><label>年龄 *</label><input v-model.number="form.age" type="number" required min="1" max="150"></div>
|
||||
<div class="field"><label>成绩 *</label><input v-model.number="form.score" type="number" required min="0" max="100"></div>
|
||||
</div>
|
||||
<div class="field"><label>邮箱 *</label><input v-model="form.email" type="email" required placeholder="example@mail.com"></div>
|
||||
<p class="err" v-if="error">{{ error }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-cancel" @click="$emit('close')">取消</button>
|
||||
<button type="submit" class="btn-save" :disabled="loading">{{ loading ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({ visible: Boolean, student: Object, loading: Boolean })
|
||||
const emit = defineEmits(['close', 'submit', 'update:student'])
|
||||
|
||||
const isEdit = ref(false)
|
||||
const form = ref({ name: '', age: 18, email: '', score: 80 })
|
||||
const error = ref('')
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (!v) return
|
||||
error.value = ''
|
||||
if (props.student) {
|
||||
isEdit.value = true
|
||||
form.value = { ...props.student }
|
||||
} else {
|
||||
isEdit.value = false
|
||||
form.value = { name: '', age: 18, email: '', score: 80 }
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (!form.value.name.trim()) { error.value = '姓名不能为空'; return }
|
||||
if (!form.value.email.trim()) { error.value = '邮箱不能为空'; return }
|
||||
if (form.value.score < 0 || form.value.score > 100) { error.value = '成绩必须在 0-100 之间'; return }
|
||||
emit('submit', { ...form.value, id: props.student?.id })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
opacity: 0; pointer-events: none; transition: opacity 0.25s;
|
||||
}
|
||||
.overlay.show { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; padding: 28px 30px; width: 90%; max-width: 480px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal h2 { margin-bottom: 18px; font-size: 1.2em; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: 500; }
|
||||
.field input {
|
||||
width: 100%; padding: 9px 12px; border: 1.5px solid #ddd; border-radius: 8px; font-size: 0.93em;
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #667eea; }
|
||||
.row { display: flex; gap: 12px; }
|
||||
.row .field { flex: 1; }
|
||||
.actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
.btn-cancel { padding: 9px 20px; border: 1.5px solid #ddd; background: #fff; border-radius: 8px; cursor: pointer; }
|
||||
.btn-save { padding: 9px 28px; background: #667eea; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
|
||||
.btn-save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-save:not(:disabled):hover { background: #5a6fd6; }
|
||||
.err { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; }
|
||||
</style>
|
||||
7
week7/frontend/src/main.js
Normal file
7
week7/frontend/src/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
46
week7/frontend/src/router/index.js
Normal file
46
week7/frontend/src/router/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// 路由配置 + 导航守卫(Day 5 + Day 7)
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/students',
|
||||
name: 'StudentList',
|
||||
component: () => import('../views/StudentListView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
|
||||
// 全局前置守卫:未登录重定向到登录页
|
||||
router.beforeEach((to, from, next) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||
next('/login')
|
||||
} else if (to.meta.guest && auth.isLoggedIn) {
|
||||
next('/') // 已登录用户访问登录页时跳转首页
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
43
week7/frontend/src/stores/auth.js
Normal file
43
week7/frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// 认证 Store — JWT Token 管理(Day 2 + Day 6)
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { loginApi, registerApi } from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('wk7_token') || '')
|
||||
const username = ref(localStorage.getItem('wk7_username') || '')
|
||||
const role = ref(localStorage.getItem('wk7_role') || '')
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => role.value === 'ADMIN')
|
||||
|
||||
function saveAuth(data) {
|
||||
token.value = data.token
|
||||
username.value = data.username
|
||||
role.value = data.role
|
||||
localStorage.setItem('wk7_token', data.token)
|
||||
localStorage.setItem('wk7_username', data.username)
|
||||
localStorage.setItem('wk7_role', data.role)
|
||||
}
|
||||
|
||||
async function login(user, pass) {
|
||||
const result = await loginApi(user, pass)
|
||||
saveAuth(result.data)
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function register(user, pass) {
|
||||
const result = await registerApi(user, pass)
|
||||
saveAuth(result.data)
|
||||
return result.data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''; username.value = ''; role.value = ''
|
||||
localStorage.removeItem('wk7_token')
|
||||
localStorage.removeItem('wk7_username')
|
||||
localStorage.removeItem('wk7_role')
|
||||
}
|
||||
|
||||
return { token, username, role, isLoggedIn, isAdmin, login, register, logout }
|
||||
})
|
||||
8
week7/frontend/src/style.css
Normal file
8
week7/frontend/src/style.css
Normal file
@@ -0,0 +1,8 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; color: #333;
|
||||
}
|
||||
79
week7/frontend/src/views/HomeView.vue
Normal file
79
week7/frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- 首页仪表盘(Day 1 + Day 5:架构总览 + 路由) -->
|
||||
<template>
|
||||
<div>
|
||||
<NavBar />
|
||||
<div class="page">
|
||||
<h1>欢迎回来,{{ auth.username }}</h1>
|
||||
<p class="role">角色:<strong>{{ auth.role }}</strong> | ORM:<strong>{{ orm }}</strong></p>
|
||||
|
||||
<div class="cards">
|
||||
<div class="card primary" @click="$router.push('/students')">
|
||||
<div class="num">{{ stats.total }}</div>
|
||||
<div class="label">学生总数</div>
|
||||
</div>
|
||||
<div class="card success">
|
||||
<div class="num">{{ stats.excellent }}</div>
|
||||
<div class="label">优秀 (≥85)</div>
|
||||
</div>
|
||||
<div class="card info">
|
||||
<div class="num">{{ orm }}</div>
|
||||
<div class="label">ORM 引擎</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-links">
|
||||
<router-link to="/students" class="btn">查看学生列表</router-link>
|
||||
<button v-if="auth.isAdmin" class="btn primary" @click="$router.push('/students')">+ 新增学生</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { fetchStudents, fetchExcellent } from '../api/student'
|
||||
import NavBar from '../components/NavBar.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const orm = ref('--')
|
||||
const stats = ref({ total: 0, excellent: 0 })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await fetchStudents()
|
||||
stats.value.total = list.extra?.total ?? list.data?.length ?? 0
|
||||
orm.value = list.extra?.orm ?? '--'
|
||||
const exc = await fetchExcellent(85)
|
||||
stats.value.excellent = exc.data ?? 0
|
||||
} catch { /* 后端未启动时显示默认值 */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { max-width: 800px; margin: 60px auto; padding: 0 20px; text-align: center; }
|
||||
h1 { font-size: 1.6em; color: #fff; margin-bottom: 6px; }
|
||||
.role { color: rgba(255,255,255,0.8); margin-bottom: 32px; font-size: 0.93em; }
|
||||
|
||||
.cards { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||
.card {
|
||||
flex: 1; min-width: 160px; padding: 28px 16px; border-radius: 12px;
|
||||
color: #fff; cursor: pointer; transition: transform 0.2s;
|
||||
}
|
||||
.card:hover { transform: translateY(-3px); }
|
||||
.card.primary { background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.card.success { background: linear-gradient(135deg, #2ecc71, #27ae60); }
|
||||
.card.info { background: linear-gradient(135deg, #3498db, #2980b9); cursor: default; }
|
||||
.num { font-size: 2.4em; font-weight: 700; }
|
||||
.label { font-size: 0.88em; opacity: 0.85; margin-top: 4px; }
|
||||
|
||||
.quick-links { margin-top: 32px; display: flex; gap: 12px; justify-content: center; }
|
||||
.btn {
|
||||
padding: 12px 28px; border-radius: 8px; font-size: 0.95em; cursor: pointer;
|
||||
background: rgba(255,255,255,0.15); color: #fff; border: 1.5px solid rgba(255,255,255,0.3);
|
||||
text-decoration: none; transition: all 0.2s; display: inline-block;
|
||||
}
|
||||
.btn:hover { background: rgba(255,255,255,0.25); }
|
||||
.btn.primary { background: #fff; color: #667eea; border-color: #fff; }
|
||||
.btn.primary:hover { background: #f0f2ff; }
|
||||
</style>
|
||||
69
week7/frontend/src/views/LoginView.vue
Normal file
69
week7/frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- 登录页(Day 2:登录注册 + JWT 对接) -->
|
||||
<template>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<h1>学生管理系统 v3</h1>
|
||||
<p class="sub">Vue 3 + Spring Boot 前后端分离</p>
|
||||
<div class="field"><input v-model="username" placeholder="用户名" @keyup.enter="handleLogin" autofocus></div>
|
||||
<div class="field"><input v-model="password" type="password" placeholder="密码" @keyup.enter="handleLogin"></div>
|
||||
<p class="err" v-if="error">{{ error }}</p>
|
||||
<button class="btn-login" :disabled="loading" @click="handleLogin">{{ loading ? '登录中...' : '登录' }}</button>
|
||||
<p class="hint">默认: admin/123456 (管理员) 或 user/123456 (普通用户)</p>
|
||||
<p class="hint">没有账号?<router-link to="/register">注册</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) { error.value = '请输入用户名和密码'; return }
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrapper {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.login-card {
|
||||
background: #fff; border-radius: 14px; padding: 40px 36px; width: 400px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.15); text-align: center;
|
||||
}
|
||||
h1 { font-size: 1.5em; margin-bottom: 4px; }
|
||||
.sub { color: #999; font-size: 0.88em; margin-bottom: 24px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field input {
|
||||
width: 100%; padding: 11px 14px; border: 1.5px solid #ddd; border-radius: 8px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #667eea; }
|
||||
.err { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; min-height: 1.3em; }
|
||||
.btn-login {
|
||||
width: 100%; padding: 12px; background: #667eea; color: #fff; border: none;
|
||||
border-radius: 8px; font-size: 1em; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-login:not(:disabled):hover { background: #5a6fd6; }
|
||||
.hint { margin-top: 14px; font-size: 0.82em; color: #aaa; }
|
||||
.hint a { color: #667eea; }
|
||||
</style>
|
||||
62
week7/frontend/src/views/RegisterView.vue
Normal file
62
week7/frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<!-- 注册页(Day 2:注册 + 自动登录) -->
|
||||
<template>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<h1>注册新账号</h1>
|
||||
<div class="field"><input v-model="username" placeholder="用户名" @keyup.enter="handleRegister" autofocus></div>
|
||||
<div class="field"><input v-model="password" type="password" placeholder="密码" @keyup.enter="handleRegister"></div>
|
||||
<p class="err" v-if="error">{{ error }}</p>
|
||||
<button class="btn-login" :disabled="loading" @click="handleRegister">{{ loading ? '注册中...' : '注册' }}</button>
|
||||
<p class="hint"><router-link to="/login">返回登录</router-link></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) { error.value = '请输入用户名和密码'; return }
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register(username.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrapper { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.login-card {
|
||||
background: #fff; border-radius: 14px; padding: 40px 36px; width: 400px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.15); text-align: center;
|
||||
}
|
||||
h1 { font-size: 1.4em; margin-bottom: 22px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field input {
|
||||
width: 100%; padding: 11px 14px; border: 1.5px solid #ddd; border-radius: 8px; font-size: 0.95em;
|
||||
}
|
||||
.field input:focus { outline: none; border-color: #667eea; }
|
||||
.err { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; min-height: 1.3em; }
|
||||
.btn-login {
|
||||
width: 100%; padding: 12px; background: #667eea; color: #fff; border: none;
|
||||
border-radius: 8px; font-size: 1em; cursor: pointer;
|
||||
}
|
||||
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.hint { margin-top: 14px; font-size: 0.82em; color: #aaa; }
|
||||
.hint a { color: #667eea; }
|
||||
</style>
|
||||
208
week7/frontend/src/views/StudentListView.vue
Normal file
208
week7/frontend/src/views/StudentListView.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<!-- 学生列表页(Day 3-6:CRUD + 分页 + 搜索 + Loading + 空状态 + 错误处理)-->
|
||||
<template>
|
||||
<div>
|
||||
<NavBar />
|
||||
<div class="page">
|
||||
<!-- 工具栏:Day 3 搜索 + Day 4 分页 -->
|
||||
<div class="toolbar">
|
||||
<input v-model="keyword" placeholder="搜索姓名..." @keyup.enter="search" />
|
||||
<button class="btn-outline" @click="search">搜索</button>
|
||||
<button class="btn-outline" @click="refresh">刷新</button>
|
||||
<button v-if="auth.isAdmin" class="btn-add" @click="openForm()">+ 新增</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示(Day 6) -->
|
||||
<div class="error-bar" v-if="error">{{ error }}<button @click="refresh">重试</button></div>
|
||||
|
||||
<!-- Loading 状态(Day 6) -->
|
||||
<div class="loading" v-if="loading">加载中...</div>
|
||||
|
||||
<!-- 空状态(Day 6) -->
|
||||
<div class="empty" v-else-if="!loading && students.length === 0">
|
||||
暂无数据
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-wrap" v-else>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>姓名</th><th>年龄</th><th>邮箱</th><th>成绩</th><th v-if="auth.isAdmin">操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in students" :key="s.id">
|
||||
<td>{{ s.id }}</td>
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td>{{ s.age }}</td>
|
||||
<td>{{ s.email }}</td>
|
||||
<td><span class="badge" :class="scoreClass(s.score)">{{ s.score }}</span></td>
|
||||
<td v-if="auth.isAdmin">
|
||||
<button class="btn-edit" @click="openForm(s)">编辑</button>
|
||||
<button class="btn-del" @click="handleDelete(s.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页组件(Day 4) -->
|
||||
<Pagination :current="page" :totalPages="totalPages" @change="goPage" />
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="v">{{ statsTotal }}</span><span class="l">总数</span></div>
|
||||
<div class="stat"><span class="v">{{ statsExcellent }}</span><span class="l">优秀</span></div>
|
||||
<div class="stat"><span class="v">{{ orm }}</span><span class="l">ORM</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗(Day 3 + Day 5:表单 + 提交) -->
|
||||
<StudentForm
|
||||
:visible="formVisible"
|
||||
:student="editTarget"
|
||||
:loading="formLoading"
|
||||
@close="formVisible = false"
|
||||
@submit="handleFormSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { fetchStudents, fetchExcellent, createStudent, updateStudent, deleteStudent } from '../api/student'
|
||||
import NavBar from '../components/NavBar.vue'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import StudentForm from '../components/StudentForm.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
// 列表状态
|
||||
const students = ref([])
|
||||
const keyword = ref('')
|
||||
const page = ref(0)
|
||||
const size = ref(10)
|
||||
const totalPages = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const statsTotal = ref(0)
|
||||
const statsExcellent = ref(0)
|
||||
const orm = ref('--')
|
||||
|
||||
// 表单状态
|
||||
const formVisible = ref(false)
|
||||
const editTarget = ref(null)
|
||||
const formLoading = ref(false)
|
||||
|
||||
function scoreClass(s) {
|
||||
if (s >= 90) return 'badge-excellent'
|
||||
if (s >= 80) return 'badge-good'
|
||||
if (s >= 60) return 'badge-normal'
|
||||
return 'badge-poor'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const result = await fetchStudents(keyword.value || undefined, page.value, size.value)
|
||||
students.value = result.data || []
|
||||
statsTotal.value = result.extra?.total ?? students.value.length
|
||||
if (result.extra?.pages != null) totalPages.value = result.extra.pages
|
||||
else totalPages.value = Math.ceil((result.extra?.total ?? students.value.length) / size.value)
|
||||
orm.value = result.extra?.orm ?? '--'
|
||||
const exc = await fetchExcellent(85)
|
||||
statsExcellent.value = exc.data ?? 0
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
students.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() { page.value = 0; loadData() }
|
||||
function refresh() { keyword.value = ''; page.value = 0; loadData() }
|
||||
function goPage(p) { page.value = p; loadData() }
|
||||
|
||||
function openForm(student) {
|
||||
editTarget.value = student || null
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function handleFormSubmit(data) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (data.id) {
|
||||
await updateStudent(data.id, data)
|
||||
} else {
|
||||
await createStudent(data)
|
||||
}
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('确认删除?')) return
|
||||
try {
|
||||
await deleteStudent(id)
|
||||
loadData()
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { max-width: 1000px; margin: 30px auto; padding: 0 20px; }
|
||||
|
||||
/* 工具栏 */
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 16px; background: #fff; padding: 14px 18px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.toolbar input { flex: 1; padding: 9px 12px; border: 1.5px solid #ddd; border-radius: 8px; font-size: 0.93em; }
|
||||
.toolbar input:focus { outline: none; border-color: #667eea; }
|
||||
.btn-outline { padding: 9px 18px; border: 1.5px solid #667eea; background: #fff; color: #667eea; border-radius: 8px; cursor: pointer; font-size: 0.9em; }
|
||||
.btn-outline:hover { background: #f0f2ff; }
|
||||
.btn-add { padding: 9px 22px; background: #667eea; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.9em; }
|
||||
.btn-add:hover { background: #5a6fd6; }
|
||||
|
||||
/* 错误/加载/空状态 */
|
||||
.error-bar { background: #fff3cd; color: #856404; padding: 12px 18px; border-radius: 8px; margin-bottom: 14px; display: flex; justify-content: space-between; }
|
||||
.error-bar button { background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer; }
|
||||
.loading, .empty { text-align: center; padding: 80px 0; color: #fff; font-size: 1.1em; }
|
||||
|
||||
/* 表格 */
|
||||
.table-wrap { background: #fff; border-radius: 10px; padding: 0 20px 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.93em; }
|
||||
thead th { text-align: left; padding: 14px 10px; border-bottom: 2px solid #667eea; color: #667eea; font-weight: 600; white-space: nowrap; }
|
||||
tbody td { padding: 12px 10px; border-bottom: 1px solid #f0f0f0; }
|
||||
tbody tr:hover { background: #f8f9ff; }
|
||||
|
||||
/* 成绩徽章 */
|
||||
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.82em; font-weight: 600; color: #fff; }
|
||||
.badge-excellent { background: #2ecc71; }
|
||||
.badge-good { background: #3498db; }
|
||||
.badge-normal { background: #f39c12; }
|
||||
.badge-poor { background: #e74c3c; }
|
||||
|
||||
/* 操作按钮 */
|
||||
.btn-edit { padding: 4px 12px; background: #2ecc71; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82em; margin-right: 6px; }
|
||||
.btn-edit:hover { background: #27ae60; }
|
||||
.btn-del { padding: 4px 12px; background: #e74c3c; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82em; }
|
||||
.btn-del:hover { background: #c0392b; }
|
||||
|
||||
/* 统计 */
|
||||
.stats { background: #fff; border-radius: 10px; padding: 16px 20px; display: flex; gap: 14px; margin-top: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.stat { flex: 1; text-align: center; padding: 14px 10px; border-radius: 8px; background: linear-gradient(135deg, #667eea, #764ba2); }
|
||||
.v { display: block; font-size: 1.5em; font-weight: 700; color: #fff; }
|
||||
.l { display: block; font-size: 0.78em; color: rgba(255,255,255,0.8); margin-top: 3px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.toolbar { flex-wrap: wrap; }
|
||||
.stats { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
12
week7/frontend/vite.config.js
Normal file
12
week7/frontend/vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8080', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
241
week7/教案.md
Normal file
241
week7/教案.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Week 7:前后端分离实战
|
||||
|
||||
**目标**:将 Vue 3 前端与 Spring Boot 后端完整对接,交付前后端分离的学生管理系统 v3。
|
||||
|
||||
**前置**:完成 Week 5(Spring Boot 后端)和 Week 6(Vue 3 前端基础)。
|
||||
|
||||
**本周产出**:前后端分离的完整 SPA 应用,含登录注册、JWT 认证、CRUD、分页搜索、角色权限。
|
||||
|
||||
**启动方式**:
|
||||
```bash
|
||||
# 1. 启动后端(IDEA 中运行 Week7Application.java)
|
||||
# 2. 启动前端
|
||||
cd week7/frontend
|
||||
npm install # 首次运行
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Day 1:前后端分离架构设计
|
||||
|
||||
**知识点**:
|
||||
- 前后端分离 vs 单体架构:各自优劣
|
||||
- 前端:Vite 开发服务器(5173)→ 用户浏览器渲染
|
||||
- 后端:Spring Boot(8080)→ 只返回 JSON,不渲染 HTML
|
||||
- 通信方式:HTTP + JSON,JWT 无状态认证
|
||||
- Vite proxy:开发环境将 `/api` 请求代理到后端(解决跨域)
|
||||
- 生产环境:Nginx 反向代理,统一域名/端口
|
||||
- CORS(Cross-Origin Resource Sharing):Spring Security 如何配置
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 阅读 `vite.config.js` 的 `server.proxy` 配置,理解代理原理
|
||||
2. 阅读 `SecurityConfig.java` 的 `corsConfigurationSource()` Bean
|
||||
3. 在 DevTools Network 面板查看:`/api/students` 请求的远程地址是 5173 还是 8080?
|
||||
4. 对比 Week 5 的单体架构和 Week 7 的分离架构图
|
||||
5. 把 Vite proxy 注释掉,观察跨域错误(CORS error)
|
||||
|
||||
**核心文件**:
|
||||
- `frontend/vite.config.js` → `server.proxy`
|
||||
- `backend/.../config/SecurityConfig.java` → CORS 配置
|
||||
- `frontend/src/api/http.js` → Axios 实例
|
||||
|
||||
**思考题**:前后端分离的核心优势是什么?有什么代价(首次加载速度、SEO)?
|
||||
|
||||
---
|
||||
|
||||
## Day 2:登录注册页面 + JWT 对接
|
||||
|
||||
**知识点**:
|
||||
- 登录流程:表单输入 → Axios POST → 后端验证 → 返回 JWT → Pinia store 保存 → 跳转首页
|
||||
- 注册流程:表单输入 → POST → 后端创建用户 → 返回 JWT → 自动登录
|
||||
- Pinia Auth Store:`token`、`username`、`role` 状态管理
|
||||
- `localStorage`:持久化 Token,页面刷新不丢失
|
||||
- `<router-link>` vs 编程式导航 `router.push()`
|
||||
- 导航守卫:`router.beforeEach()` 检查登录状态
|
||||
- HTTP 拦截器:自动附加 `Authorization: Bearer <token>`
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 打开 DevTools → Application → Local Storage,观察 `wk7_token`、`wk7_username`、`wk7_role`
|
||||
2. 在登录页输入错误密码,观察 Network 面板的 401 响应
|
||||
3. 在 DevTools 中手动删除 Local Storage 的 token,刷新页面,观察跳转
|
||||
4. 阅读 `router/index.js` 的 `beforeEach` 守卫逻辑
|
||||
5. 注册一个新账号后,检查数据库中 users 表是否新增记录
|
||||
|
||||
**核心文件**:
|
||||
- `frontend/src/views/LoginView.vue` — 登录页面
|
||||
- `frontend/src/views/RegisterView.vue` — 注册页面
|
||||
- `frontend/src/stores/auth.js` — 认证状态管理
|
||||
- `frontend/src/api/auth.js` — 认证 API
|
||||
- `frontend/src/router/index.js` — 导航守卫
|
||||
|
||||
**思考题**:为什么 Token 存在 localStorage 而不是 Pinia store 中?Pinia store 刷新后会丢失吗?
|
||||
|
||||
---
|
||||
|
||||
## Day 3:CRUD 页面实现
|
||||
|
||||
**知识点**:
|
||||
- 列表渲染:`v-for` + Axios GET 请求
|
||||
- 新增/编辑弹窗:`v-if` 控制显示,表单双向绑定 `v-model`
|
||||
- 表单校验:HTML5 原生校验 + 自定义 JS 校验
|
||||
- 删除确认:`confirm()` + DELETE 请求
|
||||
- 区分新增和编辑:根据是否传入 student 对象
|
||||
- 角色权限:`v-if="auth.isAdmin"` 控制按钮显示
|
||||
- 乐观更新 vs 悲观更新(操作后重新拉取数据)
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 用 admin 登录,点击"新增",填写表单提交 → 观察 Network 面板 POST 请求
|
||||
2. 点击"编辑",修改成绩后保存 → 观察 PUT 请求的请求体
|
||||
3. 点击"删除" → 确认后观察 DELETE 请求,刷新页面确认数据消失
|
||||
4. 用 user 登录,观察"新增/编辑/删除"按钮是否隐藏
|
||||
5. 用 user 登录,在浏览器控制台执行 `fetch('/api/students', {method:'POST'})` 看 403
|
||||
|
||||
**核心文件**:
|
||||
- `frontend/src/views/StudentListView.vue` — 列表 + 表格
|
||||
- `frontend/src/components/StudentForm.vue` — 表单弹窗(props/emits)
|
||||
- `frontend/src/api/student.js` — CRUD API 调用
|
||||
|
||||
**思考题**:为什么不直接在表格行内编辑,而是用弹窗?各自适用什么场景?
|
||||
|
||||
---
|
||||
|
||||
## Day 4:分页、搜索、排序
|
||||
|
||||
**知识点**:
|
||||
- 分页参数:`page`(0-based)、`size`(每页数量)
|
||||
- 前端分页 vs 后端分页:数据量决定策略
|
||||
- 分页组件:首页/上一页/页码/下一页/末页 + 省略号逻辑
|
||||
- 搜索防抖:`@keyup.enter` 触发搜索(避免每次按键都请求)
|
||||
- 排序:后端 `ORDER BY` 实现(MP LambdaQueryWrapper / JPA Sort)
|
||||
- URL 状态同步:搜索关键词和页码是否反映在 URL 参数中
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 在搜索框输入"张",按回车 → 观察请求 URL 中的 `keyword` 参数
|
||||
2. 点击分页组件的"下一页" → 观察 page 参数变化
|
||||
3. 打开 `Pagination.vue`,理解省略号(...)的生成逻辑
|
||||
4. 修改后端 `StudentMpService.list()` 中的排序字段为 `age` → 观察列表顺序变化
|
||||
5. 在 Network 面板中对比:搜索前后 total 数量的变化
|
||||
|
||||
**核心文件**:
|
||||
- `frontend/src/components/Pagination.vue` — 分页组件
|
||||
- `frontend/src/views/StudentListView.vue` → `search()` / `goPage()` 方法
|
||||
- `backend/.../service/mp/StudentMpService.java` → LambdaQueryWrapper 排序
|
||||
|
||||
**思考题**:为什么页码从 0 开始(Spring Data Pageable),而不是从 1 开始?
|
||||
|
||||
---
|
||||
|
||||
## Day 5:文件上传(头像)
|
||||
|
||||
**知识点**:
|
||||
- 前端:`<input type="file">` + `FormData`
|
||||
- 后端:`MultipartFile` 接收文件
|
||||
- Spring Boot 文件上传配置:`spring.servlet.multipart.max-file-size`
|
||||
- 文件存储:本地目录 vs OSS(对象存储)
|
||||
- 文件命名:UUID 避免冲突
|
||||
- 文件访问:静态资源映射 `addResourceHandlers()`
|
||||
- 预览:上传后在前端显示缩略图
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 在表单中增加 `<input type="file">` 字段
|
||||
2. 用 `FormData` 封装文件 + JSON 字段,POST 到后端
|
||||
3. 检查后端 `StudentController` 中的 `@RequestParam MultipartFile` 处理
|
||||
4. 上传一个图片后,浏览器直接访问上传后的 URL
|
||||
5. 尝试上传超大文件(>10MB),观察 `MaxUploadSizeExceededException`
|
||||
|
||||
**核心文件**:
|
||||
- `frontend/src/components/StudentForm.vue` → 文件输入
|
||||
- `backend/.../controller/StudentController.java` → MultipartFile 处理
|
||||
- `backend/.../application.yml` → multipart 配置
|
||||
|
||||
**思考题**:为什么不在数据库中存储文件二进制数据(BLOB),而是存储文件路径?
|
||||
|
||||
---
|
||||
|
||||
## Day 6:交互体验完善
|
||||
|
||||
**知识点**:
|
||||
- Loading 状态:数据加载中显示 spinner/骨架屏
|
||||
- 错误状态:网络异常、后端报错时的用户提示
|
||||
- 空状态:无数据时的友好占位
|
||||
- 按钮 Loading:提交时禁用按钮 + 显示"保存中..."
|
||||
- Toast 消息:操作成功/失败的轻提示
|
||||
- 乐观更新:先更新 UI 再发请求(失败时回滚)
|
||||
- 防抖(debounce)和节流(throttle)
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 强制停止后端,刷新前端页面 → 观察错误状态展示
|
||||
2. 删除所有学生数据 → 观察空状态提示
|
||||
3. 点击保存按钮时,快速双击 → 观察 loading 状态是否阻止了重复提交
|
||||
4. 修改 `StudentListView.vue` 中的 loading 实现为骨架屏
|
||||
5. 给搜索框加 300ms 的防抖
|
||||
|
||||
**核心文件**:
|
||||
- `frontend/src/views/StudentListView.vue` → loading / error / empty 三种状态
|
||||
- `frontend/src/components/StudentForm.vue` → `formLoading` 按钮状态
|
||||
|
||||
**思考题**:Loading、Empty、Error 三种状态分别应该在哪里处理(组件内 vs 全局)?
|
||||
|
||||
---
|
||||
|
||||
## Day 7:前后端联调 & 部署概念
|
||||
|
||||
**知识点**:
|
||||
- 前后端联调:同时启动两个项目,确认所有接口正常
|
||||
- Vite build:生产环境构建 → 输出 `dist/` 目录
|
||||
- Nginx 部署:反向代理将前后端统一到 80 端口
|
||||
- Nginx 配置示例:`location /api { proxy_pass http://localhost:8080; }` + `location / { root dist/; }`
|
||||
- 环境变量:`.env.development` vs `.env.production`
|
||||
- Docker 部署概念:前端容器 + 后端容器 + Nginx 容器
|
||||
- 部署检查清单:数据库连接、Redis 连接、CORS 配置
|
||||
|
||||
**动手 → 理解**:
|
||||
1. 运行 `npm run build`,观察生成的 `dist/` 目录结构
|
||||
2. 用 `npx serve dist/` 预览生产构建
|
||||
3. 用 Postman 测试后端所有 API 端点(GET/POST/PUT/DELETE + Token)
|
||||
4. 画出完整的请求流程图:浏览器 → Nginx → 前端静态文件 / 后端 API
|
||||
5. 阅读 Nginx 反向代理配置示例
|
||||
|
||||
**核心概念**:
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 浏览器 │
|
||||
│ localhost:80 │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Nginx │
|
||||
│ 端口 80 │
|
||||
└──┬────────┬───┘
|
||||
│ │
|
||||
/ │ │ /api
|
||||
│ │
|
||||
┌─────┴──┐ ┌─────┴─────┐
|
||||
│ dist/ │ │ Spring │
|
||||
│ 静态 │ │ Boot:8080 │
|
||||
└────────┘ └───────────┘
|
||||
```
|
||||
|
||||
**思考题**:Vite proxy 只用于开发环境,为什么生产环境要用 Nginx 而不是继续用 Vite proxy?
|
||||
|
||||
---
|
||||
|
||||
## Week 7 总结
|
||||
|
||||
| 维度 | Week 5(单体) | Week 6(纯前端) | Week 7(分离) |
|
||||
|------|--------------|---------------|-------------|
|
||||
| 前端框架 | 原生 HTML/JS | Vue 3 独立 | Vue 3 + 后端对接 |
|
||||
| 页面路由 | 无 | Vue Router | Vue Router + 导航守卫 |
|
||||
| 状态管理 | localStorage | Pinia 独立 | Pinia + API 同步 |
|
||||
| 认证 | Login 页面(display 切换) | 模拟登录 | JWT 真实认证 |
|
||||
| 数据 | fetch 直连 | 无后端 | Axios + 拦截器 |
|
||||
| 部署 | Spring Boot 单端口 | Vite dev | 前后端分端口 / Nginx |
|
||||
|
||||
**下一阶段:Week 8 — 工程化能力**
|
||||
- JUnit 5 + Mockito 单元测试
|
||||
- Testcontainers 集成测试
|
||||
- Knife4j API 文档
|
||||
- Docker 容器化
|
||||
- Git 工作流
|
||||
- CI/CD 入门
|
||||
Reference in New Issue
Block a user