From 59bb8e16f5058ea42c3d2bb99581cbc4b5548a55 Mon Sep 17 00:00:00 2001 From: liu <362165265@qq.com> Date: Sat, 31 Jan 2026 00:51:14 +0800 Subject: [PATCH] Initial commit: DataTool backend, frontend and Docker --- .dockerignore | 27 + .gitignore | 19 + README.md | 117 + backend/pom.xml | 68 + .../com/datatool/DataToolApplication.java | 15 + .../datatool/config/SpaResourceConfig.java | 33 + .../datatool/config/TransferProperties.java | 50 + .../com/datatool/config/WebSocketConfig.java | 53 + .../datatool/controller/RoomController.java | 83 + .../controller/RoomFileController.java | 179 ++ .../com/datatool/message/MessagePayload.java | 198 ++ .../com/datatool/message/MessageType.java | 20 + .../src/main/java/com/datatool/room/Room.java | 68 + .../java/com/datatool/room/RoomService.java | 118 + .../java/com/datatool/room/SessionInfo.java | 61 + .../com/datatool/room/SessionRegistry.java | 33 + .../service/ScheduledUploadCleanup.java | 71 + .../service/UploadCleanupService.java | 83 + .../com/datatool/ws/RoomWsController.java | 126 + .../datatool/ws/WebSocketEventListener.java | 89 + backend/src/main/resources/application.yml | 26 + docker/Dockerfile | 38 + docker/deploy.ps1 | 21 + docker/deploy.sh | 22 + docker/docker-compose.yml | 17 + docs/00-数据传输助手-开发文档.md | 890 ++++++ docs/01-整体架构与核心概念.md | 28 + docs/02-房间管理(创建-加入-退出).md | 39 + ...ebSocket连接管理(连接-订阅-心跳-重连).md | 49 + ...4-消息协议与消息分发(统一Message模型).md | 97 + docs/05-文本传输(含大文本分片).md | 36 + docs/06-文件传输(拖拽-分片-进度-下载).md | 42 + docs/07-图片预览(Base64内联显示).md | 29 + docs/08-剪贴板集成(读取-粘贴-降级).md | 26 + docs/09-在线用户列表(房间成员与系统消息).md | 30 + docs/10-历史记录(本地存储-清空-导出).md | 26 + docs/11-UI设计规范与交互原则.md | 299 ++ frontend/index.html | 13 + frontend/package-lock.json | 2805 +++++++++++++++++ frontend/package.json | 28 + frontend/postcss.config.cjs | 7 + frontend/src/App.vue | 60 + frontend/src/api/room.ts | 111 + frontend/src/api/websocket.ts | 149 + frontend/src/assets/main.css | 35 + frontend/src/components/FileDropZone.vue | 215 ++ frontend/src/components/FileMessage.vue | 149 + frontend/src/components/ImageMessage.vue | 278 ++ frontend/src/components/MessageInput.vue | 103 + frontend/src/components/MessageItem.vue | 89 + frontend/src/components/RoomPanel.vue | 131 + frontend/src/components/SystemMessage.vue | 14 + frontend/src/components/UserList.vue | 63 + frontend/src/components/ui/BaseButton.vue | 63 + frontend/src/components/ui/StatusDot.vue | 47 + frontend/src/env.d.ts | 2 + frontend/src/main.ts | 13 + frontend/src/router/index.ts | 25 + frontend/src/stores/wsStore.ts | 757 +++++ frontend/src/types/room.ts | 54 + frontend/src/utils/avatar.ts | 88 + frontend/src/utils/clipboard.ts | 168 + frontend/src/utils/fileChunker.ts | 97 + frontend/src/views/HomeView.vue | 124 + frontend/src/views/RoomView.vue | 332 ++ frontend/src/ws/RoomWsClient.ts | 114 + frontend/tailwind.config.cjs | 36 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 32 + 69 files changed, 9449 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/datatool/DataToolApplication.java create mode 100644 backend/src/main/java/com/datatool/config/SpaResourceConfig.java create mode 100644 backend/src/main/java/com/datatool/config/TransferProperties.java create mode 100644 backend/src/main/java/com/datatool/config/WebSocketConfig.java create mode 100644 backend/src/main/java/com/datatool/controller/RoomController.java create mode 100644 backend/src/main/java/com/datatool/controller/RoomFileController.java create mode 100644 backend/src/main/java/com/datatool/message/MessagePayload.java create mode 100644 backend/src/main/java/com/datatool/message/MessageType.java create mode 100644 backend/src/main/java/com/datatool/room/Room.java create mode 100644 backend/src/main/java/com/datatool/room/RoomService.java create mode 100644 backend/src/main/java/com/datatool/room/SessionInfo.java create mode 100644 backend/src/main/java/com/datatool/room/SessionRegistry.java create mode 100644 backend/src/main/java/com/datatool/service/ScheduledUploadCleanup.java create mode 100644 backend/src/main/java/com/datatool/service/UploadCleanupService.java create mode 100644 backend/src/main/java/com/datatool/ws/RoomWsController.java create mode 100644 backend/src/main/java/com/datatool/ws/WebSocketEventListener.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 docker/Dockerfile create mode 100644 docker/deploy.ps1 create mode 100644 docker/deploy.sh create mode 100644 docker/docker-compose.yml create mode 100644 docs/00-数据传输助手-开发文档.md create mode 100644 docs/01-整体架构与核心概念.md create mode 100644 docs/02-房间管理(创建-加入-退出).md create mode 100644 docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md create mode 100644 docs/04-消息协议与消息分发(统一Message模型).md create mode 100644 docs/05-文本传输(含大文本分片).md create mode 100644 docs/06-文件传输(拖拽-分片-进度-下载).md create mode 100644 docs/07-图片预览(Base64内联显示).md create mode 100644 docs/08-剪贴板集成(读取-粘贴-降级).md create mode 100644 docs/09-在线用户列表(房间成员与系统消息).md create mode 100644 docs/10-历史记录(本地存储-清空-导出).md create mode 100644 docs/11-UI设计规范与交互原则.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.cjs create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/room.ts create mode 100644 frontend/src/api/websocket.ts create mode 100644 frontend/src/assets/main.css create mode 100644 frontend/src/components/FileDropZone.vue create mode 100644 frontend/src/components/FileMessage.vue create mode 100644 frontend/src/components/ImageMessage.vue create mode 100644 frontend/src/components/MessageInput.vue create mode 100644 frontend/src/components/MessageItem.vue create mode 100644 frontend/src/components/RoomPanel.vue create mode 100644 frontend/src/components/SystemMessage.vue create mode 100644 frontend/src/components/UserList.vue create mode 100644 frontend/src/components/ui/BaseButton.vue create mode 100644 frontend/src/components/ui/StatusDot.vue create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/wsStore.ts create mode 100644 frontend/src/types/room.ts create mode 100644 frontend/src/utils/avatar.ts create mode 100644 frontend/src/utils/clipboard.ts create mode 100644 frontend/src/utils/fileChunker.ts create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/RoomView.vue create mode 100644 frontend/src/ws/RoomWsClient.ts create mode 100644 frontend/tailwind.config.cjs create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts 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 + + + + 拖拽文件到此处,或按 Ctrl+V 粘贴 + + 读取剪贴板 + + + + + + + +``` + +--- + +## 八、部署配置 + +### 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`。 + - 使用语义标签(``, ``, ``, ``)构建布局。 + - **动画与动效** + - 常规动效时长控制在 150–250ms 内。 + - 支持 `prefers-reduced-motion`,在该设置下减弱或关闭动画。 + + --- + + ## 8. 动画与微交互规则 + + - **Hover 与点击** + - 按钮、卡片 hover 使用颜色/阴影变化,不使用大幅 scale 动画。 + - 点击反馈可使用轻微按压效果(阴影变小/颜色略深)。 + - **加载与进度** + - 使用细线进度条或简洁 Spinner,不使用大型覆盖式 Loading 遮罩,避免阻塞操作。 + - **列表更新** + - 新消息出现时可使用轻微淡入效果(不超过 150ms),避免大幅滑入动画。 + + --- + + ## 9. 实现与扩展建议 + + - 建议在前端建立统一的设计 token(如 `dt-color-primary`, `dt-radius-card`, `dt-spacing-md` 等),由基础样式文件或 Tailwind 配置统一生成。 + - 新增页面或组件时: + - **必须** 复用现有颜色、字体、间距与组件模式。 + - 如需突破现有规范(例如新增“标签页导航组件”),请先在本文件新增对应的组件规范小节,再在代码中实现。 + - 后续 Phase 3/4 中的剪贴板、图片预览、历史记录等功能,应基于本规范中的组件(文件卡片、图片卡片、Toast、Modal 等)组合实现,不单独发明新样式。 + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1daafe1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + DataTool - 房间数据传输助手 + + + + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1362f81 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2805 @@ +{ + "name": "datatool-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "datatool-frontend", + "version": "0.0.1", + "dependencies": { + "@stomp/stompjs": "^7.0.0", + "axios": "^1.7.0", + "pinia": "^2.1.7", + "sockjs-client": "^1.6.1", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.4.0", + "vite": "^5.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stomp/stompjs": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.2.1.tgz", + "integrity": "sha512-DLd/WeicnHS5SsWWSk3x6/pcivqchNaEvg9UEGVqAcfYEBVmS9D6980ckXjTtfpXLjdLDsd96M7IuX4w7nzq5g==", + "license": "Apache-2.0" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.283", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", + "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a318c50 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "datatool-frontend", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.0", + "pinia": "^2.1.7", + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "sockjs-client": "^1.6.1", + "@stomp/stompjs": "^7.0.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.4.0", + "vite": "^5.0.0" + } +} + diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..c21c076 --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..dc28964 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,60 @@ + + + + + DataTool + + 轻量级房间数据传输助手 + + + + + {{ statusLabel }} + + + + + + + + + + + + diff --git a/frontend/src/api/room.ts b/frontend/src/api/room.ts new file mode 100644 index 0000000..747e2d0 --- /dev/null +++ b/frontend/src/api/room.ts @@ -0,0 +1,111 @@ +/** + * 房间相关 REST API:文件上传与下载 URL。 + * 大文件走 HTTP 上传/下载,避免 WebSocket 长传断连。 + */ + +export interface UploadFileResponse { + fileId: string; + fileName: string; + fileSize: number; + mimeType: string; +} + +/** + * 上传文件到房间,落盘到服务器,返回文件元数据。 + * onProgress(0~100):上传进度;当 lengthComputable 为 false 时用 file.size 估算。 + */ +export async function uploadRoomFile( + roomCode: string, + file: File, + onProgress?: (percent: number) => void, +): Promise { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + const url = `/api/room/${encodeURIComponent(roomCode)}/file/upload`; + + return new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', (e) => { + if (!onProgress) return; + if (e.lengthComputable) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } else if (e.loaded > 0 && file.size > 0) { + onProgress(Math.min(99, Math.round((e.loaded / file.size) * 100))); + } + }); + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText) as UploadFileResponse; + resolve(data); + } catch { + reject(new Error('解析响应失败')); + } + } else { + let message = `上传失败 ${xhr.status}`; + try { + const body = JSON.parse(xhr.responseText); + if (body.message) message = body.message; + } catch { + if (xhr.responseText) message = xhr.responseText; + } + reject(new Error(message)); + } + }); + xhr.addEventListener('error', () => reject(new Error('网络错误'))); + xhr.addEventListener('abort', () => reject(new Error('上传已取消'))); + + xhr.open('POST', url); + xhr.send(formData); + }); +} + +/** + * 获取当前客户端的 IP(由服务端从请求中解析),用于作为默认昵称。 + */ +export async function getMyIp(apiBase = ''): Promise { + const url = `${apiBase}/api/room/my-ip`.replace(/\/+/g, '/'); + const res = await fetch(url); + if (!res.ok) throw new Error(`获取 IP 失败: ${res.status}`); + const data = (await res.json()) as { ip?: string }; + return data.ip ?? ''; +} + +/** + * 返回房间内文件的下载 URL(相对路径,走当前 origin 的 /api 代理)。 + */ +export function getFileDownloadUrl(roomCode: string, fileId: string): string { + return `/api/room/${encodeURIComponent(roomCode)}/file/${encodeURIComponent(fileId)}`; +} + +/** + * 带进度的 HTTP 下载,返回 Blob。onProgress(0~100) 在 lengthComputable 时有效。 + */ +export function downloadWithProgress( + url: string, + onProgress?: (percent: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = 'blob'; + + xhr.addEventListener('progress', (e) => { + if (onProgress && e.lengthComputable && e.total > 0) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response as Blob); + } else { + reject(new Error(xhr.status === 404 ? '文件不存在' : `下载失败 ${xhr.status}`)); + } + }); + xhr.addEventListener('error', () => reject(new Error('网络错误'))); + xhr.addEventListener('abort', () => reject(new Error('已取消'))); + + xhr.open('GET', url); + xhr.send(); + }); +} diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts new file mode 100644 index 0000000..091768d --- /dev/null +++ b/frontend/src/api/websocket.ts @@ -0,0 +1,149 @@ +/** + * WebSocket 连接管理封装(SockJS + STOMP) + * 文档:03-WebSocket连接管理(连接-订阅-心跳-重连) + * + * - connect:创建 STOMP Client,启用 reconnectDelay 与心跳 + * - subscribe:按 destination 保存订阅,便于统一退订 + * - send:发送 JSON 消息 + * - disconnect:取消订阅并关闭连接 + */ + +import { Client, type IMessage, type StompSubscription } from '@stomp/stompjs'; +import SockJS from 'sockjs-client'; + +export type ConnectionStatus = + | 'connecting' + | 'connected' + | 'reconnecting' + | 'disconnected'; + +export type OnConnect = () => void; +export type OnError = (err: unknown) => void; +export type OnStatusChange = (status: ConnectionStatus) => void; + +const DEFAULT_RECONNECT_DELAY_MS = 2000; +const DEFAULT_HEARTBEAT_IN_MS = 10000; +const DEFAULT_HEARTBEAT_OUT_MS = 10000; + +export interface WebSocketApi { + connect( + url: string, + onConnect: OnConnect, + onError?: OnError, + onStatusChange?: OnStatusChange, + ): void; + subscribe(destination: string, callback: (message: IMessage) => void): StompSubscription | null; + send(destination: string, body: object | string): void; + disconnect(): void; + getStatus(): ConnectionStatus; +} + +/** + * 创建 WebSocket 封装实例,具备心跳保活与自动重连。 + * 连接时序建议:connect 成功 → subscribe /topic/... → send /app/... → 收发消息。 + */ +export function createWebSocketClient(): WebSocketApi { + let client: Client | null = null; + const subscriptions: StompSubscription[] = []; + let status: ConnectionStatus = 'disconnected'; + let onStatusChangeCb: OnStatusChange | undefined; + + function setStatus(s: ConnectionStatus) { + status = s; + onStatusChangeCb?.(s); + } + + function connect( + url: string, + onConnect: OnConnect, + onError?: OnError, + onStatusChange?: OnStatusChange, + ): void { + onStatusChangeCb = onStatusChange; + if (client && status === 'connected') return; + + subscriptions.forEach((s) => { + try { + s.unsubscribe(); + } catch { + // ignore + } + }); + subscriptions.length = 0; + + setStatus('connecting'); + + client = new Client({ + webSocketFactory: () => new SockJS(url) as unknown as WebSocket, + reconnectDelay: DEFAULT_RECONNECT_DELAY_MS, + heartbeatIncoming: DEFAULT_HEARTBEAT_IN_MS, + heartbeatOutgoing: DEFAULT_HEARTBEAT_OUT_MS, + // 大消息分片:64KB base64 分片约 87KB,需拆分发送;与 Spring 后端 1MB 限制配合 + splitLargeFrames: true, + maxWebSocketChunkSize: 64 * 1024, + onConnect: () => { + // 重连后旧订阅已失效,清空列表由调用方重新 subscribe + join + subscriptions.length = 0; + setStatus('connected'); + onConnect(); + }, + onStompError: (frame) => { + setStatus('disconnected'); + onError?.(frame); + }, + onWebSocketClose: () => { + if (client?.active) { + setStatus('reconnecting'); + } else { + setStatus('disconnected'); + } + }, + }); + + client.activate(); + } + + function subscribe( + destination: string, + callback: (message: IMessage) => void, + ): StompSubscription | null { + if (!client?.connected) return null; + const sub = client.subscribe(destination, callback); + subscriptions.push(sub); + return sub; + } + + function send(destination: string, body: object | string): void { + if (!client?.connected) return; + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + client.publish({ destination, body: bodyStr }); + } + + function disconnect(): void { + subscriptions.forEach((s) => { + try { + s.unsubscribe(); + } catch { + // ignore + } + }); + subscriptions.length = 0; + if (client) { + client.deactivate(); + client = null; + } + setStatus('disconnected'); + } + + function getStatus(): ConnectionStatus { + return status; + } + + return { + connect, + subscribe, + send, + disconnect, + getStatus, + }; +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..10b7f1b --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,35 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --dt-radius-btn: 4px; + --dt-radius-card: 8px; + --dt-radius-modal: 12px; +} + +body { + @apply text-slate-900 bg-slate-50; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro SC", + "PingFang SC", "Microsoft YaHei", sans-serif; +} + +.dt-input { + @apply w-full h-10 px-3 rounded-[var(--dt-radius-btn)] border border-slate-200 bg-white text-sm text-slate-900 + placeholder:text-slate-400 outline-none focus:border-primary focus:ring-1 focus:ring-primary transition; +} + +.dt-textarea { + @apply w-full px-3 py-2 rounded-[var(--dt-radius-card)] border border-slate-200 bg-white text-sm text-slate-900 + placeholder:text-slate-400 outline-none focus:border-primary focus:ring-1 focus:ring-primary transition resize-y; +} + +/* Toast 淡入淡出(文档 15:约 150ms) */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.15s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} \ No newline at end of file diff --git a/frontend/src/components/FileDropZone.vue b/frontend/src/components/FileDropZone.vue new file mode 100644 index 0000000..44d708a --- /dev/null +++ b/frontend/src/components/FileDropZone.vue @@ -0,0 +1,215 @@ + + + + + + + + + + 拖拽文件到此处,或点击 / Ctrl+V 粘贴 + · 单文件最大 50MB + + + + + + + + + + {{ isReadingClipboard ? '读取中...' : '读取剪贴板' }} + + + {{ clipboardMessage }} + + + + + + diff --git a/frontend/src/components/FileMessage.vue b/frontend/src/components/FileMessage.vue new file mode 100644 index 0000000..c21d7d0 --- /dev/null +++ b/frontend/src/components/FileMessage.vue @@ -0,0 +1,149 @@ + + + + {{ avatarLetter }} + + + + + {{ shortDisplayName }} + + + + + + + + + + + {{ displayFileName }} + + + {{ formatSize(fileSize) }} + + + + + + + + + {{ statusLabel }} + + + + + 下载 + + + + 传输失败 + + 重试 + + + + + + + diff --git a/frontend/src/components/ImageMessage.vue b/frontend/src/components/ImageMessage.vue new file mode 100644 index 0000000..f19784c --- /dev/null +++ b/frontend/src/components/ImageMessage.vue @@ -0,0 +1,278 @@ + + + + {{ avatarLetter }} + + + + + {{ shortDisplayName }} + + + {{ formatTime(timestamp) }} + + + + + + + + + + {{ status === 'sending' ? '发送中' : '接收中' }} {{ progress }}% + + + + + + + + + + + + + 点击查看大图 + + + + + + + + + + 图片加载失败 + + + + + + {{ displayFileName }} + + + + + + + + + + + + + + + + + + + + + 下载 + + + + + + + + + + + diff --git a/frontend/src/components/MessageInput.vue b/frontend/src/components/MessageInput.vue new file mode 100644 index 0000000..445b930 --- /dev/null +++ b/frontend/src/components/MessageInput.vue @@ -0,0 +1,103 @@ + + + + + + 文本消息 + + + + 已 {{ text.length }} / {{ maxLength }} 字 + + + Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴 + + 发送 + + + + + + + diff --git a/frontend/src/components/MessageItem.vue b/frontend/src/components/MessageItem.vue new file mode 100644 index 0000000..892ba6f --- /dev/null +++ b/frontend/src/components/MessageItem.vue @@ -0,0 +1,89 @@ + + + + + {{ avatarLetter }} + + + + + + {{ shortDisplayName }} + + + {{ formatTime(timestamp) }} + + + + {{ content }} + + + + + + diff --git a/frontend/src/components/RoomPanel.vue b/frontend/src/components/RoomPanel.vue new file mode 100644 index 0000000..9452d11 --- /dev/null +++ b/frontend/src/components/RoomPanel.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/components/SystemMessage.vue b/frontend/src/components/SystemMessage.vue new file mode 100644 index 0000000..05cf981 --- /dev/null +++ b/frontend/src/components/SystemMessage.vue @@ -0,0 +1,14 @@ + + + {{ message }} + + + + diff --git a/frontend/src/components/UserList.vue b/frontend/src/components/UserList.vue new file mode 100644 index 0000000..094a7c1 --- /dev/null +++ b/frontend/src/components/UserList.vue @@ -0,0 +1,63 @@ + + + + 在线用户 + {{ userList.length }} 人 + + + + + + {{ getAvatarLetter(user.nickname || '匿名') }} + + + + {{ getShortDisplayName(user.nickname || '匿名') }} + (我) + + + + + 暂无用户在线 + + + + + + diff --git a/frontend/src/components/ui/BaseButton.vue b/frontend/src/components/ui/BaseButton.vue new file mode 100644 index 0000000..88e5eee --- /dev/null +++ b/frontend/src/components/ui/BaseButton.vue @@ -0,0 +1,63 @@ + + + + + + + + + diff --git a/frontend/src/components/ui/StatusDot.vue b/frontend/src/components/ui/StatusDot.vue new file mode 100644 index 0000000..a803eec --- /dev/null +++ b/frontend/src/components/ui/StatusDot.vue @@ -0,0 +1,47 @@ + + + + + + + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..ed77210 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..2b9435a --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import router from './router'; +import './assets/main.css'; + +const app = createApp(App); + +app.use(createPinia()); +app.use(router); + +app.mount('#app'); + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..d5d3237 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,25 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; +import HomeView from '@/views/HomeView.vue'; +import RoomView from '@/views/RoomView.vue'; + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'home', + component: HomeView, + }, + { + path: '/room/:roomCode', + name: 'room', + component: RoomView, + props: true, + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +export default router; + diff --git a/frontend/src/stores/wsStore.ts b/frontend/src/stores/wsStore.ts new file mode 100644 index 0000000..26242cb --- /dev/null +++ b/frontend/src/stores/wsStore.ts @@ -0,0 +1,757 @@ +import type { IMessage } from '@stomp/stompjs'; +import { defineStore } from 'pinia'; +import { ref, watch } from 'vue'; +import { RoomWsClient, type ConnectionStatus } from '@/ws/RoomWsClient'; +import type { RoomMessagePayload, SessionInfo } from '@/types/room'; +import { isImageMimeType } from '@/types/room'; +import { uploadRoomFile } from '@/api/room'; +import { mergeChunksToBlob } from '@/utils/fileChunker'; + +const HISTORY_KEY_PREFIX = 'DataTool-history-'; +/** 历史记录最大条数,超出则淘汰最旧(doc10 容量) */ +const MAX_HISTORY_ITEMS = 500; +/** 历史记录最大保留时间(毫秒),默认 7 天 */ +const MAX_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000; +/** 超过此长度则分片发送(与 doc05 一致,如 32KB) */ +const CHUNK_THRESHOLD = 32 * 1024; +const CHUNK_SIZE = 32 * 1024; +/** 分片缓存超时(ms),超时未收齐则清理并可选提示 */ +const CHUNK_CACHE_TTL = 60 * 1000; +/** 小图直发阈值(doc07:200KB 以下直接发送 base64) */ +const IMAGE_INLINE_THRESHOLD = 200 * 1024; +/** 单文件大小上限(100MB),与后端 transfer.max-file-size 一致 */ +const MAX_FILE_SIZE = 100 * 1024 * 1024; + +interface JoinRoomPayload { + roomCode: string; + senderId?: string; + nickname?: string; +} + +function randomUserId(): string { + return `u_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function loadHistory(roomCode: string): RoomMessagePayload[] { + try { + const raw = localStorage.getItem(HISTORY_KEY_PREFIX + roomCode); + if (!raw) return []; + return JSON.parse(raw) as RoomMessagePayload[]; + } catch { + return []; + } +} + +function trimHistory(messages: RoomMessagePayload[]): RoomMessagePayload[] { + const now = Date.now(); + let list = messages.filter((m) => { + if (m.timestamp == null) return true; + return m.timestamp >= now - MAX_HISTORY_AGE_MS; + }); + if (list.length > MAX_HISTORY_ITEMS) { + list = list.slice(-MAX_HISTORY_ITEMS); + } + return list; +} + +function saveHistory(roomCode: string, messages: RoomMessagePayload[]): void { + try { + const trimmed = trimHistory(messages); + localStorage.setItem(HISTORY_KEY_PREFIX + roomCode, JSON.stringify(trimmed)); + } catch { + // ignore + } +} + +export const useWsStore = defineStore('ws', () => { + const status = ref('disconnected'); + const client = ref(null); + const myUserId = ref(randomUserId()); + const myNickname = ref('匿名用户'); + const currentRoomCode = ref(null); + const userList = ref([]); + const roomMessages = ref([]); + /** 文件接收进度 fileId -> 0-100,CHUNK 写入时更新 */ + const fileProgress = ref>({}); + /** 当前正在 HTTP 下载的 fileId(点击下载后置 true,完成后置 false,用于区分「未开始」与「0%」) */ + const downloadingFileIds = ref>({}); + /** 文件发送进度 fileId -> 0-100 */ + const sendingProgress = ref>({}); + /** 当前正在发送的 fileId,用于收到自己发的 FILE 时不重复追加 */ + const sendingFileIds = new Set(); + /** 接收完成后 fileId -> object URL,用于下载 */ + const fileBlobUrls = ref>({}); + /** 图片消息的 blob URL 或 data URL:fileId -> URL */ + const imageBlobUrls = ref>({}); + /** 文件分片缓存:fileId -> { meta, chunks, receivedAt },收齐后合并为 Blob */ + const fileChunkCache = new Map< + string, + { + meta: { + fileName: string; + fileSize: number; + mimeType: string; + totalChunks: number; + senderId?: string; + senderName?: string; + }; + chunks: Map; + receivedAt: number; + } + >(); + /** 文本分片缓存:messageId -> 分片信息,用于重组 */ + const textChunkCache = new Map< + string, + { + chunks: Map; + totalChunks: number; + senderId?: string; + senderName?: string; + timestamp?: number; + receivedAt: number; + } + >(); + + function init(baseUrl: string, endpoint = '/ws') { + if (!client.value) { + client.value = new RoomWsClient({ baseUrl, endpoint }); + } + } + + function connect() { + if (!client.value) return; + client.value.connect((s) => { + status.value = s; + }); + } + + function disconnect() { + Object.values(fileBlobUrls.value).forEach((url) => URL.revokeObjectURL(url)); + fileBlobUrls.value = {}; + // 清理图片 URL(只清理 blob URL,不清理 data URL) + Object.entries(imageBlobUrls.value).forEach(([, url]) => { + if (url.startsWith('blob:')) URL.revokeObjectURL(url); + }); + imageBlobUrls.value = {}; + fileChunkCache.clear(); + sendingFileIds.clear(); + sendingProgress.value = {}; + client.value?.disconnect(); + status.value = 'disconnected'; + currentRoomCode.value = null; + userList.value = []; + roomMessages.value = []; + } + + function tryReassembleAndPush(messageId: string, roomCode: string): void { + const entry = textChunkCache.get(messageId); + if (!entry || entry.chunks.size !== entry.totalChunks) return; + const sorted = Array.from(entry.chunks.entries()).sort((a, b) => a[0] - b[0]); + const content = sorted.map(([, c]) => c).join(''); + const msg: RoomMessagePayload = { + roomCode, + type: 'TEXT', + senderId: entry.senderId, + senderName: entry.senderName, + timestamp: entry.timestamp, + content, + }; + roomMessages.value.push(msg); + textChunkCache.delete(messageId); + if (currentRoomCode.value === roomCode) { + saveHistory(roomCode, roomMessages.value); + } + } + + function handleRoomMessage(msg: { body: string }) { + try { + const payload = JSON.parse(msg.body) as RoomMessagePayload; + console.log('[WS] 收到消息:', payload.type, payload.fileId ?? '', payload); + if (payload.type === 'SYSTEM' && payload.data) { + if (payload.data.userList !== undefined) { + userList.value = payload.data.userList; + } + roomMessages.value.push(payload); + } else if (payload.type === 'TEXT') { + if (payload.isChunk && payload.messageId != null && payload.chunkIndex != null && payload.totalChunks != null) { + const messageId = payload.messageId; + let entry = textChunkCache.get(messageId); + if (!entry) { + entry = { + chunks: new Map(), + totalChunks: payload.totalChunks, + senderId: payload.senderId, + senderName: payload.senderName, + timestamp: payload.timestamp, + receivedAt: Date.now(), + }; + textChunkCache.set(messageId, entry); + } + entry.chunks.set(payload.chunkIndex, payload.content ?? ''); + entry.receivedAt = Date.now(); + tryReassembleAndPush(messageId, payload.roomCode ?? currentRoomCode.value ?? ''); + } else { + roomMessages.value.push(payload); + } + } else if (payload.type === 'FILE' || payload.type === 'IMAGE') { + const fileId = payload.fileId ?? ''; + const isOwnFile = + payload.senderId === myUserId.value && sendingFileIds.has(fileId); + if (!isOwnFile) { + roomMessages.value.push(payload); + // IMAGE 类型:小图直发,携带 base64 数据 + if (payload.type === 'IMAGE' && payload.imageData) { + // 将 base64 转为 blob URL 以便图片组件显示 + const mimeType = payload.mimeType ?? 'image/png'; + const base64 = payload.imageData.startsWith('data:') + ? payload.imageData + : `data:${mimeType};base64,${payload.imageData}`; + imageBlobUrls.value[fileId] = base64; + } + // FILE 类型:仅当为 CHUNK 流(有 totalChunks 且非服务器存储)时初始化分片缓存;服务器文件下载走 HTTP + if ( + payload.type === 'FILE' && + fileId && + payload.totalChunks != null && + payload.storage !== 'server' + ) { + fileChunkCache.set(fileId, { + meta: { + fileName: payload.fileName ?? '未命名', + fileSize: payload.fileSize ?? 0, + mimeType: payload.mimeType ?? 'application/octet-stream', + totalChunks: payload.totalChunks, + senderId: payload.senderId, + senderName: payload.senderName, + }, + chunks: new Map(), + receivedAt: Date.now(), + }); + } + } + } else if (payload.type === 'CHUNK') { + // 文件分片:写入缓存、更新进度、收齐后合并 Blob + const fileId = payload.fileId ?? ''; + const chunkIndex = payload.chunkIndex; + const content = (payload.content ?? payload.dataBase64) ?? ''; + console.log('[WS] CHUNK:', fileId, chunkIndex, '内容长度:', content.length); + if (!fileId || chunkIndex == null || !content) { + console.warn('[WS] CHUNK 缺少必要字段'); + return; + } + + const entry = fileChunkCache.get(fileId); + if (!entry) { + console.warn('[WS] CHUNK 找不到 entry,可能 FILE 消息还没到或已处理完'); + return; + } + + entry.chunks.set(chunkIndex, content); + entry.receivedAt = Date.now(); + const received = entry.chunks.size; + const total = entry.meta.totalChunks; + fileProgress.value[fileId] = Math.min( + 100, + Math.round((received / total) * 100), + ); + + if (received === total) { + const sorted = Array.from(entry.chunks.entries()).sort( + (a, b) => a[0] - b[0], + ); + const chunks = sorted.map(([, c]) => c); + const blob = mergeChunksToBlob(chunks, entry.meta.mimeType); + const blobUrl = URL.createObjectURL(blob); + fileBlobUrls.value[fileId] = blobUrl; + // 如果是图片类型,也存入 imageBlobUrls 以便图片组件显示 + if (isImageMimeType(entry.meta.mimeType)) { + imageBlobUrls.value[fileId] = blobUrl; + } + fileProgress.value[fileId] = 100; + fileChunkCache.delete(fileId); + if (currentRoomCode.value) { + saveHistory(currentRoomCode.value, roomMessages.value); + } + } + } + // 仅对展示在列表中的消息持久化(TEXT 分片重组后由 tryReassembleAndPush 内保存) + if ( + currentRoomCode.value && + (payload.type === 'SYSTEM' || + (payload.type === 'TEXT' && !payload.isChunk) || + payload.type === 'FILE' || + payload.type === 'IMAGE') + ) { + saveHistory(currentRoomCode.value, roomMessages.value); + } + } catch { + // ignore parse error + } + } + + function handleChunkMessage(msg: IMessage) { + try { + const payload = JSON.parse(msg.body) as RoomMessagePayload; + const fileId = payload.fileId ?? ''; + const chunkIndex = payload.chunkIndex; + const content = + (payload.content ?? payload.dataBase64) ?? ''; + if (!fileId || chunkIndex == null || !content) return; + + const entry = fileChunkCache.get(fileId); + if (!entry) return; + + entry.chunks.set(chunkIndex, content); + entry.receivedAt = Date.now(); + const received = entry.chunks.size; + const total = entry.meta.totalChunks; + fileProgress.value[fileId] = Math.min( + 100, + Math.round((received / total) * 100), + ); + + if (received === total) { + const sorted = Array.from(entry.chunks.entries()).sort( + (a, b) => a[0] - b[0], + ); + const chunks = sorted.map(([, c]) => c); + const blob = mergeChunksToBlob(chunks, entry.meta.mimeType); + const blobUrl = URL.createObjectURL(blob); + fileBlobUrls.value[fileId] = blobUrl; + // 如果是图片类型,也存入 imageBlobUrls 以便图片组件显示 + if (isImageMimeType(entry.meta.mimeType)) { + imageBlobUrls.value[fileId] = blobUrl; + } + fileProgress.value[fileId] = 100; + fileChunkCache.delete(fileId); + if (currentRoomCode.value) { + saveHistory(currentRoomCode.value, roomMessages.value); + } + } + } catch { + // ignore parse error + } + } + + function joinRoom(payload: JoinRoomPayload) { + if (!client.value) return; + const nickname = payload.nickname ?? '匿名用户'; + myNickname.value = nickname; + const isNewRoom = currentRoomCode.value !== payload.roomCode; + if (isNewRoom) { + userList.value = []; + fileProgress.value = {}; + fileBlobUrls.value = {}; + imageBlobUrls.value = {}; + fileChunkCache.clear(); + roomMessages.value = loadHistory(payload.roomCode); + } + currentRoomCode.value = payload.roomCode; + const senderId = payload.senderId ?? myUserId.value; + client.value.joinRoom( + payload.roomCode, + { + roomCode: payload.roomCode, + type: 'SYSTEM', + senderId, + content: nickname, + }, + handleRoomMessage, + handleChunkMessage, + ); + } + + function leaveRoom(roomCode: string) { + if (client.value && client.value.connectionStatus === 'connected') { + client.value.leaveRoom(roomCode); + } + Object.values(fileBlobUrls.value).forEach((url) => + URL.revokeObjectURL(url), + ); + fileBlobUrls.value = {}; + // 清理图片 URL + Object.entries(imageBlobUrls.value).forEach(([, url]) => { + if (url.startsWith('blob:')) URL.revokeObjectURL(url); + }); + imageBlobUrls.value = {}; + fileChunkCache.clear(); + sendingFileIds.clear(); + sendingProgress.value = {}; + currentRoomCode.value = null; + userList.value = []; + roomMessages.value = []; + fileProgress.value = {}; + } + + function sendRoomMessage(roomCode: string, content: string) { + if (!client.value) return; + if (content.length <= CHUNK_THRESHOLD) { + client.value.sendMessage(roomCode, { + roomCode, + type: 'TEXT', + senderId: myUserId.value, + senderName: myNickname.value, + content, + }); + return; + } + const messageId = `${myUserId.value}_${Date.now()}`; + const totalChunks = Math.ceil(content.length / CHUNK_SIZE); + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const chunk = content.slice(start, start + CHUNK_SIZE); + client.value.sendMessage(roomCode, { + roomCode, + type: 'TEXT', + senderId: myUserId.value, + senderName: myNickname.value, + content: chunk, + isChunk: true, + chunkIndex: i, + totalChunks, + messageId, + }); + } + } + + function clearRoomHistory(roomCode: string): void { + roomMessages.value = []; + saveHistory(roomCode, []); + } + + function exportRoomHistory(roomCode: string): string { + const data = JSON.stringify(roomMessages.value, null, 2); + return URL.createObjectURL(new Blob([data], { type: 'application/json' })); + } + + /** 导出为纯文本,便于粘贴到工单/邮件(doc10) */ + function exportRoomHistoryAsText(roomCode: string): string { + const lines: string[] = [ + `DataTool 房间 ${roomCode} 历史记录`, + `导出时间: ${new Date().toLocaleString('zh-CN')}`, + '---', + ]; + for (const m of roomMessages.value) { + const time = m.timestamp + ? new Date(m.timestamp).toLocaleString('zh-CN') + : '-'; + const sender = m.senderName ?? m.senderId ?? '系统'; + if (m.type === 'TEXT') { + const content = (m.content ?? '').slice(0, 500); + const suffix = (m.content?.length ?? 0) > 500 ? '…' : ''; + lines.push(`[${time}] ${sender} (文本)\n${content}${suffix}`); + } else if (m.type === 'FILE' || m.type === 'IMAGE') { + const size = m.fileSize != null ? ` ${(m.fileSize / 1024).toFixed(1)}KB` : ''; + lines.push(`[${time}] ${sender} (${m.type === 'IMAGE' ? '图片' : '文件'}): ${m.fileName ?? '未命名'}${size}`); + } else if (m.type === 'SYSTEM' && m.data?.message) { + lines.push(`[${time}] 系统: ${m.data.message}`); + } else { + lines.push(`[${time}] ${sender} (${m.type})`); + } + lines.push(''); + } + const text = lines.join('\n'); + return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + } + + /** + * 将 File 转换为 base64 字符串 + */ + async function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // 移除 data:xxx;base64, 前缀,只保留纯 base64 + const base64 = result.split(',')[1] || result; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + async function sendFile( + roomCode: string, + file: File, + ): Promise<{ ok: boolean; error?: string }> { + if (!client.value) return { ok: false, error: '未连接' }; + if (file.size > MAX_FILE_SIZE) { + const limitMB = MAX_FILE_SIZE / (1024 * 1024); + return { + ok: false, + error: `文件过大(${(file.size / (1024 * 1024)).toFixed(1)}MB),当前最大支持 ${limitMB}MB`, + }; + } + + const isImage = isImageMimeType(file.type); + const isSmallImage = isImage && file.size <= IMAGE_INLINE_THRESHOLD; + + // 小图直发:使用 IMAGE 类型,payload 携带 base64 + if (isSmallImage) { + const fileId = `img_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + const imageData = await fileToBase64(file); + sendingFileIds.add(fileId); + + const optimistic: RoomMessagePayload = { + roomCode, + type: 'IMAGE', + senderId: myUserId.value, + senderName: myNickname.value, + timestamp: Date.now(), + fileId, + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + imageData, + }; + roomMessages.value.push(optimistic); + + // 为自己发送的图片也创建 URL 以便显示 + const dataUrl = `data:${file.type};base64,${imageData}`; + imageBlobUrls.value[fileId] = dataUrl; + sendingProgress.value[fileId] = 100; + + client.value.sendMessage(roomCode, { + roomCode, + type: 'IMAGE', + senderId: myUserId.value, + senderName: myNickname.value, + timestamp: Date.now(), + fileId, + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + imageData, + }); + + if (currentRoomCode.value === roomCode) { + saveHistory(roomCode, roomMessages.value); + } + return { ok: true }; + } + + // 大文件/大图走 HTTP 上传到服务器,仅通过 WebSocket 广播 FILE 元数据,避免长传断连 + const tempFileId = `uploading_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + sendingFileIds.add(tempFileId); + sendingProgress.value[tempFileId] = 0; + const optimistic: RoomMessagePayload = { + roomCode, + type: 'FILE', + senderId: myUserId.value, + senderName: myNickname.value, + timestamp: Date.now(), + fileId: tempFileId, + fileName: file.name, + fileSize: file.size, + mimeType: file.type || 'application/octet-stream', + storage: 'server', + }; + roomMessages.value.push(optimistic); + if (isImage) { + imageBlobUrls.value[tempFileId] = URL.createObjectURL(file); + } + + try { + const res = await uploadRoomFile(roomCode, file, (percent) => { + sendingProgress.value[tempFileId] = percent; + sendingProgress.value = { ...sendingProgress.value }; + }); + const actualFileId = res.fileId; + sendingFileIds.delete(tempFileId); + sendingFileIds.add(actualFileId); + sendingProgress.value[actualFileId] = 100; + delete sendingProgress.value[tempFileId]; + + const payload: RoomMessagePayload = { + ...optimistic, + fileId: actualFileId, + fileName: res.fileName, + fileSize: res.fileSize, + mimeType: res.mimeType, + storage: 'server', + }; + const idx = roomMessages.value.findIndex( + (m) => m.type === 'FILE' && m.fileId === tempFileId && m.senderId === myUserId.value, + ); + if (idx !== -1) roomMessages.value[idx] = payload; + if (isImage) { + const url = imageBlobUrls.value[tempFileId]; + if (url) { + imageBlobUrls.value[actualFileId] = url; + delete imageBlobUrls.value[tempFileId]; + } + } + + client.value.sendMessage(roomCode, payload); + + if (currentRoomCode.value === roomCode) { + saveHistory(roomCode, roomMessages.value); + } + return { ok: true }; + } catch (e) { + console.error('[sendFile] 上传失败:', e); + sendingFileIds.delete(tempFileId); + delete sendingProgress.value[tempFileId]; + const url = imageBlobUrls.value[tempFileId]; + if (url) { + URL.revokeObjectURL(url); + delete imageBlobUrls.value[tempFileId]; + } + const removeIdx = roomMessages.value.findIndex( + (m) => m.type === 'FILE' && m.fileId === tempFileId && m.senderId === myUserId.value, + ); + if (removeIdx !== -1) roomMessages.value.splice(removeIdx, 1); + const errMsg = e instanceof Error ? e.message : '发送失败,请重试'; + return { ok: false, error: errMsg }; + } + } + + function getFileProgress(fileId: string): number { + const sent = sendingProgress.value[fileId]; + if (sent != null) return sent; + return fileProgress.value[fileId] ?? 0; + } + + /** 设置文件进度(用于下载时在 UI 显示 接收中 X%) */ + function setFileProgress(fileId: string, percent: number): void { + fileProgress.value[fileId] = percent; + fileProgress.value = { ...fileProgress.value }; + } + + /** 标记该 fileId 正在 HTTP 下载(仅此时接收方显示「接收中」进度条) */ + function setDownloading(fileId: string, downloading: boolean): void { + if (downloading) { + downloadingFileIds.value = { ...downloadingFileIds.value, [fileId]: true }; + } else { + const next = { ...downloadingFileIds.value }; + delete next[fileId]; + downloadingFileIds.value = next; + } + } + + function isDownloading(fileId: string): boolean { + return !!downloadingFileIds.value[fileId]; + } + + function getFileBlobUrl(fileId: string): string | null { + return fileBlobUrls.value[fileId] ?? null; + } + + /** 是否为服务器存储文件(下载走 HTTP,无需 blob URL) */ + function isServerFile(msg: RoomMessagePayload): boolean { + return msg.type === 'FILE' && (msg.storage === 'server' || msg.totalChunks == null); + } + + function revokeFileBlobUrl(fileId: string): void { + const url = fileBlobUrls.value[fileId]; + if (url) { + URL.revokeObjectURL(url); + delete fileBlobUrls.value[fileId]; + } + } + + /** + * 获取图片的 URL(blob URL 或 data URL) + */ + function getImageUrl(fileId: string): string | null { + return imageBlobUrls.value[fileId] ?? null; + } + + /** + * 释放图片 blob URL + */ + function revokeImageUrl(fileId: string): void { + const url = imageBlobUrls.value[fileId]; + if (url && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + delete imageBlobUrls.value[fileId]; + } + + /** 分片缓存超时清理:移除过期未收齐的 messageId/fileId,可选提示 */ + function cleanupChunkCaches(): void { + const now = Date.now(); + const roomCode = currentRoomCode.value; + + for (const [messageId, entry] of textChunkCache.entries()) { + if (now - entry.receivedAt <= CHUNK_CACHE_TTL) continue; + textChunkCache.delete(messageId); + if (entry.chunks.size < entry.totalChunks && roomCode) { + roomMessages.value.push({ + roomCode, + type: 'SYSTEM', + senderId: 'system', + data: { message: '分片未完整接收' }, + }); + saveHistory(roomCode, roomMessages.value); + } + } + + for (const [fileId, entry] of fileChunkCache.entries()) { + if (now - entry.receivedAt <= CHUNK_CACHE_TTL) continue; + fileChunkCache.delete(fileId); + if (entry.chunks.size < entry.meta.totalChunks && roomCode) { + roomMessages.value.push({ + roomCode, + type: 'SYSTEM', + senderId: 'system', + data: { + message: `文件「${entry.meta.fileName}」未完整接收(发送方可能已断开)`, + }, + }); + saveHistory(roomCode, roomMessages.value); + } + } + } + + const chunkCleanupInterval = setInterval(() => cleanupChunkCaches(), 10 * 1000); + + // 重连恢复时插入系统提示(文档 15:已重新连接,已重新加入房间) + const prevStatus = ref('disconnected'); + watch( + status, + (next) => { + if (prevStatus.value === 'reconnecting' && next === 'connected' && currentRoomCode.value) { + roomMessages.value.push({ + roomCode: currentRoomCode.value, + type: 'SYSTEM', + senderId: 'system', + content: '', + data: { message: '已重新连接,已重新加入房间' }, + }); + } + prevStatus.value = next; + }, + { flush: 'sync' }, + ); + + return { + status, + myUserId, + myNickname, + currentRoomCode, + userList, + roomMessages, + fileProgress, + init, + connect, + disconnect, + joinRoom, + leaveRoom, + sendRoomMessage, + sendFile, + clearRoomHistory, + exportRoomHistory, + exportRoomHistoryAsText, + getFileProgress, + setFileProgress, + setDownloading, + isDownloading, + getFileBlobUrl, + revokeFileBlobUrl, + isServerFile, + getImageUrl, + revokeImageUrl, + }; +}); diff --git a/frontend/src/types/room.ts b/frontend/src/types/room.ts new file mode 100644 index 0000000..bf1b64e --- /dev/null +++ b/frontend/src/types/room.ts @@ -0,0 +1,54 @@ +/** + * 房间与消息相关类型(与后端协议及 doc04 一致) + */ + +export interface SessionInfo { + sessionId: string; + userId: string; + nickname: string; + joinedAt: number | string; // epoch millis or ISO-8601 +} + +export interface SystemMessageData { + event?: 'USER_JOIN' | 'USER_LEAVE' | 'ERROR'; + message?: string; + userList?: SessionInfo[]; +} + +/** 统一消息模型:与 doc04 及后端 MessagePayload 对齐 */ +export interface RoomMessagePayload { + type: 'TEXT' | 'FILE' | 'IMAGE' | 'SYSTEM' | 'CHUNK'; + roomCode?: string; + senderId?: string; + senderName?: string; + timestamp?: number; + /** 文本内容(TEXT) */ + content?: string; + /** 文本分片(TEXT):是否为分片、重组用 messageId */ + isChunk?: boolean; + messageId?: string; + /** 系统消息(SYSTEM) */ + systemCode?: string; + data?: SystemMessageData; + /** 文件相关(FILE / CHUNK / IMAGE) */ + fileId?: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + chunkIndex?: number; + /** 分片总数;无此字段或 storage===server 表示服务器存储,下载走 HTTP */ + totalChunks?: number; + /** 服务器存储文件,下载走 HTTP;无 totalChunks 时也视为服务器文件 */ + storage?: 'server'; + /** CHUNK 的 base64 数据,由后端/前端约定 */ + dataBase64?: string; + /** IMAGE 类型专用:小图直发的 base64 数据(不带 data: 前缀或带前缀均可) */ + imageData?: string; +} + +/** 判断 mimeType 是否为图片类型 */ +export function isImageMimeType(mimeType?: string): boolean { + return !!mimeType && mimeType.startsWith('image/'); +} + +export type RoomMessage = RoomMessagePayload; diff --git a/frontend/src/utils/avatar.ts b/frontend/src/utils/avatar.ts new file mode 100644 index 0000000..dd0406b --- /dev/null +++ b/frontend/src/utils/avatar.ts @@ -0,0 +1,88 @@ +/** + * 根据显示名生成头像文字与背景色,用于消息/用户列表等。 + * IP 时取最后一段(如 192.168.100.166 → 166)作为头像与显示名。 + */ + +const AVATAR_COLORS = [ + 'bg-blue-500', + 'bg-emerald-500', + 'bg-amber-500', + 'bg-violet-500', + 'bg-rose-500', + 'bg-cyan-500', + 'bg-orange-500', + 'bg-teal-500', +] as const; + +function hashString(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + n = ((n * 31 + s.charCodeAt(i)) >>> 0) % 1e9; + } + return n; +} + +/** 简单判断是否为 IPv4 字符串(如 192.168.100.166) */ +export function isIPv4(s: string): boolean { + if (!s || typeof s !== 'string') return false; + const trimmed = s.trim(); + return /^\d{1,3}(\.\d{1,3}){3}$/.test(trimmed); +} + +/** 匹配纯 IPv4 或带后缀的 IPv4(如 192.168.100.166-2) */ +const IP_OPTIONAL_SUFFIX = /^(\d{1,3}(?:\.\d{1,3}){3})(-\d+)?$/; + +/** 取 IPv4 最后一段,如 192.168.100.166 → 166;非 IP 返回原字符串 */ +export function getLastOctet(name: string): string { + const trimmed = (name ?? '').trim(); + if (!trimmed) return trimmed; + const m = trimmed.match(IP_OPTIONAL_SUFFIX); + if (m) { + const parts = m[1]!.split('.'); + return parts[parts.length - 1] ?? trimmed; + } + if (isIPv4(trimmed)) { + const parts = trimmed.split('.'); + return parts[parts.length - 1] ?? trimmed; + } + return trimmed; +} + +/** 是否为 IP 或 IP-后缀 形式 */ +function isIPOrWithSuffix(s: string): boolean { + return IP_OPTIONAL_SUFFIX.test((s ?? '').trim()); +} + +/** + * 显示用短名:IP 只显示最后一段(166、128),带后缀则 166-2;非 IP 原样。 + */ +export function getShortDisplayName(name: string): string { + const trimmed = (name ?? '').trim(); + if (!trimmed) return trimmed; + const m = trimmed.match(IP_OPTIONAL_SUFFIX); + if (m) { + const octet = m[1]!.split('.').pop() ?? m[1]; + return m[2] ? `${octet}${m[2]}` : octet; + } + if (isIPv4(trimmed)) return getLastOctet(trimmed); + return trimmed; +} + +/** + * 头像文字:IP 取最后一段(166、128),否则取首字。 + */ +export function getAvatarLetter(name: string): string { + const trimmed = (name ?? '').trim(); + if (!trimmed) return '?'; + if (isIPOrWithSuffix(trimmed)) { + const octet = getLastOctet(trimmed); + return octet.length > 0 ? octet : '?'; + } + return trimmed[0] ?? '?'; +} + +/** 根据名称哈希返回固定 Tailwind 背景类,同一名称同色 */ +export function getAvatarColorClass(name: string): string { + const n = hashString(name ?? ''); + return AVATAR_COLORS[n % AVATAR_COLORS.length]; +} diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..12b221d --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,168 @@ +/** + * 剪贴板工具模块 + * 封装剪贴板读取权限判断与异常处理 + */ + +export interface ClipboardReadResult { + success: boolean; + text?: string; + error?: string; +} + +/** + * 检查剪贴板 API 是否可用 + */ +export function isClipboardApiAvailable(): boolean { + return ( + typeof navigator !== 'undefined' && + typeof navigator.clipboard !== 'undefined' && + typeof navigator.clipboard.readText === 'function' + ); +} + +/** + * 检查是否在安全上下文中(HTTPS 或 localhost) + */ +export function isSecureContext(): boolean { + if (typeof window === 'undefined') return false; + // 使用浏览器提供的 isSecureContext 属性 + if (typeof window.isSecureContext === 'boolean') { + return window.isSecureContext; + } + // 降级检查:localhost 或 HTTPS + const { protocol, hostname } = window.location; + return ( + protocol === 'https:' || + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' + ); +} + +/** + * 获取剪贴板不可用的原因 + */ +export function getClipboardUnavailableReason(): string | null { + if (!isSecureContext()) { + return '需要 HTTPS 或 localhost 环境才能读取剪贴板'; + } + if (!isClipboardApiAvailable()) { + return '当前浏览器不支持剪贴板 API'; + } + return null; +} + +/** + * 主动读取剪贴板文本 + * 注意:此操作需要用户手势触发(如点击按钮) + */ +export async function readClipboardText(): Promise { + // 检查是否在安全上下文 + if (!isSecureContext()) { + return { + success: false, + error: '需要 HTTPS 或 localhost 环境才能读取剪贴板', + }; + } + + // 检查 API 是否可用 + if (!isClipboardApiAvailable()) { + return { + success: false, + error: '当前浏览器不支持剪贴板 API,请使用 Ctrl+V 粘贴', + }; + } + + try { + const text = await navigator.clipboard.readText(); + if (!text || !text.trim()) { + return { + success: false, + error: '剪贴板为空或不包含文本内容', + }; + } + return { + success: true, + text: text, + }; + } catch (err) { + // 常见错误:用户拒绝权限、浏览器策略限制等 + const message = + err instanceof Error ? err.message : '未知错误'; + + // 根据错误类型给出友好提示 + if (message.includes('denied') || message.includes('permission')) { + return { + success: false, + error: '剪贴板访问被拒绝,请在浏览器设置中允许访问或使用 Ctrl+V 粘贴', + }; + } + + return { + success: false, + error: `无法读取剪贴板:${message},请使用 Ctrl+V 粘贴`, + }; + } +} + +/** + * 从粘贴事件中提取文件列表 + */ +export function extractFilesFromPaste(e: ClipboardEvent): File[] { + const items = e.clipboardData?.items; + if (!items?.length) return []; + + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + return files; +} + +/** + * 从粘贴事件中提取纯文本 + */ +export function extractTextFromPaste(e: ClipboardEvent): string | null { + const items = e.clipboardData?.items; + if (!items?.length) return null; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'string' && item.type === 'text/plain') { + // 使用同步方式获取文本(通过 getData) + return e.clipboardData?.getData('text/plain') ?? null; + } + } + return null; +} + +/** + * 判断粘贴事件是否包含文件 + */ +export function pasteHasFiles(e: ClipboardEvent): boolean { + const items = e.clipboardData?.items; + if (!items?.length) return false; + + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') return true; + } + return false; +} + +/** + * 判断粘贴事件是否包含文本 + */ +export function pasteHasText(e: ClipboardEvent): boolean { + const items = e.clipboardData?.items; + if (!items?.length) return false; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'string' && item.type === 'text/plain') return true; + } + return false; +} diff --git a/frontend/src/utils/fileChunker.ts b/frontend/src/utils/fileChunker.ts new file mode 100644 index 0000000..c888b59 --- /dev/null +++ b/frontend/src/utils/fileChunker.ts @@ -0,0 +1,97 @@ +/** + * 文件分片工具(文档 06:64KB 分片、Base64 发送) + */ + +const DEFAULT_CHUNK_SIZE = 64 * 1024; // 64KB + +export interface FileChunkMeta { + fileId: string; + fileName: string; + fileSize: number; + mimeType: string; + totalChunks: number; +} + +export interface ChunkResult { + chunkIndex: number; + data: string; // base64 +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * 流式读取文件并逐片生成 base64,避免大文件一次性加载导致内存溢出。 + * 用于循环发送 CHUNK 到 /app/room/{roomCode}/file/chunk。 + */ +export async function* readFileChunksStream( + file: File, + chunkSize: number = DEFAULT_CHUNK_SIZE, +): AsyncGenerator<{ meta: FileChunkMeta } | { chunkIndex: number; data: string }> { + const fileId = `f_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + const totalChunks = Math.ceil(file.size / chunkSize); + const meta: FileChunkMeta = { + fileId, + fileName: file.name, + fileSize: file.size, + mimeType: file.type || 'application/octet-stream', + totalChunks, + }; + yield { meta }; + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const blob = file.slice(start, end); + const buffer = await blob.arrayBuffer(); + const data = arrayBufferToBase64(buffer); + yield { chunkIndex: i, data }; + } +} + +/** + * 将 File 按 chunkSize 切片,返回 base64 字符串数组。 + * 注意:会一次性加载整个文件到内存,大文件请使用 readFileChunksStream。 + */ +export async function getFileChunks( + file: File, + chunkSize: number = DEFAULT_CHUNK_SIZE, +): Promise<{ meta: FileChunkMeta; chunks: string[] }> { + const chunks: string[] = []; + let meta: FileChunkMeta | null = null; + for await (const item of readFileChunksStream(file, chunkSize)) { + if ('meta' in item) meta = item.meta; + else chunks.push(item.data); + } + return { meta: meta!, chunks }; +} + +/** + * 将按顺序的 base64 分片合并为 Blob。 + * 用于接收端收齐 CHUNK 后本地重组。 + */ +export function mergeChunksToBlob(chunks: string[], mimeType: string): Blob { + const parts: Uint8Array[] = []; + for (const b64 of chunks) { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + parts.push(bytes); + } + const totalLength = parts.reduce((acc, p) => acc + p.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const p of parts) { + merged.set(p, offset); + offset += p.length; + } + return new Blob([merged], { type: mimeType }); +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..3008a04 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,124 @@ + + + + + + 创建房间 + + 自动生成 6 位房间号,在本地浏览器会话之间快速传文本、文件和图片。 + + + + {{ createRoomCodeDisplay }} + + + 创建并进入 + + + + {{ createError }} + + + 数据不落库,仅当前会话可见。关闭页面后历史记录仅保留在本地浏览器。 + + + + + + 加入房间 + + 输入房间号,与同一房间的其他终端进行数据同步传输。 + + + + + 房间号(6 位数字) + + + + {{ joinError }} + + + + 加入房间 + + + + + + + + + diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue new file mode 100644 index 0000000..d410131 --- /dev/null +++ b/frontend/src/views/RoomView.vue @@ -0,0 +1,332 @@ + + + + + 房间 {{ roomCode }} + + 退出 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ downloadToast }} + + + + + + + diff --git a/frontend/src/ws/RoomWsClient.ts b/frontend/src/ws/RoomWsClient.ts new file mode 100644 index 0000000..c096a11 --- /dev/null +++ b/frontend/src/ws/RoomWsClient.ts @@ -0,0 +1,114 @@ +import type { IMessage, StompSubscription } from '@stomp/stompjs'; +import { + createWebSocketClient, + type ConnectionStatus, + type WebSocketApi, +} from '@/api/websocket'; + +export type { ConnectionStatus }; + +export interface WsConfig { + baseUrl: string; + endpoint: string; +} + +export type MessageHandler = (message: IMessage) => void; + +export class RoomWsClient { + private api: WebSocketApi; + private roomSubscriptions: StompSubscription[] = []; + private readonly config: WsConfig; + + constructor(config: WsConfig) { + this.config = config; + this.api = createWebSocketClient(); + } + + get connectionStatus(): ConnectionStatus { + return this.api.getStatus(); + } + + connect(onStatusChange?: (status: ConnectionStatus) => void): void { + if (this.api.getStatus() === 'connected') return; + + const socketUrl = `${this.config.baseUrl}${this.config.endpoint}`; + this.api.connect( + socketUrl, + () => { + // 连接/重连成功:由 wsStore watch status 触发 joinRoom,重新 subscribe + join + }, + () => { + onStatusChange?.('disconnected'); + }, + onStatusChange, + ); + } + + disconnect(): void { + this.roomSubscriptions.forEach((s) => { + try { + s.unsubscribe(); + } catch { + // ignore + } + }); + this.roomSubscriptions = []; + this.api.disconnect(); + } + + /** CHUNK 回调:收到 /topic/room/{roomCode}/file/{fileId} 的消息,body 为 JSON(fileId/chunkIndex/content) */ + joinRoom( + roomCode: string, + payload: unknown, + onMessage: MessageHandler, + onFileChunk?: (message: IMessage) => void, + ): void { + if (this.api.getStatus() !== 'connected') return; + + // 重连后旧订阅已失效,清空本地引用 + this.roomSubscriptions.forEach((s) => { + try { + s.unsubscribe(); + } catch { + // ignore + } + }); + this.roomSubscriptions = []; + + const roomSub = this.api.subscribe(`/topic/room/${roomCode}`, onMessage); + if (roomSub) this.roomSubscriptions.push(roomSub); + + if (onFileChunk) { + const fileSub = this.api.subscribe( + `/topic/room/${roomCode}/file/*`, + onFileChunk, + ); + if (fileSub) this.roomSubscriptions.push(fileSub); + } + + this.api.send(`/app/room/${roomCode}/join`, payload as object); + } + + leaveRoom(roomCode: string): void { + if (this.api.getStatus() !== 'connected') return; + this.api.send(`/app/room/${roomCode}/leave`, {}); + this.roomSubscriptions.forEach((s) => { + try { + s.unsubscribe(); + } catch { + // ignore + } + }); + this.roomSubscriptions = []; + } + + sendMessage(roomCode: string, payload: unknown): void { + if (this.api.getStatus() !== 'connected') return; + this.api.send(`/app/room/${roomCode}/message`, payload as object); + } + + sendFileChunk(roomCode: string, payload: unknown): void { + if (this.api.getStatus() !== 'connected') return; + this.api.send(`/app/room/${roomCode}/file/chunk`, payload as object); + } +} diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs new file mode 100644 index 0000000..0587fff --- /dev/null +++ b/frontend/tailwind.config.cjs @@ -0,0 +1,36 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./index.html', './src/**/*.{vue,ts,tsx}'], + theme: { + extend: { + colors: { + primary: { + DEFAULT: '#2563EB', // blue-600 + hover: '#1D4ED8', // blue-700 + active: '#1E40AF', // blue-800 + }, + success: '#16A34A', // green-600 + danger: '#DC2626', // red-600 + warning: '#F97316', // orange-500 + }, + borderRadius: { + btn: '4px', + card: '8px', + modal: '12px', + }, + spacing: { + 1: '4px', + 2: '8px', + 3: '12px', + 4: '16px', + 6: '24px', + 8: '32px', + }, + boxShadow: { + 'elevated': '0 10px 25px rgba(15,23,42,0.08)', + }, + }, + }, + plugins: [], +}; + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..86aa9f6 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c990186 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import path from 'path'; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + // 为 sockjs-client 等依赖提供浏览器环境下的 global 替代 + define: { + global: 'window', + }, + server: { + port: 5173, + // 允许局域网内其它设备访问(如手机、其它电脑) + host: true, + // 开发时代理:前端用同源请求,由 Vite 转发到后端,跨设备访问时无需改 API 地址 + // xfwd: true 会把真实客户端 IP 写入 X-Forwarded-For,后端 /api/room/my-ip 才能拿到各机器的 IP + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + xfwd: true, + }, + '/ws': { target: 'http://localhost:8080', ws: true }, + }, + }, +}); +
+ {{ displayFileName }} +
+ {{ formatSize(fileSize) }} +
+ {{ statusLabel }} +
传输失败
+ {{ status === 'sending' ? '发送中' : '接收中' }} {{ progress }}% +
图片加载失败
+ {{ content }} +
+ 自动生成 6 位房间号,在本地浏览器会话之间快速传文本、文件和图片。 +
+ {{ createError }} +
+ 数据不落库,仅当前会话可见。关闭页面后历史记录仅保留在本地浏览器。 +
+ 输入房间号,与同一房间的其他终端进行数据同步传输。 +
+ {{ joinError }} +