Compare commits
31 Commits
9ff222c22c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fb6969d29 | |||
| 6f19f7468c | |||
| 09abb50a58 | |||
| 35f9b9c9a6 | |||
| 9b16c0fc83 | |||
| fae01fdf43 | |||
| dd1bda704f | |||
| b0751cf45e | |||
| f556bdc09a | |||
| 7a246619c4 | |||
| a4980df3f1 | |||
| ae3ac9b819 | |||
| d4cc7a6b73 | |||
| d008278b6a | |||
| 97f1482497 | |||
| d47c60a5e0 | |||
| 242a4347df | |||
| 70c69e16cc | |||
| a1b17c11c1 | |||
| bdd6c0828c | |||
| 8a14c5244e | |||
| 4d1003e467 | |||
| f55ff5fc24 | |||
| 4c22109a95 | |||
| a4a18f2b82 | |||
| a9ae4bce44 | |||
| 5367ad61db | |||
| 3f4a294128 | |||
| 2ee194ebdd | |||
| 4ac6e85aca | |||
| dc666897c6 |
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
@@ -7,6 +7,7 @@
|
||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<module name="system-pro" />
|
||||
<module name="file-system-backend" />
|
||||
</profile>
|
||||
</annotationProcessing>
|
||||
@@ -14,6 +15,7 @@
|
||||
<component name="JavacSettings">
|
||||
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
|
||||
<module name="file-system-backend" options="-parameters" />
|
||||
<module name="system-pro" options="-parameters" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
13
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<Languages>
|
||||
<language minSize="91" name="Java" />
|
||||
</Languages>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ExtractMethodRecommender" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="minLength" value="1089" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -8,5 +8,5 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="java-17" project-jdk-type="JavaSDK" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="JDK17" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
@@ -3,9 +3,11 @@ package com.filesystem;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.filesystem.mapper")
|
||||
@EnableScheduling
|
||||
public class FileSystemApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -2,22 +2,59 @@ package com.filesystem.config;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 处理文件上传大小超限异常
|
||||
*/
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
.body(Map.of("message", "文件大小超过限制,单次上传总大小不能超过500MB"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理参数校验异常
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<?> handleValidationException(MethodArgumentNotValidException e) {
|
||||
String errorMsg = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining(", "));
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("message", errorMsg.isEmpty() ? "参数校验失败" : errorMsg));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理非法参数异常
|
||||
*/
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<?> handleIllegalArgumentException(IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("message", e.getMessage() != null ? e.getMessage() : "参数错误"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理所有运行时异常
|
||||
*/
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
||||
e.printStackTrace();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
// 业务异常返回 400,避免触发前端登录跳转
|
||||
HttpStatus status = HttpStatus.BAD_REQUEST;
|
||||
return ResponseEntity.status(status)
|
||||
.body(Map.of("message", e.getMessage() != null ? e.getMessage() : "服务器内部错误"));
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,13 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// API 公开接口
|
||||
.requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/logout").permitAll()
|
||||
.requestMatchers("/api/captcha").permitAll()
|
||||
.requestMatchers("/api/files/test").permitAll()
|
||||
.requestMatchers("/api/users/config").permitAll()
|
||||
// WebSocket
|
||||
.requestMatchers("/ws/**").permitAll()
|
||||
// 静态资源
|
||||
.requestMatchers("/webapp/**", "/assets/**", "/uploads/**", "/files/avatar/**", "/api/files/avatar/**", "/api/messages/file/**").permitAll()
|
||||
.requestMatchers("/webapp/**", "/assets/**", "/uploads/**", "/files/avatar/**", "/api/files/avatar/**", "/api/messages/file/**", "/api/messages/conversation/**").permitAll()
|
||||
// 前端页面路由(所有非 /api/ 的路由,由 Vue Router 处理)
|
||||
.requestMatchers("/", "/login", "/register", "/desktop/**", "/favicon.ico").permitAll()
|
||||
// 其他所有请求需要认证
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.filesystem.entity.User;
|
||||
import com.filesystem.security.UserPrincipal;
|
||||
import com.filesystem.service.UserService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -14,62 +16,71 @@ import java.util.Map;
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
|
||||
@Resource
|
||||
private UserService userService;
|
||||
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
|
||||
public ResponseEntity<?> login(@RequestBody Map<String, String> request, HttpSession session, HttpServletRequest httpRequest) {
|
||||
String username = request.get("username");
|
||||
String password = request.get("password");
|
||||
|
||||
String captcha = request.get("captcha");
|
||||
|
||||
// 验证码校验
|
||||
if (!CaptchaController.verify(session, captcha)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "验证码错误或已过期"));
|
||||
}
|
||||
|
||||
try {
|
||||
String token = userService.login(username, password);
|
||||
// 获取客户端 IP
|
||||
String clientIp = getClientIp(httpRequest);
|
||||
String token = userService.login(username, password, clientIp);
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 精确重算存储空间
|
||||
long storageUsed = userService.recalculateStorage(user.getId());
|
||||
long storageLimit = user.getStorageLimit() != null ? user.getStorageLimit() : 20L * 1024 * 1024 * 1024;
|
||||
|
||||
|
||||
long storageUsed = user.getStorageUsed();
|
||||
long storageLimit = user.getStorageLimit();
|
||||
|
||||
Map<String, Object> userData = new HashMap<>();
|
||||
userData.put("id", user.getId());
|
||||
userData.put("username", user.getUsername());
|
||||
userData.put("nickname", user.getNickname() != null ? user.getNickname() : "");
|
||||
userData.put("signature", user.getSignature() != null ? user.getSignature() : "");
|
||||
userData.put("avatar", user.getAvatar() != null ? user.getAvatar() : "");
|
||||
userData.put("phone", user.getPhone() != null ? user.getPhone() : "");
|
||||
userData.put("email", user.getEmail() != null ? user.getEmail() : "");
|
||||
userData.put("nickname", user.getNickname());
|
||||
userData.put("signature", user.getSignature());
|
||||
userData.put("avatar", user.getAvatar());
|
||||
userData.put("phone", user.getPhone());
|
||||
userData.put("email", user.getEmail());
|
||||
userData.put("storageUsed", storageUsed);
|
||||
userData.put("storageLimit", storageLimit);
|
||||
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", token);
|
||||
result.put("user", userData);
|
||||
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("data", result);
|
||||
body.put("message", "登录成功");
|
||||
|
||||
|
||||
return ResponseEntity.ok(body);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
|
||||
public ResponseEntity<?> register(@RequestBody Map<String, String> request, HttpServletRequest httpRequest) {
|
||||
String username = request.get("username");
|
||||
String password = request.get("password");
|
||||
String nickname = request.get("nickname");
|
||||
|
||||
|
||||
if (userService.findByUsername(username) != null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "用户名已存在"));
|
||||
}
|
||||
|
||||
User user = userService.createUser(username, password, nickname != null ? nickname : username);
|
||||
|
||||
// 获取客户端 IP
|
||||
String clientIp = getClientIp(httpRequest);
|
||||
User user = userService.createUser(username, password, nickname, clientIp);
|
||||
return ResponseEntity.ok(Map.of("message", "注册成功", "data", Map.of("id", user.getId())));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal != null) {
|
||||
@@ -77,36 +88,53 @@ public class AuthController {
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("message", "退出成功"));
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<?> getUserInfo(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("message", "未登录"));
|
||||
}
|
||||
|
||||
|
||||
User user = userService.findById(principal.getUserId());
|
||||
if (user == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("message", "用户不存在"));
|
||||
}
|
||||
|
||||
|
||||
// 精确重算存储空间
|
||||
long storageUsed = userService.recalculateStorage(user.getId());
|
||||
long storageLimit = user.getStorageLimit() != null ? user.getStorageLimit() : 20L * 1024 * 1024 * 1024;
|
||||
|
||||
long storageLimit = user.getStorageLimit();
|
||||
|
||||
Map<String, Object> userData = new HashMap<>();
|
||||
userData.put("id", user.getId());
|
||||
userData.put("username", user.getUsername());
|
||||
userData.put("nickname", user.getNickname() != null ? user.getNickname() : "");
|
||||
userData.put("signature", user.getSignature() != null ? user.getSignature() : "");
|
||||
userData.put("avatar", user.getAvatar() != null ? user.getAvatar() : "");
|
||||
userData.put("email", user.getEmail() != null ? user.getEmail() : "");
|
||||
userData.put("phone", user.getPhone() != null ? user.getPhone() : "");
|
||||
userData.put("nickname", user.getNickname());
|
||||
userData.put("signature", user.getSignature());
|
||||
userData.put("avatar", user.getAvatar());
|
||||
userData.put("email", user.getEmail());
|
||||
userData.put("phone", user.getPhone());
|
||||
userData.put("storageUsed", storageUsed);
|
||||
userData.put("storageLimit", storageLimit);
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("data", userData);
|
||||
|
||||
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实 IP(支持代理)
|
||||
*/
|
||||
private String getClientIp(jakarta.servlet.http.HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 多级代理时取第一个
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
130
src/main/java/com/filesystem/controller/CaptchaController.java
Normal file
130
src/main/java/com/filesystem/controller/CaptchaController.java
Normal file
@@ -0,0 +1,130 @@
|
||||
package com.filesystem.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/captcha")
|
||||
public class CaptchaController {
|
||||
|
||||
private static final String CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
private static final int WIDTH = 140;
|
||||
private static final int HEIGHT = 40;
|
||||
private static final int CODE_LENGTH = 5;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getCaptcha(HttpSession session) {
|
||||
// 生成随机验证码
|
||||
String code = generateCode();
|
||||
|
||||
// 存入 session
|
||||
session.setAttribute("captcha", code.toLowerCase());
|
||||
session.setAttribute("captchaTime", System.currentTimeMillis());
|
||||
|
||||
// 生成图片
|
||||
String imageBase64 = generateCaptchaImage(code);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"data", Map.of(
|
||||
"image", "data:image/png;base64," + imageBase64
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
*/
|
||||
public static boolean verify(HttpSession session, String inputCode) {
|
||||
if (inputCode == null || inputCode.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String storedCode = (String) session.getAttribute("captcha");
|
||||
Long captchaTime = (Long) session.getAttribute("captchaTime");
|
||||
|
||||
if (storedCode == null || captchaTime == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证码 5 分钟有效
|
||||
if (System.currentTimeMillis() - captchaTime > 5 * 60 * 1000) {
|
||||
session.removeAttribute("captcha");
|
||||
session.removeAttribute("captchaTime");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证后清除,防止重复使用
|
||||
session.removeAttribute("captcha");
|
||||
session.removeAttribute("captchaTime");
|
||||
|
||||
return storedCode.equals(inputCode.toLowerCase());
|
||||
}
|
||||
|
||||
private String generateCode() {
|
||||
Random random = new Random();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < CODE_LENGTH; i++) {
|
||||
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String generateCaptchaImage(String code) {
|
||||
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
|
||||
// 设置抗锯齿
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
// 背景
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// 干扰线
|
||||
Random random = new Random();
|
||||
for (int i = 0; i < 6; i++) {
|
||||
g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200)));
|
||||
g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT),
|
||||
random.nextInt(WIDTH), random.nextInt(HEIGHT));
|
||||
}
|
||||
|
||||
// 干扰点
|
||||
for (int i = 0; i < 40; i++) {
|
||||
g.setColor(new Color(random.nextInt(255), random.nextInt(255), random.nextInt(255)));
|
||||
g.fillOval(random.nextInt(WIDTH), random.nextInt(HEIGHT), 2, 2);
|
||||
}
|
||||
|
||||
// 验证码文字
|
||||
g.setFont(new Font("Arial", Font.BOLD, 26));
|
||||
for (int i = 0; i < code.length(); i++) {
|
||||
// 随机颜色
|
||||
g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150)));
|
||||
// 随机角度
|
||||
double angle = (random.nextDouble() - 0.5) * 0.4;
|
||||
g.rotate(angle, 18 + i * 24, 26);
|
||||
g.drawString(String.valueOf(code.charAt(i)), 10 + i * 24, 28);
|
||||
g.rotate(-angle, 18 + i * 24, 26);
|
||||
}
|
||||
|
||||
g.dispose();
|
||||
|
||||
// 转为 Base64
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
javax.imageio.ImageIO.write(image, "png", baos);
|
||||
return Base64.getEncoder().encodeToString(baos.toByteArray());
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,63 @@ public class MessageController {
|
||||
m.put("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
|
||||
m.put("fromAvatar", fromUser.getAvatar());
|
||||
m.put("fromSignature", fromUser.getSignature());
|
||||
m.put("fromPhone", fromUser.getPhone());
|
||||
m.put("fromEmail", fromUser.getEmail());
|
||||
}
|
||||
return m;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(Map.of("data", result));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
public ResponseEntity<?> getHistoryMessages(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam Long userId,
|
||||
@RequestParam(required = false) String beforeTime,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
|
||||
List<Message> messages;
|
||||
if (beforeTime != null && !beforeTime.isEmpty()) {
|
||||
LocalDateTime before = LocalDateTime.parse(beforeTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
messages = messageService.getHistoryMessages(principal.getUserId(), userId, before, limit);
|
||||
} else {
|
||||
messages = messageService.getRecentMessages(principal.getUserId(), userId, limit);
|
||||
}
|
||||
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
userIds.add(principal.getUserId());
|
||||
userIds.add(userId);
|
||||
for (Message msg : messages) {
|
||||
userIds.add(msg.getFromUserId());
|
||||
userIds.add(msg.getToUserId());
|
||||
}
|
||||
Map<Long, User> userMap = new HashMap<>();
|
||||
for (Long uid : userIds) {
|
||||
User u = userService.findById(uid);
|
||||
if (u != null) userMap.put(uid, u);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> result = messages.stream().map(msg -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", msg.getId());
|
||||
m.put("fromUserId", msg.getFromUserId());
|
||||
m.put("toUserId", msg.getToUserId());
|
||||
m.put("content", msg.getContent());
|
||||
m.put("type", msg.getType());
|
||||
m.put("fileName", msg.getFileName());
|
||||
m.put("fileSize", msg.getFileSize());
|
||||
m.put("isRead", msg.getIsRead());
|
||||
m.put("createTime", msg.getCreateTime() != null ? msg.getCreateTime().toString() : "");
|
||||
|
||||
User fromUser = userMap.get(msg.getFromUserId());
|
||||
if (fromUser != null) {
|
||||
m.put("fromUsername", fromUser.getUsername());
|
||||
m.put("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
|
||||
m.put("fromAvatar", fromUser.getAvatar());
|
||||
m.put("fromSignature", fromUser.getSignature());
|
||||
m.put("fromPhone", fromUser.getPhone());
|
||||
m.put("fromEmail", fromUser.getEmail());
|
||||
}
|
||||
return m;
|
||||
}).collect(Collectors.toList());
|
||||
@@ -276,4 +333,12 @@ public class MessageController {
|
||||
messageService.markAsRead(id);
|
||||
return ResponseEntity.ok(Map.of("message", "已标记已读"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/conversation/{withUserId}")
|
||||
public ResponseEntity<?> deleteConversation(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable Long withUserId) {
|
||||
messageService.deleteConversation(principal.getUserId(), withUserId);
|
||||
return ResponseEntity.ok(Map.of("message", "已删除"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,22 @@ public class UserController {
|
||||
@Value("${file.storage.path:./uploads}")
|
||||
private String storagePath;
|
||||
|
||||
@Value("${file.user.storage-limit-gb:50}")
|
||||
private int storageLimitGb;
|
||||
|
||||
/**
|
||||
* 获取系统配置(存储配额等)
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
public ResponseEntity<?> getSystemConfig() {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"data", Map.of(
|
||||
"storageLimitGb", storageLimitGb,
|
||||
"storageLimitBytes", (long) storageLimitGb * 1024 * 1024 * 1024
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用用户(用于文件共享等场景)
|
||||
*/
|
||||
@@ -41,7 +57,7 @@ public class UserController {
|
||||
Map<String, Object> m = new java.util.HashMap<>();
|
||||
m.put("id", u.getId());
|
||||
m.put("username", u.getUsername());
|
||||
m.put("nickname", u.getNickname() != null ? u.getNickname() : u.getUsername());
|
||||
m.put("nickname", u.getNickname());
|
||||
m.put("avatar", u.getAvatar());
|
||||
m.put("signature", u.getSignature());
|
||||
return m;
|
||||
|
||||
@@ -25,9 +25,17 @@ public class User extends BaseEntity {
|
||||
|
||||
private Integer status;
|
||||
|
||||
private String role; // admin-管理员, user-普通用户
|
||||
|
||||
@TableField("storage_used")
|
||||
private Long storageUsed;
|
||||
|
||||
@TableField("storage_limit")
|
||||
private Long storageLimit;
|
||||
|
||||
@TableField("register_ip")
|
||||
private String registerIp;
|
||||
|
||||
@TableField("allowed_ips")
|
||||
private String allowedIps; // 允许的IP段,逗号分隔,如 "192.168.1.0/24,10.0.0.0/8"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.filesystem.security;
|
||||
|
||||
import com.filesystem.entity.User;
|
||||
import com.filesystem.mapper.UserMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -24,6 +26,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
@Resource
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Resource
|
||||
private UserMapper userMapper;
|
||||
|
||||
private static final List<AntPathRequestMatcher> EXCLUDE_MATCHERS = List.of(
|
||||
new AntPathRequestMatcher("/api/auth/login"),
|
||||
@@ -71,7 +76,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String username = jwtUtil.getUsernameFromToken(token);
|
||||
Long userId = jwtUtil.getUserIdFromToken(token);
|
||||
|
||||
UserPrincipal principal = new UserPrincipal(userId, username);
|
||||
// 查询用户角色
|
||||
String role = "user";
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user != null && "admin".equals(user.getRole())) {
|
||||
role = "admin";
|
||||
}
|
||||
|
||||
UserPrincipal principal = new UserPrincipal(userId, username, role);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(principal, null, new ArrayList<>());
|
||||
|
||||
@@ -8,4 +8,5 @@ import lombok.Data;
|
||||
public class UserPrincipal {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String role; // admin-管理员, user-普通用户
|
||||
}
|
||||
|
||||
@@ -48,14 +48,20 @@ public class FileService {
|
||||
wrapper.eq(FileEntity::getUserId, userId)
|
||||
.eq(FileEntity::getIsDeleted, 0);
|
||||
|
||||
if (folderId != null) {
|
||||
wrapper.eq(FileEntity::getFolderId, folderId);
|
||||
} else {
|
||||
wrapper.isNull(FileEntity::getFolderId);
|
||||
}
|
||||
boolean hasKeyword = keyword != null && !keyword.isEmpty();
|
||||
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
// 根目录:folderId == null 或 0 都视为根目录,统一用 0 查询
|
||||
Long actualFolderId = (folderId == null || folderId == 0L) ? 0L : folderId;
|
||||
|
||||
if (hasKeyword) {
|
||||
// 有搜索关键词:根目录搜索查所有,子目录搜索限当前目录
|
||||
if (!actualFolderId.equals(0L)) {
|
||||
wrapper.eq(FileEntity::getFolderId, folderId);
|
||||
}
|
||||
wrapper.like(FileEntity::getName, keyword);
|
||||
} else {
|
||||
// 无搜索关键词:正常浏览当前目录
|
||||
wrapper.eq(FileEntity::getFolderId, actualFolderId);
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(FileEntity::getIsFolder)
|
||||
@@ -70,10 +76,13 @@ public class FileService {
|
||||
.eq(FileEntity::getIsDeleted, 1)
|
||||
.orderByDesc(FileEntity::getDeletedAt);
|
||||
|
||||
if (folderId != null) {
|
||||
wrapper.eq(FileEntity::getFolderId, folderId);
|
||||
// 根目录:folderId == null 或 0 都视为根目录
|
||||
Long actualFolderId = (folderId == null || folderId == 0L) ? 0L : folderId;
|
||||
// 根目录在数据库中兼容 null 和 0 两种值
|
||||
if (actualFolderId.equals(0L)) {
|
||||
wrapper.and(w -> w.eq(FileEntity::getFolderId, 0L).or().isNull(FileEntity::getFolderId));
|
||||
} else {
|
||||
wrapper.isNull(FileEntity::getFolderId);
|
||||
wrapper.eq(FileEntity::getFolderId, actualFolderId);
|
||||
}
|
||||
|
||||
return fileMapper.selectList(wrapper);
|
||||
@@ -86,26 +95,45 @@ public class FileService {
|
||||
@Transactional
|
||||
public void moveToTrash(Long id, Long userId) {
|
||||
FileEntity file = fileMapper.selectById(id);
|
||||
if (file != null && file.getUserId().equals(userId)) {
|
||||
// 如果是文件夹,检查是否有子文件
|
||||
if (file.getIsFolder() != null && file.getIsFolder() == 1) {
|
||||
Long childCount = fileMapper.selectCount(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getFolderId, id)
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
);
|
||||
if (childCount != null && childCount > 0) {
|
||||
throw new RuntimeException("请删除该目录下文件后重试");
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 LambdaUpdateWrapper 明确指定要更新的字段
|
||||
if (file == null || !file.getUserId().equals(userId)) return;
|
||||
|
||||
if (file.getIsFolder() != null && file.getIsFolder() == 1) {
|
||||
// 文件夹:递归将所有子文件标记删除,移动到回收站根目录
|
||||
moveChildrenToTrash(id, userId);
|
||||
}
|
||||
|
||||
// 当前文件/文件夹:移动到回收站根目录
|
||||
LambdaUpdateWrapper<FileEntity> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(FileEntity::getId, id)
|
||||
.set(FileEntity::getFolderId, 0L)
|
||||
.set(FileEntity::getIsDeleted, 1)
|
||||
.set(FileEntity::getDeletedAt, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
fileMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归将文件夹下所有子文件/子文件夹标记为删除(folderId 保持不变)
|
||||
*/
|
||||
private void moveChildrenToTrash(Long parentFolderId, Long userId) {
|
||||
List<FileEntity> children = fileMapper.selectList(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getFolderId, parentFolderId)
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
);
|
||||
|
||||
for (FileEntity child : children) {
|
||||
// 子文件/子文件夹只标删除,folderId 保持原值不变
|
||||
LambdaUpdateWrapper<FileEntity> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(FileEntity::getId, id)
|
||||
wrapper.eq(FileEntity::getId, child.getId())
|
||||
.set(FileEntity::getIsDeleted, 1)
|
||||
.set(FileEntity::getDeletedAt, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
fileMapper.update(null, wrapper);
|
||||
|
||||
if (child.getIsFolder() != null && child.getIsFolder() == 1) {
|
||||
// 递归处理子文件夹
|
||||
moveChildrenToTrash(child.getId(), userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,21 +142,29 @@ public class FileService {
|
||||
FileEntity file = fileMapper.selectById(id);
|
||||
if (file == null || !file.getUserId().equals(userId)) return;
|
||||
|
||||
// 检查父文件夹是否在回收站,如果是,将文件移到根目录
|
||||
Long folderId = file.getFolderId();
|
||||
if (folderId != null) {
|
||||
FileEntity parentFolder = fileMapper.selectById(folderId);
|
||||
if (parentFolder != null && parentFolder.getIsDeleted() == 1) {
|
||||
// 父文件夹在回收站,将文件移到根目录
|
||||
folderId = null;
|
||||
// 检查父文件夹状态,决定还原目标
|
||||
// - folderId == 0 或 null:根目录,还原到根目录(folderId=0)
|
||||
// - 父文件夹存在且未删除:还原到原父文件夹
|
||||
// - 父文件夹不存在或已删除:还原到根目录(folderId=0)
|
||||
Long originalFolderId = file.getFolderId();
|
||||
Long targetFolderId = originalFolderId;
|
||||
|
||||
if (targetFolderId != null && !targetFolderId.equals(0L)) {
|
||||
FileEntity parentFolder = fileMapper.selectById(targetFolderId);
|
||||
if (parentFolder == null || parentFolder.getIsDeleted() == 1) {
|
||||
// 父文件夹不在了,还原到根目录
|
||||
targetFolderId = 0L;
|
||||
}
|
||||
} else if (targetFolderId == null || targetFolderId.equals(0L)) {
|
||||
// 本身就是根目录
|
||||
targetFolderId = 0L;
|
||||
}
|
||||
|
||||
LambdaUpdateWrapper<FileEntity> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(FileEntity::getId, id)
|
||||
.set(FileEntity::getIsDeleted, 0)
|
||||
.set(FileEntity::getDeletedAt, null)
|
||||
.set(FileEntity::getFolderId, folderId);
|
||||
.set(FileEntity::getFolderId, targetFolderId);
|
||||
fileMapper.update(null, wrapper);
|
||||
|
||||
// 如果是文件夹,递归还原所有子文件
|
||||
@@ -162,28 +198,9 @@ public class FileService {
|
||||
FileEntity file = fileMapper.selectById(id);
|
||||
if (file == null || !file.getUserId().equals(userId)) return;
|
||||
|
||||
// 如果是文件夹,检查是否有未删除的子文件
|
||||
if (file.getIsFolder() != null && file.getIsFolder() == 1) {
|
||||
Long undeletedCount = fileMapper.selectCount(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getFolderId, id)
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
);
|
||||
if (undeletedCount != null && undeletedCount > 0) {
|
||||
throw new RuntimeException("请先处理该目录下的子文件后重试");
|
||||
}
|
||||
|
||||
// 检查是否有已删除的子文件(在回收站里的)
|
||||
Long deletedChildrenCount = fileMapper.selectCount(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getFolderId, id)
|
||||
.eq(FileEntity::getIsDeleted, 1)
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
);
|
||||
if (deletedChildrenCount != null && deletedChildrenCount > 0) {
|
||||
throw new RuntimeException("请先处理该目录下的子文件后重试");
|
||||
}
|
||||
// 递归彻底删除所有子文件/子文件夹
|
||||
deleteChildrenPermanently(id, userId);
|
||||
}
|
||||
|
||||
// 删除当前文件的物理文件并扣减存储
|
||||
@@ -201,11 +218,58 @@ public class FileService {
|
||||
fileMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归彻底删除文件夹下所有子文件/子文件夹
|
||||
*/
|
||||
private void deleteChildrenPermanently(Long parentFolderId, Long userId) {
|
||||
List<FileEntity> children = fileMapper.selectList(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getFolderId, parentFolderId)
|
||||
.eq(FileEntity::getIsDeleted, 1)
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
);
|
||||
|
||||
for (FileEntity child : children) {
|
||||
if (child.getIsFolder() != null && child.getIsFolder() == 1) {
|
||||
deleteChildrenPermanently(child.getId(), userId);
|
||||
}
|
||||
|
||||
// 删除物理文件并扣减存储
|
||||
if (child.getPath() != null && !child.getPath().isEmpty()) {
|
||||
try {
|
||||
Path filePath = Paths.get(storagePath).toAbsolutePath().resolve("files").resolve(child.getPath());
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException e) {
|
||||
// ignore
|
||||
}
|
||||
if (child.getSize() != null && child.getSize() > 0) {
|
||||
userService.decreaseStorage(userId, child.getSize());
|
||||
}
|
||||
}
|
||||
fileMapper.deleteById(child.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void emptyTrash(Long userId) {
|
||||
List<FileEntity> trashFiles = getTrashFiles(userId, null);
|
||||
|
||||
// 找出回收站里所有文件夹的 ID
|
||||
java.util.Set<Long> folderIds = trashFiles.stream()
|
||||
.filter(f -> f.getIsFolder() != null && f.getIsFolder() == 1)
|
||||
.map(FileEntity::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
for (FileEntity file : trashFiles) {
|
||||
deletePermanently(file.getId(), userId);
|
||||
// 跳过子文件(folderId 指向的父文件夹也在回收站中,会被级联删除)
|
||||
if (folderIds.contains(file.getFolderId())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
deletePermanently(file.getId(), userId);
|
||||
} catch (Exception e) {
|
||||
// ignore,级联删除时子文件可能已不存在
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +280,8 @@ public class FileService {
|
||||
folder.setType("folder");
|
||||
folder.setIsFolder(1);
|
||||
folder.setUserId(userId);
|
||||
folder.setFolderId(parentId);
|
||||
// 根目录 parentId = null 或 0 → 统一用 0
|
||||
folder.setFolderId((parentId == null || parentId == 0L) ? 0L : parentId);
|
||||
folder.setSize(0L);
|
||||
folder.setIsShared(0);
|
||||
folder.setIsDeleted(0);
|
||||
@@ -344,9 +409,13 @@ public class FileService {
|
||||
new LambdaQueryWrapper<FileShare>().eq(FileShare::getShareToUserId, userId)
|
||||
);
|
||||
|
||||
// 按 fileId 去重,只保留一条记录
|
||||
return shares.stream()
|
||||
.map(share -> fileMapper.selectById(share.getFileId()))
|
||||
.filter(f -> f != null && f.getIsDeleted() == 0)
|
||||
.collect(Collectors.toMap(FileEntity::getId, f -> f, (a, b) -> a))
|
||||
.values()
|
||||
.stream()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -358,7 +427,7 @@ public class FileService {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 检查是否有共享权限
|
||||
// 检查是否有共享权限(当前用户是被共享的对象)
|
||||
List<FileShare> shares = fileShareMapper.selectList(
|
||||
new LambdaQueryWrapper<FileShare>()
|
||||
.eq(FileShare::getFileId, folderId)
|
||||
@@ -369,13 +438,33 @@ public class FileService {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 返回该文件夹内的文件
|
||||
LambdaQueryWrapper<FileEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(FileEntity::getFolderId, folderId)
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.isNull(FileEntity::getDeletedAt);
|
||||
// 用文件夹原主人的 ID 查询子文件(属于文件夹所有者的文件)
|
||||
Long ownerId = folder.getUserId();
|
||||
|
||||
// 返回该文件夹内的文件(包括自己上传的和别人共享进来的)
|
||||
// 属于 owner 的文件:用 ownerId 查
|
||||
LambdaQueryWrapper<FileEntity> ownerWrapper = new LambdaQueryWrapper<>();
|
||||
ownerWrapper.eq(FileEntity::getFolderId, folderId)
|
||||
.eq(FileEntity::getUserId, ownerId)
|
||||
.eq(FileEntity::getIsDeleted, 0);
|
||||
|
||||
return fileMapper.selectList(wrapper);
|
||||
List<FileEntity> results = new ArrayList<>(fileMapper.selectList(ownerWrapper));
|
||||
|
||||
// 属于当前用户的文件(通过共享进入的子文件夹),去掉 deleted 条件
|
||||
LambdaQueryWrapper<FileEntity> userWrapper = new LambdaQueryWrapper<>();
|
||||
userWrapper.eq(FileEntity::getFolderId, folderId)
|
||||
.ne(FileEntity::getUserId, ownerId)
|
||||
.eq(FileEntity::getIsDeleted, 0);
|
||||
List<FileEntity> sharedChildren = fileMapper.selectList(userWrapper);
|
||||
|
||||
for (FileEntity child : sharedChildren) {
|
||||
// 只有当前用户有权访问的才加入(直接共享或通过父文件夹共享)
|
||||
if (canAccessFile(child.getId(), userId)) {
|
||||
results.add(child);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
@@ -467,8 +556,13 @@ public class FileService {
|
||||
throw new RuntimeException("无法移动回收站中的文件");
|
||||
}
|
||||
|
||||
// 检查目标文件夹是否存在(如果不是根目录)
|
||||
if (targetFolderId != null) {
|
||||
// 规范化 targetFolderId:null → 0L(根目录)
|
||||
if (targetFolderId == null) {
|
||||
targetFolderId = 0L;
|
||||
}
|
||||
|
||||
// 检查目标文件夹是否存在(如果不是根目录,folderId=0 视为根目录)
|
||||
if (!targetFolderId.equals(0L)) {
|
||||
FileEntity targetFolder = fileMapper.selectById(targetFolderId);
|
||||
if (targetFolder == null) {
|
||||
throw new RuntimeException("目标文件夹不存在");
|
||||
@@ -498,20 +592,22 @@ public class FileService {
|
||||
}
|
||||
|
||||
// 检查目标位置是否已有同名文件
|
||||
FileEntity existing = fileMapper.selectOne(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
.eq(FileEntity::getFolderId, targetFolderId)
|
||||
.eq(FileEntity::getName, file.getName())
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.ne(FileEntity::getId, fileId)
|
||||
);
|
||||
LambdaQueryWrapper<FileEntity> dupWrapper = new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getUserId, userId)
|
||||
.eq(FileEntity::getName, file.getName())
|
||||
.eq(FileEntity::getIsDeleted, 0)
|
||||
.ne(FileEntity::getId, fileId);
|
||||
|
||||
// targetFolderId 为 null 或 0 → 根目录,统一用 eq(0L)
|
||||
Long actualTarget = (targetFolderId == null || targetFolderId == 0L) ? 0L : targetFolderId;
|
||||
dupWrapper.eq(FileEntity::getFolderId, actualTarget);
|
||||
|
||||
FileEntity existing = fileMapper.selectOne(dupWrapper);
|
||||
if (existing != null) {
|
||||
throw new RuntimeException("目标位置已存在同名文件");
|
||||
}
|
||||
|
||||
file.setFolderId(targetFolderId);
|
||||
// 使用直接更新确保 null 值也能被设置
|
||||
// 使用直接更新,folderId=0 表示根目录,null 表示"我的文档"原始状态(已在 moveToTrash 时处理)
|
||||
fileMapper.update(null,
|
||||
new LambdaUpdateWrapper<FileEntity>()
|
||||
.eq(FileEntity::getId, fileId)
|
||||
@@ -669,9 +765,13 @@ public class FileService {
|
||||
private List<FileEntity> buildFolderTree(List<FileEntity> allFolders, Long parentId, List<Long> excludeIds) {
|
||||
List<FileEntity> tree = new ArrayList<>();
|
||||
for (FileEntity folder : allFolders) {
|
||||
// 匹配父级关系
|
||||
boolean isChildOfParent = (parentId == null && folder.getFolderId() == null)
|
||||
|| (parentId != null && parentId.equals(folder.getFolderId()));
|
||||
// 匹配父级关系:parentId == null 或 0 都视为根目录,匹配 folderId == 0 的文件夹
|
||||
boolean isChildOfParent;
|
||||
if (parentId == null || parentId == 0L) {
|
||||
isChildOfParent = (folder.getFolderId() != null && folder.getFolderId() == 0L);
|
||||
} else {
|
||||
isChildOfParent = parentId.equals(folder.getFolderId());
|
||||
}
|
||||
|
||||
if (isChildOfParent) {
|
||||
// 递归构建子文件夹
|
||||
@@ -683,4 +783,49 @@ public class FileService {
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回收站中超过指定天数的文件
|
||||
* @param retentionDays 保留天数
|
||||
* @return 删除的文件数量
|
||||
*/
|
||||
@Transactional
|
||||
public int deleteExpiredTrashFiles(int retentionDays) {
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(retentionDays);
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
// 查询所有在回收站且超过保留天数的文件
|
||||
List<FileEntity> expiredFiles = fileMapper.selectList(
|
||||
new LambdaQueryWrapper<FileEntity>()
|
||||
.eq(FileEntity::getIsDeleted, 1)
|
||||
.isNotNull(FileEntity::getDeletedAt)
|
||||
);
|
||||
|
||||
int deletedCount = 0;
|
||||
for (FileEntity file : expiredFiles) {
|
||||
try {
|
||||
LocalDateTime deletedAt = LocalDateTime.parse(file.getDeletedAt(), formatter);
|
||||
if (deletedAt.isBefore(cutoffDate)) {
|
||||
// 彻底删除
|
||||
if (file.getPath() != null && !file.getPath().isEmpty()) {
|
||||
try {
|
||||
Path filePath = Paths.get(storagePath).toAbsolutePath().resolve("files").resolve(file.getPath());
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException e) {
|
||||
// ignore
|
||||
}
|
||||
if (file.getSize() != null && file.getSize() > 0) {
|
||||
userService.decreaseStorage(file.getUserId(), file.getSize());
|
||||
}
|
||||
}
|
||||
fileMapper.deleteById(file.getId());
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 日期解析失败,跳过
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.filesystem.entity.Message;
|
||||
import com.filesystem.mapper.MessageMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -14,8 +17,23 @@ public class MessageService {
|
||||
|
||||
@Resource
|
||||
private MessageMapper messageMapper;
|
||||
|
||||
@Autowired
|
||||
private StringRedisTemplate redisTemplate;
|
||||
|
||||
private static final String CONV_DELETED_KEY = "msg:del:";
|
||||
private static final int DEFAULT_DAYS = 30; // 默认显示最近30天
|
||||
|
||||
public List<Message> getMessages(Long userId1, Long userId2) {
|
||||
return getMessages(userId1, userId2, DEFAULT_DAYS);
|
||||
}
|
||||
|
||||
public List<Message> getMessages(Long userId1, Long userId2, int days) {
|
||||
String convKey = buildConvKey(userId1, userId2);
|
||||
String deletedKey = CONV_DELETED_KEY + userId1 + ":" + convKey;
|
||||
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||
|
||||
return messageMapper.selectList(
|
||||
new LambdaQueryWrapper<Message>()
|
||||
.and(wrapper -> wrapper
|
||||
@@ -23,10 +41,86 @@ public class MessageService {
|
||||
.or()
|
||||
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||
)
|
||||
.ge(Message::getCreateTime, since)
|
||||
.orderByAsc(Message::getCreateTime)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史消息(30天之前的)
|
||||
*/
|
||||
public List<Message> getHistoryMessages(Long userId1, Long userId2, LocalDateTime beforeTime, int limit) {
|
||||
return messageMapper.selectList(
|
||||
new LambdaQueryWrapper<Message>()
|
||||
.and(wrapper -> wrapper
|
||||
.eq(Message::getFromUserId, userId1).eq(Message::getToUserId, userId2)
|
||||
.or()
|
||||
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||
)
|
||||
.lt(Message::getCreateTime, beforeTime)
|
||||
.orderByDesc(Message::getCreateTime)
|
||||
.last("LIMIT " + limit)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的消息(分页加载)
|
||||
*/
|
||||
public List<Message> getRecentMessages(Long userId1, Long userId2, int limit) {
|
||||
return messageMapper.selectList(
|
||||
new LambdaQueryWrapper<Message>()
|
||||
.and(wrapper -> wrapper
|
||||
.eq(Message::getFromUserId, userId1).eq(Message::getToUserId, userId2)
|
||||
.or()
|
||||
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||
)
|
||||
.orderByDesc(Message::getCreateTime)
|
||||
.last("LIMIT " + limit)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最早的30天消息,用于判断是否有更早的消息
|
||||
*/
|
||||
public LocalDateTime getEarliestMessageTime(Long userId1, Long userId2) {
|
||||
List<Message> messages = messageMapper.selectList(
|
||||
new LambdaQueryWrapper<Message>()
|
||||
.and(wrapper -> wrapper
|
||||
.eq(Message::getFromUserId, userId1).eq(Message::getToUserId, userId2)
|
||||
.or()
|
||||
.eq(Message::getFromUserId, userId2).eq(Message::getToUserId, userId1)
|
||||
)
|
||||
.orderByAsc(Message::getCreateTime)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
return messages.isEmpty() ? null : messages.get(0).getCreateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除与某个用户的聊天记录(双向,本人会看不到)
|
||||
*/
|
||||
public void deleteConversation(Long userId, Long withUserId) {
|
||||
String convKey = buildConvKey(userId, withUserId);
|
||||
String deletedKey = CONV_DELETED_KEY + userId + ":" + convKey;
|
||||
// 保留7天
|
||||
redisTemplate.opsForValue().set(deletedKey, "1", java.time.Duration.ofDays(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查与某人的对话是否已被当前用户删除
|
||||
*/
|
||||
public boolean isConversationDeleted(Long userId, Long withUserId) {
|
||||
String convKey = buildConvKey(userId, withUserId);
|
||||
String deletedKey = CONV_DELETED_KEY + userId + ":" + convKey;
|
||||
return Boolean.TRUE.equals(redisTemplate.hasKey(deletedKey));
|
||||
}
|
||||
|
||||
private String buildConvKey(Long userId1, Long userId2) {
|
||||
long min = Math.min(userId1, userId2);
|
||||
long max = Math.max(userId1, userId2);
|
||||
return min + "_" + max;
|
||||
}
|
||||
|
||||
public Message sendMessage(Long fromUserId, Long toUserId, String content, String type) {
|
||||
Message message = new Message();
|
||||
message.setFromUserId(fromUserId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.filesystem.mapper.FileMapper;
|
||||
import com.filesystem.mapper.UserMapper;
|
||||
import com.filesystem.security.JwtUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -27,6 +28,9 @@ public class UserService {
|
||||
@Resource
|
||||
private BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${file.user.storage-limit-gb:50}")
|
||||
private int storageLimitGb;
|
||||
|
||||
public User findByUsername(String username) {
|
||||
return userMapper.selectOne(
|
||||
new LambdaQueryWrapper<User>().eq(User::getUsername, username)
|
||||
@@ -37,7 +41,7 @@ public class UserService {
|
||||
return userMapper.selectById(id);
|
||||
}
|
||||
|
||||
public String login(String username, String password) {
|
||||
public String login(String username, String password, String clientIp) {
|
||||
User user = findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
@@ -45,21 +49,92 @@ public class UserService {
|
||||
if (!passwordEncoder.matches(password, user.getPassword())) {
|
||||
throw new RuntimeException("密码错误");
|
||||
}
|
||||
// 校验 IP:检查客户端 IP 是否在允许范围内
|
||||
String allowedIps = user.getAllowedIps();
|
||||
if (!isIpAllowed(clientIp, allowedIps)) {
|
||||
throw new RuntimeException("登录IP不在允许范围内");
|
||||
}
|
||||
return jwtUtil.generateToken(username, user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查客户端IP是否在允许的IP段列表中
|
||||
*/
|
||||
private boolean isIpAllowed(String clientIp, String allowedIps) {
|
||||
if (clientIp == null || clientIp.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] ips = allowedIps.split(",");
|
||||
for (String ip : ips) {
|
||||
ip = ip.trim();
|
||||
if (ip.isEmpty()) continue;
|
||||
|
||||
// 精确匹配
|
||||
if (clientIp.equals(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 网段匹配(如 192.168.1.0/24)
|
||||
if (ip.contains("/")) {
|
||||
if (cidrContains(ip, clientIp)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 CIDR 网段是否包含指定 IP
|
||||
*/
|
||||
private boolean cidrContains(String cidr, String ip) {
|
||||
try {
|
||||
String[] parts = cidr.split("/");
|
||||
String baseIp = parts[0];
|
||||
int prefix = Integer.parseInt(parts[1]);
|
||||
|
||||
byte[] ipBytes = ipToBytes(ip);
|
||||
byte[] baseBytes = ipToBytes(baseIp);
|
||||
|
||||
int mask = 0xFFFFFFFF << (32 - prefix);
|
||||
|
||||
int ipInt = bytesToInt(ipBytes);
|
||||
int baseInt = bytesToInt(baseBytes);
|
||||
|
||||
return (ipInt & mask) == (baseInt & mask);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] ipToBytes(String ip) {
|
||||
String[] parts = ip.split("\\.");
|
||||
byte[] bytes = new byte[4];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
bytes[i] = (byte) Integer.parseInt(parts[i]);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private int bytesToInt(byte[] bytes) {
|
||||
return ((bytes[0] & 0xFF) << 24) | ((bytes[1] & 0xFF) << 16) |
|
||||
((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
|
||||
}
|
||||
|
||||
public void logout(Long userId) {
|
||||
jwtUtil.invalidateToken(userId);
|
||||
}
|
||||
|
||||
public User createUser(String username, String password, String nickname) {
|
||||
public User createUser(String username, String password, String nickname, String registerIp) {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
user.setNickname(nickname);
|
||||
user.setStatus(1);
|
||||
user.setStorageUsed(0L);
|
||||
user.setStorageLimit(20L * 1024 * 1024 * 1024); // 20GB
|
||||
user.setStorageLimit((long) storageLimitGb * 1024 * 1024 * 1024);
|
||||
user.setRegisterIp(registerIp);
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
@@ -84,9 +159,9 @@ public class UserService {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user != null) {
|
||||
user.setStorageUsed(total);
|
||||
// 同步存储上限为 20GB(兼容老用户)
|
||||
if (user.getStorageLimit() == null || user.getStorageLimit() < 20L * 1024 * 1024 * 1024) {
|
||||
user.setStorageLimit(20L * 1024 * 1024 * 1024);
|
||||
// 同步存储上限(兼容老用户)
|
||||
if (user.getStorageLimit() == null || user.getStorageLimit() < (long) storageLimitGb * 1024 * 1024 * 1024) {
|
||||
user.setStorageLimit((long) storageLimitGb * 1024 * 1024 * 1024);
|
||||
}
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
|
||||
27
src/main/java/com/filesystem/task/TrashCleanupTask.java
Normal file
27
src/main/java/com/filesystem/task/TrashCleanupTask.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package com.filesystem.task;
|
||||
|
||||
import com.filesystem.service.FileService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class TrashCleanupTask {
|
||||
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
|
||||
@Value("${file.trash.retention-days:30}")
|
||||
private int retentionDays;
|
||||
|
||||
/**
|
||||
* 每天凌晨2点执行清理回收站任务
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void cleanupExpiredTrashFiles() {
|
||||
System.out.println("[TrashCleanupTask] 开始清理回收站过期文件,保留天数: " + retentionDays);
|
||||
int deletedCount = fileService.deleteExpiredTrashFiles(retentionDays);
|
||||
System.out.println("[TrashCleanupTask] 清理完成,共删除 " + deletedCount + " 个文件");
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@ public class ChatHandler extends TextWebSocketHandler {
|
||||
messageData.put("fromNickname", fromUser.getNickname() != null ? fromUser.getNickname() : fromUser.getUsername());
|
||||
messageData.put("fromAvatar", fromUser.getAvatar());
|
||||
messageData.put("fromSignature", fromUser.getSignature());
|
||||
messageData.put("fromPhone", fromUser.getPhone());
|
||||
messageData.put("fromEmail", fromUser.getEmail());
|
||||
}
|
||||
|
||||
Map<String, Object> resp = Map.of("type", "chat", "message", messageData);
|
||||
|
||||
@@ -7,20 +7,20 @@ spring:
|
||||
username: dream
|
||||
password: info_dream
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: 192.168.31.194
|
||||
port: 16379
|
||||
database: 0
|
||||
database: 9
|
||||
timeout: 10000ms
|
||||
password: admin
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
enabled: true
|
||||
max-file-size: 1024MB
|
||||
max-request-size: 2048MB
|
||||
max-file-size: 512MB
|
||||
max-request-size: 1024MB
|
||||
file-size-threshold: 0
|
||||
|
||||
mybatis-plus:
|
||||
@@ -36,6 +36,10 @@ mybatis-plus:
|
||||
file:
|
||||
storage:
|
||||
path: /ogsapp/uploads
|
||||
user:
|
||||
storage-limit-gb: 200
|
||||
trash:
|
||||
retention-days: 30
|
||||
|
||||
jwt:
|
||||
secret: mySecretKeyForJWTTokenGenerationThatIsLongEnough256BitsForHS256Algorithm
|
||||
|
||||
@@ -14,8 +14,11 @@ CREATE TABLE IF NOT EXISTS sys_user (
|
||||
email VARCHAR(100) COMMENT '邮箱',
|
||||
phone VARCHAR(20) COMMENT '手机号',
|
||||
status INT DEFAULT 1 COMMENT '状态 0-禁用 1-启用',
|
||||
role VARCHAR(20) DEFAULT 'user' COMMENT '角色 admin-管理员 user-普通用户',
|
||||
storage_used BIGINT DEFAULT 0 COMMENT '已用存储空间(字节)',
|
||||
storage_limit BIGINT DEFAULT 10737418240 COMMENT '存储限制(字节) 默认10GB',
|
||||
register_ip VARCHAR(50) COMMENT '注册IP',
|
||||
allowed_ips VARCHAR(500) COMMENT '允许的IP段,逗号分隔',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
is_deleted INT DEFAULT 0 COMMENT '是否删除 0-否 1-是'
|
||||
@@ -73,6 +76,6 @@ CREATE TABLE IF NOT EXISTS sys_message (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息表';
|
||||
|
||||
-- 插入默认管理员账户 (密码: admin123)
|
||||
INSERT INTO sys_user (username, password, nickname, status, storage_limit)
|
||||
VALUES ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', '管理员', 1, 10737418240)
|
||||
INSERT INTO sys_user (username, password, nickname, status, storage_limit, role, allowed_ips)
|
||||
VALUES ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', '管理员', 1, 10737418240, 'admin', '0.0.0.0/0')
|
||||
ON DUPLICATE KEY UPDATE username = username;
|
||||
|
||||
9
web-vue/package-lock.json
generated
9
web-vue/package-lock.json
generated
@@ -886,7 +886,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1422,15 +1421,13 @@
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -1637,7 +1634,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -1697,7 +1693,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
|
||||
"integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.31",
|
||||
"@vue/compiler-sfc": "3.5.31",
|
||||
|
||||
@@ -5,3 +5,5 @@ export const login = (data) => request.post('/auth/login', data)
|
||||
export const register = (data) => request.post('/auth/register', data)
|
||||
|
||||
export const getCurrentUser = () => request.get('/auth/info')
|
||||
|
||||
export const getCaptcha = () => request.get('/captcha')
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
export const getMessages = (params) => request.get('/messages', { params })
|
||||
|
||||
export const getHistoryMessages = (params) => request.get('/messages/history', { params })
|
||||
|
||||
export const sendMessage = (data) => request.post('/messages', data)
|
||||
|
||||
export const getUnreadCount = () => request.get('/messages/unreadCount')
|
||||
@@ -10,6 +12,8 @@ export const getUnreadList = () => request.get('/messages/unreadList')
|
||||
|
||||
export const markAsRead = (id) => request.post(`/messages/${id}/read`)
|
||||
|
||||
export const deleteConversation = (withUserId) => request.delete(`/messages/conversation/${withUserId}`)
|
||||
|
||||
export const getUsers = () => request.get('/messages/users')
|
||||
|
||||
// 聊天文件上传(图片和文件统一接口)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 300000
|
||||
timeout: 0 // 不限制超时,大文件上传需要
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
@@ -19,7 +19,24 @@ request.interceptors.request.use(
|
||||
request.interceptors.response.use(
|
||||
response => response.data,
|
||||
error => {
|
||||
// 后端不可用(网络错误、无响应),跳转到登录页
|
||||
if (!error.response) {
|
||||
const isLoginPage = window.location.pathname === '/login'
|
||||
if (!isLoginPage) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('userId')
|
||||
localStorage.removeItem('nickname')
|
||||
localStorage.removeItem('avatar')
|
||||
localStorage.removeItem('storageUsed')
|
||||
localStorage.removeItem('storageLimit')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const status = error.response?.status
|
||||
const errorMsg = error.response?.data?.message || error.message || '请求失败'
|
||||
|
||||
// 只有 401/403 才清理 token 并跳转登录页
|
||||
// 但在登录页时不跳转(避免死循环),登录接口的 401 也不跳转
|
||||
@@ -41,9 +58,20 @@ request.interceptors.response.use(
|
||||
localStorage.removeItem('storageLimit')
|
||||
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
// 413 文件过大,返回带消息的错误
|
||||
if (status === 413) {
|
||||
const err = new Error(errorMsg)
|
||||
err.response = error.response
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
// 其他错误,返回带消息的错误
|
||||
const err = new Error(errorMsg)
|
||||
err.response = error.response
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import request from './request'
|
||||
|
||||
export const getUsers = () => request.get('/users')
|
||||
|
||||
export const getSystemConfig = () => request.get('/users/config')
|
||||
|
||||
@@ -50,7 +50,7 @@ const visible = computed({
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const targetFolderId = ref(null)
|
||||
const targetFolderId = ref(0)
|
||||
|
||||
// 树形配置
|
||||
const treeProps = {
|
||||
@@ -64,7 +64,7 @@ const folderTreeData = computed(() => {
|
||||
// 根节点
|
||||
const rootNodes = [
|
||||
{
|
||||
id: 'root',
|
||||
id: 0,
|
||||
name: '根目录',
|
||||
children: []
|
||||
}
|
||||
@@ -90,20 +90,21 @@ const folderTreeData = computed(() => {
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
// targetFolderId = 0 表示根目录,null 表示未选择
|
||||
emit('confirm', targetFolderId.value)
|
||||
visible.value = false
|
||||
targetFolderId.value = null
|
||||
targetFolderId.value = 0
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
targetFolderId.value = null
|
||||
targetFolderId.value = 0
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 重置选中状态
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
targetFolderId.value = null
|
||||
targetFolderId.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -39,9 +39,11 @@
|
||||
<div class="sidebar-item-name">{{ chat.name }}</div>
|
||||
<div class="sidebar-item-msg">{{ chat.lastMsg || '暂无消息' }}</div>
|
||||
</div>
|
||||
<el-button v-if="chat.lastMsg" class="delete-btn" link type="danger" @click.stop="deleteRecentChat(chat)">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
<el-tooltip content="删除会话" placement="top">
|
||||
<el-button link type="danger" @click.stop="deleteRecentChat(chat)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="recentChats.length === 0" class="sidebar-empty">暂无最近聊天</div>
|
||||
</div>
|
||||
@@ -76,13 +78,18 @@
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="chat-messages" ref="messagesRef">
|
||||
<div class="chat-messages" ref="messagesRef" @scroll="handleScroll">
|
||||
<div class="history-loader" v-if="hasMoreHistory" @click="loadMoreHistory">
|
||||
<el-button link type="primary" :loading="loadingHistory">
|
||||
加载更多消息
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="loadingMessages" class="messages-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon> 加载中..
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="msg in currentMessages" :key="msg.id" class="message-wrapper" :class="{ self: msg.isSelf }">
|
||||
<el-popover placement="left" :width="200" trigger="hover">
|
||||
<el-popover :placement="msg.isSelf ? 'left' : 'right'" :width="240" trigger="hover">
|
||||
<template #reference>
|
||||
<el-avatar
|
||||
:size="30"
|
||||
@@ -102,9 +109,17 @@
|
||||
<span class="user-info-label">昵称:</span>
|
||||
<span class="user-info-value">{{ msg.isSelf ? (currentUserNickname || currentUserName) : (msg.fromNickname || '未知') }}</span>
|
||||
</div>
|
||||
<div class="user-info-row signature-row">
|
||||
<div class="user-info-row">
|
||||
<span class="user-info-label">电话:</span>
|
||||
<span class="user-info-value">{{ msg.isSelf ? (userStore.phone || '暂无') : (msg.fromPhone || '暂无') }}</span>
|
||||
</div>
|
||||
<div class="user-info-row">
|
||||
<span class="user-info-label">邮箱:</span>
|
||||
<span class="user-info-value">{{ msg.isSelf ? (userStore.email || '暂无') : (msg.fromEmail || '暂无') }}</span>
|
||||
</div>
|
||||
<div class="user-info-row">
|
||||
<span class="user-info-label">签名:</span>
|
||||
<div class="user-info-signature">
|
||||
<div class="user-info-signature-box">
|
||||
{{ msg.isSelf ? (userStore.signature || '暂无') : (msg.fromSignature || '暂无') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,9 +230,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatLineRound, User, Search, Close, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion } from '@element-plus/icons-vue'
|
||||
import { ChatLineRound, User, Search, Loading, Picture, Paperclip, Download, ChatDotRound, Promotion, Delete } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getUsers, getMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList } from '@/api/message'
|
||||
import { getUsers, getMessages, getHistoryMessages, sendMessage as sendMessageApi, uploadChatFile, getUnreadList, deleteConversation } from '@/api/message'
|
||||
import { chatService } from '@/services/chat'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
@@ -240,6 +255,8 @@ const currentUserNickname = ref('')
|
||||
const currentContact = ref(null)
|
||||
const inputText = ref('')
|
||||
const loadingMessages = ref(false)
|
||||
const loadingHistory = ref(false)
|
||||
const hasMoreHistory = ref(false)
|
||||
const emojiVisible = ref(false)
|
||||
const messagesRef = ref(null)
|
||||
const imageInputRef = ref(null)
|
||||
@@ -370,10 +387,11 @@ const loadUnreadChats = async () => {
|
||||
const selectContact = async (contact) => {
|
||||
currentContact.value = contact
|
||||
contact.unread = 0
|
||||
hasMoreHistory.value = false
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const res = await getMessages({ userId: contact.id })
|
||||
messages.value[contact.id] = (res.data || []).map(msg => {
|
||||
const list = (res.data || []).map(msg => {
|
||||
const isSelf = String(msg.fromUserId) === String(userStore.userId)
|
||||
return {
|
||||
...msg,
|
||||
@@ -383,6 +401,9 @@ const selectContact = async (contact) => {
|
||||
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
|
||||
}
|
||||
})
|
||||
messages.value[contact.id] = list
|
||||
// 如果消息条数 >= 20,可能还有更早的历史
|
||||
hasMoreHistory.value = list.length >= 20
|
||||
updateRecentChat(contact, '')
|
||||
} catch (e) {
|
||||
ElMessage.error('加载消息失败')
|
||||
@@ -392,6 +413,51 @@ const selectContact = async (contact) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreHistory = async () => {
|
||||
if (!currentContact.value || loadingHistory.value || !hasMoreHistory.value) return
|
||||
const list = messages.value[currentContact.value.id]
|
||||
if (!list || list.length === 0) return
|
||||
|
||||
loadingHistory.value = true
|
||||
// 记录当前滚动高度,加载后恢复
|
||||
const prevScrollHeight = messagesRef.value?.scrollHeight || 0
|
||||
try {
|
||||
const firstMsg = list[0]
|
||||
const beforeTime = firstMsg.createTime || firstMsg.id
|
||||
const res = await getHistoryMessages({ userId: currentContact.value.id, beforeTime, limit: 20 })
|
||||
const historyList = (res.data || []).map(msg => {
|
||||
const isSelf = String(msg.fromUserId) === String(userStore.userId)
|
||||
return {
|
||||
...msg,
|
||||
isSelf,
|
||||
sending: false,
|
||||
failed: false,
|
||||
fromColor: colors[Math.abs(Number(isSelf ? userStore.userId : msg.fromUserId) % colors.length)]
|
||||
}
|
||||
})
|
||||
|
||||
if (historyList.length === 0) {
|
||||
hasMoreHistory.value = false
|
||||
} else {
|
||||
// 插入到消息列表前面
|
||||
messages.value[currentContact.value.id] = [...historyList, ...list]
|
||||
// 如果返回条数少于 limit,没有更多了
|
||||
hasMoreHistory.value = historyList.length >= 20
|
||||
// 恢复滚动位置
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
const newScrollHeight = messagesRef.value.scrollHeight
|
||||
messagesRef.value.scrollTop = newScrollHeight - prevScrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('加载历史消息失败')
|
||||
} finally {
|
||||
loadingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateRecentChat = (contact, lastMsg) => {
|
||||
let chat = recentChats.value.find(c => c.id === contact.id)
|
||||
if (!chat) {
|
||||
@@ -571,7 +637,12 @@ const downloadFile = async (msg) => {
|
||||
|
||||
// ======== 其他操作 ========
|
||||
|
||||
const deleteRecentChat = (chat) => {
|
||||
const deleteRecentChat = async (chat) => {
|
||||
try {
|
||||
await deleteConversation(chat.id)
|
||||
} catch (e) {
|
||||
// 即使 API 失败也删本地
|
||||
}
|
||||
const idx = recentChats.value.findIndex(c => c.id === chat.id)
|
||||
if (idx > -1) recentChats.value.splice(idx, 1)
|
||||
if (currentContact.value?.id === chat.id) currentContact.value = null
|
||||
@@ -585,6 +656,16 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 滚动到顶部时加载更多历史消息
|
||||
const handleScroll = () => {
|
||||
if (!messagesRef.value || !currentContact.value) return
|
||||
const { scrollTop } = messagesRef.value
|
||||
// 滚动到顶部 50px 以内时触发
|
||||
if (scrollTop < 50 && hasMoreHistory.value && !loadingHistory.value) {
|
||||
loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// ======== WebSocket ========
|
||||
|
||||
const onDialogOpen = () => {
|
||||
@@ -668,7 +749,7 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container { display: flex; height: 600px; gap: 12px; }
|
||||
.chat-container { display: flex; height: calc(70vh - 120px); gap: 12px; }
|
||||
.chat-sidebar { width: 280px; display: flex; flex-direction: column; border-right: 1px solid #e4e7ed; }
|
||||
.sidebar-tabs { display: flex; border-bottom: 1px solid #e4e7ed; }
|
||||
.sidebar-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.3s; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; }
|
||||
@@ -684,8 +765,6 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
|
||||
.sidebar-item-status { font-size: 12px; color: #909399; }
|
||||
.sidebar-item-status.online { color: #67c23a; }
|
||||
.sidebar-empty { padding: 20px; text-align: center; color: #909399; font-size: 12px; }
|
||||
.delete-btn { opacity: 0; transition: opacity 0.2s; }
|
||||
.sidebar-item:hover .delete-btn { opacity: 1; }
|
||||
|
||||
.chat-main { flex: 1; display: flex; flex-direction: column; }
|
||||
.chat-header { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid #e4e7ed; }
|
||||
@@ -748,15 +827,38 @@ onUnmounted(() => { if (unsubscribeWs) unsubscribeWs() })
|
||||
.uploading-mini { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; color: #409eff; font-size: 12px; background: #ecf5ff; border-radius: 4px; }
|
||||
.uploading-mini .el-icon { font-size: 14px; }
|
||||
|
||||
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; }
|
||||
.user-info-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.user-info-label { font-weight: 500; min-width: 50px; color: #303133; }
|
||||
.user-info-popover { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
|
||||
.user-info-row { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.user-info-label { font-weight: 500; min-width: 50px; color: #303133; flex-shrink: 0; padding-top: 6px; }
|
||||
.user-info-value { color: #606266; word-break: break-all; flex: 1; }
|
||||
.signature-row { flex-direction: column; align-items: flex-start; }
|
||||
.user-info-signature { color: #606266; font-size: 12px; line-height: 1.4; word-break: break-all; margin-top: 4px; }
|
||||
.user-info-signature-box {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #909399; gap: 12px; }
|
||||
.chat-empty p { margin: 0; font-size: 14px; }
|
||||
|
||||
.chat-dialog :deep(.el-dialog) { height: 720px; margin: auto !important; top: 50% !important; transform: translateY(-50%) !important; }
|
||||
.history-loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.chat-dialog :deep(.el-dialog) {
|
||||
height: 70vh;
|
||||
margin: auto !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<el-upload
|
||||
drag
|
||||
multiple
|
||||
:limit="11"
|
||||
:limit="31"
|
||||
:auto-upload="false"
|
||||
:on-change="handleChange"
|
||||
:on-exceed="handleExceed"
|
||||
@@ -17,7 +17,7 @@
|
||||
:show-file-list="false"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择</em>(最多10个)</div>
|
||||
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择</em>(最多30个)</div>
|
||||
</el-upload>
|
||||
|
||||
<div class="storage-info">
|
||||
@@ -44,6 +44,7 @@
|
||||
<div class="file-list-footer">
|
||||
<span>共 {{ fileList.length }} 个文件,总大小:{{ formatSize(totalSize) }}</span>
|
||||
<span v-if="isOverLimit" class="warning-text">(超出剩余空间)</span>
|
||||
<span v-if="isOverSizeLimit" class="warning-text">(单次上传不能超过500MB)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +53,7 @@
|
||||
<el-icon><Close /></el-icon>
|
||||
<span style="margin-left: 4px">取消</span>
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleUpload" :loading="uploading" :disabled="isOverLimit">
|
||||
<el-button type="primary" @click="handleUpload" :loading="uploading" :disabled="isOverLimit || isOverSizeLimit">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span style="margin-left: 4px">开始上传</span>
|
||||
</el-button>
|
||||
@@ -90,17 +91,23 @@ const isOverLimit = computed(() => {
|
||||
return totalSize.value > props.remainingStorage
|
||||
})
|
||||
|
||||
// 是否超过500MB限制
|
||||
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024 // 500MB
|
||||
const isOverSizeLimit = computed(() => {
|
||||
return totalSize.value > MAX_UPLOAD_SIZE
|
||||
})
|
||||
|
||||
const handleChange = (file, list) => {
|
||||
if (list.length > 10) {
|
||||
ElMessage.warning('最多一次上传10个文件')
|
||||
fileList.value = list.slice(0, 10)
|
||||
if (list.length > 30) {
|
||||
ElMessage.warning('最多一次上传30个文件')
|
||||
fileList.value = list.slice(0, 30)
|
||||
return
|
||||
}
|
||||
fileList.value = list
|
||||
}
|
||||
|
||||
const handleExceed = () => {
|
||||
ElMessage.warning('最多一次上传10个文件')
|
||||
ElMessage.warning('最多一次上传30个文件')
|
||||
}
|
||||
|
||||
const removeFile = (index) => {
|
||||
@@ -124,6 +131,11 @@ const handleUpload = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOverSizeLimit.value) {
|
||||
ElMessage.warning('单次上传总大小不能超过500MB')
|
||||
return
|
||||
}
|
||||
|
||||
emit('upload', fileList.value.map(f => f.raw))
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getSystemConfig } from '@/api/user'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
@@ -11,7 +12,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const phone = ref(localStorage.getItem('phone') || '')
|
||||
const email = ref(localStorage.getItem('email') || '')
|
||||
const storageUsed = ref(Number(localStorage.getItem('storageUsed')) || 0)
|
||||
const storageLimit = ref(Number(localStorage.getItem('storageLimit')) || 20 * 1024 * 1024 * 1024)
|
||||
const storageLimit = ref(Number(localStorage.getItem('storageLimit')) || 0)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
@@ -54,11 +55,23 @@ export const useUserStore = defineStore('user', () => {
|
||||
localStorage.setItem('storageUsed', storageUsed.value)
|
||||
}
|
||||
if (user.storageLimit !== undefined) {
|
||||
storageLimit.value = Number(user.storageLimit) || 20 * 1024 * 1024 * 1024
|
||||
storageLimit.value = Number(user.storageLimit) || 0
|
||||
localStorage.setItem('storageLimit', storageLimit.value)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStorageLimit = async () => {
|
||||
try {
|
||||
const res = await getSystemConfig()
|
||||
if (res.data?.storageLimitBytes) {
|
||||
storageLimit.value = res.data.storageLimitBytes
|
||||
localStorage.setItem('storageLimit', storageLimit.value)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取存储配额失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
username.value = ''
|
||||
@@ -69,7 +82,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
phone.value = ''
|
||||
email.value = ''
|
||||
storageUsed.value = 0
|
||||
storageLimit.value = 20 * 1024 * 1024 * 1024
|
||||
storageLimit.value = 0
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('userId')
|
||||
@@ -82,5 +95,5 @@ export const useUserStore = defineStore('user', () => {
|
||||
localStorage.removeItem('storageLimit')
|
||||
}
|
||||
|
||||
return { token, username, userId, nickname, signature, avatar, phone, email, storageUsed, storageLimit, isLoggedIn, setToken, setUser, logout }
|
||||
return { token, username, userId, nickname, signature, avatar, phone, email, storageUsed, storageLimit, isLoggedIn, setToken, setUser, fetchStorageLimit, logout }
|
||||
})
|
||||
|
||||
@@ -225,7 +225,7 @@ const movableFolders = ref([])
|
||||
|
||||
// 存储 —— 真实数据
|
||||
const storagePercent = computed(() => {
|
||||
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
|
||||
const limit = userStore.storageLimit || 0
|
||||
const used = userStore.storageUsed || 0
|
||||
if (limit <= 0) return 0
|
||||
return Math.min(Math.round((used / limit) * 100), 100)
|
||||
@@ -237,13 +237,14 @@ const usedStorage = computed(() => {
|
||||
: (used / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
})
|
||||
const totalStorage = computed(() => {
|
||||
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
|
||||
const limit = userStore.storageLimit || 0
|
||||
if (limit <= 0) return '—'
|
||||
return (limit / (1024 * 1024 * 1024)).toFixed(0) + ' GB'
|
||||
})
|
||||
|
||||
// 剩余存储空间(字节)
|
||||
const remainingStorage = computed(() => {
|
||||
const limit = userStore.storageLimit || 20 * 1024 * 1024 * 1024
|
||||
const limit = userStore.storageLimit || 0
|
||||
const used = userStore.storageUsed || 0
|
||||
return Math.max(0, limit - used)
|
||||
})
|
||||
@@ -251,12 +252,14 @@ const remainingStorage = computed(() => {
|
||||
// 刷新存储数据(从后端精确重算)
|
||||
const refreshStorage = async () => {
|
||||
try {
|
||||
// 先获取系统配置(存储配额)
|
||||
await userStore.fetchStorageLimit()
|
||||
// 再获取用户当前用量
|
||||
const res = await getCurrentUser()
|
||||
const data = res.data
|
||||
if (data) {
|
||||
userStore.setUser({
|
||||
storageUsed: data.storageUsed ?? 0,
|
||||
storageLimit: data.storageLimit ?? 20 * 1024 * 1024 * 1024
|
||||
storageUsed: data.storageUsed ?? 0
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -386,7 +389,8 @@ const handleUpload = async (fileList) => {
|
||||
loadFiles()
|
||||
refreshStorage()
|
||||
} catch (e) {
|
||||
ElMessage.error('上传失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '上传失败'
|
||||
ElMessage.error(errorMsg)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
@@ -398,7 +402,8 @@ const handleCreateFolder = async (name) => {
|
||||
ElMessage.success('创建成功')
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('创建失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '创建失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +447,8 @@ const handleConfirmRename = async ({ id, name }) => {
|
||||
ElMessage.success('重命名成功')
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('重命名失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '重命名失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +462,8 @@ const handleDownload = async (file) => {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
ElMessage.error('下载失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '下载失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +486,8 @@ const handleRestore = async (file) => {
|
||||
ElMessage.success('已还原')
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('还原失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '还原失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +499,8 @@ const handleDeletePermanently = async (file) => {
|
||||
loadFiles()
|
||||
refreshStorage()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '删除失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +512,8 @@ const handleEmptyTrash = async () => {
|
||||
loadFiles()
|
||||
refreshStorage()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '操作失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +531,8 @@ const handleConfirmShare = async ({ users, permission }) => {
|
||||
ElMessage.success('共享成功')
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('共享失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '共享失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,7 +543,8 @@ const handleCancelShare = async (file) => {
|
||||
ElMessage.success('已取消共享')
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '操作失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,19 +617,14 @@ const handleBatchMove = async () => {
|
||||
movableFolders.value = res.data || []
|
||||
batchMoveVisible.value = true
|
||||
} catch (e) {
|
||||
ElMessage.error('获取目录列表失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '获取目录列表失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmBatchMove = async (targetFolderId) => {
|
||||
// Handle target folder ID
|
||||
let finalFolderId = null
|
||||
|
||||
if (targetFolderId === 'root' || targetFolderId === '' || targetFolderId === null || targetFolderId === undefined) {
|
||||
finalFolderId = null
|
||||
} else {
|
||||
finalFolderId = targetFolderId
|
||||
}
|
||||
// targetFolderId = 0 表示根目录,null 表示未选择(此时用 0 作为默认值)
|
||||
let finalFolderId = (targetFolderId === null || targetFolderId === undefined) ? 0 : targetFolderId
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
@@ -698,7 +705,8 @@ const handleBatchCancelShare = async () => {
|
||||
selectedFiles.value = []
|
||||
loadFiles()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '操作失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,12 +724,14 @@ const handleBatchRestore = async () => {
|
||||
loadFiles()
|
||||
refreshStorage()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
const errorMsg = e.response?.data?.message || e.message || '操作失败'
|
||||
ElMessage.error(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshStorage()
|
||||
onMounted(() => {
|
||||
// 并行执行,不阻塞页面渲染
|
||||
refreshStorage()
|
||||
loadFiles()
|
||||
})
|
||||
|
||||
|
||||
@@ -19,60 +19,136 @@
|
||||
<template #prefix><el-icon><Lock /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha">
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
v-model="form.captcha"
|
||||
placeholder="请输入验证码"
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #prefix><el-icon><Key /></el-icon></template>
|
||||
</el-input>
|
||||
<img
|
||||
:src="captchaImage"
|
||||
class="captcha-img"
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="!isFormComplete"
|
||||
native-type="submit"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">登录</span>
|
||||
<template v-if="loading">
|
||||
<span>登录中...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">登录</span>
|
||||
</template>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Right } from '@element-plus/icons-vue'
|
||||
import { login } from '@/api/auth'
|
||||
import { User, Lock, Right, Key } from '@element-plus/icons-vue'
|
||||
import { login, getCaptcha } from '@/api/auth'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const captchaImage = ref('')
|
||||
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const form = reactive({ username: '', password: '', captcha: '' })
|
||||
|
||||
// 表单是否填写完整
|
||||
const isFormComplete = computed(() => {
|
||||
return form.username.trim() && form.password.trim() && form.captcha.trim()
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const res = await getCaptcha()
|
||||
captchaImage.value = res.data.image
|
||||
} catch (e) {
|
||||
console.error('获取验证码失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refreshCaptcha
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid || loading.value) return
|
||||
if (!isFormComplete.value || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login(form)
|
||||
// res.data = { token, user }
|
||||
ElMessage.success('登录成功,正在跳转...')
|
||||
emit('success', res.data)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '账号或密码错误')
|
||||
// 登录失败刷新验证码
|
||||
refreshCaptcha()
|
||||
form.captcha = ''
|
||||
if (!e.response) {
|
||||
ElMessage.error('后端服务不可用,请稍后重试')
|
||||
} else if (e.response.status >= 500) {
|
||||
ElMessage.error('服务器异常,请稍后重试')
|
||||
} else {
|
||||
ElMessage.error(e.response.data?.message || '账号或密码错误')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-tips {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
margin-top: -8px;
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-row .el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
width: 140px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdfe6;
|
||||
object-fit: fill;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.captcha-img:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -107,7 +107,13 @@ const handleRegister = async () => {
|
||||
ElMessage.success('注册成功,请登录')
|
||||
emit('success')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '注册失败,请重试')
|
||||
if (!e.response) {
|
||||
ElMessage.error('后端服务不可用,请稍后重试')
|
||||
} else if (e.response.status >= 500) {
|
||||
ElMessage.error('服务器异常,请稍后重试')
|
||||
} else {
|
||||
ElMessage.error(e.response.data?.message || '注册失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<el-icon><Check /></el-icon>
|
||||
<span>20GB 超大存储空间</span>
|
||||
<span>{{ storageLimitText }} 存储空间</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<el-icon><Check /></el-icon>
|
||||
@@ -40,10 +40,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<LoginForm v-if="isLogin" @success="onLoginSuccess" />
|
||||
<LoginForm v-show="isLogin" ref="loginFormRef" @success="onLoginSuccess" />
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<RegisterForm v-else @success="onRegisterSuccess" />
|
||||
<RegisterForm v-show="!isLogin" @success="onRegisterSuccess" />
|
||||
|
||||
<!-- 底部切换 -->
|
||||
<div class="card-footer">
|
||||
@@ -58,20 +58,48 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FolderOpened, Check } from '@element-plus/icons-vue'
|
||||
import LoginForm from './LoginForm.vue'
|
||||
import RegisterForm from './RegisterForm.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { getSystemConfig } from '@/api/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isLogin = ref(true)
|
||||
const loginFormRef = ref(null)
|
||||
const storageLimitGb = ref(50)
|
||||
|
||||
const storageLimitText = computed(() => {
|
||||
return storageLimitGb.value >= 1024
|
||||
? (storageLimitGb.value / 1024).toFixed(1) + 'TB'
|
||||
: storageLimitGb.value + 'GB'
|
||||
})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await getSystemConfig()
|
||||
if (res.data?.storageLimitGb) {
|
||||
storageLimitGb.value = res.data.storageLimitGb
|
||||
}
|
||||
} catch (e) {
|
||||
// 使用默认值
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
|
||||
const toggleMode = () => {
|
||||
isLogin.value = !isLogin.value
|
||||
// 切换到登录时刷新验证码
|
||||
if (isLogin.value && loginFormRef.value) {
|
||||
loginFormRef.value.refreshCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
const onLoginSuccess = (data) => {
|
||||
|
||||
Reference in New Issue
Block a user