v1
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# --- Common ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# --- IDEs & Editors ---
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
.factorypath
|
||||
|
||||
# --- Java / Maven (Backend) ---
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# --- Node / Vue (Frontend) ---
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
coverage/
|
||||
*.local
|
||||
.npm/
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# --- Application Specific ---
|
||||
# Uploaded files
|
||||
uploads/
|
||||
# Ignore local environment override files if any
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
117
hertz_springboot/pom.xml
Normal file
117
hertz_springboot/pom.xml
Normal file
@@ -0,0 +1,117 @@
|
||||
<?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.4.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.hertz</groupId>
|
||||
<artifactId>hertz-springboot-backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>hertz-springboot-backend</name>
|
||||
<description>Hertz 权限管理系统后端</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<mybatis-plus.version>3.5.8</mybatis-plus.version>
|
||||
<jjwt.version>0.12.6</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-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</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>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<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>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce-java</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireJavaVersion>
|
||||
<version>[21,)</version>
|
||||
</requireJavaVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.hertz;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.hertz.**.mapper")
|
||||
public class HertzApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(HertzApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.hertz.common.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ApiResponse<T>(int code, String message, T data) {
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(0, "ok", data);
|
||||
}
|
||||
|
||||
public static ApiResponse<Void> ok() {
|
||||
return new ApiResponse<>(0, "ok", null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> fail(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.hertz.common.exception;
|
||||
|
||||
public class BusinessException extends RuntimeException {
|
||||
private final int code;
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.hertz.common.exception;
|
||||
|
||||
import com.hertz.common.api.ApiResponse;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
|
||||
var status = HttpStatus.BAD_REQUEST;
|
||||
if (e.getCode() == 40100) {
|
||||
status = HttpStatus.UNAUTHORIZED;
|
||||
} else if (e.getCode() == 40300) {
|
||||
status = HttpStatus.FORBIDDEN;
|
||||
}
|
||||
return ResponseEntity.status(status).body(ApiResponse.fail(e.getCode(), e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ApiResponse<Void> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
|
||||
var first = e.getBindingResult().getFieldErrors().stream().findFirst().orElse(null);
|
||||
var message = first == null ? "参数错误" : first.getField() + " " + first.getDefaultMessage();
|
||||
return ApiResponse.fail(40001, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ApiResponse<Void> handleConstraintViolation(ConstraintViolationException e) {
|
||||
return ApiResponse.fail(40001, e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
public ApiResponse<Void> handleAuthentication(AuthenticationException e) {
|
||||
return ApiResponse.fail(40100, "未登录或登录已过期");
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
|
||||
return ApiResponse.fail(40300, "无权限");
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ApiResponse<Void> handleException(Exception e) {
|
||||
return ApiResponse.fail(50000, "系统异常");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hertz.security;
|
||||
|
||||
import com.hertz.system.service.AuthzService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
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;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
private final JwtService jwtService;
|
||||
private final AuthzService authzService;
|
||||
|
||||
public JwtAuthFilter(JwtService jwtService, AuthzService authzService) {
|
||||
this.jwtService = jwtService;
|
||||
this.authzService = authzService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
var header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = header.substring("Bearer ".length()).trim();
|
||||
try {
|
||||
var claims = jwtService.parse(token);
|
||||
var userId = claims.get("uid", Number.class).longValue();
|
||||
var username = claims.getSubject();
|
||||
var authorities = authzService.loadAuthorities(userId).stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toSet());
|
||||
var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
|
||||
auth.setDetails(userId);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} catch (Exception ignored) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hertz.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import javax.crypto.SecretKey;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class JwtService {
|
||||
private final SecretKey key;
|
||||
private final long expireSeconds;
|
||||
|
||||
public JwtService(
|
||||
@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.expire-seconds}") long expireSeconds
|
||||
) {
|
||||
if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalArgumentException("app.jwt.secret 至少 32 字节");
|
||||
}
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expireSeconds = expireSeconds;
|
||||
}
|
||||
|
||||
public String createToken(long userId, String username) {
|
||||
var now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("uid", userId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(expireSeconds)))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.hertz.security;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
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;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
var config = new CorsConfiguration();
|
||||
config.addAllowedOriginPattern("*");
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
var source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(
|
||||
HttpSecurity http,
|
||||
JwtAuthFilter jwtAuthFilter
|
||||
) throws Exception {
|
||||
return http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**", "/error", "/uploads/**").permitAll()
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hertz.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
public final class SecurityUtils {
|
||||
private SecurityUtils() {
|
||||
}
|
||||
|
||||
public static Long getCurrentUserId() {
|
||||
return getCurrentUserId(SecurityContextHolder.getContext().getAuthentication());
|
||||
}
|
||||
|
||||
public static Long getCurrentUserId(Authentication authentication) {
|
||||
if (authentication == null) {
|
||||
return null;
|
||||
}
|
||||
var details = authentication.getDetails();
|
||||
if (details instanceof Number n) {
|
||||
return n.longValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.hertz.system.config;
|
||||
|
||||
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() {
|
||||
var interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.hertz.system.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.upload.root-path}")
|
||||
private String uploadRootPath;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations("file:" + uploadRootPath + "/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.hertz.system.controller;
|
||||
|
||||
import com.hertz.common.api.ApiResponse;
|
||||
import com.hertz.common.exception.BusinessException;
|
||||
import com.hertz.security.JwtService;
|
||||
import com.hertz.security.SecurityUtils;
|
||||
import com.hertz.system.dto.AuthDtos;
|
||||
import com.hertz.system.mapper.SysRoleMapper;
|
||||
import com.hertz.system.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
private final UserService userService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final SysRoleMapper roleMapper;
|
||||
|
||||
public AuthController(
|
||||
UserService userService,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
SysRoleMapper roleMapper
|
||||
) {
|
||||
this.userService = userService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.roleMapper = roleMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<AuthDtos.MeResponse> register(@Valid @RequestBody AuthDtos.RegisterRequest req) {
|
||||
var user = userService.register(req.username(), req.password(), req.nickname());
|
||||
return ApiResponse.ok(new AuthDtos.MeResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getNickname(),
|
||||
user.getAvatarPath(),
|
||||
user.getPhone(),
|
||||
user.getEmail(),
|
||||
user.getGender(),
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AuthDtos.LoginResponse> login(@Valid @RequestBody AuthDtos.LoginRequest req) {
|
||||
var user = userService.findByUsername(req.username());
|
||||
if (user == null || user.getStatus() == null || user.getStatus() != 1) {
|
||||
throw new BusinessException(40003, "用户名或密码错误");
|
||||
}
|
||||
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
|
||||
throw new BusinessException(40003, "用户名或密码错误");
|
||||
}
|
||||
var token = jwtService.createToken(user.getId(), user.getUsername());
|
||||
var roles = roleMapper.selectRoleKeysByUserId(user.getId());
|
||||
return ApiResponse.ok(new AuthDtos.LoginResponse(token, user.getId(), user.getUsername(), roles));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<AuthDtos.MeResponse> me(Authentication authentication) {
|
||||
var userId = SecurityUtils.getCurrentUserId(authentication);
|
||||
if (userId == null) {
|
||||
throw new BusinessException(40100, "未登录或登录已过期");
|
||||
}
|
||||
var user = userService.findByUsername(authentication.getName());
|
||||
var roles = roleMapper.selectRoleKeysByUserId(userId);
|
||||
return ApiResponse.ok(new AuthDtos.MeResponse(
|
||||
userId,
|
||||
authentication.getName(),
|
||||
user == null ? null : user.getNickname(),
|
||||
user == null ? null : user.getAvatarPath(),
|
||||
user == null ? null : user.getPhone(),
|
||||
user == null ? null : user.getEmail(),
|
||||
user == null ? null : user.getGender(),
|
||||
roles
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/profile")
|
||||
public ApiResponse<Void> updateProfile(@RequestBody @Valid AuthDtos.UpdateProfileRequest req) {
|
||||
var userId = SecurityUtils.getCurrentUserId();
|
||||
if (userId == null) {
|
||||
throw new BusinessException(40100, "未登录或登录已过期");
|
||||
}
|
||||
userService.updateProfile(userId, req);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public ApiResponse<Void> updatePassword(@RequestBody @Valid AuthDtos.UpdatePasswordRequest req) {
|
||||
var userId = SecurityUtils.getCurrentUserId();
|
||||
if (userId == null) {
|
||||
throw new BusinessException(40100, "未登录或登录已过期");
|
||||
}
|
||||
userService.updatePassword(userId, req);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.hertz.system.controller;
|
||||
|
||||
import com.hertz.common.api.ApiResponse;
|
||||
import com.hertz.common.exception.BusinessException;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/common/file")
|
||||
public class FileController {
|
||||
|
||||
@Value("${app.upload.root-path}")
|
||||
private String uploadRootPath;
|
||||
|
||||
@Value("${app.upload.avatar-path}")
|
||||
private String avatarPath;
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ApiResponse<UploadResult> upload(@RequestParam("file") MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
throw new BusinessException(400, "File cannot be empty");
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
if (!suffix.equalsIgnoreCase(".jpg") && !suffix.equalsIgnoreCase(".png") && !suffix.equalsIgnoreCase(".jpeg")) {
|
||||
throw new BusinessException(400, "Only JPG/PNG formats are supported");
|
||||
}
|
||||
|
||||
if (file.getSize() > 2 * 1024 * 1024) {
|
||||
throw new BusinessException(400, "File size exceeds 2MB");
|
||||
}
|
||||
|
||||
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
|
||||
// Construct the relative path (e.g., avatar/2023/10/27/uuid.jpg)
|
||||
// Note: avatarPath should end with /
|
||||
String relativePath = avatarPath + datePath + "/" + fileName;
|
||||
// Construct full path
|
||||
String fullPath = uploadRootPath + File.separator + relativePath;
|
||||
|
||||
File dest = new File(fullPath);
|
||||
if (!dest.getParentFile().exists()) {
|
||||
dest.getParentFile().mkdirs();
|
||||
}
|
||||
|
||||
try {
|
||||
file.transferTo(dest);
|
||||
|
||||
// Return relative path. Frontend should prepend the base URL.
|
||||
return ApiResponse.ok(new UploadResult("/uploads/" + relativePath, null));
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(500, "File upload failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public record UploadResult(String url, String thumbUrl) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.hertz.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.hertz.common.api.ApiResponse;
|
||||
import com.hertz.common.exception.BusinessException;
|
||||
import com.hertz.security.SecurityUtils;
|
||||
import com.hertz.system.dto.MenuDto;
|
||||
import com.hertz.system.entity.SysMenu;
|
||||
import com.hertz.system.service.MenuService;
|
||||
import java.util.List;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/system/menus")
|
||||
public class MenuController {
|
||||
private final MenuService menuService;
|
||||
|
||||
public MenuController(MenuService menuService) {
|
||||
this.menuService = menuService;
|
||||
}
|
||||
|
||||
@GetMapping("/tree")
|
||||
public ApiResponse<List<MenuDto>> tree(Authentication authentication) {
|
||||
var userId = SecurityUtils.getCurrentUserId(authentication);
|
||||
if (userId == null) {
|
||||
throw new BusinessException(40100, "未登录或登录已过期");
|
||||
}
|
||||
return ApiResponse.ok(menuService.getMenuTreeByUserId(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:view')")
|
||||
public ApiResponse<IPage<SysMenu>> page(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
return ApiResponse.ok(menuService.pageMenus(page, size, keyword));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:add')")
|
||||
public ApiResponse<Void> create(@RequestBody SysMenu menu) {
|
||||
menuService.saveMenu(menu);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:edit')")
|
||||
public ApiResponse<Void> update(@RequestBody SysMenu menu) {
|
||||
menuService.updateMenu(menu);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:menu:remove')")
|
||||
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
|
||||
menuService.deleteMenu(id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.hertz.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.hertz.common.api.ApiResponse;
|
||||
import com.hertz.system.entity.SysRole;
|
||||
import com.hertz.system.service.RoleService;
|
||||
import java.util.List;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/system/roles")
|
||||
public class RoleController {
|
||||
private final RoleService roleService;
|
||||
|
||||
public RoleController(RoleService roleService) {
|
||||
this.roleService = roleService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')")
|
||||
public ApiResponse<List<SysRole>> list() {
|
||||
return ApiResponse.ok(roleService.listEnabledRoles());
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')")
|
||||
public ApiResponse<IPage<SysRole>> page(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
return ApiResponse.ok(roleService.pageRoles(page, size, keyword));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:add')")
|
||||
public ApiResponse<Void> create(@RequestBody SysRole role) {
|
||||
roleService.saveRole(role);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:edit')")
|
||||
public ApiResponse<Void> update(@RequestBody SysRole role) {
|
||||
roleService.updateRole(role);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:remove')")
|
||||
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
|
||||
roleService.deleteRole(id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/menus")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:view')")
|
||||
public ApiResponse<List<Long>> getRoleMenus(@PathVariable("id") Long id) {
|
||||
return ApiResponse.ok(roleService.getRoleMenuIds(id));
|
||||
}
|
||||
|
||||
public record UpdateRoleMenusRequest(List<Long> menuIds) {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/menus")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:role:assign')")
|
||||
public ApiResponse<Void> updateRoleMenus(@PathVariable("id") Long id, @RequestBody UpdateRoleMenusRequest req) {
|
||||
roleService.updateRolePermissions(id, req.menuIds());
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.hertz.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hertz.common.api.ApiResponse;
|
||||
import com.hertz.system.entity.SysUser;
|
||||
import com.hertz.system.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/system/users")
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
|
||||
public UserController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')")
|
||||
public ApiResponse<IPage<UserListItem>> page(
|
||||
@RequestParam(defaultValue = "1") @Min(1) int page,
|
||||
@RequestParam(defaultValue = "10") @Min(1) @Max(200) int size,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
var p = userService.pageUsers(page, size, keyword);
|
||||
var dtoPage = Page.<UserListItem>of(p.getCurrent(), p.getSize(), p.getTotal());
|
||||
dtoPage.setRecords(p.getRecords().stream().map(u -> {
|
||||
var roles = userService.getUserRoles(u.getId()).stream()
|
||||
.map(com.hertz.system.entity.SysRole::getRoleName)
|
||||
.toList();
|
||||
return new UserListItem(
|
||||
u.getId(),
|
||||
u.getUsername(),
|
||||
u.getNickname(),
|
||||
u.getAvatarPath(),
|
||||
u.getPhone(),
|
||||
u.getEmail(),
|
||||
u.getGender(),
|
||||
u.getStatus(),
|
||||
u.getCreatedAt(),
|
||||
roles
|
||||
);
|
||||
}).toList());
|
||||
return ApiResponse.ok(dtoPage);
|
||||
}
|
||||
|
||||
public record UserListItem(
|
||||
Long id,
|
||||
String username,
|
||||
String nickname,
|
||||
String avatarPath,
|
||||
String phone,
|
||||
String email,
|
||||
Integer gender,
|
||||
Integer status,
|
||||
LocalDateTime createdAt,
|
||||
List<String> roles
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateUserRequest(
|
||||
@NotBlank(message = "用户名不能为空") String username,
|
||||
@NotBlank(message = "密码不能为空") String password,
|
||||
@NotBlank(message = "昵称不能为空") String nickname,
|
||||
String avatarPath,
|
||||
String phone,
|
||||
String email,
|
||||
Integer gender,
|
||||
Integer status
|
||||
) {}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:add')")
|
||||
public ApiResponse<Void> create(@RequestBody @Valid CreateUserRequest req) {
|
||||
var u = new SysUser();
|
||||
u.setUsername(req.username());
|
||||
u.setPassword(req.password());
|
||||
u.setNickname(req.nickname());
|
||||
u.setAvatarPath(req.avatarPath());
|
||||
u.setPhone(req.phone());
|
||||
u.setEmail(req.email());
|
||||
u.setGender(req.gender());
|
||||
u.setStatus(req.status());
|
||||
userService.createUser(u);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
public record UpdateUserRequest(
|
||||
Long id,
|
||||
String password,
|
||||
@NotBlank(message = "昵称不能为空") String nickname,
|
||||
String avatarPath,
|
||||
String phone,
|
||||
String email,
|
||||
Integer gender,
|
||||
Integer status
|
||||
) {}
|
||||
|
||||
@PutMapping
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:edit')")
|
||||
public ApiResponse<Void> update(@RequestBody @Valid UpdateUserRequest req) {
|
||||
var u = new SysUser();
|
||||
u.setId(req.id());
|
||||
u.setPassword(req.password());
|
||||
u.setNickname(req.nickname());
|
||||
u.setAvatarPath(req.avatarPath());
|
||||
u.setPhone(req.phone());
|
||||
u.setEmail(req.email());
|
||||
u.setGender(req.gender());
|
||||
u.setStatus(req.status());
|
||||
userService.updateUser(u);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:remove')")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/roles")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:view')")
|
||||
public ApiResponse<List<Long>> roleIds(@PathVariable("id") long userId) {
|
||||
return ApiResponse.ok(userService.getUserRoleIds(userId));
|
||||
}
|
||||
|
||||
public record UpdateRolesRequest(List<Long> roleIds) {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/roles")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasAuthority('system:user:assign')")
|
||||
public ApiResponse<Void> updateRoles(@PathVariable("id") long userId, @RequestBody UpdateRolesRequest req) {
|
||||
userService.updateUserRoles(userId, req == null ? List.of() : req.roleIds());
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hertz.system.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.List;
|
||||
|
||||
public class AuthDtos {
|
||||
public record LoginRequest(
|
||||
@NotBlank String username,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank String username,
|
||||
@NotBlank String password,
|
||||
String nickname
|
||||
) {
|
||||
}
|
||||
|
||||
public record LoginResponse(
|
||||
String token,
|
||||
long userId,
|
||||
String username,
|
||||
List<String> roles
|
||||
) {
|
||||
}
|
||||
|
||||
public record MeResponse(
|
||||
long userId,
|
||||
String username,
|
||||
String nickname,
|
||||
String avatarPath,
|
||||
String phone,
|
||||
String email,
|
||||
Integer gender,
|
||||
List<String> roles
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
@NotBlank(message = "昵称不能为空") String nickname,
|
||||
String avatarPath,
|
||||
String phone,
|
||||
String email,
|
||||
Integer gender
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdatePasswordRequest(
|
||||
@NotBlank(message = "旧密码不能为空") String oldPassword,
|
||||
@NotBlank(message = "新密码不能为空") String newPassword
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.hertz.system.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class MenuDto {
|
||||
private Long id;
|
||||
private Long parentId;
|
||||
private String type;
|
||||
private String name;
|
||||
private String path;
|
||||
private String component;
|
||||
private String perms;
|
||||
private String icon;
|
||||
private Integer sort;
|
||||
private List<MenuDto> children = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.hertz.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_menu")
|
||||
public class SysMenu {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long parentId;
|
||||
private String type;
|
||||
private String name;
|
||||
private String path;
|
||||
private String component;
|
||||
private String perms;
|
||||
private String icon;
|
||||
private Integer sort;
|
||||
private Integer visible;
|
||||
private Integer status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.hertz.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_role")
|
||||
public class SysRole {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String roleKey;
|
||||
private String roleName;
|
||||
private Integer status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hertz.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_role_menu")
|
||||
public class SysRoleMenu {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long roleId;
|
||||
private Long menuId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hertz.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_user")
|
||||
public class SysUser {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String username;
|
||||
private String password;
|
||||
private String nickname;
|
||||
private String avatarPath;
|
||||
private String phone;
|
||||
private String email;
|
||||
/**
|
||||
* 0-未知 1-男 2-女
|
||||
*/
|
||||
private Integer gender;
|
||||
private Integer status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hertz.system.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("sys_user_role")
|
||||
public class SysUserRole {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private Long roleId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.hertz.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hertz.system.entity.SysMenu;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface SysMenuMapper extends BaseMapper<SysMenu> {
|
||||
@Select("""
|
||||
SELECT DISTINCT m.*
|
||||
FROM sys_menu m
|
||||
INNER JOIN sys_role_menu rm ON rm.menu_id = m.id
|
||||
INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id
|
||||
WHERE ur.user_id = #{userId}
|
||||
AND m.status = 1
|
||||
AND m.visible = 1
|
||||
AND m.type IN ('D','M')
|
||||
ORDER BY m.sort ASC, m.id ASC
|
||||
""")
|
||||
List<SysMenu> selectMenusByUserId(@Param("userId") long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT m.*
|
||||
FROM sys_menu m
|
||||
WHERE m.status = 1
|
||||
AND m.visible = 1
|
||||
AND m.type IN ('D','M')
|
||||
ORDER BY m.sort ASC, m.id ASC
|
||||
""")
|
||||
List<SysMenu> selectAllVisibleMenus();
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT m.perms
|
||||
FROM sys_menu m
|
||||
INNER JOIN sys_role_menu rm ON rm.menu_id = m.id
|
||||
INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id
|
||||
WHERE ur.user_id = #{userId}
|
||||
AND m.status = 1
|
||||
AND m.perms IS NOT NULL
|
||||
AND m.perms <> ''
|
||||
""")
|
||||
List<String> selectPermsByUserId(@Param("userId") long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT m.perms
|
||||
FROM sys_menu m
|
||||
WHERE m.status = 1
|
||||
AND m.perms IS NOT NULL
|
||||
AND m.perms <> ''
|
||||
""")
|
||||
List<String> selectAllPerms();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hertz.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hertz.system.entity.SysRole;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface SysRoleMapper extends BaseMapper<SysRole> {
|
||||
@Select("""
|
||||
SELECT r.role_key
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_user_role ur ON ur.role_id = r.id
|
||||
WHERE ur.user_id = #{userId} AND r.status = 1
|
||||
""")
|
||||
List<String> selectRoleKeysByUserId(@Param("userId") long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT r.*
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_user_role ur ON ur.role_id = r.id
|
||||
WHERE ur.user_id = #{userId} AND r.status = 1
|
||||
ORDER BY r.id ASC
|
||||
""")
|
||||
List<SysRole> selectRolesByUserId(@Param("userId") long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.hertz.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hertz.system.entity.SysRoleMenu;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysRoleMenuMapper extends BaseMapper<SysRoleMenu> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.hertz.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hertz.system.entity.SysUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.hertz.system.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hertz.system.entity.SysUserRole;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.hertz.system.service;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public interface AuthzService {
|
||||
Set<String> loadAuthorities(long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.hertz.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.hertz.system.dto.MenuDto;
|
||||
import com.hertz.system.entity.SysMenu;
|
||||
import java.util.List;
|
||||
|
||||
public interface MenuService {
|
||||
List<MenuDto> getMenuTreeByUserId(long userId);
|
||||
|
||||
IPage<SysMenu> pageMenus(int page, int size, String keyword);
|
||||
|
||||
void saveMenu(SysMenu menu);
|
||||
|
||||
void updateMenu(SysMenu menu);
|
||||
|
||||
void deleteMenu(Long id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hertz.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.hertz.system.entity.SysRole;
|
||||
import java.util.List;
|
||||
|
||||
public interface RoleService {
|
||||
List<SysRole> listEnabledRoles();
|
||||
|
||||
IPage<SysRole> pageRoles(int page, int size, String keyword);
|
||||
|
||||
void saveRole(SysRole role);
|
||||
|
||||
void updateRole(SysRole role);
|
||||
|
||||
void deleteRole(Long id);
|
||||
|
||||
void updateRolePermissions(Long roleId, List<Long> menuIds);
|
||||
|
||||
List<Long> getRoleMenuIds(Long roleId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hertz.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.hertz.system.dto.AuthDtos;
|
||||
import com.hertz.system.entity.SysUser;
|
||||
import java.util.List;
|
||||
|
||||
public interface UserService {
|
||||
SysUser register(String username, String rawPassword, String nickname);
|
||||
|
||||
SysUser findByUsername(String username);
|
||||
|
||||
IPage<SysUser> pageUsers(int page, int size, String keyword);
|
||||
|
||||
void createUser(SysUser user);
|
||||
|
||||
void updateUser(SysUser user);
|
||||
|
||||
void updateProfile(Long userId, AuthDtos.UpdateProfileRequest req);
|
||||
|
||||
void updatePassword(Long userId, AuthDtos.UpdatePasswordRequest req);
|
||||
|
||||
void deleteUser(Long id);
|
||||
|
||||
void updateUserRoles(long userId, List<Long> roleIds);
|
||||
|
||||
List<Long> getUserRoleIds(long userId);
|
||||
|
||||
List<com.hertz.system.entity.SysRole> getUserRoles(long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hertz.system.service.impl;
|
||||
|
||||
import com.hertz.system.mapper.SysMenuMapper;
|
||||
import com.hertz.system.mapper.SysRoleMapper;
|
||||
import com.hertz.system.service.AuthzService;
|
||||
import java.util.HashSet;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AuthzServiceImpl implements AuthzService {
|
||||
private final SysRoleMapper roleMapper;
|
||||
private final SysMenuMapper menuMapper;
|
||||
|
||||
public AuthzServiceImpl(SysRoleMapper roleMapper, SysMenuMapper menuMapper) {
|
||||
this.roleMapper = roleMapper;
|
||||
this.menuMapper = menuMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HashSet<String> loadAuthorities(long userId) {
|
||||
var roleKeys = roleMapper.selectRoleKeysByUserId(userId);
|
||||
var authorities = new HashSet<String>();
|
||||
for (var roleKey : roleKeys) {
|
||||
if (roleKey != null && !roleKey.isBlank()) {
|
||||
authorities.add("ROLE_" + roleKey);
|
||||
}
|
||||
}
|
||||
if (roleKeys.stream().anyMatch(r -> "ADMIN".equalsIgnoreCase(r))) {
|
||||
authorities.addAll(menuMapper.selectAllPerms());
|
||||
} else {
|
||||
authorities.addAll(menuMapper.selectPermsByUserId(userId));
|
||||
}
|
||||
return authorities;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.hertz.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hertz.common.exception.BusinessException;
|
||||
import com.hertz.system.dto.MenuDto;
|
||||
import com.hertz.system.entity.SysMenu;
|
||||
import com.hertz.system.entity.SysRoleMenu;
|
||||
import com.hertz.system.mapper.SysMenuMapper;
|
||||
import com.hertz.system.mapper.SysRoleMapper;
|
||||
import com.hertz.system.mapper.SysRoleMenuMapper;
|
||||
import com.hertz.system.service.MenuService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class MenuServiceImpl implements MenuService {
|
||||
private final SysMenuMapper menuMapper;
|
||||
private final SysRoleMapper roleMapper;
|
||||
private final SysRoleMenuMapper roleMenuMapper;
|
||||
|
||||
public MenuServiceImpl(SysMenuMapper menuMapper, SysRoleMapper roleMapper, SysRoleMenuMapper roleMenuMapper) {
|
||||
this.menuMapper = menuMapper;
|
||||
this.roleMapper = roleMapper;
|
||||
this.roleMenuMapper = roleMenuMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MenuDto> getMenuTreeByUserId(long userId) {
|
||||
var roleKeys = roleMapper.selectRoleKeysByUserId(userId);
|
||||
var menus = roleKeys.stream().anyMatch(r -> "ADMIN".equalsIgnoreCase(r))
|
||||
? menuMapper.selectAllVisibleMenus()
|
||||
: menuMapper.selectMenusByUserId(userId);
|
||||
|
||||
var map = new HashMap<Long, MenuDto>();
|
||||
for (var m : menus) {
|
||||
var dto = new MenuDto();
|
||||
dto.setId(m.getId());
|
||||
dto.setParentId(m.getParentId());
|
||||
dto.setType(m.getType());
|
||||
dto.setName(m.getName());
|
||||
dto.setPath(m.getPath());
|
||||
dto.setComponent(m.getComponent());
|
||||
dto.setPerms(m.getPerms());
|
||||
dto.setIcon(m.getIcon());
|
||||
dto.setSort(m.getSort());
|
||||
map.put(dto.getId(), dto);
|
||||
}
|
||||
|
||||
var roots = new ArrayList<MenuDto>();
|
||||
for (var dto : map.values()) {
|
||||
var parentId = dto.getParentId() == null ? 0L : dto.getParentId();
|
||||
if (parentId == 0L || !map.containsKey(parentId)) {
|
||||
roots.add(dto);
|
||||
} else {
|
||||
map.get(parentId).getChildren().add(dto);
|
||||
}
|
||||
}
|
||||
|
||||
Comparator<MenuDto> comparator = Comparator
|
||||
.comparing((MenuDto d) -> d.getSort() == null ? 0 : d.getSort())
|
||||
.thenComparing(d -> d.getId() == null ? 0 : d.getId());
|
||||
sortRecursively(roots, comparator);
|
||||
return roots;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<SysMenu> pageMenus(int page, int size, String keyword) {
|
||||
var wrapper = new LambdaQueryWrapper<SysMenu>().orderByAsc(SysMenu::getSort);
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
wrapper.like(SysMenu::getName, keyword);
|
||||
}
|
||||
return menuMapper.selectPage(Page.of(page, size), wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveMenu(SysMenu menu) {
|
||||
menu.setCreatedAt(java.time.LocalDateTime.now());
|
||||
menu.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
menuMapper.insert(menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateMenu(SysMenu menu) {
|
||||
var existing = menuMapper.selectById(menu.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(404, "Menu not found");
|
||||
}
|
||||
existing.setParentId(menu.getParentId());
|
||||
existing.setType(menu.getType());
|
||||
existing.setName(menu.getName());
|
||||
existing.setPath(menu.getPath());
|
||||
existing.setComponent(menu.getComponent());
|
||||
existing.setPerms(menu.getPerms());
|
||||
existing.setIcon(menu.getIcon());
|
||||
existing.setSort(menu.getSort());
|
||||
existing.setVisible(menu.getVisible());
|
||||
existing.setStatus(menu.getStatus());
|
||||
existing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
menuMapper.updateById(existing);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteMenu(Long id) {
|
||||
if (menuMapper.selectCount(new LambdaQueryWrapper<SysMenu>().eq(SysMenu::getParentId, id)) > 0) {
|
||||
throw new BusinessException(400, "Has sub-menus, cannot delete");
|
||||
}
|
||||
menuMapper.deleteById(id);
|
||||
roleMenuMapper.delete(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getMenuId, id));
|
||||
}
|
||||
|
||||
private void sortRecursively(List<MenuDto> nodes, Comparator<MenuDto> comparator) {
|
||||
nodes.sort(comparator);
|
||||
for (var n : nodes) {
|
||||
if (n.getChildren() != null && !n.getChildren().isEmpty()) {
|
||||
sortRecursively(n.getChildren(), comparator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.hertz.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hertz.common.exception.BusinessException;
|
||||
import com.hertz.system.entity.SysRole;
|
||||
import com.hertz.system.entity.SysRoleMenu;
|
||||
import com.hertz.system.mapper.SysRoleMapper;
|
||||
import com.hertz.system.mapper.SysRoleMenuMapper;
|
||||
import com.hertz.system.service.RoleService;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class RoleServiceImpl implements RoleService {
|
||||
private final SysRoleMapper roleMapper;
|
||||
private final SysRoleMenuMapper roleMenuMapper;
|
||||
|
||||
public RoleServiceImpl(SysRoleMapper roleMapper, SysRoleMenuMapper roleMenuMapper) {
|
||||
this.roleMapper = roleMapper;
|
||||
this.roleMenuMapper = roleMenuMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SysRole> listEnabledRoles() {
|
||||
return roleMapper.selectList(new LambdaQueryWrapper<SysRole>()
|
||||
.eq(SysRole::getStatus, 1)
|
||||
.orderByAsc(SysRole::getId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<SysRole> pageRoles(int page, int size, String keyword) {
|
||||
var wrapper = new LambdaQueryWrapper<SysRole>().orderByDesc(SysRole::getId);
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
wrapper.and(w -> w.like(SysRole::getRoleName, keyword).or().like(SysRole::getRoleKey, keyword));
|
||||
}
|
||||
return roleMapper.selectPage(Page.of(page, size), wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void saveRole(SysRole role) {
|
||||
if (roleMapper.selectCount(new LambdaQueryWrapper<SysRole>().eq(SysRole::getRoleKey, role.getRoleKey())) > 0) {
|
||||
throw new BusinessException(400, "Role key already exists");
|
||||
}
|
||||
role.setCreatedAt(java.time.LocalDateTime.now());
|
||||
role.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
roleMapper.insert(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateRole(SysRole role) {
|
||||
var existing = roleMapper.selectById(role.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(404, "Role not found");
|
||||
}
|
||||
existing.setRoleName(role.getRoleName());
|
||||
existing.setRoleKey(role.getRoleKey());
|
||||
existing.setStatus(role.getStatus());
|
||||
existing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
roleMapper.updateById(existing);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteRole(Long id) {
|
||||
roleMapper.deleteById(id);
|
||||
roleMenuMapper.delete(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateRolePermissions(Long roleId, List<Long> menuIds) {
|
||||
roleMenuMapper.delete(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId));
|
||||
if (menuIds != null) {
|
||||
menuIds.stream().distinct().forEach(menuId -> {
|
||||
SysRoleMenu rm = new SysRoleMenu();
|
||||
rm.setRoleId(roleId);
|
||||
rm.setMenuId(menuId);
|
||||
roleMenuMapper.insert(rm);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getRoleMenuIds(Long roleId) {
|
||||
return roleMenuMapper.selectList(new LambdaQueryWrapper<SysRoleMenu>().eq(SysRoleMenu::getRoleId, roleId))
|
||||
.stream().map(SysRoleMenu::getMenuId).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.hertz.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.hertz.common.exception.BusinessException;
|
||||
import com.hertz.system.entity.SysUser;
|
||||
import com.hertz.system.entity.SysUserRole;
|
||||
import com.hertz.system.mapper.SysRoleMapper;
|
||||
import com.hertz.system.mapper.SysUserMapper;
|
||||
import com.hertz.system.mapper.SysUserRoleMapper;
|
||||
import com.hertz.system.service.UserService;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
private final SysUserMapper userMapper;
|
||||
private final SysUserRoleMapper userRoleMapper;
|
||||
private final SysRoleMapper roleMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public UserServiceImpl(SysUserMapper userMapper, SysUserRoleMapper userRoleMapper, SysRoleMapper roleMapper, PasswordEncoder passwordEncoder) {
|
||||
this.userMapper = userMapper;
|
||||
this.userRoleMapper = userRoleMapper;
|
||||
this.roleMapper = roleMapper;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysUser register(String username, String rawPassword, String nickname) {
|
||||
var exists = userMapper.selectCount(new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getUsername, username)) > 0;
|
||||
if (exists) {
|
||||
throw new BusinessException(40002, "用户名已存在");
|
||||
}
|
||||
var user = new SysUser();
|
||||
user.setUsername(username);
|
||||
user.setPassword(passwordEncoder.encode(rawPassword));
|
||||
user.setNickname(nickname);
|
||||
user.setStatus(1);
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysUser findByUsername(String username) {
|
||||
return userMapper.selectOne(new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<SysUser> pageUsers(int page, int size, String keyword) {
|
||||
var wrapper = new LambdaQueryWrapper<SysUser>()
|
||||
.orderByDesc(SysUser::getId);
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
wrapper.and(w -> w.like(SysUser::getUsername, keyword)
|
||||
.or().like(SysUser::getNickname, keyword)
|
||||
.or().like(SysUser::getPhone, keyword)
|
||||
.or().like(SysUser::getEmail, keyword));
|
||||
}
|
||||
return userMapper.selectPage(Page.of(page, size), wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void createUser(SysUser user) {
|
||||
if (userMapper.selectCount(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, user.getUsername())) > 0) {
|
||||
throw new BusinessException(400, "用户名已存在");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
user.setCreatedAt(java.time.LocalDateTime.now());
|
||||
user.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
userMapper.insert(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateUser(SysUser user) {
|
||||
var existing = userMapper.selectById(user.getId());
|
||||
if (existing == null) {
|
||||
throw new BusinessException(404, "用户不存在");
|
||||
}
|
||||
existing.setNickname(user.getNickname());
|
||||
existing.setPhone(user.getPhone());
|
||||
existing.setEmail(user.getEmail());
|
||||
existing.setGender(user.getGender());
|
||||
existing.setStatus(user.getStatus());
|
||||
existing.setAvatarPath(user.getAvatarPath());
|
||||
existing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
|
||||
if (user.getPassword() != null && !user.getPassword().isBlank()) {
|
||||
existing.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
}
|
||||
|
||||
userMapper.updateById(existing);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateProfile(Long userId, com.hertz.system.dto.AuthDtos.UpdateProfileRequest req) {
|
||||
var existing = userMapper.selectById(userId);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(404, "用户不存在");
|
||||
}
|
||||
existing.setNickname(req.nickname());
|
||||
// Only update avatarPath if it's provided (not null and not empty)
|
||||
// Actually, req.avatarPath() might be empty string if cleared, but here we assume if it is provided we update it.
|
||||
// If the frontend sends null, we might want to skip update or clear it?
|
||||
// Let's assume frontend sends the new path if updated.
|
||||
if (req.avatarPath() != null) {
|
||||
existing.setAvatarPath(req.avatarPath());
|
||||
}
|
||||
existing.setPhone(req.phone());
|
||||
existing.setEmail(req.email());
|
||||
existing.setGender(req.gender());
|
||||
existing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
userMapper.updateById(existing);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updatePassword(Long userId, com.hertz.system.dto.AuthDtos.UpdatePasswordRequest req) {
|
||||
var existing = userMapper.selectById(userId);
|
||||
if (existing == null) {
|
||||
throw new BusinessException(404, "用户不存在");
|
||||
}
|
||||
if (!passwordEncoder.matches(req.oldPassword(), existing.getPassword())) {
|
||||
throw new BusinessException(400, "旧密码错误");
|
||||
}
|
||||
existing.setPassword(passwordEncoder.encode(req.newPassword()));
|
||||
existing.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
userMapper.updateById(existing);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteUser(Long id) {
|
||||
userMapper.deleteById(id);
|
||||
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateUserRoles(long userId, List<Long> roleIds) {
|
||||
userRoleMapper.delete(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId));
|
||||
if (roleIds == null || roleIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (var roleId : roleIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList())) {
|
||||
var ur = new SysUserRole();
|
||||
ur.setUserId(userId);
|
||||
ur.setRoleId(roleId);
|
||||
userRoleMapper.insert(ur);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getUserRoleIds(long userId) {
|
||||
var list = userRoleMapper.selectList(new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId));
|
||||
return list.stream().map(SysUserRole::getRoleId).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<com.hertz.system.entity.SysRole> getUserRoles(long userId) {
|
||||
return roleMapper.selectRolesByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
30
hertz_springboot/src/main/resources/application.yml
Normal file
30
hertz_springboot/src/main/resources/application.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: Hertz-Springboot
|
||||
sql:
|
||||
init:
|
||||
mode: never # 禁用 SQL 初始化(不自动执行 schema.sql / data.sql)可选:always(默认)、never
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 2MB
|
||||
max-request-size: 10MB
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/hertz_springboot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: 123456
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: change-me-to-a-long-random-string-change-me-to-a-long-random-string
|
||||
expire-seconds: 86400
|
||||
upload:
|
||||
root-path: d:\LocalFile\hertz_springboot\uploads
|
||||
avatar-path: avatar/
|
||||
107
hertz_springboot/src/main/resources/schema.sql
Normal file
107
hertz_springboot/src/main/resources/schema.sql
Normal file
@@ -0,0 +1,107 @@
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_user
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_user`;
|
||||
CREATE TABLE `sys_user` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
|
||||
`username` varchar(50) NOT NULL COMMENT '用户名',
|
||||
`password` varchar(100) NOT NULL COMMENT '加密密码',
|
||||
`nickname` varchar(50) NOT NULL COMMENT '用户昵称',
|
||||
`avatar_path` varchar(255) DEFAULT NULL COMMENT '头像路径',
|
||||
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
|
||||
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
|
||||
`gender` tinyint(1) DEFAULT '0' COMMENT '0-未知 1-男 2-女',
|
||||
`status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_username` (`username`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_role
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_role`;
|
||||
CREATE TABLE `sys_role` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
|
||||
`role_key` varchar(50) NOT NULL COMMENT '角色标识',
|
||||
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
|
||||
`status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_role_key` (`role_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_menu
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_menu`;
|
||||
CREATE TABLE `sys_menu` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
|
||||
`parent_id` bigint DEFAULT '0' COMMENT '父菜单ID',
|
||||
`type` varchar(10) NOT NULL COMMENT 'D-目录 M-菜单 B-按钮',
|
||||
`name` varchar(50) NOT NULL COMMENT '菜单名称',
|
||||
`path` varchar(200) DEFAULT NULL COMMENT '路由路径',
|
||||
`component` varchar(200) DEFAULT NULL COMMENT '组件路径',
|
||||
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
|
||||
`icon` varchar(100) DEFAULT NULL COMMENT '菜单图标',
|
||||
`sort` int DEFAULT '0' COMMENT '排序',
|
||||
`visible` tinyint DEFAULT '1' COMMENT '0-隐藏 1-显示',
|
||||
`status` tinyint DEFAULT '1' COMMENT '0-禁用 1-启用',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_user_role
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_user_role`;
|
||||
CREATE TABLE `sys_user_role` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`role_id` bigint NOT NULL COMMENT '角色ID',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_role` (`user_id`,`role_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_role_menu
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_role_menu`;
|
||||
CREATE TABLE `sys_role_menu` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`role_id` bigint NOT NULL COMMENT '角色ID',
|
||||
`menu_id` bigint NOT NULL COMMENT '菜单ID',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_role_menu` (`role_id`,`menu_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Init Data
|
||||
-- ----------------------------
|
||||
INSERT INTO `sys_role` (`id`, `role_key`, `role_name`) VALUES
|
||||
(1, 'ADMIN', '管理员');
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `parent_id`, `type`, `name`, `path`, `component`, `perms`, `icon`, `sort`) VALUES
|
||||
(1, 0, 'M', '仪表盘', '/admin/dashboard', 'admin/Dashboard', NULL, 'DataLine', 0),
|
||||
(2, 0, 'D', '系统管理', '/admin/system', NULL, NULL, 'Setting', 10),
|
||||
(3, 2, 'M', '用户管理', '/admin/system/user', 'admin/system/User', 'system:user:view', 'User', 0),
|
||||
(4, 2, 'M', '角色管理', '/admin/system/role', 'admin/system/Role', 'system:role:view', 'Tickets', 1),
|
||||
(5, 2, 'M', '菜单管理', '/admin/system/menu', 'admin/system/Menu', 'system:menu:view', 'Menu', 2);
|
||||
|
||||
INSERT INTO `sys_user` (`id`, `username`, `password`, `nickname`, `phone`, `email`, `gender`, `status`) VALUES
|
||||
(1, 'hertz', '$2a$10$Gker6.ggCxG3wfZ13rE/Eu7aDnB.DX2JmP6h6vct30RTtBr9.q5Pq', '管理员', '18888888888', 'hertz@hertz.com', 1, 1),
|
||||
(2, 'demo', '$2a$10$PSIz9pWXAwXfB32HWSxTjeGhVi0bixsSKxzeX8YAdKnRRXPxJC3Xe', '普通用户', '13888888888', 'demo@hertz.com', 1, 1);
|
||||
|
||||
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES
|
||||
(1, 1);
|
||||
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(1, 3),
|
||||
(1, 4),
|
||||
(1, 5);
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.hertz;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class HertzApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
|
||||
2
hertz_springboot_ui/.env.development
Normal file
2
hertz_springboot_ui/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=http://localhost:8080
|
||||
|
||||
5
hertz_springboot_ui/README.md
Normal file
5
hertz_springboot_ui/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
hertz_springboot_ui/index.html
Normal file
13
hertz_springboot_ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/index1.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hertz Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
hertz_springboot_ui/jsconfig.json
Normal file
20
hertz_springboot_ui/jsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": false,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.js", "src/**/*.vue", "vite.config.js"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
2045
hertz_springboot_ui/package-lock.json
generated
Normal file
2045
hertz_springboot_ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
hertz_springboot_ui/package.json
Normal file
23
hertz_springboot_ui/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.2",
|
||||
"element-plus": "^2.13.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
BIN
hertz_springboot_ui/public/index.png
Normal file
BIN
hertz_springboot_ui/public/index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
hertz_springboot_ui/public/index1.png
Normal file
BIN
hertz_springboot_ui/public/index1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
hertz_springboot_ui/public/logo.png
Normal file
BIN
hertz_springboot_ui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
3
hertz_springboot_ui/src/App.vue
Normal file
3
hertz_springboot_ui/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
24
hertz_springboot_ui/src/api/auth.js
Normal file
24
hertz_springboot_ui/src/api/auth.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http } from './http'
|
||||
|
||||
export async function login(req) {
|
||||
const { data } = await http.post('/api/auth/login', req)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function register(req) {
|
||||
const { data } = await http.post('/api/auth/register', req)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function me() {
|
||||
const { data } = await http.get('/api/auth/me')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateProfile(data) {
|
||||
await http.post('/api/auth/profile', data)
|
||||
}
|
||||
|
||||
export async function updatePassword(data) {
|
||||
await http.post('/api/auth/password', data)
|
||||
}
|
||||
12
hertz_springboot_ui/src/api/common.js
Normal file
12
hertz_springboot_ui/src/api/common.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { http } from './http'
|
||||
|
||||
export async function uploadFile(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const { data } = await http.post('/api/common/file/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
39
hertz_springboot_ui/src/api/http.js
Normal file
39
hertz_springboot_ui/src/api/http.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.token) {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${auth.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err) => {
|
||||
if (err?.response?.status === 401) {
|
||||
const auth = useAuthStore()
|
||||
auth.logout()
|
||||
}
|
||||
if (err?.response?.status === 403) {
|
||||
import('../router')
|
||||
.then(({ default: router }) => {
|
||||
const current = router.currentRoute.value
|
||||
if (current.path !== '/403') {
|
||||
router.replace({ path: '/403', query: { from: current.fullPath } })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (window.location.pathname !== '/403') window.location.replace('/403')
|
||||
})
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
81
hertz_springboot_ui/src/api/system.js
Normal file
81
hertz_springboot_ui/src/api/system.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { http } from './http'
|
||||
|
||||
export async function fetchMenuTree() {
|
||||
const { data } = await http.get('/api/system/menus/tree')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function pageMenus(params) {
|
||||
const { data } = await http.get('/api/system/menus/page', { params })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createMenu(data) {
|
||||
await http.post('/api/system/menus', data)
|
||||
}
|
||||
|
||||
export async function updateMenu(data) {
|
||||
await http.put('/api/system/menus', data)
|
||||
}
|
||||
|
||||
export async function deleteMenu(id) {
|
||||
await http.delete(`/api/system/menus/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchUsers(params) {
|
||||
const { data } = await http.get('/api/system/users', { params })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createUser(data) {
|
||||
await http.post('/api/system/users', data)
|
||||
}
|
||||
|
||||
export async function updateUser(data) {
|
||||
await http.put('/api/system/users', data)
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
await http.delete(`/api/system/users/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchRoles() {
|
||||
const { data } = await http.get('/api/system/roles')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function pageRoles(params) {
|
||||
const { data } = await http.get('/api/system/roles/page', { params })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createRole(data) {
|
||||
await http.post('/api/system/roles', data)
|
||||
}
|
||||
|
||||
export async function updateRole(data) {
|
||||
await http.put('/api/system/roles', data)
|
||||
}
|
||||
|
||||
export async function deleteRole(id) {
|
||||
await http.delete(`/api/system/roles/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchRoleMenuIds(roleId) {
|
||||
const { data } = await http.get(`/api/system/roles/${roleId}/menus`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateRoleMenus(roleId, menuIds) {
|
||||
await http.put(`/api/system/roles/${roleId}/menus`, { menuIds })
|
||||
}
|
||||
|
||||
export async function fetchUserRoleIds(userId) {
|
||||
const { data } = await http.get(`/api/system/users/${userId}/roles`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateUserRoles(userId, roleIds) {
|
||||
const { data } = await http.put(`/api/system/users/${userId}/roles`, { roleIds })
|
||||
return data.data
|
||||
}
|
||||
BIN
hertz_springboot_ui/src/assets/img/profile_bg.jpg
Normal file
BIN
hertz_springboot_ui/src/assets/img/profile_bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 841 KiB |
242
hertz_springboot_ui/src/assets/style.css
Normal file
242
hertz_springboot_ui/src/assets/style.css
Normal file
@@ -0,0 +1,242 @@
|
||||
:root {
|
||||
/* iOS Color Palette */
|
||||
--ios-primary: #007AFF;
|
||||
--ios-primary-light: #47a0ff;
|
||||
--ios-success: #34C759;
|
||||
--ios-warning: #FF9500;
|
||||
--ios-danger: #FF3B30;
|
||||
--ios-gray: #8E8E93;
|
||||
--ios-bg: #F2F4F8;
|
||||
--ios-surface: rgba(255, 255, 255, 0.7);
|
||||
--ios-border: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Shapes & Shadows */
|
||||
--ios-radius: 16px;
|
||||
--ios-shadow: 0 4px 24px rgba(0, 0, 0, 0.04);
|
||||
--ios-shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Fonts */
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Element Plus Overrides Variables */
|
||||
--el-color-primary: var(--ios-primary);
|
||||
--el-border-radius-base: 8px;
|
||||
--el-border-radius-small: 6px;
|
||||
--el-border-radius-round: 20px;
|
||||
--el-bg-color-page: var(--ios-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--ios-bg);
|
||||
color: #1d1d1f;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Glassmorphism Utilities */
|
||||
.glass {
|
||||
background: var(--ios-surface);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.el-card, .ios-card {
|
||||
border: none !important;
|
||||
border-radius: var(--ios-radius) !important;
|
||||
background: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--ios-border) !important;
|
||||
padding: 16px 24px !important;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
.el-button {
|
||||
border-radius: 10px !important; /* Pill shape */
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.el-button:not(.is-circle):not(.is-text) {
|
||||
height: 36px !important;
|
||||
padding: 0 20px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Input Styling */
|
||||
.el-input__wrapper {
|
||||
height: 36px !important;
|
||||
border-radius: 10px !important;
|
||||
padding: 0 12px !important;
|
||||
background-color: rgba(255,255,255,0.8) !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.el-table {
|
||||
--el-table-border-color: var(--ios-border);
|
||||
--el-table-header-bg-color: transparent;
|
||||
background-color: transparent !important;
|
||||
border-radius: var(--ios-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background-color: #fafafa !important;
|
||||
font-weight: 600;
|
||||
color: #86868b;
|
||||
border-bottom: 1px solid var(--ios-border) !important;
|
||||
}
|
||||
|
||||
.el-table td.el-table__cell {
|
||||
border-bottom: 1px solid var(--ios-border) !important;
|
||||
}
|
||||
|
||||
.el-table--enable-row-hover .el-table__body tr:hover > td.el-table__cell {
|
||||
background-color: rgba(0, 122, 255, 0.03) !important;
|
||||
}
|
||||
|
||||
/* Dialog Styling */
|
||||
.el-dialog {
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin-right: 0 !important;
|
||||
padding: 20px 24px !important;
|
||||
}
|
||||
|
||||
/* Dropdown (User menu) */
|
||||
.ios-dropdown {
|
||||
min-width: 220px;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.ios-dropdown-popper {
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.ios-dropdown-popper.el-popper {
|
||||
border-radius: 18px !important;
|
||||
border: 1px solid var(--ios-border) !important;
|
||||
box-shadow: var(--ios-shadow) !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ios-dropdown-popper .el-popper__content {
|
||||
border-radius: 18px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
background: rgba(255, 255, 255, 0.98) !important;
|
||||
}
|
||||
|
||||
.ios-dropdown-popper .el-popper__arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ios-dropdown .el-dropdown-menu__item {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
border-radius: 14px;
|
||||
margin: 2px 0;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.ios-dropdown .el-dropdown-menu__item:not(.is-disabled):hover {
|
||||
background-color: rgba(0, 0, 0, 0.04) !important;
|
||||
}
|
||||
|
||||
.ios-dropdown .el-dropdown-menu__item.is-disabled {
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ios-dropdown .el-dropdown-menu__item.is-divided {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ios-dropdown .el-dropdown-menu__item.is-divided::before {
|
||||
height: 1px;
|
||||
background-color: var(--ios-border);
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.ios-dropdown .ios-user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.ios-dropdown .ios-user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ios-dropdown .ios-user-name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ios-dropdown .ios-user-email {
|
||||
font-size: 12px;
|
||||
color: #8E8E93;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ios-dropdown .ios-logout-item {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.ios-dropdown .ios-logout-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02) !important;
|
||||
}
|
||||
|
||||
.ios-dropdown .el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Scrollbar Hiding (Global) */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
html, body {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
207
hertz_springboot_ui/src/components/Error.vue
Normal file
207
hertz_springboot_ui/src/components/Error.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-card glass">
|
||||
<div class="error-visual" :class="{ forbidden: codeText === '403' }">
|
||||
<div class="error-code">{{ codeText }}</div>
|
||||
<div class="error-badge">
|
||||
<el-icon :size="18">
|
||||
<component :is="iconComponent" />
|
||||
</el-icon>
|
||||
<span class="error-badge-text">{{ badgeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-content">
|
||||
<div class="error-title">{{ displayTitle }}</div>
|
||||
<div class="error-subtitle">{{ displaySubTitle }}</div>
|
||||
<div class="error-actions">
|
||||
<el-button type="primary" @click="goHome">
|
||||
<el-icon class="btn-icon"><House /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
<el-button @click="goBack">
|
||||
<el-icon class="btn-icon"><ArrowLeft /></el-icon>
|
||||
返回上一页
|
||||
</el-button>
|
||||
<el-button v-if="codeText === '403'" type="danger" plain @click="switchAccount">
|
||||
<el-icon class="btn-icon"><SwitchButton /></el-icon>
|
||||
切换账号
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { ArrowLeft, CircleCloseFilled, House, SwitchButton, WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
code: { type: [String, Number], default: '404' },
|
||||
title: { type: String, default: '' },
|
||||
subTitle: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const codeText = computed(() => String(props.code || '404'))
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (codeText.value === '403') return '无权限'
|
||||
return '未找到'
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (codeText.value === '403') return CircleCloseFilled
|
||||
return WarningFilled
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (props.title) return props.title
|
||||
if (codeText.value === '403') return '抱歉,您没有权限访问该页面'
|
||||
return '抱歉,您访问的页面不存在'
|
||||
})
|
||||
|
||||
const displaySubTitle = computed(() => {
|
||||
if (props.subTitle) return props.subTitle
|
||||
if (codeText.value === '403') return '请联系管理员开通权限,或切换账号后重试。'
|
||||
return '链接可能已失效,或页面已被删除。'
|
||||
})
|
||||
|
||||
function goHome() {
|
||||
if (auth.token && auth.roles.length > 0) {
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
router.push('/portal/home')
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
function switchAccount() {
|
||||
const redirect = route.query.from ? String(route.query.from) : '/admin'
|
||||
auth.logout()
|
||||
router.replace({ path: '/login', query: { redirect } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
background: radial-gradient(circle at top left, #e3f2fd, transparent 45%),
|
||||
radial-gradient(circle at bottom right, #f3e5f5, transparent 45%),
|
||||
#f2f4f8;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--ios-shadow);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.error-visual {
|
||||
padding: 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, rgba(0, 122, 255, 0.18) 0%, rgba(0, 198, 251, 0.12) 100%);
|
||||
border-right: 1px solid var(--ios-border);
|
||||
}
|
||||
|
||||
.error-visual.forbidden {
|
||||
background: linear-gradient(135deg, rgba(255, 59, 48, 0.16) 0%, rgba(255, 149, 0, 0.10) 100%);
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 68px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -2px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00c6fb 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.error-visual.forbidden .error-code {
|
||||
background: linear-gradient(135deg, var(--ios-danger) 0%, var(--ios-warning) 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.error-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
width: fit-content;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.error-badge-text {
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
padding: 28px 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.error-subtitle {
|
||||
color: #8e8e93;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.error-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.error-visual {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--ios-border);
|
||||
}
|
||||
.error-code {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
hertz_springboot_ui/src/layouts/AdminLayout.vue
Normal file
236
hertz_springboot_ui/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<el-container style="min-height: 100vh; background-color: var(--ios-bg);">
|
||||
<el-aside width="240px" class="admin-aside glass">
|
||||
<div class="logo-area" style="display: flex; align-items: center; justify-content: center; gap: 12px;">
|
||||
<img src="/logo.png" alt="Logo" style="width: 48px; height: 26px;" />
|
||||
<span class="logo-text">Hertz Admin</span>
|
||||
</div>
|
||||
<el-menu :default-active="active" class="admin-menu" @select="onSelect">
|
||||
<template v-for="m in menus" :key="m.id">
|
||||
<el-sub-menu v-if="m.children && m.children.length" :index="m.path || String(m.id)">
|
||||
<template #title>
|
||||
<el-icon v-if="iconComp(m.icon)">
|
||||
<component :is="iconComp(m.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ m.name }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="c in m.children"
|
||||
:key="c.id"
|
||||
:index="c.path || ''"
|
||||
@click="toMenuPath(c)"
|
||||
>
|
||||
<el-icon v-if="iconComp(c.icon)">
|
||||
<component :is="iconComp(c.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ c.name }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="m.path || ''" @click="toMenuPath(m)">
|
||||
<el-icon v-if="iconComp(m.icon)">
|
||||
<component :is="iconComp(m.icon)" />
|
||||
</el-icon>
|
||||
<span>{{ m.name }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header height="64px" class="admin-header glass">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/admin' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.id">{{ item.name }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<el-dropdown
|
||||
trigger="hover"
|
||||
placement="bottom-end"
|
||||
popper-class="ios-dropdown-popper"
|
||||
:popper-options="{ modifiers: [{ name: 'offset', options: { offset: [8] } }] }"
|
||||
>
|
||||
<span class="user-dropdown-link">
|
||||
<el-avatar :size="36" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
|
||||
<el-avatar :size="36" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
|
||||
<span class="username">{{ auth.nickname || auth.username }}</span>
|
||||
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="ios-dropdown">
|
||||
<el-dropdown-item disabled class="ios-dropdown-user">
|
||||
<div class="ios-user-card">
|
||||
<el-avatar :size="44" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
|
||||
<el-avatar :size="44" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
|
||||
<div class="ios-user-meta">
|
||||
<div class="ios-user-name">{{ auth.nickname || auth.username }}</div>
|
||||
<div class="ios-user-email">{{ auth.email || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="router.push('/admin/profile')">
|
||||
<el-icon><component :is="Icons.User" /></el-icon>
|
||||
<span>个人资料</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="router.push('/portal')">
|
||||
<el-icon><component :is="Icons.House" /></el-icon>
|
||||
<span>访问前台</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided class="ios-logout-item" @click="logout">
|
||||
<el-icon><component :is="Icons.SwitchButton" /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-main style="padding: 24px; overflow-x: hidden;">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useMenuStore } from '../stores/menu'
|
||||
import * as Icons from '@element-plus/icons-vue'
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const active = computed(() => route.path)
|
||||
const menus = computed(() => {
|
||||
const root = menuStore.menuTree.length === 1 ? menuStore.menuTree[0] : undefined
|
||||
if (root?.children?.length) return root.children
|
||||
return menuStore.menuTree
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const findPath = (nodes, targetPath, acc = []) => {
|
||||
for (const node of nodes) {
|
||||
if (node.path === targetPath) {
|
||||
return [...acc, node]
|
||||
}
|
||||
if (node.children && node.children.length) {
|
||||
const found = findPath(node.children, targetPath, [...acc, node])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return findPath(menuStore.menuTree, route.path) || []
|
||||
})
|
||||
|
||||
function iconComp(name) {
|
||||
if (!name) return undefined
|
||||
return Icons[name]
|
||||
}
|
||||
|
||||
function toMenuPath(node) {
|
||||
if (node.type === 'M' && node.path) router.push(node.path)
|
||||
}
|
||||
|
||||
function onSelect(index) {
|
||||
router.push(index)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
menuStore.reset()
|
||||
router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-aside {
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.logo-area {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.admin-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
padding: 16px 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item), :deep(.el-sub-menu__title) {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 6px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover), :deep(.el-sub-menu__title:hover) {
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: var(--ios-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid var(--ios-border);
|
||||
}
|
||||
|
||||
.user-dropdown-link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.default-avatar {
|
||||
background-color: var(--ios-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
167
hertz_springboot_ui/src/layouts/PortalLayout.vue
Normal file
167
hertz_springboot_ui/src/layouts/PortalLayout.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="portal-layout">
|
||||
<header class="portal-header glass">
|
||||
<div class="header-content">
|
||||
<div class="logo" style="display: flex; align-items: center; gap: 8px;">
|
||||
<img src="/logo.png" alt="Logo" style="width: 48px; height: 26px;" />
|
||||
<span>Hertz Admin</span>
|
||||
</div>
|
||||
<el-menu mode="horizontal" :default-active="active()" class="portal-menu" :ellipsis="false">
|
||||
<el-menu-item index="/portal/home" @click="go('/portal/home')">首页</el-menu-item>
|
||||
<el-menu-item index="/portal/about" @click="go('/portal/about')">关于</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="auth-actions">
|
||||
<template v-if="!auth.token">
|
||||
<el-button type="primary" @click="goLogin">登录</el-button>
|
||||
<el-button @click="goRegister" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">注册</el-button>
|
||||
</template>
|
||||
<el-dropdown v-else trigger="hover" placement="bottom-end" popper-class="ios-dropdown-popper">
|
||||
<span class="user-link">
|
||||
<el-avatar :size="32" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
|
||||
<el-avatar :size="32" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
|
||||
<span class="username">{{ auth.nickname || auth.username }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="ios-dropdown">
|
||||
<el-dropdown-item disabled class="ios-dropdown-user">
|
||||
<div class="ios-user-card">
|
||||
<el-avatar :size="44" :src="apiBase + auth.avatarPath" v-if="auth.avatarPath" />
|
||||
<el-avatar :size="44" v-else class="default-avatar">{{ auth.nickname?.charAt(0) || auth.username.charAt(0) }}</el-avatar>
|
||||
<div class="ios-user-meta">
|
||||
<div class="ios-user-name">{{ auth.nickname || auth.username }}</div>
|
||||
<div class="ios-user-email">{{ auth.email || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="go('/portal/profile')">
|
||||
<el-icon><component :is="Icons.User" /></el-icon>
|
||||
<span>个人中心</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided class="ios-logout-item" @click="logout">
|
||||
<el-icon><component :is="Icons.SwitchButton" /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="portal-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import * as Icons from '@element-plus/icons-vue'
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const active = () => route.path
|
||||
|
||||
function go(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
router.push('/register')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/portal/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.portal-layout {
|
||||
min-height: 100vh;
|
||||
background-color: var(--ios-bg);
|
||||
}
|
||||
.portal-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--ios-border);
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
margin-right: 40px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.portal-menu {
|
||||
flex: 1;
|
||||
border-bottom: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
:deep(.el-menu-item) {
|
||||
background: transparent !important;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
border-bottom: 2px solid transparent !important;
|
||||
color: #555 !important;
|
||||
}
|
||||
:deep(.el-menu-item.is-active) {
|
||||
color: var(--ios-primary) !important;
|
||||
border-bottom-color: var(--ios-primary) !important;
|
||||
}
|
||||
:deep(.el-menu-item:hover) {
|
||||
color: var(--ios-primary) !important;
|
||||
}
|
||||
.auth-actions {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.user-link {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.default-avatar {
|
||||
background-color: var(--ios-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
.username {
|
||||
font-weight: 500;
|
||||
}
|
||||
.portal-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
</style>
|
||||
22
hertz_springboot_ui/src/main.js
Normal file
22
hertz_springboot_ui/src/main.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import './assets/style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
59
hertz_springboot_ui/src/router/index.js
Normal file
59
hertz_springboot_ui/src/router/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import PortalLayout from '../layouts/PortalLayout.vue'
|
||||
import AdminLayout from '../layouts/AdminLayout.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Register from '../views/Register.vue'
|
||||
import { setupGuards } from './setupGuards'
|
||||
|
||||
export const ROUTE_NAMES = {
|
||||
AdminRoot: 'AdminRoot',
|
||||
AdminLayout: 'AdminLayout',
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/portal/home' },
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/register', component: Register },
|
||||
{
|
||||
path: '/403',
|
||||
component: () => import('../components/Error.vue'),
|
||||
props: {
|
||||
code: 403,
|
||||
title: '抱歉,您没有权限访问该页面',
|
||||
subTitle: '请联系管理员开通权限,或切换账号后重试。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/portal',
|
||||
component: PortalLayout,
|
||||
children: [
|
||||
{ path: '', redirect: '/portal/home' },
|
||||
{ path: 'home', component: () => import('../views/portal/Home.vue') },
|
||||
{ path: 'about', component: () => import('../views/portal/About.vue') },
|
||||
{ path: 'profile', component: () => import('../views/Profile.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: ROUTE_NAMES.AdminLayout,
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{ path: '', name: ROUTE_NAMES.AdminRoot, redirect: '/admin/dashboard' },
|
||||
{ path: 'profile', component: () => import('../views/Profile.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('../components/Error.vue'),
|
||||
props: { code: 404 },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
setupGuards(router)
|
||||
|
||||
export default router
|
||||
40
hertz_springboot_ui/src/router/setupGuards.js
Normal file
40
hertz_springboot_ui/src/router/setupGuards.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useMenuStore } from '../stores/menu'
|
||||
import { buildAdminRoutesFromMenus } from './utils'
|
||||
import { ROUTE_NAMES } from './index'
|
||||
|
||||
export function setupGuards(router) {
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const menu = useMenuStore()
|
||||
|
||||
const isAdminPath = to.path.startsWith('/admin')
|
||||
|
||||
if (!auth.token) {
|
||||
if (isAdminPath) return { path: '/login', query: { redirect: to.fullPath } }
|
||||
return true
|
||||
}
|
||||
|
||||
if (!auth.meLoaded) {
|
||||
await auth.fetchMe()
|
||||
}
|
||||
|
||||
if (auth.roles.length === 0) {
|
||||
if (isAdminPath) return '/portal/home'
|
||||
return true
|
||||
}
|
||||
|
||||
if (to.path === '/login' || to.path === '/register') return '/admin'
|
||||
|
||||
if (isAdminPath && !menu.routesLoaded) {
|
||||
await menu.fetchMenus()
|
||||
const adminRoutes = buildAdminRoutesFromMenus(menu.menuTree)
|
||||
for (const r of adminRoutes) router.addRoute(ROUTE_NAMES.AdminLayout, r)
|
||||
menu.routesLoaded = true
|
||||
return { ...to, replace: true }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
41
hertz_springboot_ui/src/router/utils.js
Normal file
41
hertz_springboot_ui/src/router/utils.js
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
const viewModules = import.meta.glob('../views/**/*.vue')
|
||||
|
||||
function resolveView(component) {
|
||||
if (!component) return undefined
|
||||
const key = `../views/${component}.vue`
|
||||
const loader = viewModules[key]
|
||||
if (!loader) return undefined
|
||||
return loader
|
||||
}
|
||||
|
||||
export function buildAdminRoutesFromMenus(menus) {
|
||||
const routes = []
|
||||
|
||||
const normalizeAdminChildPath = (rawPath) => {
|
||||
if (!rawPath) return ''
|
||||
if (rawPath.startsWith('/admin/')) return rawPath.slice('/admin/'.length)
|
||||
if (rawPath === '/admin') return ''
|
||||
return rawPath.startsWith('/') ? rawPath.slice(1) : rawPath
|
||||
}
|
||||
|
||||
const walk = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
if (n.type === 'M') {
|
||||
const component = resolveView(n.component)
|
||||
if (!component) continue
|
||||
const childPath = normalizeAdminChildPath(n.path || '')
|
||||
if (!childPath) continue
|
||||
routes.push({
|
||||
path: childPath,
|
||||
component,
|
||||
meta: { title: n.name, icon: n.icon },
|
||||
})
|
||||
}
|
||||
if (n.children?.length) walk(n.children)
|
||||
}
|
||||
}
|
||||
|
||||
walk(menus)
|
||||
return routes
|
||||
}
|
||||
57
hertz_springboot_ui/src/stores/auth.js
Normal file
57
hertz_springboot_ui/src/stores/auth.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { login, me, register } from '../api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('token') || '',
|
||||
meLoaded: false,
|
||||
userId: 0,
|
||||
username: '',
|
||||
nickname: '',
|
||||
avatarPath: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
gender: 0,
|
||||
roles: [],
|
||||
}),
|
||||
actions: {
|
||||
async login(req) {
|
||||
const res = await login(req)
|
||||
this.token = res.token
|
||||
localStorage.setItem('token', this.token)
|
||||
// Login response only has limited info, fetch full profile
|
||||
await this.fetchMe()
|
||||
return res
|
||||
},
|
||||
async register(req) {
|
||||
return register(req)
|
||||
},
|
||||
async fetchMe() {
|
||||
const res = await me()
|
||||
this.userId = res.userId
|
||||
this.username = res.username
|
||||
this.nickname = res.nickname || ''
|
||||
this.avatarPath = res.avatarPath || ''
|
||||
this.phone = res.phone || ''
|
||||
this.email = res.email || ''
|
||||
this.gender = res.gender || 0
|
||||
this.roles = res.roles || []
|
||||
this.meLoaded = true
|
||||
return res
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
localStorage.clear()
|
||||
this.meLoaded = false
|
||||
this.userId = 0
|
||||
this.username = ''
|
||||
this.nickname = ''
|
||||
this.avatarPath = ''
|
||||
this.phone = ''
|
||||
this.email = ''
|
||||
this.gender = 0
|
||||
this.roles = []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
20
hertz_springboot_ui/src/stores/menu.js
Normal file
20
hertz_springboot_ui/src/stores/menu.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { fetchMenuTree } from '../api/system'
|
||||
|
||||
export const useMenuStore = defineStore('menu', {
|
||||
state: () => ({
|
||||
menuTree: [],
|
||||
routesLoaded: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchMenus() {
|
||||
this.menuTree = await fetchMenuTree()
|
||||
return this.menuTree
|
||||
},
|
||||
reset() {
|
||||
this.menuTree = []
|
||||
this.routesLoaded = false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
122
hertz_springboot_ui/src/views/Login.vue
Normal file
122
hertz_springboot_ui/src/views/Login.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-content">
|
||||
<el-card class="login-card glass">
|
||||
<div class="login-header">
|
||||
<img src="/logo.png" alt="Logo" style="width: 96px; height: 52px; margin-bottom: 1px;" />
|
||||
<h1>Hertz Admin</h1>
|
||||
</div>
|
||||
<el-form label-position="top" size="large" @submit.prevent="submit">
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.username" autocomplete="username" placeholder="请输入用户名" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.password" type="password" autocomplete="current-password" show-password placeholder="请输入密码" :prefix-icon="Lock" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="submit" style="width: 100%">登录</el-button>
|
||||
</el-form-item>
|
||||
<div class="form-footer">
|
||||
<el-button type="info" link @click="router.push('/portal/home')">游客访问</el-button>
|
||||
<el-button type="primary" link @click="goRegister">注册新账户</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: 'hertz',
|
||||
password: 'hertz',
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form)
|
||||
if (auth.roles.length === 0) {
|
||||
await router.replace('/portal/home')
|
||||
return
|
||||
}
|
||||
const redirect = route.query.redirect || '/admin'
|
||||
await router.replace(redirect)
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
router.push('/register')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: radial-gradient(circle at top left, #E3F2FD, transparent 40%),
|
||||
radial-gradient(circle at bottom right, #F3E5F5, transparent 40%),
|
||||
#F2F4F8;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #8E8E93;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
376
hertz_springboot_ui/src/views/Profile.vue
Normal file
376
hertz_springboot_ui/src/views/Profile.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<el-row :gutter="20">
|
||||
<!-- Left Column: Profile Card -->
|
||||
<el-col :span="8" :xs="24">
|
||||
<el-card class="profile-card" :body-style="{ padding: '0px' }">
|
||||
<div class="profile-header">
|
||||
<img src="@/assets/img/profile_bg.jpg" alt="Cover" class="cover-image" />
|
||||
<div class="avatar-wrapper">
|
||||
<el-upload
|
||||
class="avatar-uploader-card"
|
||||
:show-file-list="false"
|
||||
:http-request="handleAvatarUpload"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
>
|
||||
<img v-if="form.avatarPath" :src="apiBase + form.avatarPath" class="profile-avatar" />
|
||||
<div v-else class="profile-avatar-placeholder">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</div>
|
||||
<div class="avatar-mask">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h2 class="profile-name">{{ form.nickname || form.username || 'User' }}</h2>
|
||||
|
||||
<div class="profile-details">
|
||||
<div class="detail-item">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>{{ form.email || '未设置邮箱' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<el-icon><Iphone /></el-icon>
|
||||
<span>{{ form.phone || '未设置手机' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ form.gender === 1 ? '男' : (form.gender === 2 ? '女' : '未知') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <el-divider content-position="center">标签</el-divider>
|
||||
<div class="profile-tags">
|
||||
<el-tag effect="plain">交互专家</el-tag>
|
||||
<el-tag effect="plain" type="success">设计爱好者</el-tag>
|
||||
<el-tag effect="plain" type="warning">Vue.js</el-tag>
|
||||
<el-tag effect="plain" type="info">Spring Boot</el-tag>
|
||||
</div> -->
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- Right Column: Settings Tabs -->
|
||||
<el-col :span="16" :xs="24">
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本设置" name="info">
|
||||
<div class="tab-content">
|
||||
<el-form :model="form" label-position="top" class="settings-form">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12" :xs="24">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" :xs="24">
|
||||
<el-form-item label="性别">
|
||||
<el-select v-model="form.gender" style="width: 100%">
|
||||
<el-option label="未知" :value="0" />
|
||||
<el-option label="男" :value="1" />
|
||||
<el-option label="女" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" :xs="24">
|
||||
<el-form-item label="手机">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" :xs="24">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="个人介绍">
|
||||
<el-input
|
||||
v-model="form.bio"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="个人介绍..."
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSubmit">更新基本信息</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="安全设置" name="password">
|
||||
<div class="tab-content">
|
||||
<el-form :model="pwdForm" label-position="top" style="max-width: 500px">
|
||||
<el-form-item label="旧密码">
|
||||
<el-input v-model="pwdForm.oldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="pwdForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="pwdLoading" @click="onChangePassword">修改密码</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { updateProfile, updatePassword } from '../api/auth'
|
||||
import { uploadFile } from '../api/common'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, User, Message, Iphone, Location } from '@element-plus/icons-vue'
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const form = ref({
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
gender: 0,
|
||||
avatarPath: ''
|
||||
})
|
||||
|
||||
const pwdForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const pwdLoading = ref(false)
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('info')
|
||||
|
||||
onMounted(() => {
|
||||
form.value = {
|
||||
nickname: auth.nickname,
|
||||
phone: auth.phone,
|
||||
email: auth.email,
|
||||
gender: auth.gender,
|
||||
avatarPath: auth.avatarPath
|
||||
}
|
||||
})
|
||||
|
||||
async function handleAvatarUpload(options) {
|
||||
try {
|
||||
const res = await uploadFile(options.file)
|
||||
form.value.avatarPath = res.url
|
||||
ElMessage.success('头像上传成功')
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
function beforeAvatarUpload(rawFile) {
|
||||
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
|
||||
ElMessage.error('Avatar picture must be JPG format!')
|
||||
return false
|
||||
} else if (rawFile.size / 1024 / 1024 > 2) {
|
||||
ElMessage.error('Avatar picture size can not exceed 2MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await updateProfile(form.value)
|
||||
ElMessage.success('更新成功')
|
||||
await auth.fetchMe() // refresh store
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangePassword() {
|
||||
if (!pwdForm.oldPassword || !pwdForm.newPassword || !pwdForm.confirmPassword) {
|
||||
ElMessage.warning('请填写完整密码信息')
|
||||
return
|
||||
}
|
||||
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
|
||||
ElMessage.warning('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
pwdLoading.value = true
|
||||
try {
|
||||
await updatePassword({
|
||||
oldPassword: pwdForm.oldPassword,
|
||||
newPassword: pwdForm.newPassword
|
||||
})
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
pwdForm.oldPassword = ''
|
||||
pwdForm.newPassword = ''
|
||||
pwdForm.confirmPassword = ''
|
||||
|
||||
// Logout and redirect to login
|
||||
auth.logout()
|
||||
router.replace('/login')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '密码修改失败')
|
||||
} finally {
|
||||
pwdLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-card {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
min-height: 438px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
position: relative;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -40px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #fff;
|
||||
object-fit: cover;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.profile-avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid #fff;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-wrapper:hover .avatar-mask {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.profile-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
margin-bottom: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.profile-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 24px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 0 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.avatar-uploader .el-upload {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
}
|
||||
|
||||
.avatar-uploader .el-upload:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
121
hertz_springboot_ui/src/views/Register.vue
Normal file
121
hertz_springboot_ui/src/views/Register.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<div class="register-content">
|
||||
<el-card class="register-card glass">
|
||||
<div class="register-header">
|
||||
<h1>创建账户</h1>
|
||||
</div>
|
||||
<el-form label-position="top" size="large" @submit.prevent="submit">
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.username" autocomplete="username" placeholder="设置用户名" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.password" type="password" autocomplete="new-password" show-password placeholder="设置密码" :prefix-icon="Lock" />
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.confirmPassword" type="password" autocomplete="new-password" show-password placeholder="确认密码" :prefix-icon="Lock" />
|
||||
</el-form-item>
|
||||
<el-form-item label="">
|
||||
<el-input v-model="form.nickname" placeholder="昵称" :prefix-icon="Postcard" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="submit" style="width: 100%">立即注册</el-button>
|
||||
</el-form-item>
|
||||
<div class="form-footer">
|
||||
<el-button link @click="router.push('/login')">已有账户?去登录</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
import { User, Lock, Postcard } from '@element-plus/icons-vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nickname: '',
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (form.password !== form.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { confirmPassword, ...payload } = form
|
||||
await auth.register(payload)
|
||||
ElMessage.success('注册成功,请登录')
|
||||
await router.replace('/login')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: radial-gradient(circle at top left, #E3F2FD, transparent 40%),
|
||||
radial-gradient(circle at bottom right, #F3E5F5, transparent 40%),
|
||||
#F2F4F8;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.register-content {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.register-header h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.register-header p {
|
||||
color: #8E8E93;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
389
hertz_springboot_ui/src/views/admin/Dashboard.vue
Normal file
389
hertz_springboot_ui/src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- Welcome Section -->
|
||||
<el-card class="welcome-card mb-4" shadow="hover">
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-left">
|
||||
<el-avatar :size="64" :src="avatarSrc" class="welcome-avatar">
|
||||
<template #default>
|
||||
{{ auth.nickname?.charAt(0) || auth.username?.charAt(0) }}
|
||||
</template>
|
||||
</el-avatar>
|
||||
<div class="welcome-text">
|
||||
<h2 class="greeting">{{ greeting }},{{ auth.nickname || auth.username }}</h2>
|
||||
<p class="subtitle">欢迎回到 Hertz Admin,今天也是充满活力的一天!</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="welcome-right">
|
||||
<div class="stat-item">
|
||||
<span class="label">我的角色</span>
|
||||
<span class="value">{{ auth.roles.length }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<el-row :gutter="20" class="mb-4">
|
||||
<el-col :span="8" :xs="24" class="mb-xs-4">
|
||||
<el-card shadow="hover" class="stat-card" :body-style="{ padding: '20px' }">
|
||||
<div class="stat-icon user-bg">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.userCount }}</div>
|
||||
<div class="stat-label">总用户数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8" :xs="24" class="mb-xs-4">
|
||||
<el-card shadow="hover" class="stat-card" :body-style="{ padding: '20px' }">
|
||||
<div class="stat-icon role-bg">
|
||||
<el-icon><Stamp /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.roleCount }}</div>
|
||||
<div class="stat-label">角色数量</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8" :xs="24">
|
||||
<el-card shadow="hover" class="stat-card" :body-style="{ padding: '20px' }">
|
||||
<div class="stat-icon menu-bg">
|
||||
<el-icon><IconMenu /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.menuCount }}</div>
|
||||
<div class="stat-label">菜单项</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Content Grid -->
|
||||
<el-row :gutter="20">
|
||||
<!-- Recent Users -->
|
||||
<el-col :span="16" :xs="24" class="mb-xs-4">
|
||||
<el-card shadow="hover" class="h-100">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span><el-icon class="mr-1"><Timer /></el-icon> 最新加入用户</span>
|
||||
<el-button text type="primary" @click="router.push('/admin/system/user')">
|
||||
查看全部 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentUsers" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="username" label="用户名" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar :size="24" :src="resolveFileUrl(row.avatarPath)" class="mr-2">
|
||||
{{ row.nickname?.charAt(0) || row.username.charAt(0) }}
|
||||
</el-avatar>
|
||||
<span>{{ row.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" min-width="120" />
|
||||
<el-table-column prop="createdAt" label="注册时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<el-col :span="8" :xs="24">
|
||||
<el-card shadow="hover" class="h-100">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>快捷导航</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<div
|
||||
v-for="action in quickActions"
|
||||
:key="action.path"
|
||||
class="action-item"
|
||||
@click="router.push(action.path)"
|
||||
>
|
||||
<div class="action-icon" :style="{ backgroundColor: action.color + '20', color: action.color }">
|
||||
<el-icon><component :is="action.icon" /></el-icon>
|
||||
</div>
|
||||
<span class="action-name">{{ action.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { fetchUsers, pageRoles, pageMenus } from '../../api/system'
|
||||
import { User, Stamp, Menu as IconMenu, Timer, ArrowRight, DataLine, Setting } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
|
||||
|
||||
const stats = ref({
|
||||
userCount: 0,
|
||||
roleCount: 0,
|
||||
menuCount: 0
|
||||
})
|
||||
|
||||
const recentUsers = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 6) return '夜深了'
|
||||
if (hour < 9) return '早上好'
|
||||
if (hour < 12) return '上午好'
|
||||
if (hour < 14) return '中午好'
|
||||
if (hour < 17) return '下午好'
|
||||
if (hour < 19) return '傍晚好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
function resolveFileUrl(path) {
|
||||
if (!path) return undefined
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const normalizedBase = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${normalizedBase}${normalizedPath}`
|
||||
}
|
||||
|
||||
const avatarSrc = computed(() => resolveFileUrl(auth.avatarPath))
|
||||
|
||||
const quickActions = [
|
||||
{ name: '用户管理', path: '/admin/system/user', icon: User, color: '#409EFF' },
|
||||
{ name: '角色管理', path: '/admin/system/role', icon: Stamp, color: '#67C23A' },
|
||||
{ name: '菜单管理', path: '/admin/system/menu', icon: IconMenu, color: '#E6A23C' },
|
||||
{ name: '个人资料', path: '/admin/profile', icon: Setting, color: '#909399' }
|
||||
]
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Parallel requests for stats
|
||||
const [usersRes, rolesRes, menusRes] = await Promise.all([
|
||||
fetchUsers({ page: 1, size: 5 }), // Get first page to show recent users too
|
||||
pageRoles({ page: 1, size: 1 }),
|
||||
pageMenus({ page: 1, size: 1 })
|
||||
])
|
||||
|
||||
stats.value = {
|
||||
userCount: usersRes.total,
|
||||
roleCount: rolesRes.total,
|
||||
menuCount: menusRes.total
|
||||
}
|
||||
|
||||
recentUsers.value = usersRes.records
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mb-4 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Welcome Card */
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.welcome-text .greeting {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.welcome-text .subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-right {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* Stat Cards */
|
||||
.stat-card {
|
||||
border: none;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.user-bg { background-color: #ecf5ff; color: #409eff; }
|
||||
.role-bg { background-color: #f0f9eb; color: #67c23a; }
|
||||
.menu-bg { background-color: #fdf6ec; color: #e6a23c; }
|
||||
|
||||
.stat-info .stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-info .stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Recent Users */
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background-color: #f5f7fa;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.action-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.welcome-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.welcome-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-right: 0;
|
||||
border-top: 1px solid #EBEEF5;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.mb-xs-4 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
269
hertz_springboot_ui/src/views/admin/system/Menu.vue
Normal file
269
hertz_springboot_ui/src/views/admin/system/Menu.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>菜单管理</template>
|
||||
|
||||
<div style="margin-bottom: 12px">
|
||||
<el-button type="primary" @click="load">刷新</el-button>
|
||||
<el-button @click="openCreate(0)" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增菜单</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="menuTree"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
border
|
||||
>
|
||||
<el-table-column prop="name" label="菜单名称" width="200" />
|
||||
<el-table-column prop="icon" label="图标" width="60">
|
||||
<template #default="{ row }">
|
||||
<component :is="row.icon" v-if="row.icon" style="width: 16px; height: 16px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.type === 'D'">目录</el-tag>
|
||||
<el-tag v-else-if="row.type === 'M'" type="success">菜单</el-tag>
|
||||
<el-tag v-else type="info">按钮</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路由路径" />
|
||||
<el-table-column prop="component" label="组件路径" />
|
||||
<el-table-column prop="perms" label="权限标识" />
|
||||
<el-table-column prop="sort" label="排序" width="60" />
|
||||
<!-- <el-table-column prop="visible" label="可见" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.visible === 1 ? 'success' : 'info'">{{ row.visible === 1 ? '显示' : '隐藏' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center">
|
||||
<el-tooltip content="编辑" placement="top">
|
||||
<el-button type="primary" text bg :icon="EditPen" style="font-size: 15px; padding: 6px 8px" @click="openEdit(row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="新增子项" placement="top">
|
||||
<el-button type="primary" text bg :icon="Plus" style="font-size: 15px; padding: 6px 8px" @click="openCreate(row.id)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button type="danger" text bg :icon="Delete" style="font-size: 15px; padding: 6px 8px" @click="handleDelete(row)" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogOpen" :title="formData.id ? '编辑菜单' : '新增菜单'" width="600px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px" v-loading="formLoading">
|
||||
<el-form-item label="上级菜单" prop="parentId">
|
||||
<el-tree-select
|
||||
v-model="formData.parentId"
|
||||
:data="menuOptions"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="formData.type">
|
||||
<el-radio value="D">目录</el-radio>
|
||||
<el-radio value="M">菜单</el-radio>
|
||||
<el-radio value="B">按钮</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单名称" prop="name">
|
||||
<el-input v-model="formData.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路由路径" prop="path" v-if="formData.type !== 'B'">
|
||||
<el-input v-model="formData.path" placeholder="例如:/system/user" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组件路径" prop="component" v-if="formData.type === 'M'">
|
||||
<el-input v-model="formData.component" placeholder="例如:admin/system/User" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限标识" prop="perms" v-if="formData.type !== 'D'">
|
||||
<el-input v-model="formData.perms" placeholder="例如:system:user:view" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标" prop="icon" v-if="formData.type !== 'B'">
|
||||
<el-input v-model="formData.icon" placeholder="Element Plus Icon Name">
|
||||
<template #prefix>
|
||||
<component :is="formData.icon" v-if="formData.icon" style="width: 16px; height: 16px" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<!-- <el-col :span="12">
|
||||
<el-form-item label="显示状态" prop="visible" v-if="formData.type !== 'B'">
|
||||
<el-radio-group v-model="formData.visible">
|
||||
<el-radio :value="1">显示</el-radio>
|
||||
<el-radio :value="0">隐藏</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col> -->
|
||||
</el-row>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="formLoading" @click="saveMenu">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, EditPen, Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createMenu,
|
||||
deleteMenu,
|
||||
fetchMenuTree,
|
||||
updateMenu,
|
||||
} from '../../../api/system'
|
||||
|
||||
import { useMenuStore } from '../../../stores/menu'
|
||||
|
||||
const menuTree = ref([])
|
||||
const loading = ref(false)
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
// The backend's /tree endpoint returns hierarchical data, which is perfect for el-table tree-props.
|
||||
// If we wanted pagination, we would use pageMenus(), but for menus, a full tree is usually better
|
||||
// to visualize structure. Since the requirement mentioned "page query" for menu management,
|
||||
// we can either switch to flat table with pagination or keep the tree view (which is standard for menus).
|
||||
// Given the UI is "Menu Management", a Tree Table is the best UX.
|
||||
// However, the backend `MenuController.page()` returns a flat list (or tree? checking logic).
|
||||
// Wait, the backend implementation of `pageMenus` returns `IPage<SysMenu>`, which is a flat list of menus sorted by sort.
|
||||
// But for a hierarchical display, we need a tree.
|
||||
// Let's use `fetchMenuTree` (which is already implemented) for the main view to support parent-child relationships clearly.
|
||||
// If the user *strictly* wants pagination, we can switch, but tree view is superior for menus.
|
||||
// Let's stick to Tree Table using `fetchMenuTree` as it's more functional for this use case.
|
||||
menuTree.value = await fetchMenuTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
const dialogOpen = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
const formData = reactive({
|
||||
id: undefined,
|
||||
parentId: 0,
|
||||
type: 'M',
|
||||
name: '',
|
||||
path: '',
|
||||
component: '',
|
||||
perms: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
visible: 1,
|
||||
status: 1,
|
||||
})
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
})
|
||||
|
||||
// We need a flat list or tree for the "Parent Menu" selector.
|
||||
// We can reuse `menuTree` for the TreeSelect.
|
||||
// We should insert a "Root" node option.
|
||||
const menuOptions = ref([])
|
||||
|
||||
function updateMenuOptions() {
|
||||
const root = { id: 0, name: '主类目', children: [] }
|
||||
// Deep copy menuTree to avoid modifying the view
|
||||
// And maybe disable self and children to prevent cycles (if editing)
|
||||
menuOptions.value = [root, ...menuTree.value]
|
||||
}
|
||||
|
||||
function openCreate(parentId = 0) {
|
||||
formData.id = undefined
|
||||
formData.parentId = parentId
|
||||
formData.type = 'M'
|
||||
formData.name = ''
|
||||
formData.path = ''
|
||||
formData.component = ''
|
||||
formData.perms = ''
|
||||
formData.icon = ''
|
||||
formData.sort = 0
|
||||
formData.visible = 1
|
||||
formData.status = 1
|
||||
updateMenuOptions()
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(formData, row)
|
||||
if (formData.parentId === null || formData.parentId === undefined) {
|
||||
formData.parentId = 0
|
||||
}
|
||||
updateMenuOptions()
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function saveMenu() {
|
||||
if (formRef.value) {
|
||||
await formRef.value.validate()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (formData.id) {
|
||||
await updateMenu(formData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createMenu(formData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
load()
|
||||
// Refresh the sidebar menu store as well
|
||||
menuStore.routesLoaded = false
|
||||
await menuStore.fetchMenus()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm(`确认删除菜单 "${row.name}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteMenu(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
// Refresh the sidebar menu store as well
|
||||
menuStore.routesLoaded = false
|
||||
await menuStore.fetchMenus()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
246
hertz_springboot_ui/src/views/admin/system/Role.vue
Normal file
246
hertz_springboot_ui/src/views/admin/system/Role.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>角色管理</template>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||||
<el-input v-model="query.keyword" placeholder="角色名称/标识" style="max-width: 260px" clearable />
|
||||
<el-button type="primary" @click="() => ((query.page = 1), load())">查询</el-button>
|
||||
<el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增角色</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="roles" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="roleKey" label="角色标识" />
|
||||
<el-table-column prop="roleName" label="角色名称" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center">
|
||||
<el-tooltip content="编辑" placement="top">
|
||||
<el-button type="primary" text bg :icon="EditPen" style="font-size: 15px; padding: 6px 8px" @click="openEdit(row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="分配权限" placement="top">
|
||||
<el-button type="primary" text bg :icon="Setting" style="font-size: 15px; padding: 6px 8px" @click="openPermissions(row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button type="danger" text bg :icon="Delete" style="font-size: 15px; padding: 6px 8px" @click="handleDelete(row)" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 12px">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="query.size"
|
||||
:current-page="query.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Role Dialog -->
|
||||
<el-dialog v-model="roleDialogOpen" :title="roleData.id ? '编辑角色' : '新增角色'" width="500px">
|
||||
<el-form ref="roleFormRef" :model="roleData" :rules="roleRules" label-width="80px" v-loading="roleLoading">
|
||||
<el-form-item label="角色标识" prop="roleKey">
|
||||
<el-input v-model="roleData.roleKey" :disabled="!!roleData.id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="roleName">
|
||||
<el-input v-model="roleData.roleName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="roleData.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="roleDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="roleLoading" @click="saveRole">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Permission Dialog -->
|
||||
<el-dialog v-model="permDialogOpen" title="分配权限" width="500px">
|
||||
<div v-loading="permLoading" style="height: 400px; overflow-y: auto">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="menuTree"
|
||||
show-checkbox
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="permDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permLoading" @click="savePermissions">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, EditPen, Setting } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
fetchMenuTree,
|
||||
fetchRoleMenuIds,
|
||||
pageRoles,
|
||||
updateRole,
|
||||
updateRoleMenus,
|
||||
} from '../../../api/system'
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const roles = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const page = await pageRoles(query)
|
||||
roles.value = page.records
|
||||
total.value = page.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p) {
|
||||
query.page = p
|
||||
load()
|
||||
}
|
||||
|
||||
// Role Form
|
||||
const roleDialogOpen = ref(false)
|
||||
const roleLoading = ref(false)
|
||||
const roleFormRef = ref(null)
|
||||
const roleData = reactive({
|
||||
id: undefined,
|
||||
roleKey: '',
|
||||
roleName: '',
|
||||
status: 1,
|
||||
})
|
||||
const roleRules = {
|
||||
roleKey: [{ required: true, message: '请输入角色标识', trigger: 'blur' }],
|
||||
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
roleData.id = undefined
|
||||
roleData.roleKey = ''
|
||||
roleData.roleName = ''
|
||||
roleData.status = 1
|
||||
roleDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(roleData, row)
|
||||
roleDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
if (!roleFormRef.value) return
|
||||
await roleFormRef.value.validate()
|
||||
roleLoading.value = true
|
||||
try {
|
||||
if (roleData.id) {
|
||||
await updateRole(roleData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createRole(roleData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
roleDialogOpen.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
roleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm(`确认删除角色 "${row.roleName}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteRole(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Permissions
|
||||
const permDialogOpen = ref(false)
|
||||
const permLoading = ref(false)
|
||||
const menuTree = ref([])
|
||||
const currentRole = ref(null)
|
||||
const treeRef = ref(null)
|
||||
|
||||
async function openPermissions(row) {
|
||||
currentRole.value = row
|
||||
permDialogOpen.value = true
|
||||
permLoading.value = true
|
||||
try {
|
||||
const [menus, checkedKeys] = await Promise.all([fetchMenuTree(), fetchRoleMenuIds(row.id)])
|
||||
// fetchMenuTree returns a tree.
|
||||
// However, if we're not admin, it might return partial tree.
|
||||
// The requirement says "admin has all menus".
|
||||
menuTree.value = menus
|
||||
// We need to wait for next tick or manually set checked keys
|
||||
// But tree might render async.
|
||||
// Element Plus tree setCheckedKeys works better after data is set.
|
||||
setTimeout(() => {
|
||||
treeRef.value?.setCheckedKeys(checkedKeys, false)
|
||||
}, 0)
|
||||
} finally {
|
||||
permLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePermissions() {
|
||||
if (!currentRole.value) return
|
||||
permLoading.value = true
|
||||
try {
|
||||
// We need both checked nodes and half-checked nodes if the backend logic requires it.
|
||||
// Typically backend expects all menu IDs needed to reconstruct the permission set.
|
||||
// Element Plus: getCheckedKeys() returns only checked leaf nodes by default if we don't include half-checked.
|
||||
// But usually for menu permissions we want all checked + half-checked (parent nodes).
|
||||
const checked = treeRef.value.getCheckedKeys()
|
||||
const halfChecked = treeRef.value.getHalfCheckedKeys()
|
||||
const allIds = [...checked, ...halfChecked]
|
||||
await updateRoleMenus(currentRole.value.id, allIds)
|
||||
ElMessage.success('权限保存成功')
|
||||
permDialogOpen.value = false
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
permLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
347
hertz_springboot_ui/src/views/admin/system/User.vue
Normal file
347
hertz_springboot_ui/src/views/admin/system/User.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<template #header>用户管理</template>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px">
|
||||
<el-input v-model="query.keyword" placeholder="用户名/昵称/手机号/邮箱" style="max-width: 260px" clearable />
|
||||
<el-button type="primary" @click="() => ((query.page = 1), loadUsers())">查询</el-button>
|
||||
<el-button @click="openCreate" style="border: 1px solid var(--ios-primary); color: var(--ios-primary);">新增用户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="users" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<el-avatar shape="square" :size="40" :src="apiBase + row.avatarPath" v-if="row.avatarPath" />
|
||||
<el-avatar shape="square" :size="40" v-else>{{ row.nickname?.charAt(0) }}</el-avatar>
|
||||
<div style="line-height: 1.2">
|
||||
<div style="font-weight: 600">{{ row.username }}</div>
|
||||
<div style="font-size: 12px; color: var(--el-text-color-secondary)">{{ row.email || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" />
|
||||
<el-table-column label="角色">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="r in row.roles" :key="r" size="small" style="margin-right: 4px">{{ r }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column label="性别" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.gender === 1">男</span>
|
||||
<span v-else-if="row.gender === 2">女</span>
|
||||
<span v-else>未知</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center">
|
||||
<el-tooltip content="编辑" placement="top">
|
||||
<el-button type="primary" text bg :icon="EditPen" style="font-size: 15px; padding: 6px 8px" @click="openEdit(row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="分配角色" placement="top">
|
||||
<el-button type="primary" text bg :icon="UserFilled" style="font-size: 15px; padding: 6px 8px" @click="openAssignRoles(row)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button type="danger" text bg :icon="Delete" style="font-size: 15px; padding: 6px 8px" @click="handleDelete(row)" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 12px">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="query.size"
|
||||
:current-page="query.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="roleDialogOpen" title="分配角色" width="520px">
|
||||
<el-form v-loading="roleLoading" label-width="90px">
|
||||
<el-form-item label="用户">
|
||||
<span>{{ currentUser?.username }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="selectedRoleIds" multiple filterable style="width: 100%">
|
||||
<el-option v-for="r in allRoles" :key="r.id" :label="`${r.roleName}(${r.roleKey})`" :value="r.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="roleDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="roleLoading" @click="saveRoles">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="formDialogOpen" :title="formData.id ? '编辑用户' : '新增用户'" width="520px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px" v-loading="formLoading">
|
||||
<el-form-item label="头像">
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="handleAvatarUpload"
|
||||
style="border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; overflow: hidden; display: inline-block;"
|
||||
>
|
||||
<img v-if="formData.avatarPath" :src="apiBase + formData.avatarPath" class="avatar" />
|
||||
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="formData.username" :disabled="!!formData.id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="formData.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="formData.password" type="password" placeholder="不修改请留空" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="formData.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="formData.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="formData.gender">
|
||||
<el-radio :value="0">未知</el-radio>
|
||||
<el-radio :value="1">男</el-radio>
|
||||
<el-radio :value="2">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="formLoading" @click="saveUser">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, EditPen, Plus, UserFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
fetchRoles,
|
||||
fetchUserRoleIds,
|
||||
fetchUsers,
|
||||
updateUser,
|
||||
updateUserRoles,
|
||||
} from '../../../api/system'
|
||||
import { uploadFile } from '../../../api/common'
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const page = await fetchUsers(query)
|
||||
users.value = page.records
|
||||
total.value = page.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formDialogOpen = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
const formData = reactive({
|
||||
id: undefined,
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
avatarPath: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
gender: 0,
|
||||
status: 1,
|
||||
})
|
||||
const rules = computed(() => {
|
||||
const r = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||
}
|
||||
if (!formData.id) {
|
||||
r.password = [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
formData.id = undefined
|
||||
formData.username = ''
|
||||
formData.password = ''
|
||||
formData.nickname = ''
|
||||
formData.avatarPath = ''
|
||||
formData.phone = ''
|
||||
formData.email = ''
|
||||
formData.gender = 0
|
||||
formData.status = 1
|
||||
formDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
Object.assign(formData, row)
|
||||
formData.password = '' // Don't show password
|
||||
formDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
formLoading.value = true
|
||||
try {
|
||||
if (formData.id) {
|
||||
await updateUser(formData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createUser(formData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
formDialogOpen.value = false
|
||||
loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm(`确认删除用户 "${row.username}" 吗?`, '提示', {
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(options) {
|
||||
try {
|
||||
const res = await uploadFile(options.file)
|
||||
// The backend returns { url: '...', thumbUrl: '...' }
|
||||
// We store the relative path or full URL depending on requirement.
|
||||
// Here we assume the backend returns a relative path like 'avatar/...'
|
||||
// and we prepend the base URL when displaying.
|
||||
// But for simplicity, let's just save what the backend returns.
|
||||
formData.avatarPath = res.url
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
const roleDialogOpen = ref(false)
|
||||
const roleLoading = ref(false)
|
||||
const allRoles = ref([])
|
||||
const selectedRoleIds = ref([])
|
||||
const currentUser = ref(null)
|
||||
|
||||
async function openAssignRoles(u) {
|
||||
currentUser.value = u
|
||||
roleDialogOpen.value = true
|
||||
roleLoading.value = true
|
||||
try {
|
||||
const [roles, roleIds] = await Promise.all([fetchRoles(), fetchUserRoleIds(u.id)])
|
||||
allRoles.value = roles
|
||||
selectedRoleIds.value = roleIds
|
||||
} finally {
|
||||
roleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRoles() {
|
||||
if (!currentUser.value) return
|
||||
roleLoading.value = true
|
||||
try {
|
||||
await updateUserRoles(currentUser.value.id, selectedRoleIds.value)
|
||||
ElMessage.success('保存成功')
|
||||
roleDialogOpen.value = false
|
||||
loadUsers()
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||
} finally {
|
||||
roleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p) {
|
||||
query.page = p
|
||||
loadUsers()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-uploader .el-upload {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
}
|
||||
|
||||
.avatar-uploader .el-upload:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
8
hertz_springboot_ui/src/views/portal/About.vue
Normal file
8
hertz_springboot_ui/src/views/portal/About.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>关于</template>
|
||||
<div>Spring Boot 3 + Vue 3 + Element Plus + MyBatis-Plus + RBAC 动态菜单。</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
88
hertz_springboot_ui/src/views/portal/Home.vue
Normal file
88
hertz_springboot_ui/src/views/portal/Home.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<div class="hero-section glass">
|
||||
<h1 class="hero-title">Hertz Admin</h1>
|
||||
<p class="hero-subtitle">基于 Spring Boot 3 与 Vue 3 的现代化权限管理系统</p>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" size="large" @click="router.push('/login')">立即开始</el-button>
|
||||
<el-button size="large" @click="router.push('/portal/about')">了解更多</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<el-card class="feature-card glass">
|
||||
<h3>RBAC 权限</h3>
|
||||
<p>基于角色的访问控制,细粒度的权限管理。</p>
|
||||
</el-card>
|
||||
<el-card class="feature-card glass">
|
||||
<h3>动态菜单</h3>
|
||||
<p>根据用户角色动态生成侧边栏菜单。</p>
|
||||
</el-card>
|
||||
<el-card class="feature-card glass">
|
||||
<h3>现代化 UI</h3>
|
||||
<p>采用 Element Plus 与 iOS 风格设计。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(240,249,255,0.8) 100%);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 16px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--ios-primary);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
13
hertz_springboot_ui/vite.config.js
Normal file
13
hertz_springboot_ui/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
||||
125
数据库说明文档.md
Normal file
125
数据库说明文档.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Hertz 权限管理系统数据库说明文档
|
||||
|
||||
## 1. 数据库概述
|
||||
|
||||
- **数据库名称**: `hertz_springboot`
|
||||
- **数据库类型**: MySQL
|
||||
- **字符集**: utf8mb4
|
||||
- **排序规则**: utf8mb4_general_ci (推荐)
|
||||
|
||||
## 2. ER 图设计概要
|
||||
|
||||
系统主要包含 5 张表,采用标准的 RBAC(用户-角色-权限)设计模型:
|
||||
|
||||
- `sys_user`: 用户表
|
||||
- `sys_role`: 角色表
|
||||
- `sys_menu`: 菜单/权限表
|
||||
- `sys_user_role`: 用户-角色关联表
|
||||
- `sys_role_menu`: 角色-菜单关联表
|
||||
|
||||
## 3. 表结构详解
|
||||
|
||||
### 3.1 系统用户表 (sys_user)
|
||||
|
||||
存储系统的登录用户信息。
|
||||
|
||||
| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID |
|
||||
| username | varchar | 50 | NO | | 用户名 (唯一) |
|
||||
| password | varchar | 100 | NO | | 加密密码 (BCrypt) |
|
||||
| nickname | varchar | 50 | NO | | 用户昵称 |
|
||||
| avatar_path | varchar | 255 | YES | NULL | 头像路径 |
|
||||
| phone | varchar | 20 | YES | NULL | 手机号 |
|
||||
| email | varchar | 100 | YES | NULL | 邮箱 |
|
||||
| gender | tinyint | 1 | YES | 0 | 0-未知 1-男 2-女 |
|
||||
| status | tinyint | 4 | YES | 1 | 0-禁用 1-启用 |
|
||||
| created_at | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | datetime | | YES | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
### 3.2 系统角色表 (sys_role)
|
||||
|
||||
存储系统的角色信息。
|
||||
|
||||
| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID |
|
||||
| role_key | varchar | 50 | NO | | 角色标识 (唯一,如 ADMIN) |
|
||||
| role_name | varchar | 50 | NO | | 角色名称 (如 管理员) |
|
||||
| status | tinyint | 4 | YES | 1 | 0-禁用 1-启用 |
|
||||
| created_at | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | datetime | | YES | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
### 3.3 系统菜单表 (sys_menu)
|
||||
|
||||
存储菜单和按钮权限信息,支持树形结构。
|
||||
|
||||
| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID |
|
||||
| parent_id | bigint | 20 | YES | 0 | 父菜单 ID (0为顶级) |
|
||||
| type | varchar | 10 | NO | | 菜单类型: D-目录 M-菜单 B-按钮 |
|
||||
| name | varchar | 50 | NO | | 菜单名称/按钮名称 |
|
||||
| path | varchar | 200 | YES | NULL | 路由路径 (前端路由) |
|
||||
| component | varchar | 200 | YES | NULL | 组件路径 (Vue组件) |
|
||||
| perms | varchar | 100 | YES | NULL | 权限标识 (如 system:user:view) |
|
||||
| icon | varchar | 100 | YES | NULL | 菜单图标 |
|
||||
| sort | int | 11 | YES | 0 | 排序 (数值越小越靠前) |
|
||||
| visible | tinyint | 4 | YES | 1 | 0-隐藏 1-显示 |
|
||||
| status | tinyint | 4 | YES | 1 | 0-禁用 1-启用 |
|
||||
| created_at | datetime | | YES | CURRENT_TIMESTAMP | 创建时间 |
|
||||
| updated_at | datetime | | YES | CURRENT_TIMESTAMP | 更新时间 |
|
||||
|
||||
### 3.4 用户角色关联表 (sys_user_role)
|
||||
|
||||
用户与角色的多对多关系表。
|
||||
|
||||
| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID |
|
||||
| user_id | bigint | 20 | NO | | 用户 ID |
|
||||
| role_id | bigint | 20 | NO | | 角色 ID |
|
||||
|
||||
### 3.5 角色菜单关联表 (sys_role_menu)
|
||||
|
||||
角色与菜单(权限)的多对多关系表。
|
||||
|
||||
| 字段名 | 类型 | 长度 | 允许空 | 默认值 | 说明 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **id** | bigint | 20 | NO | AUTO_INCREMENT | 主键 ID |
|
||||
| role_id | bigint | 20 | NO | | 角色 ID |
|
||||
| menu_id | bigint | 20 | NO | | 菜单 ID |
|
||||
|
||||
## 4. 初始数据说明
|
||||
|
||||
系统初始化脚本 (`schema.sql`) 会预置以下数据:
|
||||
|
||||
1. **初始角色**:
|
||||
- `ADMIN`: 超级管理员,拥有所有权限。
|
||||
|
||||
2. **初始菜单结构**:
|
||||
- 仪表盘
|
||||
- 系统管理
|
||||
- 用户管理
|
||||
- 角色管理
|
||||
- 菜单管理
|
||||
|
||||
3. **初始用户**:
|
||||
- `hertz`: 管理员账号,已绑定 ADMIN 角色。
|
||||
- `demo`: 演示账号。
|
||||
|
||||
## 5. 数据字典与枚举
|
||||
|
||||
- **用户/角色/菜单状态 (status)**:
|
||||
- `1`: 启用 (Normal)
|
||||
- `0`: 禁用 (Disabled)
|
||||
|
||||
- **菜单类型 (type)**:
|
||||
- `D`: 目录 (Directory) - 不对应具体页面,仅用于分组
|
||||
- `M`: 菜单 (Menu) - 对应具体的前端页面
|
||||
- `B`: 按钮 (Button) - 页面内的功能按钮,用于权限控制
|
||||
|
||||
- **性别 (gender)**:
|
||||
- `0`: 未知
|
||||
- `1`: 男
|
||||
- `2`: 女
|
||||
108
项目说明文档.md
Normal file
108
项目说明文档.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Hertz 权限管理系统项目说明文档
|
||||
|
||||
## 1. 项目简介
|
||||
|
||||
Hertz 权限管理系统是一个基于前后端分离架构的轻量级权限管理平台。系统集成了用户管理、角色管理、菜单管理等核心功能,采用 RBAC(Role-Based Access Control)模型实现细粒度的权限控制。
|
||||
|
||||
### 1.1 项目结构
|
||||
|
||||
项目采用典型的多模块(或目录分离)结构:
|
||||
|
||||
- **hertz_springboot**: 后端工程,基于 Spring Boot 3 + MyBatis-Plus。
|
||||
- **hertz_springboot_ui**: 前端工程,基于 Vue 3 + Vite + Element Plus。
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
### 2.1 后端技术栈 (hertz_springboot)
|
||||
|
||||
- **核心框架**: Spring Boot 3.4.1
|
||||
- **持久层框架**: MyBatis-Plus 3.5.8
|
||||
- **安全框架**: Spring Security + JJWT 0.12.6 (实现无状态 JWT 认证)
|
||||
- **数据库连接池**: HikariCP (Spring Boot 默认)
|
||||
- **数据库驱动**: MySQL Connector/J
|
||||
- **工具库**: Lombok
|
||||
- **运行环境**: Java 21
|
||||
|
||||
### 2.2 前端技术栈 (hertz_springboot_ui)
|
||||
|
||||
- **核心框架**: Vue 3.5.24
|
||||
- **构建工具**: Vite 7.2.4
|
||||
- **UI 组件库**: Element Plus 2.13.1
|
||||
- **状态管理**: Pinia 3.0.4
|
||||
- **路由管理**: Vue Router 4.6.4
|
||||
- **HTTP 客户端**: Axios 1.13.2
|
||||
|
||||
## 3. 功能模块
|
||||
|
||||
1. **认证模块**: 支持用户登录、注册(可选)、JWT Token 颁发与验证。
|
||||
2. **系统管理**:
|
||||
- **用户管理**: 用户的增删改查、分配角色、状态控制。
|
||||
- **角色管理**: 角色的增删改查、分配菜单权限。
|
||||
- **菜单管理**: 动态菜单配置,支持目录、菜单、按钮三种类型,支持权限标识配置。
|
||||
3. **个人中心**: 用户资料修改、密码修改、头像上传。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
### 4.1 环境准备
|
||||
|
||||
- JDK 21+
|
||||
- Node.js 18+
|
||||
- MySQL 8.0+
|
||||
|
||||
### 4.2 后端启动
|
||||
|
||||
1. 进入后端目录:
|
||||
```bash
|
||||
cd hertz_springboot
|
||||
```
|
||||
2. 配置数据库:
|
||||
- 创建数据库 `hertz_springboot`。
|
||||
- 导入 `src/main/resources/schema.sql` 初始化表结构和数据。
|
||||
- 修改 `src/main/resources/application.yml` 中的数据库连接信息(url, username, password)。
|
||||
3. 运行项目:
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
```
|
||||
或者在 IDE 中运行 `HertzApplication.java`。
|
||||
后端服务将启动在 `http://localhost:8080`。
|
||||
|
||||
### 4.3 前端启动
|
||||
|
||||
1. 进入前端目录:
|
||||
```bash
|
||||
cd hertz_springboot_ui
|
||||
```
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. 启动开发服务器:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
前端服务通常启动在 `http://localhost:5173`。
|
||||
|
||||
## 5. 默认账号
|
||||
|
||||
初始化 SQL 脚本中包含以下默认账号(密码均为 `123456`):
|
||||
|
||||
- **管理员**: `hertz`
|
||||
- **普通用户**: `demo`
|
||||
|
||||
## 6. 配置说明
|
||||
|
||||
### 6.1 后端配置 (application.yml)
|
||||
|
||||
- **Server Port**: 8080
|
||||
- **File Upload**:
|
||||
- 最大文件大小: 2MB
|
||||
- 最大请求大小: 10MB
|
||||
- 上传根路径: `d:\LocalFile\hertz_springboot\uploads` (请根据实际环境修改)
|
||||
- **JWT**:
|
||||
- 密钥: `app.jwt.secret` (建议在生产环境中修改为强随机字符串)
|
||||
- 过期时间: 86400秒 (24小时)
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
- **文件上传**: 默认配置了本地文件存储路径,请确保该路径存在或有写入权限,或者在 `application.yml` 中修改为合适的路径。
|
||||
- **跨域**: 前端开发环境通常通过 Vite 代理解决跨域问题,生产环境需配置 Nginx 或后端 CORS。
|
||||
Reference in New Issue
Block a user