commit 59bb8e16f5058ea42c3d2bb99581cbc4b5548a55 Author: liu <362165265@qq.com> Date: Sat Jan 31 00:51:14 2026 +0800 Initial commit: DataTool backend, frontend and Docker diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f7ca56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Git +.git +.gitignore + +# IDE +.idea +.vscode +*.iml + +# 前端 +frontend/node_modules +frontend/dist +frontend/.vite + +# 后端 +backend/target + +# 文档与杂项 +docs +*.md +!README.md +.jabbarc +use-java.ps1 + +# 本地数据 +backend/data +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f887b92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Git / IDE +.git +.idea +.vscode +*.iml + +# 前端 +frontend/node_modules +frontend/dist +frontend/.vite + +# 后端 +backend/target + +# 本地 / 杂项 +backend/data +*.log +.jabbarc +use-java.ps1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9351538 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# DataTool - 房间数据传输助手 + +基于 WebSocket 的轻量级数据传输工具,适用于 VNC/远程桌面、内网隔离等场景。 + +## 技术栈 + +- **后端**: Spring Boot 3.2.5 + Java 17 + WebSocket (STOMP/SockJS) +- **前端**: Vue 3 + TypeScript + Vite + TailwindCSS + Pinia + +## 前置要求 + +### Java 版本要求 + +**重要**: 本项目需要 **Java 17 或更高版本**。 + +当前检测到您的系统使用的是 Java 8,需要升级: + +1. **下载并安装 Java 17+** + - 推荐使用 [Eclipse Temurin (Adoptium)](https://adoptium.net/) + - 或 [Oracle JDK](https://www.oracle.com/java/technologies/downloads/) + - 或 [Amazon Corretto](https://aws.amazon.com/corretto/) + +2. **配置 JAVA_HOME 环境变量** + ```powershell + # Windows PowerShell (管理员权限) + [System.Environment]::SetEnvironmentVariable('JAVA_HOME', 'C:\Program Files\Eclipse Adoptium\jdk-17.x.x-hotspot', 'Machine') + ``` + +3. **验证 Java 版本** + ```powershell + java -version + # 应该显示 java version "17.x.x" 或更高 + + mvn -v + # 应该显示 Java version: 17.x.x + ``` + +### Node.js 版本要求 + +- Node.js 18+ 和 npm 9+ + +## 快速开始 + +### 后端启动 + +```bash +cd backend +mvn clean install +mvn spring-boot:run +``` + +后端将在 `http://localhost:8080` 启动,WebSocket 端点为 `/ws`。 + +### 前端启动 + +```bash +cd frontend +npm install +npm run dev +``` + +前端将在 `http://localhost:5173` 启动。 + +## 项目结构 + +``` +DataTool/ +├── backend/ # Spring Boot 后端 +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/datatool/ +│ │ │ │ ├── config/ # WebSocket 配置 +│ │ │ │ ├── room/ # 房间与会话管理 +│ │ │ │ ├── message/ # 消息模型 +│ │ │ │ └── ws/ # WebSocket 控制器 +│ │ │ └── resources/ +│ │ │ └── application.yml +│ └── pom.xml +├── frontend/ # Vue 3 前端 +│ ├── src/ +│ │ ├── components/ # Vue 组件 +│ │ ├── views/ # 页面视图 +│ │ ├── stores/ # Pinia 状态管理 +│ │ ├── ws/ # WebSocket 客户端 +│ │ └── router/ # 路由配置 +│ └── package.json +└── docs/ # 项目文档 +``` + +## WebSocket 端点说明 + +- **连接端点**: `/ws` (支持 SockJS) +- **应用前缀**: `/app` +- **广播前缀**: `/topic` + +### 主要消息路径 + +- 加入房间: `/app/room/{roomCode}/join` +- 发送消息: `/app/room/{roomCode}/message` +- 文件分片: `/app/room/{roomCode}/file/chunk` +- 房间广播: `/topic/room/{roomCode}` +- 文件分片广播: `/topic/room/{roomCode}/file/{fileId}` + +## 开发计划 + +当前已完成基础架构搭建,包括: +- ✅ 后端 WebSocket/STOMP 配置 +- ✅ 房间与会话管理 +- ✅ 前端 Vue 3 + TypeScript + Tailwind 脚手架 +- ✅ WebSocket 客户端封装 +- ✅ 基础 UI 组件与布局 + +后续功能开发请参考 `docs/` 目录下的详细文档。 + +## 许可证 + +[待定] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..c298ae4 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.datatool + datatool-backend + 0.0.1-SNAPSHOT + DataTool Backend + DataTool WebSocket backend for room-based data transfer + + + 17 + 17 + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-validation + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/backend/src/main/java/com/datatool/DataToolApplication.java b/backend/src/main/java/com/datatool/DataToolApplication.java new file mode 100644 index 0000000..0218d51 --- /dev/null +++ b/backend/src/main/java/com/datatool/DataToolApplication.java @@ -0,0 +1,15 @@ +package com.datatool; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class DataToolApplication { + + public static void main(String[] args) { + SpringApplication.run(DataToolApplication.class, args); + } +} + diff --git a/backend/src/main/java/com/datatool/config/SpaResourceConfig.java b/backend/src/main/java/com/datatool/config/SpaResourceConfig.java new file mode 100644 index 0000000..adcd66c --- /dev/null +++ b/backend/src/main/java/com/datatool/config/SpaResourceConfig.java @@ -0,0 +1,33 @@ +package com.datatool.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.PathResourceResolver; + +import java.io.IOException; + +/** + * SPA 静态资源:将 dist 放在 classpath:/static/ 后,未匹配到文件时回退到 index.html(Vue Router history 模式)。 + */ +@Configuration +public class SpaResourceConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/**") + .addResourceLocations("classpath:/static/") + .resourceChain(true) + .addResolver(new PathResourceResolver() { + @Override + protected Resource getResource(String resourcePath, Resource location) throws IOException { + Resource resource = location.createRelative(resourcePath); + if (resource.exists() && resource.isReadable()) { + return resource; + } + return location.createRelative("index.html"); + } + }); + } +} diff --git a/backend/src/main/java/com/datatool/config/TransferProperties.java b/backend/src/main/java/com/datatool/config/TransferProperties.java new file mode 100644 index 0000000..d07294a --- /dev/null +++ b/backend/src/main/java/com/datatool/config/TransferProperties.java @@ -0,0 +1,50 @@ +package com.datatool.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 文件传输配置:上传目录、单文件大小上限、房间过期时间。 + */ +@Component +@ConfigurationProperties(prefix = "datatool.transfer") +public class TransferProperties { + + private String uploadDir = "./data/uploads"; + private long maxFileSize = 104_857_600L; // 100MB + private int roomExpireHours = 24; + /** 定时过期清理间隔(毫秒),默认 1 小时。 */ + private long cleanupIntervalMs = 3600000L; + + public String getUploadDir() { + return uploadDir; + } + + public void setUploadDir(String uploadDir) { + this.uploadDir = uploadDir; + } + + public long getMaxFileSize() { + return maxFileSize; + } + + public void setMaxFileSize(long maxFileSize) { + this.maxFileSize = maxFileSize; + } + + public int getRoomExpireHours() { + return roomExpireHours; + } + + public void setRoomExpireHours(int roomExpireHours) { + this.roomExpireHours = roomExpireHours; + } + + public long getCleanupIntervalMs() { + return cleanupIntervalMs; + } + + public void setCleanupIntervalMs(long cleanupIntervalMs) { + this.cleanupIntervalMs = cleanupIntervalMs; + } +} diff --git a/backend/src/main/java/com/datatool/config/WebSocketConfig.java b/backend/src/main/java/com/datatool/config/WebSocketConfig.java new file mode 100644 index 0000000..70b071b --- /dev/null +++ b/backend/src/main/java/com/datatool/config/WebSocketConfig.java @@ -0,0 +1,53 @@ +package com.datatool.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +/** + * WebSocket / STOMP 基础配置。 + * + * - STOMP 端点:/ws(支持 SockJS) + * - 应用前缀:/app + * - 广播前缀:/topic + * + * 与文档中约定的路径保持一致: + * - 客户端发送:/app/room/{roomCode}/join + * - 客户端发送:/app/room/{roomCode}/message + * - 客户端发送:/app/room/{roomCode}/file/chunk + * - 服务端广播:/topic/room/{roomCode}[/**] + */ +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + /** 64KB 分片 base64 后约 87KB,默认 64KB 限制会导致消息被拒、连接断开。设为 1MB 以支持文件分片。 */ + private static final int MESSAGE_SIZE_LIMIT = 1024 * 1024; + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + registration.setMessageSizeLimit(MESSAGE_SIZE_LIMIT); + registration.setSendBufferSizeLimit(MESSAGE_SIZE_LIMIT); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // 客户端订阅前缀,例如 /topic/room/{roomCode} + config.enableSimpleBroker("/topic"); + // 客户端发送消息前缀,例如 /app/room/{roomCode}/message + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // STOMP 端点,前端通过 SockJS/WebSocket 连接到此路径 + registry.addEndpoint("/ws") + // 生产环境建议改为具体域名或从配置读取 + .setAllowedOriginPatterns("*") + .withSockJS(); + } +} + diff --git a/backend/src/main/java/com/datatool/controller/RoomController.java b/backend/src/main/java/com/datatool/controller/RoomController.java new file mode 100644 index 0000000..af67f4b --- /dev/null +++ b/backend/src/main/java/com/datatool/controller/RoomController.java @@ -0,0 +1,83 @@ +package com.datatool.controller; + +import com.datatool.room.RoomService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 房间相关 REST 接口。 + * + * - POST /api/room/create:创建房间,返回 6 位房间号 + * - GET /api/room/my-ip:获取当前请求的客户端 IP,用于作为默认昵称 + */ +@RestController +@RequestMapping("/api/room") +@CrossOrigin(origins = "*") +public class RoomController { + + private final RoomService roomService; + + public RoomController(RoomService roomService) { + this.roomService = roomService; + } + + @PostMapping("/create") + public Map createRoom() { + String roomCode = roomService.createRoom(); + return Map.of("roomCode", roomCode); + } + + /** + * 获取当前请求的客户端真实 IP。 + * 优先级:X-Real-IP → X-Forwarded-For(取第一个)→ getRemoteAddr()。 + * 若结果为回环地址(127.0.0.1、::1 等),返回「本机」便于展示。 + */ + @GetMapping("/my-ip") + public Map getMyIp(HttpServletRequest request) { + String ip = request.getHeader("X-Real-IP"); + if (ip == null || ip.isBlank()) { + ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isBlank()) { + int comma = ip.indexOf(','); + ip = comma > 0 ? ip.substring(0, comma).trim() : ip.trim(); + } + } + if (ip == null || ip.isBlank()) { + ip = request.getRemoteAddr(); + } + if (ip == null) { + ip = ""; + } + // IPv4-mapped IPv6(如 ::ffff:192.168.1.1)只保留 IPv4 部分便于展示 + ip = normalizeIp(ip); + // 回环地址统一显示为「本机」 + if (isLoopback(ip)) { + ip = "本机"; + } + return Map.of("ip", ip); + } + + /** 去掉 ::ffff: 前缀,便于显示纯 IPv4 */ + private static String normalizeIp(String ip) { + if (ip == null) return ""; + String s = ip.trim(); + if (s.toLowerCase().startsWith("::ffff:")) { + return s.substring(7).trim(); + } + return s; + } + + private static boolean isLoopback(String ip) { + if (ip == null || ip.isBlank()) return true; + String normalized = ip.trim().toLowerCase(); + return "127.0.0.1".equals(normalized) + || "0:0:0:0:0:0:0:1".equals(normalized) + || "::1".equals(normalized); + } +} diff --git a/backend/src/main/java/com/datatool/controller/RoomFileController.java b/backend/src/main/java/com/datatool/controller/RoomFileController.java new file mode 100644 index 0000000..8bf4415 --- /dev/null +++ b/backend/src/main/java/com/datatool/controller/RoomFileController.java @@ -0,0 +1,179 @@ +package com.datatool.controller; + +import com.datatool.config.TransferProperties; +import com.datatool.service.UploadCleanupService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; + +/** + * 房间内文件上传/下载 REST 接口。 + * 大文件走 HTTP,上传落盘后仅通过 WebSocket 广播元数据,下载从服务器目录流式返回。 + * + * - POST /api/room/{roomCode}/file/upload + * - GET /api/room/{roomCode}/file/{fileId} + * - DELETE /api/room/{roomCode}/files (删除该房间下全部上传文件) + * - DELETE /api/room/{roomCode}/file/{fileId} (删除单个文件及 .meta) + */ +@RestController +@RequestMapping("/api/room") +@CrossOrigin(origins = "*") +public class RoomFileController { + + private final TransferProperties transferProperties; + private final UploadCleanupService uploadCleanupService; + private final ObjectMapper objectMapper; + + public RoomFileController(TransferProperties transferProperties, + UploadCleanupService uploadCleanupService, + ObjectMapper objectMapper) { + this.transferProperties = transferProperties; + this.uploadCleanupService = uploadCleanupService; + this.objectMapper = objectMapper; + } + + private Path resolveUploadRoot() { + return uploadCleanupService.resolveUploadRoot(); + } + + @PostMapping("/{roomCode}/file/upload") + public Map upload( + @PathVariable String roomCode, + @RequestParam("file") MultipartFile file) throws IOException { + if (file.isEmpty()) { + throw new IllegalArgumentException("文件不能为空"); + } + if (file.getSize() > transferProperties.getMaxFileSize()) { + throw new IllegalArgumentException("文件大小超过限制"); + } + if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) { + throw new IllegalArgumentException("房间号无效"); + } + + String fileId = "f_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + Path uploadRoot = resolveUploadRoot(); + Path baseDir = uploadRoot.resolve(roomCode); + Files.createDirectories(baseDir); + Path filePath = baseDir.resolve(fileId); + Path metaPath = baseDir.resolve(fileId + ".meta"); + + try (InputStream in = file.getInputStream()) { + Files.copy(in, filePath); + } + + Map meta = Map.of( + "fileName", sanitizeFileName(file.getOriginalFilename() != null ? file.getOriginalFilename() : "download"), + "mimeType", file.getContentType() != null ? file.getContentType() : "application/octet-stream" + ); + Files.writeString(metaPath, objectMapper.writeValueAsString(meta)); + + return Map.of( + "fileId", fileId, + "fileName", meta.get("fileName"), + "fileSize", file.getSize(), + "mimeType", meta.get("mimeType") + ); + } + + @GetMapping("/{roomCode}/file/{fileId}") + public ResponseEntity download( + @PathVariable String roomCode, + @PathVariable String fileId) throws IOException { + if (!isSafeRoomCode(roomCode) || !isSafeFileId(fileId)) { + return ResponseEntity.notFound().build(); + } + + Path uploadRoot = resolveUploadRoot(); + Path baseDir = uploadRoot.resolve(roomCode); + Path filePath = baseDir.resolve(fileId); + Path metaPath = baseDir.resolve(fileId + ".meta"); + + if (!Files.isRegularFile(filePath)) { + return ResponseEntity.notFound().build(); + } + + String fileName = fileId; + String mimeType = "application/octet-stream"; + if (Files.isRegularFile(metaPath)) { + String json = Files.readString(metaPath); + @SuppressWarnings("unchecked") + Map meta = objectMapper.readValue(json, Map.class); + fileName = meta.getOrDefault("fileName", fileId); + mimeType = meta.getOrDefault("mimeType", mimeType); + } + + String contentDisposition = "attachment; filename=\"" + escapeForQuotedString(fileName) + "\""; + + Resource resource = new FileSystemResource(filePath); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(mimeType)) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) + .body(resource); + } + + /** + * 删除该房间下全部上传文件(仅删磁盘目录,不删内存房间)。 + */ + @DeleteMapping("/{roomCode}/files") + public ResponseEntity deleteRoomFiles(@PathVariable String roomCode) { + if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) { + return ResponseEntity.notFound().build(); + } + boolean deleted = uploadCleanupService.deleteRoomFolder(roomCode); + return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + } + + /** + * 删除单个文件及其 .meta。 + */ + @DeleteMapping("/{roomCode}/file/{fileId}") + public ResponseEntity deleteFile( + @PathVariable String roomCode, + @PathVariable String fileId) throws IOException { + if (!isSafeRoomCode(roomCode) || roomCode.length() != 6 || !isSafeFileId(fileId)) { + return ResponseEntity.notFound().build(); + } + Path uploadRoot = resolveUploadRoot(); + Path baseDir = uploadRoot.resolve(roomCode); + Path filePath = baseDir.resolve(fileId); + Path metaPath = baseDir.resolve(fileId + ".meta"); + if (!Files.isRegularFile(filePath)) { + return ResponseEntity.notFound().build(); + } + Files.deleteIfExists(filePath); + Files.deleteIfExists(metaPath); + return ResponseEntity.noContent().build(); + } + + private static boolean isSafeRoomCode(String roomCode) { + return roomCode != null && roomCode.matches("[0-9]+"); + } + + private static boolean isSafeFileId(String fileId) { + return fileId != null && fileId.matches("f_[0-9]+_[a-zA-Z0-9]{12}"); + } + + private static String sanitizeFileName(String name) { + if (name == null) return "download"; + String s = name.replaceAll("[\\\\/<>:\"|?*]", "_"); + return s.isEmpty() ? "download" : s; + } + + private static String escapeForQuotedString(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/backend/src/main/java/com/datatool/message/MessagePayload.java b/backend/src/main/java/com/datatool/message/MessagePayload.java new file mode 100644 index 0000000..db43aae --- /dev/null +++ b/backend/src/main/java/com/datatool/message/MessagePayload.java @@ -0,0 +1,198 @@ +package com.datatool.message; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 统一消息模型,对应文档中的 Message。 + * + * 支持类型: + * - TEXT / IMAGE:使用 content + * - FILE:fileId + fileName + fileSize + * - CHUNK:fileId + chunkIndex + totalChunks + chunkSize + * - SYSTEM:systemCode + data + */ +public class MessagePayload { + + @NotBlank + private String roomCode; + + @NotNull + private MessageType type; + + // 发送方标识(可选) + private String senderId; + + // 发送方昵称(可选,用于前端展示) + private String senderName; + + // 文本内容或图片 Base64 / URL(视前端实现而定) + private String content; + + // 文本分片(TEXT):是否为分片、重组用 messageId(doc05) + private Boolean isChunk; + private String messageId; + + // 文件/分片相关(doc06:FILE 含 mimeType,CHUNK 含 content base64) + private String fileId; + private String fileName; + private Long fileSize; + private String mimeType; + /** IMAGE 类型小图直发:base64 图片数据(doc07) */ + private String imageData; + private Integer chunkIndex; + private Integer totalChunks; + private Integer chunkSize; + + // 系统消息相关 + private String systemCode; + private Object data; + + // 由服务端统一补齐 + private Long timestamp; + + public String getRoomCode() { + return roomCode; + } + + public void setRoomCode(String roomCode) { + this.roomCode = roomCode; + } + + public MessageType getType() { + return type; + } + + public void setType(MessageType type) { + this.type = type; + } + + public String getSenderId() { + return senderId; + } + + public void setSenderId(String senderId) { + this.senderId = senderId; + } + + public String getSenderName() { + return senderName; + } + + public void setSenderName(String senderName) { + this.senderName = senderName; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Boolean getIsChunk() { + return isChunk; + } + + public void setIsChunk(Boolean isChunk) { + this.isChunk = isChunk; + } + + public String getMessageId() { + return messageId; + } + + public void setMessageId(String messageId) { + this.messageId = messageId; + } + + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public Long getFileSize() { + return fileSize; + } + + public void setFileSize(Long fileSize) { + this.fileSize = fileSize; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getImageData() { + return imageData; + } + + public void setImageData(String imageData) { + this.imageData = imageData; + } + + public Integer getChunkIndex() { + return chunkIndex; + } + + public void setChunkIndex(Integer chunkIndex) { + this.chunkIndex = chunkIndex; + } + + public Integer getTotalChunks() { + return totalChunks; + } + + public void setTotalChunks(Integer totalChunks) { + this.totalChunks = totalChunks; + } + + public Integer getChunkSize() { + return chunkSize; + } + + public void setChunkSize(Integer chunkSize) { + this.chunkSize = chunkSize; + } + + public String getSystemCode() { + return systemCode; + } + + public void setSystemCode(String systemCode) { + this.systemCode = systemCode; + } + + public Object getData() { + return data; + } + + public void setData(Object data) { + this.data = data; + } + + public Long getTimestamp() { + return timestamp; + } + + public void setTimestamp(Long timestamp) { + this.timestamp = timestamp; + } +} + diff --git a/backend/src/main/java/com/datatool/message/MessageType.java b/backend/src/main/java/com/datatool/message/MessageType.java new file mode 100644 index 0000000..c0df624 --- /dev/null +++ b/backend/src/main/java/com/datatool/message/MessageType.java @@ -0,0 +1,20 @@ +package com.datatool.message; + +/** + * 消息类型枚举。 + * + * 对应文档中的几类传输数据: + * - TEXT:文本 + * - FILE:文件(整体文件级别状态) + * - IMAGE:图片(可视为特殊的文件/内容) + * - SYSTEM:系统消息(用户加入/离开、重连等) + * - CHUNK:文件分片 + */ +public enum MessageType { + TEXT, + FILE, + IMAGE, + SYSTEM, + CHUNK +} + diff --git a/backend/src/main/java/com/datatool/room/Room.java b/backend/src/main/java/com/datatool/room/Room.java new file mode 100644 index 0000000..8b71ec0 --- /dev/null +++ b/backend/src/main/java/com/datatool/room/Room.java @@ -0,0 +1,68 @@ +package com.datatool.room; + +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 房间模型。 + * + * - roomCode:6 位数字房间号 + * - sessions:当前在线会话 + * - createdAt / expiresAt:为后续 NFR(过期回收)预留 + */ +public class Room { + + private String roomCode; + private final Map sessions = new ConcurrentHashMap<>(); + private final Instant createdAt; + private Instant expiresAt; + + public Room(String roomCode) { + this.roomCode = roomCode; + this.createdAt = Instant.now(); + } + + public String getRoomCode() { + return roomCode; + } + + public void setRoomCode(String roomCode) { + this.roomCode = roomCode; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public void addSession(SessionInfo sessionInfo) { + if (sessionInfo != null && sessionInfo.getSessionId() != null) { + sessions.put(sessionInfo.getSessionId(), sessionInfo); + } + } + + public void removeSession(String sessionId) { + if (sessionId != null) { + sessions.remove(sessionId); + } + } + + public SessionInfo getSession(String sessionId) { + return sessionId == null ? null : sessions.get(sessionId); + } + + public Collection getSessions() { + return Collections.unmodifiableCollection(sessions.values()); + } +} + diff --git a/backend/src/main/java/com/datatool/room/RoomService.java b/backend/src/main/java/com/datatool/room/RoomService.java new file mode 100644 index 0000000..ccffcc4 --- /dev/null +++ b/backend/src/main/java/com/datatool/room/RoomService.java @@ -0,0 +1,118 @@ +package com.datatool.room; + +import com.datatool.service.UploadCleanupService; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 房间与会话管理服务。 + * + * - 创建房间:生成 6 位数字房间号 + * - 负责加入/离开房间 + * - 维护房间内在线用户列表 + * - 预留房间过期清理能力 + */ +@Service +public class RoomService { + + private static final SecureRandom RANDOM = new SecureRandom(); + private final Map rooms = new ConcurrentHashMap<>(); + private final SessionRegistry sessionRegistry; + private final UploadCleanupService uploadCleanupService; + + public RoomService(SessionRegistry sessionRegistry, UploadCleanupService uploadCleanupService) { + this.sessionRegistry = sessionRegistry; + this.uploadCleanupService = uploadCleanupService; + } + + /** + * 创建房间:生成唯一 6 位数字房间号并创建房间。 + */ + public String createRoom() { + String roomCode; + do { + roomCode = String.valueOf(100000 + RANDOM.nextInt(900000)); + } while (rooms.containsKey(roomCode)); + rooms.put(roomCode, new Room(roomCode)); + return roomCode; + } + + /** + * 房间内昵称唯一化:若已有同名则追加 -2、-3 等,便于同 IP 多端区分。 + */ + public String ensureUniqueNickname(String roomCode, String nickname) { + if (nickname == null || nickname.isBlank()) return nickname; + Room room = rooms.get(roomCode); + if (room == null) return nickname; + Set used = room.getSessions().stream() + .map(SessionInfo::getNickname) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (!used.contains(nickname)) return nickname; + for (int i = 2; ; i++) { + String candidate = nickname + "-" + i; + if (!used.contains(candidate)) return candidate; + } + } + + /** + * 用户加入房间。 + */ + public Room joinRoom(String roomCode, String sessionId, String userId, String nickname) { + Room room = rooms.computeIfAbsent(roomCode, Room::new); + String uniqueNickname = ensureUniqueNickname(roomCode, nickname); + SessionInfo sessionInfo = new SessionInfo(sessionId, userId, uniqueNickname, Instant.now()); + room.addSession(sessionInfo); + sessionRegistry.register(sessionId, roomCode); + return room; + } + + /** + * 会话离开所在房间。 + */ + public Room leaveRoom(String sessionId) { + String roomCode = sessionRegistry.getRoomCode(sessionId); + if (roomCode == null) { + return null; + } + Room room = rooms.get(roomCode); + if (room != null) { + room.removeSession(sessionId); + if (room.getSessions().isEmpty()) { + rooms.remove(roomCode); + uploadCleanupService.deleteRoomFolder(roomCode); + } + } + sessionRegistry.remove(sessionId); + return room; + } + + /** + * 内存中是否仍存在该房间(用于定时清理判断:仅清理“已不在内存”且过期的目录)。 + */ + public boolean hasRoom(String roomCode) { + return roomCode != null && rooms.containsKey(roomCode); + } + + public Collection getUsers(String roomCode) { + Room room = rooms.get(roomCode); + return room == null ? java.util.List.of() : room.getSessions(); + } + + /** + * 获取房间内指定会话的信息(用于离开时广播昵称)。 + */ + public SessionInfo getSessionInfo(String roomCode, String sessionId) { + Room room = rooms.get(roomCode); + return room == null ? null : room.getSession(sessionId); + } +} + diff --git a/backend/src/main/java/com/datatool/room/SessionInfo.java b/backend/src/main/java/com/datatool/room/SessionInfo.java new file mode 100644 index 0000000..6eb9ed2 --- /dev/null +++ b/backend/src/main/java/com/datatool/room/SessionInfo.java @@ -0,0 +1,61 @@ +package com.datatool.room; + +import java.time.Instant; + +/** + * WebSocket 会话信息。 + * + * - sessionId:底层 WebSocket/STOMP 会话 ID + * - userId / nickname:前端可选传入的用户标识 + * - joinedAt:加入房间时间 + */ +public class SessionInfo { + + private String sessionId; + private String userId; + private String nickname; + private Instant joinedAt; + + public SessionInfo() { + } + + public SessionInfo(String sessionId, String userId, String nickname, Instant joinedAt) { + this.sessionId = sessionId; + this.userId = userId; + this.nickname = nickname; + this.joinedAt = joinedAt; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public Instant getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(Instant joinedAt) { + this.joinedAt = joinedAt; + } +} + diff --git a/backend/src/main/java/com/datatool/room/SessionRegistry.java b/backend/src/main/java/com/datatool/room/SessionRegistry.java new file mode 100644 index 0000000..95f7caf --- /dev/null +++ b/backend/src/main/java/com/datatool/room/SessionRegistry.java @@ -0,0 +1,33 @@ +package com.datatool.room; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 维护 sessionId 与 roomCode 的映射关系, + * 方便在断线或显式离开时进行清理。 + */ +@Component +public class SessionRegistry { + + private final Map sessionRoomMap = new ConcurrentHashMap<>(); + + public void register(String sessionId, String roomCode) { + if (sessionId != null && roomCode != null) { + sessionRoomMap.put(sessionId, roomCode); + } + } + + public String getRoomCode(String sessionId) { + return sessionId == null ? null : sessionRoomMap.get(sessionId); + } + + public void remove(String sessionId) { + if (sessionId != null) { + sessionRoomMap.remove(sessionId); + } + } +} + diff --git a/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java b/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java new file mode 100644 index 0000000..8da1b26 --- /dev/null +++ b/backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java @@ -0,0 +1,71 @@ +package com.datatool.service; + +import com.datatool.config.TransferProperties; +import com.datatool.room.RoomService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.stream.Stream; + +/** + * 定时过期清理:扫描上传根目录下各房间目录,对“内存已无房间且目录年龄超过 room-expire-hours”的进行删除。 + */ +@Component +public class ScheduledUploadCleanup { + + private static final Logger log = LoggerFactory.getLogger(ScheduledUploadCleanup.class); + + private static final String ROOM_CODE_PATTERN = "[0-9]{6}"; + + private final UploadCleanupService uploadCleanupService; + private final RoomService roomService; + private final TransferProperties transferProperties; + + public ScheduledUploadCleanup(UploadCleanupService uploadCleanupService, + RoomService roomService, + TransferProperties transferProperties) { + this.uploadCleanupService = uploadCleanupService; + this.roomService = roomService; + this.transferProperties = transferProperties; + } + + @Scheduled(fixedRateString = "${datatool.transfer.cleanup-interval-ms:3600000}") + public void cleanupExpiredRoomFolders() { + Path root = uploadCleanupService.resolveUploadRoot(); + if (!Files.isDirectory(root)) { + return; + } + int expireHours = transferProperties.getRoomExpireHours(); + long expireSeconds = expireHours * 3600L; + long nowSeconds = Instant.now().getEpochSecond(); + + try (Stream list = Files.list(root)) { + list.filter(Files::isDirectory) + .map(Path::getFileName) + .map(Path::toString) + .filter(roomCode -> roomCode.matches(ROOM_CODE_PATTERN)) + .forEach(roomCode -> { + if (roomService.hasRoom(roomCode)) { + return; + } + Path roomDir = root.resolve(roomCode); + try { + long lastModified = Files.getLastModifiedTime(roomDir).toInstant().getEpochSecond(); + if (nowSeconds - lastModified >= expireSeconds) { + uploadCleanupService.deleteRoomFolder(roomCode); + } + } catch (IOException e) { + log.warn("[ScheduledUploadCleanup] 无法读取目录时间 roomCode={} error={}", roomCode, e.getMessage()); + } + }); + } catch (IOException e) { + log.warn("[ScheduledUploadCleanup] 列出上传目录失败 error={}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/datatool/service/UploadCleanupService.java b/backend/src/main/java/com/datatool/service/UploadCleanupService.java new file mode 100644 index 0000000..1cb5934 --- /dev/null +++ b/backend/src/main/java/com/datatool/service/UploadCleanupService.java @@ -0,0 +1,83 @@ +package com.datatool.service; + +import com.datatool.config.TransferProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * 上传目录清理:按房间删除磁盘目录,供“房间空置即删”、定时过期清理、删除 API”使用。 + */ +@Service +public class UploadCleanupService { + + private static final Logger log = LoggerFactory.getLogger(UploadCleanupService.class); + + /** 房间号格式:6 位数字,防止路径穿越。 */ + private static final String ROOM_CODE_PATTERN = "[0-9]{6}"; + + private final TransferProperties transferProperties; + + public UploadCleanupService(TransferProperties transferProperties) { + this.transferProperties = transferProperties; + } + + /** + * 解析上传根目录为绝对路径,兼容 Linux 与 Windows。 + * 相对路径基于进程工作目录(user.dir)解析。 + */ + public Path resolveUploadRoot() { + String dir = transferProperties.getUploadDir(); + if (dir == null || dir.isBlank()) { + dir = "./data/uploads"; + } + Path p = Paths.get(dir); + if (!p.isAbsolute()) { + p = Paths.get(System.getProperty("user.dir", ".")).resolve(p); + } + return p.normalize().toAbsolutePath(); + } + + /** + * 删除指定房间的上传目录(含其下所有文件及 .meta)。 + * 仅当 roomCode 符合 6 位数字时执行,否则不删并打日志。 + * + * @param roomCode 6 位数字房间号 + * @return 是否执行了删除(目录存在且已删为 true;未通过校验或目录不存在为 false) + */ + public boolean deleteRoomFolder(String roomCode) { + if (roomCode == null || !roomCode.matches(ROOM_CODE_PATTERN)) { + log.warn("[UploadCleanup] 跳过删除:房间号格式无效 roomCode={}", roomCode); + return false; + } + Path root = resolveUploadRoot(); + Path roomDir = root.resolve(roomCode); + if (!Files.isDirectory(roomDir)) { + log.debug("[UploadCleanup] 房间目录不存在,跳过 roomCode={}", roomCode); + return false; + } + try { + try (Stream walk = Files.walk(roomDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + log.warn("[UploadCleanup] 删除项失败 path={} error={}", p, e.getMessage()); + } + }); + } + log.info("[UploadCleanup] 已删除房间上传目录 roomCode={}", roomCode); + return true; + } catch (IOException e) { + log.error("[UploadCleanup] 删除房间目录失败 roomCode={} error={}", roomCode, e.getMessage()); + return false; + } + } +} diff --git a/backend/src/main/java/com/datatool/ws/RoomWsController.java b/backend/src/main/java/com/datatool/ws/RoomWsController.java new file mode 100644 index 0000000..1b5fd52 --- /dev/null +++ b/backend/src/main/java/com/datatool/ws/RoomWsController.java @@ -0,0 +1,126 @@ +package com.datatool.ws; + +import com.datatool.message.MessagePayload; +import com.datatool.message.MessageType; +import com.datatool.room.RoomService; +import com.datatool.room.SessionInfo; +import com.datatool.room.SessionRegistry; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * 房间相关 WebSocket/STOMP 端点。 + * + * 路径与文档保持一致: + * - 加入房间:/app/room/{roomCode}/join + * - 发送消息:/app/room/{roomCode}/message + * - 文件分片:/app/room/{roomCode}/file/chunk + * + * 广播路径: + * - 房间广播:/topic/room/{roomCode} + * - 文件分片:/topic/room/{roomCode}/file/{fileId} + */ +@Controller +public class RoomWsController { + + private final RoomService roomService; + private final SessionRegistry sessionRegistry; + private final SimpMessagingTemplate messagingTemplate; + + public RoomWsController(RoomService roomService, + SessionRegistry sessionRegistry, + SimpMessagingTemplate messagingTemplate) { + this.roomService = roomService; + this.sessionRegistry = sessionRegistry; + this.messagingTemplate = messagingTemplate; + } + + /** + * 客户端加入房间后,服务端更新房间用户列表并广播 SYSTEM(USER_JOIN + userList)。 + */ + @MessageMapping("/room/{roomCode}/join") + public void joinRoom(@DestinationVariable String roomCode, + MessagePayload payload, + SimpMessageHeaderAccessor headerAccessor) { + String sessionId = headerAccessor.getSessionId(); + String userId = payload.getSenderId(); + String nickname = payload.getContent(); // 暂将 content 视为昵称占位,后续可单独扩展字段 + + roomService.joinRoom(roomCode, sessionId, userId, nickname); + + String joinMessage = (nickname != null && !nickname.isEmpty()) ? nickname + " 加入房间" : "有用户加入房间"; + Map joinData = new HashMap<>(); + joinData.put("message", joinMessage); + joinData.put("userList", roomService.getUsers(roomCode)); + + MessagePayload system = new MessagePayload(); + system.setRoomCode(roomCode); + system.setType(MessageType.SYSTEM); + system.setSystemCode("USER_JOIN"); + system.setData(joinData); + system.setTimestamp(Instant.now().toEpochMilli()); + + messagingTemplate.convertAndSend("/topic/room/" + roomCode, system); + } + + /** + * 客户端主动离开房间:移除会话、若房间空则移除房间,并广播 SYSTEM(USER_LEAVE + userList)。 + */ + @MessageMapping("/room/{roomCode}/leave") + public void leaveRoom(@DestinationVariable String roomCode, + SimpMessageHeaderAccessor headerAccessor) { + String sessionId = headerAccessor.getSessionId(); + String currentRoomCode = sessionRegistry.getRoomCode(sessionId); + if (currentRoomCode == null || !currentRoomCode.equals(roomCode)) { + return; + } + SessionInfo leavingSession = roomService.getSessionInfo(roomCode, sessionId); + String leaveNickname = leavingSession != null && leavingSession.getNickname() != null + ? leavingSession.getNickname() : "某用户"; + roomService.leaveRoom(sessionId); + MessagePayload system = new MessagePayload(); + system.setRoomCode(roomCode); + system.setType(MessageType.SYSTEM); + system.setSystemCode("USER_LEAVE"); + system.setTimestamp(Instant.now().toEpochMilli()); + Map data = new HashMap<>(); + data.put("message", leaveNickname != null ? leaveNickname + " 离开房间" : "有用户离开房间"); + data.put("userList", roomService.getUsers(roomCode)); + system.setData(data); + messagingTemplate.convertAndSend("/topic/room/" + roomCode, system); + } + + /** + * 文本/文件/图片/系统消息统一入口,服务端补齐时间戳并广播。 + */ + @MessageMapping("/room/{roomCode}/message") + public void sendMessage(@DestinationVariable String roomCode, + MessagePayload payload) { + payload.setRoomCode(roomCode); + payload.setTimestamp(Instant.now().toEpochMilli()); + + messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload); + } + + /** + * 文件分片透明转发到房间主 topic(SimpleBroker 不支持通配符订阅,统一走主 topic)。 + */ + @MessageMapping("/room/{roomCode}/file/chunk") + public void sendFileChunk(@DestinationVariable String roomCode, + MessagePayload payload) { + payload.setRoomCode(roomCode); + payload.setType(MessageType.CHUNK); + payload.setTimestamp(Instant.now().toEpochMilli()); + + // 统一发到房间主 topic,前端按 type=CHUNK 处理 + messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload); + } +} + diff --git a/backend/src/main/java/com/datatool/ws/WebSocketEventListener.java b/backend/src/main/java/com/datatool/ws/WebSocketEventListener.java new file mode 100644 index 0000000..fe6137e --- /dev/null +++ b/backend/src/main/java/com/datatool/ws/WebSocketEventListener.java @@ -0,0 +1,89 @@ +package com.datatool.ws; + +import com.datatool.message.MessagePayload; +import com.datatool.message.MessageType; +import com.datatool.room.Room; +import com.datatool.room.RoomService; +import com.datatool.room.SessionInfo; +import com.datatool.room.SessionRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * WebSocket 事件监听器。 + * + * 处理断线事件: + * - 当用户 WebSocket 断开时(关闭浏览器、网络中断等),自动触发 leave 并广播用户列表更新。 + * - 符合文档 09:断线处理 - 需要在服务端捕获 disconnect 事件,触发 leave 并广播列表更新。 + */ +@Component +public class WebSocketEventListener { + + private static final Logger log = LoggerFactory.getLogger(WebSocketEventListener.class); + + private final RoomService roomService; + private final SessionRegistry sessionRegistry; + private final SimpMessagingTemplate messagingTemplate; + + public WebSocketEventListener(RoomService roomService, + SessionRegistry sessionRegistry, + SimpMessagingTemplate messagingTemplate) { + this.roomService = roomService; + this.sessionRegistry = sessionRegistry; + this.messagingTemplate = messagingTemplate; + } + + /** + * 处理 WebSocket 断开连接事件。 + * 当用户断线时,自动将其从房间移除并广播用户列表更新。 + */ + @EventListener + public void handleSessionDisconnect(SessionDisconnectEvent event) { + String sessionId = event.getSessionId(); + log.info("[WS] 会话断开: sessionId={}", sessionId); + + // 获取该会话所在的房间 + String roomCode = sessionRegistry.getRoomCode(sessionId); + if (roomCode == null) { + log.debug("[WS] 断开的会话不在任何房间中: sessionId={}", sessionId); + return; + } + + // 获取离开用户的信息(用于广播消息) + SessionInfo leavingSession = roomService.getSessionInfo(roomCode, sessionId); + String leaveNickname = (leavingSession != null && leavingSession.getNickname() != null) + ? leavingSession.getNickname() + : "某用户"; + + // 从房间移除该用户 + Room room = roomService.leaveRoom(sessionId); + if (room == null) { + log.warn("[WS] 移除会话失败,房间可能已不存在: roomCode={}, sessionId={}", roomCode, sessionId); + return; + } + + log.info("[WS] 用户断线离开房间: roomCode={}, nickname={}, sessionId={}", roomCode, leaveNickname, sessionId); + + // 广播 SYSTEM(USER_LEAVE + userList) + MessagePayload system = new MessagePayload(); + system.setRoomCode(roomCode); + system.setType(MessageType.SYSTEM); + system.setSystemCode("USER_LEAVE"); + system.setTimestamp(Instant.now().toEpochMilli()); + + Map data = new HashMap<>(); + data.put("message", leaveNickname + " 离开房间"); + data.put("userList", roomService.getUsers(roomCode)); + system.setData(data); + + messagingTemplate.convertAndSend("/topic/room/" + roomCode, system); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..6890f65 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,26 @@ +server: + port: 8080 + # 允许其它设备直连后端(如不通过 Vite 代理、直接访问本机 IP:8080 时) + address: 0.0.0.0 + +spring: + application: + name: datatool-backend + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + +# WebSocket / STOMP 基础配置占位,后续可扩展 +datatool: + websocket: + endpoint: /ws + allowed-origins: + - "*" + # 文件上传存储与限制(大文件走 HTTP 上传/下载,避免 WebSocket 断连) + transfer: + upload-dir: ./data/uploads + max-file-size: 104857600 # 100MB + room-expire-hours: 24 + cleanup-interval-ms: 3600000 # 定时过期清理间隔(毫秒),默认 1 小时 + diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c8a13a5 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,38 @@ +# ========== 阶段一:构建前端 dist ========== +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci --ignore-scripts || npm install --legacy-peer-deps + +COPY frontend/ ./ +RUN npm run build + +# ========== 阶段二:构建后端 JAR(含 dist 到 static) ========== +FROM eclipse-temurin:17-jdk-alpine AS backend-builder + +WORKDIR /app + +# 安装 Maven +RUN apk add --no-cache maven + +COPY backend/ ./backend/ +COPY --from=frontend-builder /app/frontend/dist ./backend/src/main/resources/static + +WORKDIR /app/backend +RUN mvn package -DskipTests -q -B + +# ========== 阶段三:运行 ========== +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 上传目录与数据持久化 +RUN mkdir -p /app/data/uploads + +COPY --from=backend-builder /app/backend/target/datatool-backend-*.jar ./app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/docker/deploy.ps1 b/docker/deploy.ps1 new file mode 100644 index 0000000..7c1b546 --- /dev/null +++ b/docker/deploy.ps1 @@ -0,0 +1,21 @@ +# DataTool 一键部署:拉取代码 -> 进入 docker 目录 -> 构建并启动 +# 用法: .\deploy.ps1 [-NoPull] + +param([switch]$NoPull) + +$ErrorActionPreference = "Stop" +# 脚本在 docker/ 下,仓库根目录为上一级 +$RepoRoot = Split-Path $PSScriptRoot -Parent +Set-Location $RepoRoot + +if (-not $NoPull) { + Write-Host ">>> 拉取最新代码..." + git pull 2>$null +} + +Write-Host ">>> 进入 docker 目录并执行构建、启动..." +Set-Location (Join-Path $RepoRoot "docker") +docker compose build --no-cache +docker compose up -d + +Write-Host ">>> 部署完成。访问 http://localhost:8080" diff --git a/docker/deploy.sh b/docker/deploy.sh new file mode 100644 index 0000000..e84dc69 --- /dev/null +++ b/docker/deploy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# DataTool 一键部署:拉取代码 -> 进入 docker 目录 -> 构建并启动 +# 用法: ./deploy.sh [--no-pull] + +set -e +# 脚本在 docker/ 下,仓库根目录为上一级 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$REPO_ROOT" + +# 可选:跳过拉取(传 --no-pull 时) +if [[ "$1" != "--no-pull" ]]; then + echo ">>> 拉取最新代码..." + git pull || true +fi + +echo ">>> 进入 docker 目录并执行构建、启动..." +cd "$REPO_ROOT/docker" +docker compose build --no-cache +docker compose up -d + +echo ">>> 部署完成。访问 http://localhost:8080" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..012bd8d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,17 @@ +services: + datatool: + build: + context: .. + dockerfile: docker/Dockerfile + image: datatool:latest + container_name: datatool + ports: + - "8080:8080" + volumes: + - datatool-uploads:/app/data/uploads + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + +volumes: + datatool-uploads: diff --git a/docs/00-数据传输助手-开发文档.md b/docs/00-数据传输助手-开发文档.md new file mode 100644 index 0000000..f0dcf21 --- /dev/null +++ b/docs/00-数据传输助手-开发文档.md @@ -0,0 +1,890 @@ +# 数据传输助手 - 开发文档 + +## 拆分文档目录(01/02/03…) +- [01 - 整体架构与核心概念](docs/01-整体架构与核心概念.md) +- [02 - 房间管理(创建 / 加入 / 退出)](docs/02-房间管理(创建-加入-退出).md) +- [03 - WebSocket连接管理(连接 / 订阅 / 心跳 / 重连)](docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md) +- [04 - 消息协议与消息分发(统一Message模型)](docs/04-消息协议与消息分发(统一Message模型).md) +- [05 - 文本传输(含大文本分片)](docs/05-文本传输(含大文本分片).md) +- [06 - 文件传输(拖拽 / 分片 / 进度 / 下载)](docs/06-文件传输(拖拽-分片-进度-下载).md) +- [07 - 图片预览(Base64内联显示)](docs/07-图片预览(Base64内联显示).md) +- [08 - 剪贴板集成(读取 / 粘贴 / 降级)](docs/08-剪贴板集成(读取-粘贴-降级).md) +- [09 - 在线用户列表(房间成员与系统消息)](docs/09-在线用户列表(房间成员与系统消息).md) +- [10 - 历史记录(本地存储 / 清空 / 导出)](docs/10-历史记录(本地存储-清空-导出).md) +- [11 - 数据库设计(可选:持久化与审计)](docs/11-数据库设计(可选:持久化与审计).md) +- [12 - 部署与环境配置(后端 / 前端 / Nginx)](docs/12-部署与环境配置(后端-前端-Nginx).md) +- [13 - 安全与风控(基础措施与可选增强)](docs/13-安全与风控(基础措施与可选增强).md) +- [14 - 开发计划与里程碑(Phase 1~4)](docs/14-开发计划与里程碑(Phase1-4).md) + +## 一、项目概述 + +### 1.1 项目背景 +在VNC远程桌面、内网环境或安全隔离场景中,由于剪贴板共享被禁用或系统限制,用户无法直接进行复制粘贴操作。本工具提供一个基于浏览器的轻量级数据传输方案,通过WebSocket实现多端实时数据同步。 + +### 1.2 应用场景 +- VNC/远程桌面环境的数据传输 +- 内外网隔离环境下的文件/文本交换 +- 临时性的跨设备数据共享 +- 无法使用U盘或即时通讯工具的场景 + +### 1.3 技术栈 +| 层级 | 技术 | 版本建议 | +|------|------|----------| +| 后端 | Spring Boot | 2.7.x / 3.x | +| 后端 | Java | 11 / 17 | +| 实时通信 | WebSocket (STOMP) | - | +| 前端 | Vue | 3.x | +| UI组件库 | Element Plus | 2.x | +| 构建工具 | Vite | 4.x | + +--- + +## 二、系统架构 + +### 2.1 架构图 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 发送端浏览器 │◄───►│ WebSocket │◄───►│ 接收端浏览器 │ +│ (Vue + Element)│ │ 服务器(Java) │ │ (Vue + Element)│ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┴───────────────────────┘ + 同一房间号(room) +``` + +### 2.2 核心概念 +- **房间(Room)**:数据传输的隔离空间,通过6位数字房间号标识 +- **会话(Session)**:WebSocket连接实例 +- **消息(Message)**:传输的数据单元,支持文本、文件元数据、二进制分片 + +--- + +## 三、功能设计 + +### 3.1 功能模块 + +``` +├── 房间管理 +│ ├── 创建房间(自动生成6位房间号) +│ ├── 加入房间(输入房间号) +│ └── 退出房间 +│ +├── 数据传输 +│ ├── 文本传输(支持大文本,自动分片) +│ ├── 文件传输(支持拖拽上传、进度显示) +│ ├── 图片预览(Base64内联显示) +│ └── 剪贴板读取(读取系统剪贴板内容) +│ +├── 连接管理 +│ ├── 在线用户列表 +│ ├── 连接状态指示 +│ └── 心跳保活 +│ +└── 历史记录 + ├── 消息历史(本地存储) + ├── 清空记录 + └── 导出记录 +``` + +### 3.2 页面布局 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 数据传输助手 [连接状态] │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌───────────────────────────────────────┐ │ +│ │ 房间信息 │ │ 消息展示区 │ │ +│ │ ───────── │ │ ┌─────────────────────────────────┐ │ │ +│ │ 房间号: │ │ │ [系统] xxx 加入了房间 │ │ │ +│ │ 123456 │ │ │ [我] 这是一段文本消息... │ │ │ +│ │ │ │ │ [对方] 收到,这是回复... │ │ │ +│ │ ───────── │ │ │ [文件] 文档.pdf (2.5MB) [下载] │ │ │ +│ │ 在线用户 │ │ └─────────────────────────────────┘ │ │ +│ │ ● 用户A │ │ │ │ +│ │ ○ 用户B │ └───────────────────────────────────────┘ │ +│ │ │ ┌───────────────────────────────────────┐ │ +│ │ ───────── │ │ [粘贴/拖拽区域] │ │ +│ │ 快捷操作 │ │ 支持: 文本粘贴、文件拖拽、剪贴板读取 │ │ +│ │ [清空] │ └───────────────────────────────────────┘ │ +│ │ [导出] │ ┌────────────────────┐ ┌──────────────┐ │ │ +│ └─────────────┘ │ 输入框... │ │ 发送 [▶] │ │ │ +│ └────────────────────┘ └──────────────┘ │ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、数据库设计 + +### 4.1 实体设计(可选,支持消息持久化) + +```sql +-- 房间表 +CREATE TABLE room ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + room_code VARCHAR(6) UNIQUE NOT NULL COMMENT '房间号', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP COMMENT '过期时间', + is_active TINYINT DEFAULT 1 +); + +-- 消息记录表(可选,用于审计或消息回放) +CREATE TABLE message ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + room_code VARCHAR(6) NOT NULL, + sender_id VARCHAR(64) NOT NULL, + sender_name VARCHAR(32), + msg_type TINYINT COMMENT '1-文本 2-文件 3-图片', + content TEXT COMMENT '文本内容或文件元数据JSON', + file_size BIGINT COMMENT '文件大小', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_room_time (room_code, created_at) +); +``` + +--- + +## 五、接口设计 + +### 5.1 WebSocket 端点 + +| 端点 | 说明 | +|------|------| +| `/ws/data-transfer` | WebSocket连接入口 | +| `/topic/room/{roomCode}` | 房间广播频道(订阅) | +| `/app/room/{roomCode}/join` | 加入房间(发送) | +| `/app/room/{roomCode}/leave` | 离开房间(发送) | +| `/app/room/{roomCode}/message` | 发送消息(发送) | +| `/app/room/{roomCode}/file/chunk` | 发送文件分片(发送) | + +### 5.2 消息协议 + +#### 基础消息格式 +```json +{ + "type": "TEXT | FILE | IMAGE | SYSTEM | CHUNK", + "senderId": "uuid", + "senderName": "用户昵称", + "timestamp": 1706345600000, + "payload": {} +} +``` + +#### 文本消息 +```json +{ + "type": "TEXT", + "senderId": "uuid", + "senderName": "用户A", + "timestamp": 1706345600000, + "payload": { + "content": "要传输的文本内容", + "isChunk": false, + "chunkIndex": 0, + "totalChunks": 1 + } +} +``` + +#### 文件元数据消息 +```json +{ + "type": "FILE", + "senderId": "uuid", + "senderName": "用户A", + "timestamp": 1706345600000, + "payload": { + "fileId": "uuid", + "fileName": "document.pdf", + "fileSize": 2621440, + "mimeType": "application/pdf", + "totalChunks": 10 + } +} +``` + +#### 文件分片消息 +```json +{ + "type": "CHUNK", + "senderId": "uuid", + "payload": { + "fileId": "uuid", + "chunkIndex": 0, + "data": "base64EncodedChunkData" + } +} +``` + +#### 系统消息 +```json +{ + "type": "SYSTEM", + "payload": { + "event": "USER_JOIN | USER_LEAVE | ERROR", + "message": "xxx 加入了房间", + "userList": [{"id": "uuid", "name": "用户A"}] + } +} +``` + +--- + +## 六、后端实现 + +### 6.1 项目结构 + +``` +data-transfer-server/ +├── src/main/java/com/example/datatransfer/ +│ ├── config/ +│ │ ├── WebSocketConfig.java # WebSocket配置 +│ │ └── CorsConfig.java # 跨域配置 +│ ├── controller/ +│ │ └── WebSocketController.java # WebSocket消息处理器 +│ ├── service/ +│ │ ├── RoomService.java # 房间管理 +│ │ ├── MessageService.java # 消息处理 +│ │ └── FileTransferService.java # 文件传输管理 +│ ├── model/ +│ │ ├── Message.java # 消息实体 +│ │ ├── Room.java # 房间实体 +│ │ └── FileChunk.java # 文件分片 +│ ├── handler/ +│ │ └── CustomHandshakeHandler.java # 握手处理器 +│ └── DataTransferApplication.java +├── src/main/resources/ +│ └── application.yml +└── pom.xml +``` + +### 6.2 核心代码示例 + +#### WebSocketConfig.java +```java +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws/data-transfer") + .setAllowedOriginPatterns("*") + .withSockJS(); + } +} +``` + +#### WebSocketController.java +```java +@Controller +public class WebSocketController { + + @Autowired + private SimpMessagingTemplate messagingTemplate; + + @Autowired + private RoomService roomService; + + // 加入房间 + @MessageMapping("/room/{roomCode}/join") + public void joinRoom(@DestinationVariable String roomCode, + @Payload JoinRequest request, + SimpMessageHeaderAccessor headerAccessor) { + String sessionId = headerAccessor.getSessionId(); + roomService.joinRoom(roomCode, sessionId, request.getUserName()); + + // 广播用户加入消息 + Message systemMsg = Message.builder() + .type(MessageType.SYSTEM) + .payload(Map.of( + "event", "USER_JOIN", + "message", request.getUserName() + " 加入了房间", + "userList", roomService.getUserList(roomCode) + )) + .build(); + + messagingTemplate.convertAndSend("/topic/room/" + roomCode, systemMsg); + } + + // 发送文本消息 + @MessageMapping("/room/{roomCode}/message") + public void sendMessage(@DestinationVariable String roomCode, + @Payload Message message) { + message.setTimestamp(System.currentTimeMillis()); + messagingTemplate.convertAndSend("/topic/room/" + roomCode, message); + } + + // 发送文件分片 + @MessageMapping("/room/{roomCode}/file/chunk") + public void sendFileChunk(@DestinationVariable String roomCode, + @Payload FileChunk chunk) { + messagingTemplate.convertAndSend( + "/topic/room/" + roomCode + "/file/" + chunk.getFileId(), + chunk + ); + } +} +``` + +#### RoomService.java +```java +@Service +public class RoomService { + + // 内存存储,生产环境可改用Redis + private ConcurrentHashMap rooms = new ConcurrentHashMap<>(); + + public String createRoom() { + String roomCode = generateRoomCode(); + Room room = new Room(roomCode); + rooms.put(roomCode, room); + return roomCode; + } + + public void joinRoom(String roomCode, String sessionId, String userName) { + Room room = rooms.computeIfAbsent(roomCode, k -> new Room(k)); + room.addUser(sessionId, userName); + } + + public void leaveRoom(String roomCode, String sessionId) { + Room room = rooms.get(roomCode); + if (room != null) { + room.removeUser(sessionId); + if (room.isEmpty()) { + rooms.remove(roomCode); + } + } + } + + private String generateRoomCode() { + // 生成6位数字房间号 + Random random = new Random(); + return String.format("%06d", random.nextInt(1000000)); + } +} +``` + +--- + +## 七、前端实现 + +### 7.1 项目结构 + +``` +data-transfer-web/ +├── public/ +├── src/ +│ ├── api/ +│ │ └── websocket.js # WebSocket封装 +│ ├── components/ +│ │ ├── RoomPanel.vue # 房间信息面板 +│ │ ├── MessageList.vue # 消息列表 +│ │ ├── MessageInput.vue # 消息输入 +│ │ ├── FileDropZone.vue # 文件拖拽区域 +│ │ └── UserList.vue # 在线用户列表 +│ ├── views/ +│ │ ├── HomeView.vue # 首页(创建/加入房间) +│ │ └── RoomView.vue # 房间页面 +│ ├── stores/ +│ │ └── room.js # Pinia状态管理 +│ ├── utils/ +│ │ ├── fileChunker.js # 文件分片工具 +│ │ └── clipboard.js # 剪贴板工具 +│ ├── App.vue +│ └── main.js +├── index.html +├── package.json +└── vite.config.js +``` + +### 7.2 核心代码示例 + +#### WebSocket封装 (websocket.js) +```javascript +import SockJS from 'sockjs-client' +import { Client } from '@stomp/stompjs' + +class WebSocketService { + constructor() { + this.client = null + this.subscriptions = new Map() + } + + connect(url, onConnect, onError) { + this.client = new Client({ + webSocketFactory: () => new SockJS(url), + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, + }) + + this.client.onConnect = onConnect + this.client.onStompError = onError + this.client.activate() + } + + subscribe(destination, callback) { + if (this.client && this.client.connected) { + const subscription = this.client.subscribe(destination, (message) => { + callback(JSON.parse(message.body)) + }) + this.subscriptions.set(destination, subscription) + } + } + + send(destination, body) { + if (this.client && this.client.connected) { + this.client.publish({ + destination, + body: JSON.stringify(body) + }) + } + } + + disconnect() { + this.subscriptions.forEach(sub => sub.unsubscribe()) + this.subscriptions.clear() + if (this.client) { + this.client.deactivate() + } + } +} + +export default new WebSocketService() +``` + +#### 房间页面 (RoomView.vue) +```vue + + + +``` + +#### 文件拖拽组件 (FileDropZone.vue) +```vue + + + + + +``` + +--- + +## 八、部署配置 + +### 8.1 后端部署 (application.yml) + +```yaml +server: + port: 8080 + +spring: + websocket: + message-buffer-size: 8192 + + # 如需消息持久化,配置数据库 + datasource: + url: jdbc:mysql://localhost:3306/data_transfer + username: root + password: xxx + +# 文件传输配置 +transfer: + chunk-size: 65536 # 64KB分片 + max-file-size: 104857600 # 100MB + room-expire-hours: 24 # 房间过期时间 +``` + +### 8.2 前端部署 (vite.config.js) + +```javascript +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/ws': { + target: 'http://localhost:8080', + ws: true, + changeOrigin: true + }, + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets' + } +}) +``` + +### 8.3 Nginx配置(生产环境) + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + root /path/to/data-transfer-web/dist; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /ws { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /api { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +--- + +## 九、安全考虑 + +### 9.1 安全措施 + +| 风险 | 解决方案 | +|------|----------| +| 房间号暴力破解 | 6位数字+过期机制,限制尝试频率 | +| 大文件攻击 | 限制单文件100MB,限制房间总传输量 | +| XSS攻击 | 消息内容HTML转义,使用textContent | +| 数据泄露 | 房间自动过期,服务端不持久化敏感内容 | +| WebSocket劫持 | 握手时验证Origin,使用WSS加密 | + +### 9.2 可选增强 +- 添加房间密码 +- 端到端加密(WebCrypto API) +- IP白名单限制 +- 操作日志审计 + +--- + +## 十、开发计划 + +### 10.1 里程碑 + +| 阶段 | 时间 | 交付物 | +|------|------|--------| +| Phase 1 | 3天 | 基础WebSocket连接、文本传输、房间管理 | +| Phase 2 | 3天 | 文件传输(分片)、进度显示、拖拽上传 | +| Phase 3 | 2天 | 剪贴板集成、图片预览、历史记录 | +| Phase 4 | 2天 | UI优化、响应式适配、测试修复 | + +### 10.2 依赖清单 + +**后端 (pom.xml)** +```xml + + + org.springframework.boot + spring-boot-starter-websocket + + + org.webjars + sockjs-client + 1.5.1 + + + org.webjars + stomp-websocket + 2.3.4 + + +``` + +**前端 (package.json)** +```json +{ + "dependencies": { + "vue": "^3.3.4", + "vue-router": "^4.2.4", + "pinia": "^2.1.6", + "element-plus": "^2.3.14", + "@stomp/stompjs": "^7.0.0", + "sockjs-client": "^1.6.1" + }, + "devDependencies": { + "vite": "^4.4.9", + "@vitejs/plugin-vue": "^4.3.4" + } +} +``` + +--- + +## 十一、附录 + +### 11.1 参考文档 +- [Spring WebSocket官方文档](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket) +- [STOMP协议规范](https://stomp.github.io/) +- [Element Plus文档](https://element-plus.org/) +- [Vue 3文档](https://vuejs.org/) + +### 11.2 常见问题 + +**Q: 文件传输大小限制?** +A: 默认100MB,可通过配置调整。超大文件建议分片后逐片发送。 + +**Q: 支持多文件同时传输吗?** +A: 支持,每个文件有独立fileId,可并行传输。 + +**Q: 断线重连如何处理?** +STOMP客户端内置重连机制,重连后需重新加入房间。 + +**Q: 内网环境如何部署?** +A: 打包后部署到内网服务器,确保客户端能访问WebSocket端口。 + +--- + +**文档版本**: v1.0 +**编写日期**: 2026-01-27 +**作者**: AI Assistant diff --git a/docs/01-整体架构与核心概念.md b/docs/01-整体架构与核心概念.md new file mode 100644 index 0000000..12e9cea --- /dev/null +++ b/docs/01-整体架构与核心概念.md @@ -0,0 +1,28 @@ +# 01 - 整体架构与核心概念 + +## 目标与范围 +- **目标**:在 VNC/远程桌面、内网隔离等场景下,提供基于浏览器的轻量数据传输能力,通过 WebSocket 实现多端实时同步。 +- **范围**:文本、文件(分片)、图片预览、剪贴板读取/粘贴、在线用户列表、历史记录(本地)。 + +## 架构概览 +``` +发送端浏览器(Vue) <——WebSocket(STOMP/SockJS)——> 服务端(Spring Boot) <——> 接收端浏览器(Vue) + │ + └—— 同一房间号(roomCode)隔离广播 +``` + +## 核心概念 +- **房间(Room)**:数据传输隔离空间,通过 **6 位数字** `roomCode` 标识。 +- **会话(Session)**:WebSocket 连接实例,通常以 `sessionId` 识别。 +- **消息(Message)**:传输数据单元,支持 `TEXT/FILE/IMAGE/SYSTEM/CHUNK`。 + +## 关键数据流 +- **加入房间**:客户端连接成功后发送 `/app/room/{roomCode}/join` → 服务端更新房间用户 → 广播 `SYSTEM(USER_JOIN + userList)` 到 `/topic/room/{roomCode}`。 +- **发送消息**:客户端发送 `/app/room/{roomCode}/message`(TEXT/FILE/IMAGE/SYSTEM)→ 服务端补齐时间戳并广播到 `/topic/room/{roomCode}`。 +- **文件分片**:客户端发送 `/app/room/{roomCode}/file/chunk`(CHUNK)→ 服务端转发到 `/topic/room/{roomCode}/file/{fileId}`(或同房间通道内约定字段分发)。 + +## 非功能性要求(NFR) +- **可用性**:断线自动重连;重连后需重新 join。 +- **性能**:大文本/大文件分片;限制单文件大小与分片大小;避免 UI 卡顿(增量渲染/节流)。 +- **安全**:默认不落库;房间过期;XSS 防护;可选 WSS/Origin 校验。 + diff --git a/docs/02-房间管理(创建-加入-退出).md b/docs/02-房间管理(创建-加入-退出).md new file mode 100644 index 0000000..44dfb62 --- /dev/null +++ b/docs/02-房间管理(创建-加入-退出).md @@ -0,0 +1,39 @@ +# 02 - 房间管理(创建 / 加入 / 退出) + +## 功能目标 +- **创建房间**:自动生成 6 位数字房间号 `roomCode`。 +- **加入房间**:输入房间号加入;服务端维护在线用户列表。 +- **退出房间**:主动退出或断线退出;房间空则销毁或等待过期。 + +## 前端(Vue) +- **页面/组件** + - `HomeView.vue`:创建房间、加入房间(输入 `roomCode`)。 + - `RoomView.vue`:展示房间信息与离开操作入口。 + - `RoomPanel.vue`:显示房间号、在线用户、退出按钮、快捷操作(清空/导出等)。 +- **交互细节** + - 加入房间前校验 `roomCode` 为 6 位数字。 + - join/leave 成功后,在消息区插入系统提示(“xxx加入/离开”)。 + +## 后端(Spring Boot) +- **核心服务:`RoomService`** + - `createRoom()`:生成 `roomCode` 并创建房间。 + - `joinRoom(roomCode, sessionId, userName)`:绑定用户到房间。 + - `leaveRoom(roomCode, sessionId)`:移除用户;若空房间则移除。 + - (可选)`expireRoom()`:定时清理过期房间(按 `transfer.room-expire-hours`)。 +- **消息入口(STOMP)** + - `/app/room/{roomCode}/join`:加入房间 + - `/app/room/{roomCode}/leave`:离开房间 + +## 协议与数据 +- **JoinRequest** + - `userName`:用户昵称(前端生成或用户输入) +- **系统消息(SYSTEM)** + - `payload.event`:`USER_JOIN | USER_LEAVE | ERROR` + - `payload.message`:提示文本 + - `payload.userList`:在线用户列表(数组) + +## 边界与注意点 +- **房间是否需要“先创建后加入”**:文档示例允许 `computeIfAbsent`,即加入时若不存在会自动创建(实现上需确定产品规则)。 +- **断线离开**:建议监听会话断开事件,同步触发离房逻辑并广播 `USER_LEAVE`。 +- **防暴力猜测**:限制 join 尝试频率(可选增强)。 + diff --git a/docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md b/docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md new file mode 100644 index 0000000..9b79564 --- /dev/null +++ b/docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md @@ -0,0 +1,49 @@ +# 03 - WebSocket连接管理(连接 / 订阅 / 心跳 / 重连) + +## 功能目标 +- 建立浏览器到服务端的 WebSocket 连接(SockJS + STOMP)。 +- 订阅房间广播通道,实时接收消息。 +- 具备心跳保活与自动重连;UI 显示连接状态。 + +## 前端(Vue) +- **封装层**:`src/api/websocket.js` + - `connect(url, onConnect, onError)`:创建 STOMP Client,启用 `reconnectDelay` 与心跳参数。 + - `subscribe(destination, callback)`:按 destination 保存订阅,便于统一退订。 + - `send(destination, body)`:publish JSON 消息。 + - `disconnect()`:取消订阅并关闭连接。 +- **连接时序(建议)** + 1. connect 成功 + 2. subscribe `/topic/room/{roomCode}` + 3. send `/app/room/{roomCode}/join` + 4. 开始收发消息 +- **连接状态 UI** + - 显示:连接中 / 已连接 / 断开 / 重连中 + - 重连成功后:自动重新 subscribe + 重新 join + +## 后端(Spring Boot) +- **WebSocket 配置** + - 端点:`/ws/data-transfer` + - broker:`/topic` + - 应用前缀:`/app` + - SockJS:`.withSockJS()` +- **跨域与握手** + - `setAllowedOriginPatterns("*")`(开发期) + - 生产期建议:限制 Origin + 启用 WSS +- **心跳** + - 与前端 STOMP 心跳保持一致(如需更严格,改用外部 broker 或自定义心跳策略) + +## 协议与通道 +- 订阅(接收) + - `/topic/room/{roomCode}`:房间内通用消息广播 + - `/topic/room/{roomCode}/file/{fileId}`:某文件分片通道(可选设计) +- 发送(服务端处理) + - `/app/room/{roomCode}/join` + - `/app/room/{roomCode}/leave` + - `/app/room/{roomCode}/message` + - `/app/room/{roomCode}/file/chunk` + +## 边界与注意点 +- **重连后的状态恢复**:需要重新 join 才能恢复在线用户列表与系统消息一致性。 +- **订阅泄漏**:房间切换、页面卸载必须 disconnect/退订,避免重复订阅造成重复消息。 +- **代理/反代**:生产 Nginx 需支持 Upgrade,`/ws` 走 ws 反代。 + diff --git a/docs/04-消息协议与消息分发(统一Message模型).md b/docs/04-消息协议与消息分发(统一Message模型).md new file mode 100644 index 0000000..929d922 --- /dev/null +++ b/docs/04-消息协议与消息分发(统一Message模型).md @@ -0,0 +1,97 @@ +# 04 - 消息协议与消息分发(统一 Message 模型) + +## 功能目标 +- 定义统一消息格式(便于扩展与兼容)。 +- 服务端负责房间维度的消息广播/转发。 +- 前端按 `type` 分发渲染与业务处理。 + +## 协议定义 +### 基础消息格式 +```json +{ + "type": "TEXT | FILE | IMAGE | SYSTEM | CHUNK", + "senderId": "uuid", + "senderName": "用户昵称", + "timestamp": 1706345600000, + "payload": {} +} +``` + +### 文本消息(TEXT) +```json +{ + "type": "TEXT", + "senderId": "uuid", + "senderName": "用户A", + "timestamp": 1706345600000, + "payload": { + "content": "要传输的文本内容", + "isChunk": false, + "chunkIndex": 0, + "totalChunks": 1 + } +} +``` + +### 文件元数据(FILE) +```json +{ + "type": "FILE", + "senderId": "uuid", + "senderName": "用户A", + "timestamp": 1706345600000, + "payload": { + "fileId": "uuid", + "fileName": "document.pdf", + "fileSize": 2621440, + "mimeType": "application/pdf", + "totalChunks": 10 + } +} +``` + +### 文件分片(CHUNK) +```json +{ + "type": "CHUNK", + "senderId": "uuid", + "payload": { + "fileId": "uuid", + "chunkIndex": 0, + "data": "base64EncodedChunkData" + } +} +``` + +### 系统消息(SYSTEM) +```json +{ + "type": "SYSTEM", + "payload": { + "event": "USER_JOIN | USER_LEAVE | ERROR", + "message": "xxx 加入了房间", + "userList": [{"id": "uuid", "name": "用户A"}] + } +} +``` + +## 前端分发(Vue) +- **入口**:`RoomView` 的 `handleMessage(msg)` 或等价逻辑。 +- **分发规则(示例)** + - `SYSTEM`:更新 `userList`,并向消息列表追加系统提示。 + - `TEXT`:追加文本消息(区分 me/other)。 + - `FILE`:追加文件卡片(显示名称、大小、下载按钮、进度)。 + - `CHUNK`:写入文件缓存并更新进度;完成后合并为 Blob。 + +## 后端分发(Spring Boot) +- **控制器**:`WebSocketController` + - `/message`:广播常规消息到 `/topic/room/{roomCode}` + - `/file/chunk`:转发分片到文件通道或同房间通道 +- **补齐与校验(建议)** + - 服务端补齐 `timestamp`,必要时校验 `type/payload` 的字段完整性、大小限制与频率限制。 + +## 边界与注意点 +- **通道设计一致性**:若采用“文件独立 topic”,前端需额外 subscribe;若统一走房间 topic,需在 payload 内携带 fileId 并做路由分发。 +- **大消息**:避免一次性发送超大 payload;统一走分片策略。 +- **安全**:前端渲染文本必须转义,禁止把用户内容当 HTML 渲染。 + diff --git a/docs/05-文本传输(含大文本分片).md b/docs/05-文本传输(含大文本分片).md new file mode 100644 index 0000000..7dd4b0c --- /dev/null +++ b/docs/05-文本传输(含大文本分片).md @@ -0,0 +1,36 @@ +# 05 - 文本传输(含大文本分片) + +## 功能目标 +- 支持在房间内发送/接收文本消息。 +- 支持大文本自动分片(避免单包过大导致失败或卡顿)。 + +## 前端(Vue) +- **输入与发送** + - `MessageInput.vue`:输入框 + 发送按钮;支持 Ctrl+V 粘贴填充。 + - 发送前校验:非空、长度上限(建议)。 +- **大文本分片(建议实现方式)** + - 当文本长度超过阈值(如 8KB/32KB)时: + - 生成 `messageId`(可复用 `senderId + timestamp` 或 UUID) + - 拆分为 `totalChunks` 段 + - 逐段发送 `TEXT`,携带 `isChunk=true/chunkIndex/totalChunks`,并在 payload 中携带 `messageId` + - 接收端按 `messageId` 缓存分片并重组,重组完成后再写入消息列表。 +- **展示与防护** + - 文本内容按纯文本展示(转义),禁止 `v-html`。 + +## 后端(Spring Boot) +- **处理策略** + - 默认透传广播即可:在 `/app/room/{roomCode}/message` 收到后补齐 `timestamp` 并广播。 + - (可选)限流/长度限制:防刷屏与恶意超大文本。 + +## 协议与数据 +- `type=TEXT` +- `payload` 推荐字段: + - `content`:文本内容(单片) + - `isChunk`:是否为分片 + - `chunkIndex/totalChunks`:分片序号与总片数 + - `messageId`:(建议新增)用于重组与去重 + +## 边界与注意点 +- **重组内存**:前端缓存分片需设定超时清理,避免长期占用内存。 +- **乱序/丢片**:WebSocket 一般有序但重连/异常情况下仍可能丢失;可以在 UI 上提示“分片未完整接收”。 + diff --git a/docs/06-文件传输(拖拽-分片-进度-下载).md b/docs/06-文件传输(拖拽-分片-进度-下载).md new file mode 100644 index 0000000..eed3166 --- /dev/null +++ b/docs/06-文件传输(拖拽-分片-进度-下载).md @@ -0,0 +1,42 @@ +# 06 - 文件传输(拖拽 / 分片 / 进度 / 下载) + +## 功能目标 +- 支持拖拽上传、粘贴文件、发送文件元数据与分片内容。 +- 接收端显示进度并可下载(本地重组 Blob)。 + +## 前端(Vue) +- **入口组件** + - `FileDropZone.vue`:拖拽、粘贴;触发 `file-selected` 事件。 +- **分片与发送** + - `fileChunker.js`:按 `chunkSize` 切片(文档示例:64KB)。 + - 发送流程: + 1. 生成 `fileId` + 2. 发送 `FILE` 元数据到 `/app/room/{roomCode}/message` + 3. 循环发送 `CHUNK` 到 `/app/room/{roomCode}/file/chunk` +- **进度显示(建议)** + - 发送端:`sentChunks / totalChunks` + - 接收端:`receivedChunks / totalChunks` + - 通过消息列表中的文件卡片展示(进度条 + 状态:接收中/完成/失败)。 +- **接收与下载(建议实现方式)** + - 建立 `fileId -> { meta, chunks[] }` 缓存 + - 收到所有分片后,按顺序拼接为 `Uint8Array`/Blob + - 使用 `URL.createObjectURL(blob)` 生成下载链接 + +## 后端(Spring Boot) +- **转发策略** + - 元数据:作为 `FILE` 消息广播到房间 topic。 + - 分片:作为 `CHUNK` 消息转发(可用独立 topic 或统一 topic)。 +- **限制与风控(建议)** + - 单文件最大值(默认 100MB,可配置)。 + - 分片大小(默认 64KB,可配置)。 + - 频率限制(防止刷分片)。 + +## 协议与数据 +- `FILE.payload`:`fileId/fileName/fileSize/mimeType/totalChunks` +- `CHUNK.payload`:`fileId/chunkIndex/data(base64)` + +## 边界与注意点 +- **Base64 膨胀**:Base64 会增加体积(约 33%),大文件会更慢;如需优化可改二进制 WebSocket(后续增强)。 +- **并行多文件**:以 `fileId` 隔离缓存即可并行;UI 需支持多文件卡片。 +- **断线续传**:当前协议未定义续传(可选增强:chunk ack / 断点续传)。 + diff --git a/docs/07-图片预览(Base64内联显示).md b/docs/07-图片预览(Base64内联显示).md new file mode 100644 index 0000000..f067e8e --- /dev/null +++ b/docs/07-图片预览(Base64内联显示).md @@ -0,0 +1,29 @@ +# 07 - 图片预览(Base64 内联显示) + +## 功能目标 +- 在消息列表中对图片进行缩略图预览与放大查看。 +- 支持通过“文件传输通道”或“图片专用消息”传输图片数据。 + +## 前端(Vue) +- **识别规则** + - `mimeType` 满足 `image/*` 时按图片渲染。 +- **展示形态(建议)** + - 消息卡片中显示缩略图(限制最大宽高)。 + - 点击后弹窗预览(Element Plus `el-dialog`/`el-image` 预览)。 +- **传输策略(两种可选)** + - **复用文件分片**(推荐一致性):图片走 `FILE + CHUNK`,接收端完成后生成 Blob 并预览。 + - **小图直发**:当图片小于阈值(如 200KB)时发送 `IMAGE`,payload 直接携带 base64(需限制大小)。 + +## 后端(Spring Boot) +- 默认透传(与 FILE/CHUNK 一致);在生产环境建议增加大小限制与频率限制。 + +## 协议与数据(建议) +- 若使用 `IMAGE`: + - `payload.data`:base64(不带 dataURL 前缀或带前缀需约定) + - `payload.mimeType`:如 `image/png` + - `payload.fileName`:(可选) + +## 边界与注意点 +- **性能**:图片 base64 可能导致消息体很大,优先走分片。 +- **安全**:只当作图片二进制展示,不执行任何脚本;避免把 payload 当 HTML 渲染。 + diff --git a/docs/08-剪贴板集成(读取-粘贴-降级).md b/docs/08-剪贴板集成(读取-粘贴-降级).md new file mode 100644 index 0000000..ac064e4 --- /dev/null +++ b/docs/08-剪贴板集成(读取-粘贴-降级).md @@ -0,0 +1,26 @@ +# 08 - 剪贴板集成(读取 / 粘贴 / 降级) + +## 功能目标 +- 支持 Ctrl+V 粘贴文本/文件到传输区域。 +- 支持按钮主动读取系统剪贴板文本(在权限允许的情况下)。 +- 在权限受限或浏览器不支持时提供清晰降级提示。 + +## 前端(Vue) +- **粘贴事件处理** + - 监听 `paste`,遍历 `e.clipboardData.items`: + - `kind=file`:提取 File,走文件传输流程 + - `kind=string && type=text/plain`:提取文本,填充输入框或直接发送 +- **主动读取剪贴板** + - `navigator.clipboard.readText()`:读取文本并 emit `text-pasted` + - 失败提示:“无法读取剪贴板,请手动粘贴”(常见于非 HTTPS、权限未授予、浏览器策略限制) +- **组件建议** + - `FileDropZone.vue`:承载粘贴与读取按钮 + - `utils/clipboard.js`:封装权限判断与异常处理(便于复用) + +## 后端(Spring Boot) +- 无需专门接口支持;剪贴板只影响前端如何生成 `TEXT/FILE` 消息。 + +## 边界与注意点 +- **HTTPS/权限**:`navigator.clipboard` 通常要求安全上下文(HTTPS/localhost)与用户手势。 +- **可用性**:粘贴是最稳妥的降级方式,读取按钮只是增强能力。 + diff --git a/docs/09-在线用户列表(房间成员与系统消息).md b/docs/09-在线用户列表(房间成员与系统消息).md new file mode 100644 index 0000000..473b1ad --- /dev/null +++ b/docs/09-在线用户列表(房间成员与系统消息).md @@ -0,0 +1,30 @@ +# 09 - 在线用户列表(房间成员与系统消息) + +## 功能目标 +- 在房间侧边栏展示在线用户列表。 +- 用户加入/离开时,实时更新列表并显示系统提示。 + +## 前端(Vue) +- **组件** + - `UserList.vue`:渲染 `userList`(在线/离线样式可选)。 + - `RoomPanel.vue`:承载房间号与用户列表。 +- **更新机制** + - 订阅房间 topic 后,收到 `SYSTEM` 且携带 `payload.userList` 时更新 `userList`。 + - 在消息区插入 `payload.message` 作为系统提示。 + +## 后端(Spring Boot) +- **RoomService** + - 存储结构(示例):`roomCode -> { sessionId -> userName }` + - `getUserList(roomCode)`:返回数组(包含 id/name)。 +- **广播时机** + - join 成功后广播 `SYSTEM(USER_JOIN + userList)` + - leave 成功后广播 `SYSTEM(USER_LEAVE + userList)` + +## 协议与数据 +- `SYSTEM.payload.userList`: + - `[{ "id": "uuid/sessionId", "name": "用户A" }, ...]` + +## 边界与注意点 +- **用户身份**:示例使用 `sessionId` 当 id;如需“跨重连保持身份”,需引入客户端生成的 stableId(可选增强)。 +- **断线处理**:需要在服务端捕获 disconnect 事件,触发 leave 并广播列表更新。 + diff --git a/docs/10-历史记录(本地存储-清空-导出).md b/docs/10-历史记录(本地存储-清空-导出).md new file mode 100644 index 0000000..1a89adf --- /dev/null +++ b/docs/10-历史记录(本地存储-清空-导出).md @@ -0,0 +1,26 @@ +# 10 - 历史记录(本地存储 / 清空 / 导出) + +## 功能目标 +- 保存消息历史(默认本地存储,不依赖服务端)。 +- 支持清空当前房间记录、导出记录(便于审计或留存)。 + +## 前端(Vue) +- **本地存储策略(建议)** + - 按 `roomCode` 分桶保存:`history:{roomCode}`。 + - 存储介质: + - 少量文本:`localStorage` + - 含文件/图片元数据与大量消息:建议 `IndexedDB`(可选) + - 存储内容建议只存元数据与文本,不存大块二进制(避免爆仓)。 +- **功能入口** + - `RoomPanel.vue`:提供“清空”“导出”按钮。 +- **导出格式(建议)** + - JSON:包含 `type/sender/timestamp/payload摘要` + - 或文本:便于粘贴到工单/邮件 + +## 后端(Spring Boot) +- 默认无需支持;若开启服务端持久化则由后端提供查询/导出(可选增强)。 + +## 边界与注意点 +- **隐私**:本地存储可能包含敏感信息;需明确提示用户可手动清空。 +- **容量**:浏览器存储空间有限;建议设定最大条数/最大时间窗与自动淘汰策略。 + diff --git a/docs/11-UI设计规范与交互原则.md b/docs/11-UI设计规范与交互原则.md new file mode 100644 index 0000000..816516a --- /dev/null +++ b/docs/11-UI设计规范与交互原则.md @@ -0,0 +1,299 @@ + # 15 - UI 设计规范与交互原则(DataTool) + + > 本文作为 DataTool 项目前端的统一 UI 设计规范与交互基线,后续所有功能开发应在不违背本规范的前提下扩展。若有必要变更,请同步更新本文件。 + + --- + + ## 1. 产品定位与使用场景 + + - **产品类型**:轻量级、基于浏览器的数据传输工具 + - **核心场景**:在 VNC / 远程桌面 / 内网隔离等环境中,通过 6 位房间号在多端之间快速传文本、文件、图片和剪贴板内容 + - **受众角色**:开发 / 运维 / 支持工程师、内网用户 + - **体验方向**:**稳定、可靠、低干扰、专业、轻量** + + --- + + ## 2. 整体设计原则 + + - **一致性优先** + - 所有页面沿用统一的色板、字体、圆角与阴影体系。 + - 所有按钮、输入框、消息卡片、文件卡片必须复用同一套组件样式,不允许在局部自行定义“新样式”。 + - **信息层次清晰** + - 强调房间号与连接状态,其次是消息内容与文件进度,最后是系统提示和辅助信息。 + - 使用颜色 / 字号 / 粗细 / 间距来区分重要程度,避免靠纯颜色或纯位置。 + - **状态可感知** + - WebSocket 连接状态、房间加入/退出、文件发送与接收进度必须在 UI 中可见且明确。 + - 所有异步操作(发送消息、上传文件、读取剪贴板等)都有“进行中 / 成功 / 失败”的视觉反馈。 + - **轻量与性能** + - 大量消息场景下保持滚动流畅,避免复杂阴影和高成本动画。 + - 组件优先考虑简单、可扩展的布局,预留虚拟列表等性能优化空间。 + - **可访问与可维护** + - 遵守基本对比度、可聚焦与键盘可操作性。 + - 使用统一的设计 token(颜色/间距/字号),便于全局调整。 + + --- + + ## 3. 视觉设计规范 + + ### 3.1 色彩体系(建议映射到 CSS 变量 / Tailwind token) + + - **品牌主色(Primary)** + - 基准色:`#2563EB`(蓝,类似 Tailwind `blue-600`) + - Hover:`#1D4ED8`(`blue-700`) + - Active:`#1E40AF`(`blue-800`) + - 主要用于:主按钮、主操作高亮、房间号强调、连接状态“已连接”标识。 + - **强调色(Success / Danger / Warning)** + - Success:`#16A34A`(`green-600`)—— 文件传输完成、连接成功、操作成功提示。 + - Danger:`#DC2626`(`red-600`)—— 错误、连接失败、限制触发。 + - Warning:`#F97316`(`orange-500`)—— 限制接近阈值、网络不稳定等。 + - **中性色(背景 / 边框 / 文本)** + - 页面背景:`#F8FAFC`(`slate-50`) + - 卡片背景:`#FFFFFF` + - 边框:`#E2E8F0`(`slate-200`) + - 分割线:`#E5E7EB`(`gray-200`) + - 主文本:`#0F172A`(`slate-900`) + - 次级文本:`#475569`(`slate-600`) + - 弱化文本/占位:`#9CA3AF`(`gray-400`)及以上,不使用更浅灰值作为正文。 + - **系统消息与提示色** + - 系统消息背景:`#EFF6FF`(`blue-50`),文字使用 `#1D4ED8`。 + - 提示条(Banner):背景 `#F1F5F9`(`slate-100`),根据状态加左侧色条(蓝/绿/橙/红)。 + + > 所有新页面不得新增“第二主色”,若确有需要,先在本文件补充“辅助色(Secondary)”章节再使用。 + + ### 3.2 字体与排版 + + - **字体族** + - 优先:`system-ui, -apple-system, BlinkMacSystemFont, "SF Pro SC", "PingFang SC", "Microsoft YaHei", sans-serif` + - **字号层级(桌面端基线)** + - 标题 H1:24px / 32px,用于页面主标题(如“房间 123456”) + - 标题 H2:20px / 28px,用于分区标题(在线用户、消息区、文件传输) + - 标题 H3:16px / 24px,用于小模块标题、卡片标题 + - 正文:14px / 22px(首选),行高 1.6 + - 标签/次要信息:12px / 18px,谨慎使用,保证可读性 + - **文字对齐** + - 文本与输入框左对齐,数字型信息(如进度百分比)可以右对齐。 + - **行宽与段落** + - 文本区域最大宽度建议控制在 65–75 个汉字以内,避免极长单行。 + + ### 3.3 圆角、阴影与描边 + + - **圆角** + - 输入框/按钮:`4px` + - 卡片(消息卡片、文件卡片):`8px` + - 悬浮面板 / 弹窗:`12px` + - **阴影** + - 默认卡片:无阴影,仅边框(`border-slate-200`) + - 悬浮/高层级元素(如弹窗、悬浮面板):轻量阴影 `0 10px 25px rgba(15,23,42,0.08)` + - **描边** + - 主按钮:默认无边框,仅用色块;Hover 时可增加内阴影或细微深色。 + - 输入框:有 1px 描边,聚焦时描边色切换为主色并增加轻微光晕。 + + ### 3.4 间距与栅格 + + - 页面左右内边距:桌面端 `24px`,窄屏端 `16px` + - 区块间距(模块与模块之间):`24px` + - 元素垂直间距(标题与内容 / 行与行):`8–12px` + - 列表项内边距:上下 `8px` / 左右 `12px` + - 使用 4 的倍数作为统一间距刻度:`4 / 8 / 12 / 16 / 24 / 32` + + --- + + ## 4. 布局与信息架构 + + ### 4.1 整体布局结构 + + - **桌面端(≥ 1024px)** + - 顶部:窄高度顶部栏,包含项目 Logo/名称、当前房间号、连接状态指示、小范围操作(帮助、设置)。 + - 主区:左右双栏布局。 + - 左侧(约 25–30% 宽度):`RoomPanel`,包含房间信息、在线用户列表、基础操作(退出、清空、导出)。 + - 右侧(约 70–75% 宽度):消息与文件显示区域(消息列表 + 文件/图片预览),底部为输入与文件操作区域。 + - **窄屏端(< 1024px)** + - 顶部栏保留,主内容上下布局: + - 默认展示消息区域,在线用户通过折叠面板或底部抽屉查看。 + - 拖拽上传在窄屏上使用明显的上传按钮 + 粘贴提示,拖拽仅作为增强能力。 + + ### 4.2 关键页面说明 + + - **首页 `HomeView`** + - 中心内容区居中显示: + - 左:创建房间卡片(展示自动生成 6 位房间号 + “创建并进入”按钮) + - 右:加入房间卡片(房间号输入框 + 加入按钮) + - 明确提示“数据不落库 / 仅当前会话可见”等安全特性(简短描述)。 + - 若已有最近加入的房间记录,可在下方列出“最近使用的房间”列表(历史记录相关)。 + - **房间页 `RoomView`** + - 顶部显示: + - 房间号(可点击复制)+ 小标签(如“临时房间”) + - 连接状态:圆点 + 文本(连接中/已连接/断开/重连中) + - 左侧 `RoomPanel`: + - 在线用户列表(昵称 + 状态点),当前用户行高亮。 + - 简要统计信息:在线人数、当前传输中文件数量等。 + - 操作按钮:退出房间(弱化但明确)、清空本地历史、导出历史。 + - 右侧主区: + - 上方:消息与系统提示列表(含文本、图片、文件卡片、SYSTEM 消息)。 + - 下方:输入区域(文本输入 + 发送按钮 + 附件/文件按钮 + 剪贴板按钮)。 + + ### 4.3 消息列表布局 + + - **文本消息** + - 根据发送者区分左右对齐(可选),也可统一左对齐,通过头像首字母+昵称区分。 + - 时间戳放置在右下角,使用次级文字色。 + - **系统消息** + - 居中对齐,使用浅色条带样式,与普通消息明显区分。 + - 内容如“xxx 加入房间 / 离开房间 / 连接已重连”等。 + - **文件与图片消息** + - 使用卡片样式,包含文件名、大小、进度条、状态图标以及操作按钮(下载/打开)。 + + --- + + ## 5. 核心组件规范 + + > 实现时建议将以下元素封装为 Vue 组件,并在使用前优先复用。 + + ### 5.1 按钮(`Button`) + + - **尺寸** + - 大:高度 40–44px(主要操作,如“创建房间”“加入房间”“发送”) + - 中:高度 32–36px(列表操作、弹窗确认等) + - 小:高度 28–32px(标签型按钮、图标按钮) + - **类型** + - 主按钮(Primary):填充主色,白色文字,圆角 4px。 + - 次按钮(Secondary):白底 + 主色描边 + 主色文字。 + - 危险按钮(Danger):红色填充 + 白色文字,仅用于删除/退出等操作。 + - 文本按钮(Ghost/Text):透明背景 + 主色文字,用于低权重操作。 + - **状态** + - 默认 / Hover / Active / Disabled 四态。 + - Hover:亮度 + 边框/阴影轻微变化,不允许大幅缩放导致布局抖动。 + - Disabled:降低对比度并移除悬浮态,鼠标指针改为默认。 + + ### 5.2 输入与表单(`Input`, `Textarea`, `FormField`) + + - **样式** + - 高度 36–40px,左右有 12–16px 内边距。 + - 边框颜色:默认 `#E2E8F0`,聚焦 `primary`。 + - 占位文字使用 `gray-400`,不可与正常正文颜色相同。 + - **校验** + - 房间号输入框限制为 6 位数字,错误时在输入框下方展示红色错误文案,并将边框颜色切换为 `red-500`。 + - 提交按钮在校验不通过时为禁用状态。 + + ### 5.3 标签与状态(`Tag`, `Badge`, `StatusDot`) + + - 用于表示连接状态、房间类型、文件状态等。 + - 状态点尺寸 8–10px,放置在用户名左侧或右上角。 + - 颜色与状态映射保持统一(如:绿色=在线,灰色=离线,橙色=异常)。 + + ### 5.4 消息气泡与系统消息(`MessageItem`, `SystemMessage`) + + - 消息气泡: + - 背景使用白色或略带浅灰,边框微弱(可选)。 + - 内部包含:昵称(可选)/ 消息内容 / 时间戳。 + - 系统消息: + - 使用全宽度条带,背景为 `blue-50` 或 `slate-100`,文字居中。 + - 与气泡在视觉上区分开(不使用“对话气泡”样式)。 + + ### 5.5 文件卡片与进度条(`FileMessage`, `ProgressBar`) + + - **文件卡片内容** + - 文件名(可省略中间,用省略号显示)+ 文件大小 + 文件类型图标。 + - 进度条(发送端、接收端皆可见)+ 状态标签(传输中 / 已完成 / 失败)。 + - 操作按钮:下载、打开所在文件夹(浏览器可行范围内)、重新下载(失败时)。 + - **进度条样式** + - 高度 4–6px,圆角 999px。 + - 背景条颜色 `slate-200`,前景条使用主色或状态色。 + + ### 5.6 图片预览卡片(`ImageMessage`, `ImagePreviewModal`) + + - 消息列表中展示缩略图(固定宽高比,如 4:3),点击后打开大图预览。 + - 大图使用居中遮罩弹窗,背景半透明黑色,支持点击空白处关闭或 Esc 关闭。 + + ### 5.7 Toast 与通知(`Toast`, `AlertBanner`) + + - Toast 适合短暂状态提示(发送成功、复制成功),出现在右上角或右下角,自动消失。 + - AlertBanner 适合持久的提醒(当前使用 HTTP 协议、传输大小接近上限等),固定在内容区顶部。 + + --- + + ## 6. 关键交互流程规范 + + ### 6.1 加入 / 创建房间 + + - **创建房间** + - 点击“创建房间”后立即显示加载状态(按钮禁用 + Loading 图标)。 + - 创建成功后自动跳转到房间页,并以系统消息提示“你已创建并进入房间 123456”。 + - **加入房间** + - 用户输入 6 位房间号,实时校验;不足 6 位或包含非数字字符时按钮禁用。 + - 加入失败(房间不存在/连接错误)时: + - 在输入框下方显示清晰错误文案。 + - 可用 Toast 或 Banner 补充说明。 + + ### 6.2 WebSocket 连接与状态恢复 + + - 顶部栏右上区域固定显示连接状态: + - 连接中:黄色点 + “连接中…” + - 已连接:绿色点 + “已连接” + - 重连中:橙色点 + “重连中…” + - 已断开:红色点 + “已断开” + - 当状态从“断开/重连中”恢复到“已连接”时: + - 插入一条 SYSTEM 消息说明“已重新连接,已重新加入房间”。 + + ### 6.3 消息发送与错误处理 + + - 按下 Enter 发送消息,Shift+Enter 换行(在文本框旁明确提示)。 + - 发送过程中,发送按钮进入 Loading 状态并禁用,避免重复点击。 + - 若发送失败(网络中断等): + - 消息在列表中使用“失败”样式(如左侧红色竖线 + 灰度文字)。 + - 提供“重试发送”小按钮。 + + ### 6.4 文件拖拽上传 / 粘贴 + + - **拖拽区域** + - 在输入区域上方或消息列表顶部提供明显的拖拽提示区域(浅虚线边框 + 上传图标)。 + - 拖拽文件进入页面时,高亮整个拖拽区域,并在靠顶部位置显示提示条(如“释放鼠标以发送文件到房间 123456”)。 + - **粘贴文件 / 剪贴板** + - 当用户使用 Ctrl+V 粘贴文件或图片时,提示一次确认(可选):“是否将剪贴板中的图片/文件发送到当前房间?”。 + - 剪贴板权限被浏览器限制时,弹出说明性对话框,引导用户手动拖拽或选择文件。 + + ### 6.5 历史记录与本地存储 + + - 在房间页的 `RoomPanel` 中提供“历史记录”入口,可选择: + - 清空当前房间历史(弹出确认对话框)。 + - 导出当前房间历史(文件命名建议:`DataTool-房间号-时间戳.json`)。 + - 刷新页面后自动加载当前房间的本地历史,并通过淡入动画呈现。 + + --- + + ## 7. 可访问性与通用 UX 要求 + + - **颜色对比度** + - 正文文本与背景对比度 ≥ 4.5:1,次级文本不低于 3:1。 + - **键盘导航** + - 所有按钮和可点击卡片必须可通过 Tab 聚焦,按 Enter/Space 触发。 + - 焦点样式清晰可见(外发光或描边),不依赖颜色变化微弱的 hover 效果。 + - **ARIA 与语义化** + - 图标按钮(仅图标无文字)添加 `aria-label`。 + - 使用语义标签(`
`, `
`, `