feat: 打造若依 (RuoYi) 规范的高品质 RBAC 用户权限体系,内置 admin/admin123 超级管理员与测试读者,支持登录注册、个人中心与用户管理
Build and Deploy Blog / deploy (push) Successful in 38s

This commit is contained in:
2026-10-22 17:22:47 +08:00
parent 9ad9923d61
commit 3bee3f5663
17 changed files with 1474 additions and 22 deletions
@@ -0,0 +1,98 @@
package com.example.blog.common;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class JwtUtils {
private static final String SECRET = "XiaoDong-RuoYi-Secret-Token-Key-2026-SuperSecureJWT!@#";
private static final long EXPIRATION_MS = 7L * 24 * 60 * 60 * 1000; // 7 天有效
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 生成 JWT Token
*/
public static String createToken(Long userId, String username, String nickname, String role) {
try {
long now = System.currentTimeMillis();
long exp = now + EXPIRATION_MS;
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
Map<String, Object> payload = new HashMap<>();
payload.put("userId", userId);
payload.put("username", username);
payload.put("nickname", nickname);
payload.put("role", role);
payload.put("iat", now / 1000);
payload.put("exp", exp / 1000);
String encodedHeader = Base64.getUrlEncoder().withoutPadding()
.encodeToString(objectMapper.writeValueAsString(header).getBytes(StandardCharsets.UTF_8));
String encodedPayload = Base64.getUrlEncoder().withoutPadding()
.encodeToString(objectMapper.writeValueAsString(payload).getBytes(StandardCharsets.UTF_8));
String content = encodedHeader + "." + encodedPayload;
String signature = sign(content, SECRET);
return content + "." + signature;
} catch (Exception e) {
throw new RuntimeException("生成 Token 失败", e);
}
}
/**
* 解析并校验 Token,返回 Claims
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> parseToken(String token) {
if (token == null || token.trim().isEmpty()) {
return null;
}
if (token.startsWith("Bearer ")) {
token = token.substring(7).trim();
}
String[] parts = token.split("\\.");
if (parts.length != 3) {
return null;
}
try {
String content = parts[0] + "." + parts[1];
String expectedSignature = sign(content, SECRET);
if (!expectedSignature.equals(parts[2])) {
return null; // 签名不匹配
}
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
Map<String, Object> claims = objectMapper.readValue(payloadBytes, Map.class);
// 检查过期时间
Number exp = (Number) claims.get("exp");
if (exp != null && exp.longValue() * 1000 < System.currentTimeMillis()) {
return null; // Token 已过期
}
return claims;
} catch (Exception e) {
return null;
}
}
private static String sign(String data, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(secretKey);
byte[] rawHmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(rawHmac);
}
}
@@ -0,0 +1,42 @@
package com.example.blog.common;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
public class SecurityUtils {
/**
* 生成随机盐值
*/
public static String generateSalt() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
/**
* SHA-256 + 盐值加密密码 (若依风格安全加盐散列)
*/
public static String encryptPassword(String password, String salt) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
String salted = password + "{" + salt + "}";
byte[] bytes = md.digest(salted.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 算法未找到", e);
}
}
/**
* 校验密码
*/
public static boolean matchesPassword(String rawPassword, String encryptedPassword, String salt) {
String hash = encryptPassword(rawPassword, salt);
return hash.equalsIgnoreCase(encryptedPassword);
}
}
@@ -1,9 +1,12 @@
package com.example.blog.config; package com.example.blog.config;
import com.example.blog.common.SecurityUtils;
import com.example.blog.entity.Category; import com.example.blog.entity.Category;
import com.example.blog.entity.Post; import com.example.blog.entity.Post;
import com.example.blog.entity.SysUser;
import com.example.blog.repository.CategoryRepository; import com.example.blog.repository.CategoryRepository;
import com.example.blog.repository.PostRepository; import com.example.blog.repository.PostRepository;
import com.example.blog.repository.SysUserRepository;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -15,14 +18,21 @@ public class DataInitializer implements CommandLineRunner {
private final PostRepository postRepository; private final PostRepository postRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final SysUserRepository sysUserRepository;
public DataInitializer(PostRepository postRepository, CategoryRepository categoryRepository) { public DataInitializer(PostRepository postRepository,
CategoryRepository categoryRepository,
SysUserRepository sysUserRepository) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.sysUserRepository = sysUserRepository;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
// 0. 初始化系统内置若依风格用户 (admin / admin123 与测试读者)
initSystemUsers();
// 1. 初始化分类 // 1. 初始化分类
if (categoryRepository.count() == 0) { if (categoryRepository.count() == 0) {
List<Category> categories = Arrays.asList( List<Category> categories = Arrays.asList(
@@ -113,4 +123,36 @@ public class DataInitializer implements CommandLineRunner {
postRepository.saveAll(existingPosts); postRepository.saveAll(existingPosts);
} }
} }
private void initSystemUsers() {
// 1. 初始化内置超级管理员 admin / admin123
if (!sysUserRepository.existsByUsername("admin")) {
SysUser admin = new SysUser();
admin.setUsername("admin");
String salt = SecurityUtils.generateSalt();
admin.setSalt(salt);
admin.setPassword(SecurityUtils.encryptPassword("admin123", salt));
admin.setNickname("小东 (管理员)");
admin.setEmail("j2570852518@gmail.com");
admin.setAvatar("👨‍💻");
admin.setRole("ADMIN");
admin.setStatus("0");
sysUserRepository.save(admin);
}
// 2. 初始化内置测试访客读者 ry_user / user123
if (!sysUserRepository.existsByUsername("ry_user")) {
SysUser visitor = new SysUser();
visitor.setUsername("ry_user");
String salt = SecurityUtils.generateSalt();
visitor.setSalt(salt);
visitor.setPassword(SecurityUtils.encryptPassword("user123", salt));
visitor.setNickname("若依读者");
visitor.setEmail("visitor@example.com");
visitor.setAvatar("🚀");
visitor.setRole("USER");
visitor.setStatus("0");
sysUserRepository.save(visitor);
}
}
} }
@@ -0,0 +1,59 @@
package com.example.blog.controller;
import com.example.blog.common.JwtUtils;
import com.example.blog.common.Result;
import com.example.blog.dto.LoginBody;
import com.example.blog.dto.RegisterBody;
import com.example.blog.service.SysUserService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "*")
public class AuthController {
private final SysUserService sysUserService;
public AuthController(SysUserService sysUserService) {
this.sysUserService = sysUserService;
}
@PostMapping("/login")
public Result<Map<String, Object>> login(@RequestBody LoginBody loginBody, HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return Result.success(sysUserService.login(loginBody, ip));
}
@PostMapping("/register")
public Result<Void> register(@RequestBody RegisterBody registerBody) {
sysUserService.register(registerBody);
return Result.success();
}
@GetMapping("/info")
public Result<Map<String, Object>> getInfo(@RequestHeader(value = "Authorization", required = false) String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
return Result.error(401, "请先登录");
}
Map<String, Object> claims = JwtUtils.parseToken(authHeader);
if (claims == null) {
return Result.error(401, "登录凭证无效或已过期,请重新登录");
}
Number userIdNum = (Number) claims.get("userId");
if (userIdNum == null) {
return Result.error(401, "用户凭证异常");
}
return Result.success(sysUserService.getUserInfo(userIdNum.longValue()));
}
@PostMapping("/logout")
public Result<Void> logout() {
return Result.success();
}
}
@@ -1,11 +1,13 @@
package com.example.blog.controller; package com.example.blog.controller;
import com.example.blog.common.JwtUtils;
import com.example.blog.common.Result; import com.example.blog.common.Result;
import com.example.blog.entity.Comment; import com.example.blog.entity.Comment;
import com.example.blog.service.CommentService; import com.example.blog.service.CommentService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/posts/{postId}/comments") @RequestMapping("/api/posts/{postId}/comments")
@@ -24,7 +26,24 @@ public class CommentController {
} }
@PostMapping @PostMapping
public Result<Comment> addComment(@PathVariable Long postId, @RequestBody Comment comment) { public Result<Comment> addComment(@PathVariable Long postId,
@RequestBody Comment comment,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
if (authHeader != null && !authHeader.isEmpty()) {
Map<String, Object> claims = JwtUtils.parseToken(authHeader);
if (claims != null) {
String nickname = (String) claims.get("nickname");
String role = (String) claims.get("role");
if (nickname != null && !nickname.trim().isEmpty()) {
comment.setAuthor(nickname);
}
if (role != null) {
comment.setRole(role);
}
comment.setAvatar("ADMIN".equalsIgnoreCase(role) ? "👑" : "👨‍💻");
}
}
return Result.success(commentService.addComment(postId, comment)); return Result.success(commentService.addComment(postId, comment));
} }
} }
@@ -1,11 +1,13 @@
package com.example.blog.controller; package com.example.blog.controller;
import com.example.blog.common.JwtUtils;
import com.example.blog.common.Result; import com.example.blog.common.Result;
import com.example.blog.entity.Post; import com.example.blog.entity.Post;
import com.example.blog.service.PostService; import com.example.blog.service.PostService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/posts") @RequestMapping("/api/posts")
@@ -18,6 +20,20 @@ public class PostController {
this.postService = postService; this.postService = postService;
} }
private void checkAdmin(String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
throw new RuntimeException("无权操作:请使用管理员账号登录");
}
Map<String, Object> claims = JwtUtils.parseToken(authHeader);
if (claims == null) {
throw new RuntimeException("登录已失效,请重新登录");
}
String role = (String) claims.get("role");
if (!"ADMIN".equalsIgnoreCase(role)) {
throw new RuntimeException("无权操作:此功能仅限超级管理员使用");
}
}
@GetMapping @GetMapping
public Result<List<Post>> listPosts( public Result<List<Post>> listPosts(
@RequestParam(required = false) String category, @RequestParam(required = false) String category,
@@ -31,17 +47,31 @@ public class PostController {
} }
@PostMapping @PostMapping
public Result<Post> createPost(@RequestBody Post post) { public Result<Post> createPost(@RequestBody Post post,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
checkAdmin(authHeader);
Map<String, Object> claims = JwtUtils.parseToken(authHeader);
if (claims != null) {
String nickname = (String) claims.get("nickname");
if (nickname != null && !nickname.trim().isEmpty()) {
post.setAuthor(nickname);
}
}
return Result.success(postService.createPost(post)); return Result.success(postService.createPost(post));
} }
@PutMapping("/{id}") @PutMapping("/{id}")
public Result<Post> updatePost(@PathVariable Long id, @RequestBody Post post) { public Result<Post> updatePost(@PathVariable Long id,
@RequestBody Post post,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
checkAdmin(authHeader);
return Result.success(postService.updatePost(id, post)); return Result.success(postService.updatePost(id, post));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Result<Void> deletePost(@PathVariable Long id) { public Result<Void> deletePost(@PathVariable Long id,
@RequestHeader(value = "Authorization", required = false) String authHeader) {
checkAdmin(authHeader);
postService.deletePost(id); postService.deletePost(id);
return Result.success(); return Result.success();
} }
@@ -51,3 +81,4 @@ public class PostController {
return Result.success(postService.likePost(id)); return Result.success(postService.likePost(id));
} }
} }
@@ -0,0 +1,91 @@
package com.example.blog.controller;
import com.example.blog.common.JwtUtils;
import com.example.blog.common.Result;
import com.example.blog.dto.UpdateProfileBody;
import com.example.blog.dto.UpdatePwdBody;
import com.example.blog.entity.SysUser;
import com.example.blog.service.SysUserService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "*")
public class SysUserController {
private final SysUserService sysUserService;
public SysUserController(SysUserService sysUserService) {
this.sysUserService = sysUserService;
}
private Map<String, Object> checkAuth(String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
throw new RuntimeException("请先登录后操作");
}
Map<String, Object> claims = JwtUtils.parseToken(authHeader);
if (claims == null) {
throw new RuntimeException("登录凭证已失效,请重新登录");
}
return claims;
}
private void checkAdmin(String authHeader) {
Map<String, Object> claims = checkAuth(authHeader);
String role = (String) claims.get("role");
if (!"ADMIN".equalsIgnoreCase(role)) {
throw new RuntimeException("无权操作,此功能仅限超级管理员使用");
}
}
@GetMapping
public Result<List<SysUser>> listUsers(@RequestHeader(value = "Authorization", required = false) String authHeader) {
checkAdmin(authHeader);
return Result.success(sysUserService.listUsers());
}
@PostMapping
public Result<SysUser> createUser(@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody SysUser user) {
checkAdmin(authHeader);
return Result.success(sysUserService.createUser(user));
}
@PutMapping("/{id}/status")
public Result<Void> changeStatus(@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable Long id,
@RequestParam String status) {
checkAdmin(authHeader);
sysUserService.changeStatus(id, status);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> deleteUser(@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable Long id) {
checkAdmin(authHeader);
sysUserService.deleteUser(id);
return Result.success();
}
@PutMapping("/profile")
public Result<Void> updateProfile(@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody UpdateProfileBody body) {
Map<String, Object> claims = checkAuth(authHeader);
Number userId = (Number) claims.get("userId");
sysUserService.updateProfile(userId.longValue(), body);
return Result.success();
}
@PutMapping("/password")
public Result<Void> updatePassword(@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody UpdatePwdBody body) {
Map<String, Object> claims = checkAuth(authHeader);
Number userId = (Number) claims.get("userId");
sysUserService.updatePassword(userId.longValue(), body);
return Result.success();
}
}
@@ -0,0 +1,11 @@
package com.example.blog.dto;
public class LoginBody {
private String username;
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
@@ -0,0 +1,17 @@
package com.example.blog.dto;
public class RegisterBody {
private String username;
private String password;
private String nickname;
private String email;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
@@ -0,0 +1,14 @@
package com.example.blog.dto;
public class UpdateProfileBody {
private String nickname;
private String email;
private String avatar;
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
}
@@ -0,0 +1,11 @@
package com.example.blog.dto;
public class UpdatePwdBody {
private String oldPassword;
private String newPassword;
public String getOldPassword() { return oldPassword; }
public void setOldPassword(String oldPassword) { this.oldPassword = oldPassword; }
public String getNewPassword() { return newPassword; }
public void setNewPassword(String newPassword) { this.newPassword = newPassword; }
}
@@ -17,6 +17,12 @@ public class Comment {
@Column(nullable = false, length = 50) @Column(nullable = false, length = 50)
private String author = "访客"; private String author = "访客";
@Column(length = 255)
private String avatar = "👨‍💻";
@Column(length = 20)
private String role = "USER";
@Column(nullable = false, columnDefinition = "TEXT") @Column(nullable = false, columnDefinition = "TEXT")
private String content; private String content;
@@ -43,6 +49,10 @@ public class Comment {
public void setPostId(Long postId) { this.postId = postId; } public void setPostId(Long postId) { this.postId = postId; }
public String getAuthor() { return author; } public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; } public void setAuthor(String author) { this.author = author; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public String getContent() { return content; } public String getContent() { return content; }
public void setContent(String content) { this.content = content; } public void setContent(String content) { this.content = content; }
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
@@ -0,0 +1,114 @@
package com.example.blog.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "sys_user")
public class SysUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, length = 128)
private String password;
@Column(length = 50)
private String nickname;
@Column(length = 100)
private String email;
@Column(length = 255)
private String avatar;
@Column(nullable = false, length = 20)
private String role = "USER"; // ADMIN, USER
@Column(nullable = false, length = 1)
private String status = "0"; // 0: 正常, 1: 停用
@Column(length = 64)
private String salt;
@Column(name = "login_ip", length = 50)
private String loginIp;
@Column(name = "login_date")
private LocalDateTime loginDate;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
if (createdAt == null) createdAt = LocalDateTime.now();
if (updatedAt == null) updatedAt = LocalDateTime.now();
if (role == null) role = "USER";
if (status == null) status = "0";
if (nickname == null || nickname.trim().isEmpty()) nickname = username;
if (avatar == null) avatar = "👨‍💻";
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
public SysUser() {}
public SysUser(String username, String password, String nickname, String email, String role) {
this.username = username;
this.password = password;
this.nickname = nickname;
this.email = email;
this.role = role;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getSalt() { return salt; }
public void setSalt(String salt) { this.salt = salt; }
public String getLoginIp() { return loginIp; }
public void setLoginIp(String loginIp) { this.loginIp = loginIp; }
public LocalDateTime getLoginDate() { return loginDate; }
public void setLoginDate(LocalDateTime loginDate) { this.loginDate = loginDate; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,13 @@
package com.example.blog.repository;
import com.example.blog.entity.SysUser;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface SysUserRepository extends JpaRepository<SysUser, Long> {
Optional<SysUser> findByUsername(String username);
boolean existsByUsername(String username);
}
@@ -0,0 +1,31 @@
package com.example.blog.service;
import com.example.blog.dto.LoginBody;
import com.example.blog.dto.RegisterBody;
import com.example.blog.dto.UpdateProfileBody;
import com.example.blog.dto.UpdatePwdBody;
import com.example.blog.entity.SysUser;
import java.util.List;
import java.util.Map;
public interface SysUserService {
Map<String, Object> login(LoginBody loginBody, String ip);
void register(RegisterBody registerBody);
Map<String, Object> getUserInfo(Long userId);
List<SysUser> listUsers();
void changeStatus(Long userId, String status);
void updateProfile(Long userId, UpdateProfileBody body);
void updatePassword(Long userId, UpdatePwdBody body);
void deleteUser(Long userId);
SysUser createUser(SysUser user);
}
@@ -0,0 +1,226 @@
package com.example.blog.service.impl;
import com.example.blog.common.JwtUtils;
import com.example.blog.common.SecurityUtils;
import com.example.blog.dto.LoginBody;
import com.example.blog.dto.RegisterBody;
import com.example.blog.dto.UpdateProfileBody;
import com.example.blog.dto.UpdatePwdBody;
import com.example.blog.entity.SysUser;
import com.example.blog.repository.SysUserRepository;
import com.example.blog.service.SysUserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class SysUserServiceImpl implements SysUserService {
private final SysUserRepository sysUserRepository;
public SysUserServiceImpl(SysUserRepository sysUserRepository) {
this.sysUserRepository = sysUserRepository;
}
@Override
@Transactional
public Map<String, Object> login(LoginBody loginBody, String ip) {
if (loginBody.getUsername() == null || loginBody.getUsername().trim().isEmpty()) {
throw new IllegalArgumentException("用户名不能为空");
}
if (loginBody.getPassword() == null || loginBody.getPassword().trim().isEmpty()) {
throw new IllegalArgumentException("密码不能为空");
}
SysUser user = sysUserRepository.findByUsername(loginBody.getUsername().trim())
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
if ("1".equals(user.getStatus())) {
throw new RuntimeException("对不起,该账号已被停用,请联系管理员");
}
if (!SecurityUtils.matchesPassword(loginBody.getPassword(), user.getPassword(), user.getSalt())) {
throw new RuntimeException("用户名或密码错误");
}
// 更新登录信息
user.setLoginIp(ip != null ? ip : "127.0.0.1");
user.setLoginDate(LocalDateTime.now());
sysUserRepository.save(user);
// 生成 RuoYi 风格 Token 与鉴权负载
String token = JwtUtils.createToken(user.getId(), user.getUsername(), user.getNickname(), user.getRole());
Map<String, Object> result = new HashMap<>();
result.put("token", token);
Map<String, Object> safeUser = new HashMap<>();
safeUser.put("id", user.getId());
safeUser.put("username", user.getUsername());
safeUser.put("nickname", user.getNickname());
safeUser.put("email", user.getEmail());
safeUser.put("avatar", user.getAvatar());
safeUser.put("role", user.getRole());
safeUser.put("status", user.getStatus());
safeUser.put("loginDate", user.getLoginDate());
result.put("user", safeUser);
// 角色与权限标识 (若依标准格式)
result.put("roles", Collections.singletonList(user.getRole()));
if ("ADMIN".equalsIgnoreCase(user.getRole())) {
result.put("permissions", Collections.singletonList("*:*:*"));
} else {
result.put("permissions", Arrays.asList("post:view", "post:like", "comment:add"));
}
return result;
}
@Override
@Transactional
public void register(RegisterBody registerBody) {
String username = registerBody.getUsername() != null ? registerBody.getUsername().trim() : "";
String password = registerBody.getPassword() != null ? registerBody.getPassword().trim() : "";
if (username.length() < 3 || username.length() > 20) {
throw new IllegalArgumentException("用户名长度必须在 3 到 20 个字符之间");
}
if (password.length() < 5 || password.length() > 30) {
throw new IllegalArgumentException("密码长度必须在 5 到 30 个字符之间");
}
if (sysUserRepository.existsByUsername(username)) {
throw new RuntimeException("该用户名已被注册,请尝试其他用户名");
}
String salt = SecurityUtils.generateSalt();
String encryptedPassword = SecurityUtils.encryptPassword(password, salt);
SysUser user = new SysUser();
user.setUsername(username);
user.setPassword(encryptedPassword);
user.setSalt(salt);
user.setNickname(registerBody.getNickname() != null && !registerBody.getNickname().trim().isEmpty() ? registerBody.getNickname().trim() : username);
user.setEmail(registerBody.getEmail() != null ? registerBody.getEmail().trim() : "");
user.setAvatar("👨‍💻");
user.setRole("USER");
user.setStatus("0");
sysUserRepository.save(user);
}
@Override
@Transactional(readOnly = true)
public Map<String, Object> getUserInfo(Long userId) {
SysUser user = sysUserRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
Map<String, Object> result = new HashMap<>();
Map<String, Object> safeUser = new HashMap<>();
safeUser.put("id", user.getId());
safeUser.put("username", user.getUsername());
safeUser.put("nickname", user.getNickname());
safeUser.put("email", user.getEmail());
safeUser.put("avatar", user.getAvatar());
safeUser.put("role", user.getRole());
safeUser.put("status", user.getStatus());
safeUser.put("loginDate", user.getLoginDate());
safeUser.put("createdAt", user.getCreatedAt());
result.put("user", safeUser);
result.put("roles", Collections.singletonList(user.getRole()));
if ("ADMIN".equalsIgnoreCase(user.getRole())) {
result.put("permissions", Collections.singletonList("*:*:*"));
} else {
result.put("permissions", Arrays.asList("post:view", "post:like", "comment:add"));
}
return result;
}
@Override
@Transactional(readOnly = true)
public List<SysUser> listUsers() {
List<SysUser> users = sysUserRepository.findAll();
// 清除敏感字段
for (SysUser u : users) {
u.setPassword(null);
u.setSalt(null);
}
return users;
}
@Override
@Transactional
public void changeStatus(Long userId, String status) {
SysUser user = sysUserRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
if ("admin".equalsIgnoreCase(user.getUsername())) {
throw new RuntimeException("不允许停用内置超级管理员账号");
}
user.setStatus("1".equals(status) ? "1" : "0");
sysUserRepository.save(user);
}
@Override
@Transactional
public void updateProfile(Long userId, UpdateProfileBody body) {
SysUser user = sysUserRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
if (body.getNickname() != null && !body.getNickname().trim().isEmpty()) {
user.setNickname(body.getNickname().trim());
}
if (body.getEmail() != null) {
user.setEmail(body.getEmail().trim());
}
if (body.getAvatar() != null && !body.getAvatar().trim().isEmpty()) {
user.setAvatar(body.getAvatar().trim());
}
sysUserRepository.save(user);
}
@Override
@Transactional
public void updatePassword(Long userId, UpdatePwdBody body) {
SysUser user = sysUserRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
if (!SecurityUtils.matchesPassword(body.getOldPassword(), user.getPassword(), user.getSalt())) {
throw new RuntimeException("原密码错误,修改失败");
}
if (body.getNewPassword() == null || body.getNewPassword().length() < 5) {
throw new IllegalArgumentException("新密码长度不能少于 5 位");
}
String newSalt = SecurityUtils.generateSalt();
String newHash = SecurityUtils.encryptPassword(body.getNewPassword(), newSalt);
user.setSalt(newSalt);
user.setPassword(newHash);
sysUserRepository.save(user);
}
@Override
@Transactional
public void deleteUser(Long userId) {
SysUser user = sysUserRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
if ("admin".equalsIgnoreCase(user.getUsername())) {
throw new RuntimeException("不允许删除内置超级管理员账号");
}
sysUserRepository.deleteById(userId);
}
@Override
@Transactional
public SysUser createUser(SysUser user) {
if (sysUserRepository.existsByUsername(user.getUsername())) {
throw new RuntimeException("用户名已存在");
}
String salt = SecurityUtils.generateSalt();
user.setSalt(salt);
user.setPassword(SecurityUtils.encryptPassword(user.getPassword(), salt));
return sysUserRepository.save(user);
}
}
+640 -17
View File
@@ -299,13 +299,69 @@
<span>{{ backendConnected ? 'Spring Boot 3 + MySQL 8.0 在线' : '本地安全模式' }}</span> <span>{{ backendConnected ? 'Spring Boot 3 + MySQL 8.0 在线' : '本地安全模式' }}</span>
</div> </div>
<!-- 写文章按钮 (唤起现代化 Markdown 写作台) --> <!-- 写文章按钮 (仅超级管理员可见) -->
<button @click="openPublishModal" <button v-if="isAdmin" @click="openPublishModal"
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-sm font-semibold bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 text-white shadow-md shadow-sky-600/25 transition-all hover:shadow active:scale-95"> class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-sm font-semibold bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 text-white shadow-md shadow-sky-600/25 transition-all hover:shadow active:scale-95">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
<span class="hidden sm:inline">写新文章</span> <span class="hidden sm:inline">写新文章</span>
</button> </button>
<!-- 若依风格用户系统入口 -->
<div v-if="isLoggedIn" class="relative">
<button @click="showUserDropdown = !showUserDropdown"
class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white dark:bg-slate-900 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border border-slate-200 dark:border-slate-800 shadow-sm">
<span class="text-base">{{ currentUser.avatar || '👨‍💻' }}</span>
<span class="text-xs font-bold text-slate-800 dark:text-slate-200 max-w-[80px] sm:max-w-[120px] truncate">{{ currentUser.nickname }}</span>
<span :class="isAdmin ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border-amber-200' : 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 border-sky-200'"
class="px-2 py-0.5 text-[10px] font-bold rounded-full border">
{{ isAdmin ? '👑 管理员' : '👤 读者' }}
</span>
<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<!-- 下拉菜单抽屉 -->
<div v-show="showUserDropdown" @click.outside="showUserDropdown = false"
class="absolute right-0 mt-2 w-56 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-2xl py-2 z-50">
<div class="px-4 py-2.5 border-b border-slate-100 dark:border-slate-800">
<div class="font-bold text-xs text-slate-800 dark:text-slate-100 flex items-center gap-1.5">
<span>{{ currentUser.avatar || '👨‍💻' }}</span>
<span>{{ currentUser.nickname }}</span>
</div>
<div class="text-[11px] text-slate-400 font-mono">@{{ currentUser.username }}</div>
<div class="text-[10px] text-slate-400 mt-0.5 truncate">{{ currentUser.email || '未绑定邮箱' }}</div>
</div>
<button v-if="isAdmin" @click="showUserDropdown = false; openPublishModal()"
class="w-full text-left px-4 py-2 text-xs text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 flex items-center gap-2">
<span>✍️</span> <span>写新文章</span>
</button>
<button v-if="isAdmin" @click="showUserDropdown = false; openUserMgmtModal()"
class="w-full text-left px-4 py-2 text-xs text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 flex items-center gap-2">
<span>👥</span> <span>用户管理中心 (RuoYi RBAC)</span>
</button>
<button @click="showUserDropdown = false; openProfileModal()"
class="w-full text-left px-4 py-2 text-xs text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 flex items-center gap-2">
<span>👤</span> <span>个人中心设置</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800"></div>
<button @click="showUserDropdown = false; handleLogout()"
class="w-full text-left px-4 py-2 text-xs text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 flex items-center gap-2 font-medium">
<span>🚪</span> <span>退出登录</span>
</button>
</div>
</div>
<!-- 未登录状态展示登录/注册按钮 -->
<button v-else @click="openAuthModal('login')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-sky-50 hover:bg-sky-100 dark:bg-sky-950/60 dark:hover:bg-sky-900/60 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-800 transition-all shadow-sm active:scale-95">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path></svg>
<span>登录 / 注册</span>
</button>
<!-- 深色/浅色模式切换 --> <!-- 深色/浅色模式切换 -->
<button @click="toggleDarkMode" <button @click="toggleDarkMode"
class="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border border-transparent dark:border-slate-800" class="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border border-transparent dark:border-slate-800"
@@ -608,10 +664,25 @@
<!-- 撰写评论卡片 --> <!-- 撰写评论卡片 -->
<div class="bg-slate-50/80 dark:bg-slate-800/40 p-4 sm:p-5 rounded-2xl border border-slate-200/60 dark:border-slate-700/60 mb-6 space-y-3"> <div class="bg-slate-50/80 dark:bg-slate-800/40 p-4 sm:p-5 rounded-2xl border border-slate-200/60 dark:border-slate-700/60 mb-6 space-y-3">
<div class="flex items-center gap-3"> <div class="flex items-center justify-between text-xs">
<input v-model="newComment.author" type="text" placeholder="你的技术昵称 (默认: 访客)" <div v-if="isLoggedIn" class="flex items-center gap-2">
class="px-3.5 py-1.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs w-48 focus:outline-none focus:ring-2 focus:ring-sky-500"> <span class="text-base">{{ currentUser.avatar || '👨‍💻' }}</span>
<span class="text-xs text-slate-400">评论将实时持久化写入 MySQL 8.0</span> <span class="text-slate-600 dark:text-slate-300">正在以</span>
<strong class="text-slate-900 dark:text-white">{{ currentUser.nickname }}</strong>
<span :class="isAdmin ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300'"
class="px-1.5 py-0.2 text-[10px] font-bold rounded-full">
{{ isAdmin ? '👑 博主' : '👤 读者' }}
</span>
</div>
<div v-else class="flex items-center gap-2">
<input v-model="newComment.author" type="text" placeholder="你的昵称 (默认: 访客)"
class="px-3 py-1.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs w-40 focus:outline-none focus:ring-2 focus:ring-sky-500">
<span class="text-slate-400">或</span>
<button @click="openAuthModal('login')" class="text-sky-600 dark:text-sky-400 font-bold hover:underline">
👉 登录若依账号
</button>
</div>
<span class="text-slate-400 hidden sm:inline font-mono">直写 MySQL 8.0</span>
</div> </div>
<textarea v-model="newComment.content" rows="3" placeholder="写下你的技术思考、疑问或独到见解..." <textarea v-model="newComment.content" rows="3" placeholder="写下你的技术思考、疑问或独到见解..."
class="w-full px-3.5 py-2.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs sm:text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"></textarea> class="w-full px-3.5 py-2.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs sm:text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"></textarea>
@@ -630,14 +701,20 @@
class="p-4 rounded-2xl bg-slate-50/60 dark:bg-slate-800/30 border border-slate-100 dark:border-slate-800 text-xs sm:text-sm"> class="p-4 rounded-2xl bg-slate-50/60 dark:bg-slate-800/30 border border-slate-100 dark:border-slate-800 text-xs sm:text-sm">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-sky-500/20 text-sky-600 dark:text-sky-400 font-bold flex items-center justify-center text-xs"> <div class="w-7 h-7 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-sm shadow-sm">
{{ c.author ? c.author.charAt(0).toUpperCase() : 'U' }} {{ c.avatar || (c.role === 'ADMIN' ? '👑' : '👨‍💻') }}
</div> </div>
<span class="font-bold text-slate-900 dark:text-slate-100">{{ c.author || '匿名访客' }}</span> <span class="font-bold text-slate-900 dark:text-slate-100">{{ c.author || '匿名访客' }}</span>
<span v-if="c.role === 'ADMIN'" class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
👑 博主/管理员
</span>
<span v-else-if="c.role === 'USER'" class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 border border-sky-200 dark:border-sky-800">
读者
</span>
</div> </div>
<span class="text-xs text-slate-400 font-mono">{{ formatDate(c.createdAt) }}</span> <span class="text-xs text-slate-400 font-mono">{{ formatDate(c.createdAt) }}</span>
</div> </div>
<p class="text-slate-700 dark:text-slate-300 leading-relaxed pl-8">{{ c.content }}</p> <p class="text-slate-700 dark:text-slate-300 leading-relaxed pl-9">{{ c.content }}</p>
</div> </div>
</div> </div>
<div v-else class="text-center py-8 text-xs text-slate-400"> <div v-else class="text-center py-8 text-xs text-slate-400">
@@ -949,6 +1026,249 @@
</div> </div>
</div> </div>
<!-- ======================= 若依风格 1:用户认证登录/注册中心模态框 ======================= -->
<div v-if="showAuthModal"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="showAuthModal = false">
<div class="w-full max-w-md bg-white dark:bg-slate-900 rounded-3xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<!-- 头部 -->
<div class="px-6 pt-6 pb-4 text-center border-b border-slate-100 dark:border-slate-800 relative">
<button @click="showAuthModal = false" class="absolute right-4 top-4 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-lg">✕</button>
<div class="w-12 h-12 mx-auto rounded-2xl bg-gradient-to-tr from-sky-500 via-indigo-500 to-purple-600 flex items-center justify-center text-white text-xl font-black shadow-md shadow-sky-500/20 mb-3">
RY
</div>
<h3 class="text-xl font-black text-slate-900 dark:text-white">小东全栈博客 · 认证中心</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">若依 (RuoYi) 规范 RBAC 权限与用户体系</p>
<!-- 登录/注册 Tab 切换 -->
<div class="flex mt-4 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl text-xs font-bold">
<button @click="authTab = 'login'" :class="authTab === 'login' ? 'bg-white dark:bg-slate-700 text-sky-600 dark:text-sky-400 shadow-sm' : 'text-slate-500'" class="flex-1 py-1.5 rounded-lg transition-all">
账号登录
</button>
<button @click="authTab = 'register'" :class="authTab === 'register' ? 'bg-white dark:bg-slate-700 text-sky-600 dark:text-sky-400 shadow-sm' : 'text-slate-500'" class="flex-1 py-1.5 rounded-lg transition-all">
读者注册
</button>
</div>
</div>
<!-- 登录表单 -->
<div v-if="authTab === 'login'" class="p-6 space-y-4 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">用户名 *</label>
<input v-model="loginForm.username" type="text" placeholder="输入账号 (如 admin)"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none text-sm font-medium">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">密码 *</label>
<input v-model="loginForm.password" type="password" placeholder="输入密码 (如 admin123)" @keyup.enter="handleLogin"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none text-sm font-medium">
</div>
<!-- 快捷一键填入账号 (若依经典便民测试功能) -->
<div class="pt-2">
<div class="text-[11px] text-slate-400 mb-2 font-mono">⚡ 快捷填入内置测试账号:</div>
<div class="flex gap-2">
<button @click="quickFill('admin', 'admin123')"
class="flex-1 py-1.5 px-2 rounded-xl bg-amber-50 hover:bg-amber-100 dark:bg-amber-950/40 dark:hover:bg-amber-950/70 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800 font-semibold text-[11px] transition-colors">
👑 超级管理员 (admin)
</button>
<button @click="quickFill('ry_user', 'user123')"
class="flex-1 py-1.5 px-2 rounded-xl bg-sky-50 hover:bg-sky-100 dark:bg-sky-950/40 dark:hover:bg-sky-950/70 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-800 font-semibold text-[11px] transition-colors">
👤 测试读者 (ry_user)
</button>
</div>
</div>
<button @click="handleLogin" :disabled="authLoading"
class="w-full mt-4 py-2.5 rounded-xl bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 disabled:opacity-50 text-white font-bold text-sm shadow-md shadow-sky-600/30 flex items-center justify-center gap-2">
<span v-if="authLoading" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>{{ authLoading ? '验证登录中...' : '立即登录' }}</span>
</button>
</div>
<!-- 注册表单 -->
<div v-else class="p-6 space-y-3.5 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">用户名 *</label>
<input v-model="registerForm.username" type="text" placeholder="3-20 位字符,用于登录"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">个性昵称</label>
<input v-model="registerForm.nickname" type="text" placeholder="例如: 极客小明"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">登录密码 *</label>
<input v-model="registerForm.password" type="password" placeholder="不少于 5 位"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">联系邮箱</label>
<input v-model="registerForm.email" type="email" placeholder="name@example.com"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<button @click="handleRegister" :disabled="authLoading"
class="w-full mt-4 py-2.5 rounded-xl bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 disabled:opacity-50 text-white font-bold text-sm shadow-md shadow-sky-600/30 flex items-center justify-center gap-2">
<span v-if="authLoading" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>{{ authLoading ? '创建账号中...' : '立即注册读者账号' }}</span>
</button>
</div>
</div>
</div>
<!-- ======================= 若依风格 2:用户权限管理中心模态框 (RBAC 后台) ======================= -->
<div v-if="showUserMgmtModal"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-3 sm:p-6"
@click.self="showUserMgmtModal = false">
<div class="w-full max-w-4xl bg-white dark:bg-slate-900 rounded-3xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col max-h-[90vh] animate-in fade-in zoom-in-95 duration-200">
<div class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<span class="text-2xl">👥</span>
<div>
<h3 class="font-bold text-base text-slate-900 dark:text-white">若依风格 · 用户权限管理中心 (RBAC)</h3>
<p class="text-xs text-slate-400">管理全站注册用户、启用/停用状态及权限角色</p>
</div>
</div>
<button @click="showUserMgmtModal = false" class="text-slate-400 hover:text-slate-600 text-lg">✕</button>
</div>
<!-- 用户表格内容区 -->
<div class="p-6 overflow-y-auto flex-1">
<div class="flex items-center justify-between mb-4">
<div class="text-xs text-slate-500 font-mono">共 {{ userList.length }} 位注册用户</div>
<button @click="fetchUserList" class="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-xs font-semibold flex items-center gap-1.5 transition-colors">
<span>🔄</span> <span>刷新列表</span>
</button>
</div>
<div class="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-800">
<table class="w-full text-left text-xs border-collapse">
<thead>
<tr class="bg-slate-50 dark:bg-slate-800/60 border-b border-slate-200 dark:border-slate-800 text-slate-500 dark:text-slate-400 font-bold">
<th class="p-3.5">ID</th>
<th class="p-3.5">用户名</th>
<th class="p-3.5">昵称</th>
<th class="p-3.5">角色权限</th>
<th class="p-3.5">账号状态</th>
<th class="p-3.5">最近登录</th>
<th class="p-3.5 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-for="u in userList" :key="u.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition-colors">
<td class="p-3.5 font-mono">{{ u.id }}</td>
<td class="p-3.5 font-bold text-slate-900 dark:text-slate-100">{{ u.username }}</td>
<td class="p-3.5 flex items-center gap-1.5">
<span>{{ u.avatar || '👨‍💻' }}</span>
<span>{{ u.nickname }}</span>
</td>
<td class="p-3.5">
<span :class="u.role === 'ADMIN' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border-amber-200' : 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 border-sky-200'"
class="px-2 py-0.5 rounded-full font-bold text-[10px] border">
{{ u.role === 'ADMIN' ? '👑 超级管理员' : '👤 访客读者' }}
</span>
</td>
<td class="p-3.5">
<span :class="u.status === '0' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-400 border-emerald-200' : 'bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-400 border-rose-200'"
class="px-2 py-0.5 rounded-full font-bold text-[10px] border">
{{ u.status === '0' ? '正常' : '已停用' }}
</span>
</td>
<td class="p-3.5 text-slate-400 font-mono">{{ u.loginDate ? formatDate(u.loginDate) : '未登录' }}</td>
<td class="p-3.5 text-right font-medium">
<template v-if="u.username !== 'admin'">
<button @click="toggleUserStatus(u)" class="text-sky-600 hover:underline mr-3">
{{ u.status === '0' ? '停用' : '启用' }}
</button>
<button @click="deleteUser(u)" class="text-rose-600 hover:underline">
删除
</button>
</template>
<span v-else class="text-[11px] text-slate-400 font-mono">系统保护</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ======================= 若依风格 3:个人中心设置模态框 ======================= -->
<div v-if="showProfileModal"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="showProfileModal = false">
<div class="w-full max-w-md bg-white dark:bg-slate-900 rounded-3xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
<h3 class="font-bold text-base text-slate-900 dark:text-white flex items-center gap-2">
<span>👤 个人中心设置</span>
</h3>
<button @click="showProfileModal = false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-100 dark:border-slate-800 text-xs font-bold">
<button @click="profileTab = 'info'" :class="profileTab === 'info' ? 'border-b-2 border-sky-500 text-sky-600 dark:text-sky-400' : 'text-slate-400'" class="flex-1 py-3 text-center transition-colors">
基本资料
</button>
<button @click="profileTab = 'pwd'" :class="profileTab === 'pwd' ? 'border-b-2 border-sky-500 text-sky-600 dark:text-sky-400' : 'text-slate-400'" class="flex-1 py-3 text-center transition-colors">
修改密码
</button>
</div>
<!-- 修改基本资料 -->
<div v-if="profileTab === 'info'" class="p-6 space-y-4 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-400">用户名</label>
<input :value="currentUser.username" disabled class="w-full px-3.5 py-2 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 text-slate-400 cursor-not-allowed font-mono">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">修改昵称</label>
<input v-model="profileForm.nickname" type="text" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">联系邮箱</label>
<input v-model="profileForm.email" type="email" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1.5 text-slate-700 dark:text-slate-300">选择头像 Emoji</label>
<div class="flex flex-wrap gap-2">
<button v-for="av in ['👨‍💻', '🚀', '💻', '🧠', '⚡', '🐬', '🌟', '🛡️']" :key="av"
@click="profileForm.avatar = av"
:class="profileForm.avatar === av ? 'ring-2 ring-sky-500 scale-110' : 'opacity-70 hover:opacity-100'"
class="w-9 h-9 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-lg transition-all">
{{ av }}
</button>
</div>
</div>
<button @click="handleUpdateProfile" class="w-full mt-2 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-700 text-white font-bold text-xs shadow-md shadow-sky-600/30 transition-all">
保存资料修改
</button>
</div>
<!-- 修改密码 -->
<div v-else class="p-6 space-y-4 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">原密码 *</label>
<input v-model="pwdForm.oldPassword" type="password" placeholder="输入当前使用的密码" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">新密码 *</label>
<input v-model="pwdForm.newPassword" type="password" placeholder="不少于 5 位" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">确认新密码 *</label>
<input v-model="pwdForm.confirmPassword" type="password" placeholder="再次输入新密码" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<button @click="handleUpdatePwd" class="w-full mt-2 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-700 text-white font-bold text-xs shadow-md shadow-sky-600/30 transition-all">
确认修改密码
</button>
</div>
</div>
</div>
<!-- 返回顶部浮动胶囊 --> <!-- 返回顶部浮动胶囊 -->
<button v-show="showScrollTop" @click="scrollToTop" <button v-show="showScrollTop" @click="scrollToTop"
class="fixed bottom-6 right-6 z-40 p-3 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 shadow-xl hover:scale-110 active:scale-95 transition-all" class="fixed bottom-6 right-6 z-40 p-3 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 shadow-xl hover:scale-110 active:scale-95 transition-all"
@@ -1004,6 +1324,253 @@
const showScrollTop = ref(false); const showScrollTop = ref(false);
const toasts = ref([]); const toasts = ref([]);
// ============ 若依 (RuoYi) 规范 RBAC 用户认证与权限体系 ============
const token = ref(localStorage.getItem('ry_token') || '');
const currentUser = ref(null);
const roles = ref([]);
const permissions = ref([]);
const showUserDropdown = ref(false);
const showAuthModal = ref(false);
const authTab = ref('login'); // 'login' | 'register'
const authLoading = ref(false);
const loginForm = ref({ username: '', password: '' });
const registerForm = ref({ username: '', password: '', nickname: '', email: '' });
const showUserMgmtModal = ref(false);
const userList = ref([]);
const showProfileModal = ref(false);
const profileTab = ref('info'); // 'info' | 'pwd'
const profileForm = ref({ nickname: '', email: '', avatar: '👨‍💻' });
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' });
const isLoggedIn = computed(() => !!token.value && !!currentUser.value);
const isAdmin = computed(() => currentUser.value && currentUser.value.role === 'ADMIN');
const authHeaders = computed(() => {
return token.value ? { 'Authorization': 'Bearer ' + token.value } : {};
});
function openAuthModal(tab = 'login') {
authTab.value = tab;
loginForm.value = { username: '', password: '' };
registerForm.value = { username: '', password: '', nickname: '', email: '' };
showAuthModal.value = true;
}
function quickFill(u, p) {
loginForm.value.username = u;
loginForm.value.password = p;
}
async function handleLogin() {
if (!loginForm.value.username.trim() || !loginForm.value.password.trim()) {
showToast('请输入用户名和密码', 'error');
return;
}
authLoading.value = true;
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loginForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
token.value = result.data.token;
localStorage.setItem('ry_token', result.data.token);
currentUser.value = result.data.user;
roles.value = result.data.roles;
permissions.value = result.data.permissions;
showAuthModal.value = false;
showToast(`欢迎回来,${currentUser.value.nickname}!`, 'success');
} else {
showToast(result.msg || '登录失败,请检查账号密码', 'error');
}
} catch (e) {
showToast('登录服务不可用,请稍后重试', 'error');
}
authLoading.value = false;
}
async function handleRegister() {
if (!registerForm.value.username.trim() || !registerForm.value.password.trim()) {
showToast('用户名和密码不能为空', 'error');
return;
}
authLoading.value = true;
try {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('注册成功!正在为您自动登录...', 'success');
loginForm.value.username = registerForm.value.username;
loginForm.value.password = registerForm.value.password;
await handleLogin();
} else {
showToast(result.msg || '注册失败', 'error');
}
} catch (e) {
showToast('注册请求失败', 'error');
}
authLoading.value = false;
}
function handleLogout() {
token.value = '';
currentUser.value = null;
roles.value = [];
permissions.value = [];
localStorage.removeItem('ry_token');
showToast('已安全退出登录', 'info');
}
async function fetchUserInfo() {
if (!token.value) return;
try {
const res = await fetch(`${API_BASE}/auth/info`, {
headers: { ...authHeaders.value }
});
if (res.ok) {
const result = await res.json();
if (result.code === 200) {
currentUser.value = result.data.user;
roles.value = result.data.roles;
permissions.value = result.data.permissions;
return;
}
}
} catch (e) {}
localStorage.removeItem('ry_token');
token.value = '';
currentUser.value = null;
}
// 用户管理中心
function openUserMgmtModal() {
showUserMgmtModal.value = true;
fetchUserList();
}
async function fetchUserList() {
try {
const res = await fetch(`${API_BASE}/users`, {
headers: { ...authHeaders.value }
});
if (res.ok) {
const result = await res.json();
userList.value = result.data || [];
}
} catch (e) {
showToast('获取用户列表失败', 'error');
}
}
async function toggleUserStatus(u) {
const newStatus = u.status === '0' ? '1' : '0';
try {
const res = await fetch(`${API_BASE}/users/${u.id}/status?status=${newStatus}`, {
method: 'PUT',
headers: { ...authHeaders.value }
});
const result = await res.json();
if (res.ok && result.code === 200) {
u.status = newStatus;
showToast(`已${newStatus === '1' ? '停用' : '启用'}用户 [${u.username}]`, 'success');
} else {
showToast(result.msg || '操作失败', 'error');
}
} catch (e) {
showToast('网络请求失败', 'error');
}
}
async function deleteUser(u) {
if (!confirm(`确定要永久删除用户 [${u.username}] 吗?`)) return;
try {
const res = await fetch(`${API_BASE}/users/${u.id}`, {
method: 'DELETE',
headers: { ...authHeaders.value }
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast(`用户 [${u.username}] 已被删除`, 'success');
fetchUserList();
} else {
showToast(result.msg || '删除失败', 'error');
}
} catch (e) {
showToast('网络请求失败', 'error');
}
}
// 个人中心
function openProfileModal() {
profileTab.value = 'info';
profileForm.value = {
nickname: currentUser.value.nickname,
email: currentUser.value.email || '',
avatar: currentUser.value.avatar || '👨‍💻'
};
pwdForm.value = { oldPassword: '', newPassword: '', confirmPassword: '' };
showProfileModal.value = true;
}
async function handleUpdateProfile() {
try {
const res = await fetch(`${API_BASE}/users/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders.value },
body: JSON.stringify(profileForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
currentUser.value.nickname = profileForm.value.nickname;
currentUser.value.email = profileForm.value.email;
currentUser.value.avatar = profileForm.value.avatar;
showToast('个人资料已成功更新', 'success');
showProfileModal.value = false;
} else {
showToast(result.msg || '修改失败', 'error');
}
} catch (e) {
showToast('请求失败', 'error');
}
}
async function handleUpdatePwd() {
if (!pwdForm.value.oldPassword || !pwdForm.value.newPassword) {
showToast('旧密码和新密码不能为空', 'error');
return;
}
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) {
showToast('两次输入的新密码不一致', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/users/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders.value },
body: JSON.stringify(pwdForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('密码修改成功,请使用新密码重新登录', 'success');
handleLogout();
showProfileModal.value = false;
openAuthModal('login');
} else {
showToast(result.msg || '修改密码失败', 'error');
}
} catch (e) {
showToast('请求失败', 'error');
}
}
const categories = ref(['全部']); const categories = ref(['全部']);
const categoryList = ref([]); const categoryList = ref([]);
const availableCategories = computed(() => categories.value.filter(c => c !== '全部')); const availableCategories = computed(() => categories.value.filter(c => c !== '全部'));
@@ -1284,17 +1851,26 @@
showToast('评论内容不能为空', 'error'); showToast('评论内容不能为空', 'error');
return; return;
} }
if (isLoggedIn.value && currentUser.value) {
newComment.value.author = currentUser.value.nickname;
}
submittingComment.value = true; submittingComment.value = true;
try { try {
const res = await fetch(`${API_BASE}/posts/${activePost.value.id}/comments`, { const res = await fetch(`${API_BASE}/posts/${activePost.value.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...authHeaders.value
},
body: JSON.stringify(newComment.value) body: JSON.stringify(newComment.value)
}); });
if (res.ok) { const result = await res.json();
if (res.ok && result.code === 200) {
newComment.value.content = ''; newComment.value.content = '';
showToast('评论已持久化写入 MySQL 8.0', 'success'); showToast('评论已持久化写入 MySQL 8.0', 'success');
fetchComments(activePost.value.id); fetchComments(activePost.value.id);
} else {
showToast(result.msg || '评论发表失败', 'error');
} }
} catch (e) { } catch (e) {
showToast('评论提交失败,请检查网络连接', 'error'); showToast('评论提交失败,请检查网络连接', 'error');
@@ -1304,16 +1880,26 @@
// 删除文章 // 删除文章
async function deletePost(post) { async function deletePost(post) {
if (!isAdmin.value) {
showToast('无权删除:仅超级管理员可用', 'error');
return;
}
if (!confirm(`确定要永久删除文章《${post.title}》吗?`)) return; if (!confirm(`确定要永久删除文章《${post.title}》吗?`)) return;
try { try {
const res = await fetch(`${API_BASE}/posts/${post.id}`, { method: 'DELETE' }); const res = await fetch(`${API_BASE}/posts/${post.id}`, {
if (res.ok) { method: 'DELETE',
headers: { ...authHeaders.value }
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('文章已从 MySQL 数据库中移除', 'success'); showToast('文章已从 MySQL 数据库中移除', 'success');
navigateView('home'); navigateView('home');
fetchPosts(); fetchPosts();
} else {
showToast(result.msg || '删除失败', 'error');
} }
} catch (e) { } catch (e) {
showToast('删除失败', 'error'); showToast('删除失败,权限不足或服务器错误', 'error');
} }
} }
@@ -1372,6 +1958,10 @@
// 持久化发布文章至 MySQL // 持久化发布文章至 MySQL
async function publishPost() { async function publishPost() {
if (!isAdmin.value) {
showToast('无权发布:请先使用超级管理员账号登录', 'error');
return;
}
if (!newPost.value.title.trim() || !newPost.value.content.trim()) { if (!newPost.value.title.trim() || !newPost.value.content.trim()) {
showToast('文章标题和正文内容不能为空!', 'error'); showToast('文章标题和正文内容不能为空!', 'error');
return; return;
@@ -1381,15 +1971,19 @@
try { try {
const res = await fetch(`${API_BASE}/posts`, { const res = await fetch(`${API_BASE}/posts`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...authHeaders.value
},
body: JSON.stringify(newPost.value) body: JSON.stringify(newPost.value)
}); });
if (res.ok) { const result = await res.json();
if (res.ok && result.code === 200) {
showToast('文章已成功持久化写入 MySQL 8.0!', 'success'); showToast('文章已成功持久化写入 MySQL 8.0!', 'success');
showPublishModal.value = false; showPublishModal.value = false;
fetchPosts(); fetchPosts();
} else { } else {
showToast('发布失败,请稍后重试', 'error'); showToast(result.msg || '发布失败,请稍后重试', 'error');
} }
} catch (e) { } catch (e) {
showToast('发布失败,无法连接服务器', 'error'); showToast('发布失败,无法连接服务器', 'error');
@@ -1500,6 +2094,7 @@
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
window.addEventListener('hashchange', handleHashRoute); window.addEventListener('hashchange', handleHashRoute);
await fetchUserInfo();
await fetchCategories(); await fetchCategories();
await fetchPosts(); await fetchPosts();
handleHashRoute(); handleHashRoute();
@@ -1516,6 +2111,34 @@
backendConnected, backendConnected,
currentView, currentView,
navigateView, navigateView,
token,
currentUser,
isLoggedIn,
isAdmin,
showUserDropdown,
showAuthModal,
authTab,
authLoading,
loginForm,
registerForm,
openAuthModal,
quickFill,
handleLogin,
handleRegister,
handleLogout,
showUserMgmtModal,
userList,
openUserMgmtModal,
fetchUserList,
toggleUserStatus,
deleteUser,
showProfileModal,
profileTab,
profileForm,
pwdForm,
openProfileModal,
handleUpdateProfile,
handleUpdatePwd,
posts, posts,
stats, stats,
categories, categories,