Compare commits

..

31 Commits

Author SHA1 Message Date
6fb6969d29 登录增加验证码切换 2026-04-06 23:18:09 +08:00
6f19f7468c 登录增加验证码切换 2026-04-06 23:15:50 +08:00
09abb50a58 登录增加验证码切换 2026-04-06 23:14:19 +08:00
35f9b9c9a6 登录增加验证码切换 2026-04-06 23:09:05 +08:00
9b16c0fc83 登录增加验证码切换 2026-04-06 23:05:56 +08:00
fae01fdf43 登录增加验证码切换 2026-04-06 23:02:59 +08:00
dd1bda704f 登录增加验证码切换 2026-04-06 22:58:55 +08:00
b0751cf45e 登录增加验证码切换 2026-04-06 22:17:50 +08:00
f556bdc09a 登录增加验证码切换 2026-04-04 00:15:12 +08:00
7a246619c4 登录增加验证码切换 2026-04-04 00:08:57 +08:00
a4980df3f1 登录增加验证码切换 2026-04-04 00:04:37 +08:00
ae3ac9b819 增加历史记录功能 2026-04-03 23:50:45 +08:00
d4cc7a6b73 增加登录验证码功能 2026-04-03 23:12:07 +08:00
d008278b6a 增加定时清理回收站文件 2026-04-03 22:33:17 +08:00
97f1482497 增加定时清理回收站文件 2026-04-03 22:27:43 +08:00
d47c60a5e0 云文件管理系统上传组件优化 2026-04-03 18:10:54 +08:00
242a4347df fix: 搜索时folderId条件始终生效,根目录folderId为null则搜全部,子目录搜本目录 2026-04-03 18:04:20 +08:00
70c69e16cc fix: 搜索时忽略folderId条件,跨子文件夹搜索文件 2026-04-03 17:52:45 +08:00
a1b17c11c1 fix: 区分500服务器错误和网络错误提示 2026-04-03 17:50:04 +08:00
bdd6c0828c fix: 登录注册页区分网络错误和业务错误提示 2026-04-03 17:47:33 +08:00
8a14c5244e fix: 后端探测加5秒超时,避免刷新长时间等待 2026-04-03 17:45:16 +08:00
4d1003e467 fix: 路由守卫进入需认证页面前验证后端可用性 2026-04-03 17:41:22 +08:00
f55ff5fc24 fix: 后端不可用时自动跳转到登录页 2026-04-03 17:37:34 +08:00
4c22109a95 fix: 放开/api/messages/conversation/**安全拦截 2026-04-03 17:35:49 +08:00
a4a18f2b82 fix: 删除会话图标改为Delete直显 2026-04-03 17:30:30 +08:00
a9ae4bce44 fix: 删除会话改用el-popover实现,点击生效 2026-04-03 17:26:34 +08:00
5367ad61db fix: 聊天删除按钮tooltip显示+点击生效 2026-04-03 17:23:12 +08:00
3f4a294128 refactor: 聊天删除按钮改用 el-dropdown 组件 2026-04-03 17:20:28 +08:00
2ee194ebdd fix: 聊天最近列表删除按钮始终可见 2026-04-03 17:18:38 +08:00
4ac6e85aca feat: 消息聊天最近列表支持删除会话 2026-04-03 17:15:02 +08:00
dc666897c6 fix: 共享去重 + 共享文件夹内容可查看 2026-04-03 16:52:05 +08:00
34 changed files with 1170 additions and 225 deletions

2
.idea/compiler.xml generated
View File

@@ -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>

View 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
View File

@@ -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>

View File

@@ -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) {

View File

@@ -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() : "服务器内部错误"));
}

View File

@@ -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()
// 其他所有请求需要认证

View File

@@ -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;
}
}

View 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 "";
}
}
}

View File

@@ -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", "已删除"));
}
}

View File

@@ -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;

View File

@@ -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"
}

View File

@@ -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<>());

View File

@@ -8,4 +8,5 @@ import lombok.Data;
public class UserPrincipal {
private Long userId;
private String username;
private String role; // admin-管理员, user-普通用户
}

View File

@@ -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) {
// 规范化 targetFolderIdnull → 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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View 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 + " 个文件");
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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')

View File

@@ -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')
// 聊天文件上传(图片和文件统一接口)

View File

@@ -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)
}
)

View File

@@ -1,3 +1,5 @@
import request from './request'
export const getUsers = () => request.get('/users')
export const getSystemConfig = () => request.get('/users/config')

View File

@@ -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
}
})

View File

@@ -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>

View File

@@ -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))
}

View File

@@ -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) {

View File

@@ -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 }
})

View File

@@ -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()
})

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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) => {