feat(room): 增加加入令牌及分块传输支持
- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌 - 新增房间加入令牌验证接口,加入时需提供房间号和令牌 - 前端HomeView新增加入令牌输入框及验证逻辑 - Clipboard工具增加写入API支持及复制按钮 - FileDropZone支持选择文件夹批量上传 - FileMessage和ImageMessage新增分片进度提示及失败重试功能 - API层新增分块上传及断点续传实现,支持大文件分片上传 - 文件上传存储时计算文件sha256,响应中返回该值 - 下载接口支持断点续传,优化大文件下载体验 - README新增加入令牌安全说明及压力测试使用示例 - 资源清理与配置优化,添加磁盘使用水位阈值控制
This commit is contained in:
15
README.md
15
README.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
基于 WebSocket 的轻量级数据传输工具,适用于 VNC/远程桌面、内网隔离等场景。
|
基于 WebSocket 的轻量级数据传输工具,适用于 VNC/远程桌面、内网隔离等场景。
|
||||||
|
|
||||||
|
> 安全更新:创建房间后会生成 8 位加入令牌,加入方需同时提供「房间号 + 令牌」。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **后端**: Spring Boot 3.2.5 + Java 17 + WebSocket (STOMP/SockJS)
|
- **后端**: Spring Boot 3.2.5 + Java 17 + WebSocket (STOMP/SockJS)
|
||||||
@@ -112,6 +114,19 @@ DataTool/
|
|||||||
|
|
||||||
后续功能开发请参考 `docs/` 目录下的详细文档。
|
后续功能开发请参考 `docs/` 目录下的详细文档。
|
||||||
|
|
||||||
|
## 压力测试
|
||||||
|
|
||||||
|
仓库提供压测脚本:`scripts/load-test.mjs`,支持直传与分块模式。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LOAD_BASE_URL="http://127.0.0.1:18080" LOAD_MODE=direct LOAD_CONCURRENCY=8 LOAD_REQUESTS=64 LOAD_FILE_KB=512 node scripts/load-test.mjs
|
||||||
|
LOAD_BASE_URL="http://127.0.0.1:18080" LOAD_MODE=chunked LOAD_CONCURRENCY=4 LOAD_REQUESTS=24 LOAD_FILE_KB=8192 node scripts/load-test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
本次基线结果见:`docs/12-压力测试报告.md`。
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[待定]
|
[待定]
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class TransferProperties {
|
|||||||
private int roomExpireHours = 24;
|
private int roomExpireHours = 24;
|
||||||
/** 定时过期清理间隔(毫秒),默认 1 小时。 */
|
/** 定时过期清理间隔(毫秒),默认 1 小时。 */
|
||||||
private long cleanupIntervalMs = 3600000L;
|
private long cleanupIntervalMs = 3600000L;
|
||||||
|
/** 磁盘使用率高水位(%):达到后触发按最旧目录回收。 */
|
||||||
|
private int diskHighWatermarkPercent = 80;
|
||||||
|
/** 磁盘回收目标水位(%):回收到低于该值即停止。 */
|
||||||
|
private int diskTargetWatermarkPercent = 70;
|
||||||
|
|
||||||
public String getUploadDir() {
|
public String getUploadDir() {
|
||||||
return uploadDir;
|
return uploadDir;
|
||||||
@@ -47,4 +51,20 @@ public class TransferProperties {
|
|||||||
public void setCleanupIntervalMs(long cleanupIntervalMs) {
|
public void setCleanupIntervalMs(long cleanupIntervalMs) {
|
||||||
this.cleanupIntervalMs = cleanupIntervalMs;
|
this.cleanupIntervalMs = cleanupIntervalMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getDiskHighWatermarkPercent() {
|
||||||
|
return diskHighWatermarkPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDiskHighWatermarkPercent(int diskHighWatermarkPercent) {
|
||||||
|
this.diskHighWatermarkPercent = diskHighWatermarkPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDiskTargetWatermarkPercent() {
|
||||||
|
return diskTargetWatermarkPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDiskTargetWatermarkPercent(int diskTargetWatermarkPercent) {
|
||||||
|
this.diskTargetWatermarkPercent = diskTargetWatermarkPercent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.datatool.controller;
|
|||||||
|
|
||||||
import com.datatool.room.RoomService;
|
import com.datatool.room.RoomService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -29,8 +31,21 @@ public class RoomController {
|
|||||||
|
|
||||||
@PostMapping("/create")
|
@PostMapping("/create")
|
||||||
public Map<String, String> createRoom() {
|
public Map<String, String> createRoom() {
|
||||||
String roomCode = roomService.createRoom();
|
RoomService.CreateRoomResult result = roomService.createRoom();
|
||||||
return Map.of("roomCode", roomCode);
|
return Map.of(
|
||||||
|
"roomCode", result.roomCode(),
|
||||||
|
"joinToken", result.joinToken()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/verify")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> verifyRoomAccess(
|
||||||
|
@RequestParam String roomCode,
|
||||||
|
@RequestParam String joinToken) {
|
||||||
|
boolean ok = roomService.verifyJoinToken(roomCode, joinToken);
|
||||||
|
return ok
|
||||||
|
? ResponseEntity.ok(Map.of("ok", true))
|
||||||
|
: ResponseEntity.status(403).body(Map.of("ok", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,20 +3,32 @@ package com.datatool.controller;
|
|||||||
import com.datatool.config.TransferProperties;
|
import com.datatool.config.TransferProperties;
|
||||||
import com.datatool.service.UploadCleanupService;
|
import com.datatool.service.UploadCleanupService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 房间内文件上传/下载 REST 接口。
|
* 房间内文件上传/下载 REST 接口。
|
||||||
@@ -32,6 +44,8 @@ import java.util.UUID;
|
|||||||
@CrossOrigin(origins = "*")
|
@CrossOrigin(origins = "*")
|
||||||
public class RoomFileController {
|
public class RoomFileController {
|
||||||
|
|
||||||
|
private static final String UPLOAD_TMP_FOLDER = ".upload-tmp";
|
||||||
|
|
||||||
private final TransferProperties transferProperties;
|
private final TransferProperties transferProperties;
|
||||||
private final UploadCleanupService uploadCleanupService;
|
private final UploadCleanupService uploadCleanupService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@@ -70,12 +84,12 @@ public class RoomFileController {
|
|||||||
Path metaPath = baseDir.resolve(fileId + ".meta");
|
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||||
|
|
||||||
try (InputStream in = file.getInputStream()) {
|
try (InputStream in = file.getInputStream()) {
|
||||||
Files.copy(in, filePath);
|
String sha256 = writeFileAndComputeSha256(in, filePath);
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, String> meta = Map.of(
|
Map<String, String> meta = Map.of(
|
||||||
"fileName", sanitizeFileName(file.getOriginalFilename() != null ? file.getOriginalFilename() : "download"),
|
"fileName", sanitizeFileName(file.getOriginalFilename() != null ? file.getOriginalFilename() : "download"),
|
||||||
"mimeType", file.getContentType() != null ? file.getContentType() : "application/octet-stream"
|
"mimeType", file.getContentType() != null ? file.getContentType() : "application/octet-stream",
|
||||||
|
"sha256", sha256
|
||||||
);
|
);
|
||||||
Files.writeString(metaPath, objectMapper.writeValueAsString(meta));
|
Files.writeString(metaPath, objectMapper.writeValueAsString(meta));
|
||||||
|
|
||||||
@@ -83,14 +97,168 @@ public class RoomFileController {
|
|||||||
"fileId", fileId,
|
"fileId", fileId,
|
||||||
"fileName", meta.get("fileName"),
|
"fileName", meta.get("fileName"),
|
||||||
"fileSize", file.getSize(),
|
"fileSize", file.getSize(),
|
||||||
"mimeType", meta.get("mimeType")
|
"mimeType", meta.get("mimeType"),
|
||||||
|
"sha256", sha256
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/{roomCode}/file/upload/init", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public Map<String, Object> initChunkUpload(
|
||||||
|
@PathVariable String roomCode,
|
||||||
|
@RequestBody InitUploadRequest request) throws IOException {
|
||||||
|
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||||
|
throw new IllegalArgumentException("房间号无效");
|
||||||
|
}
|
||||||
|
if (request == null || request.totalChunks() == null || request.totalChunks() <= 0) {
|
||||||
|
throw new IllegalArgumentException("分片参数无效");
|
||||||
|
}
|
||||||
|
if (request.fileSize() == null || request.fileSize() <= 0 || request.fileSize() > transferProperties.getMaxFileSize()) {
|
||||||
|
throw new IllegalArgumentException("文件大小超过限制");
|
||||||
|
}
|
||||||
|
String uploadId = sanitizeUploadId(request.uploadId());
|
||||||
|
if (uploadId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("uploadId 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path uploadDir = resolveTmpUploadDir(roomCode, uploadId);
|
||||||
|
Files.createDirectories(uploadDir);
|
||||||
|
Path infoPath = uploadDir.resolve("upload-info.json");
|
||||||
|
|
||||||
|
Map<String, Object> info = Map.of(
|
||||||
|
"roomCode", roomCode,
|
||||||
|
"fileName", sanitizeFileName(request.fileName()),
|
||||||
|
"mimeType", request.mimeType() == null || request.mimeType().isBlank() ? "application/octet-stream" : request.mimeType(),
|
||||||
|
"fileSize", request.fileSize(),
|
||||||
|
"totalChunks", request.totalChunks(),
|
||||||
|
"updatedAt", Instant.now().toEpochMilli()
|
||||||
|
);
|
||||||
|
Files.writeString(infoPath, objectMapper.writeValueAsString(info));
|
||||||
|
|
||||||
|
List<Integer> uploadedChunks = listUploadedChunks(uploadDir);
|
||||||
|
return Map.of(
|
||||||
|
"uploadId", uploadId,
|
||||||
|
"uploadedChunks", uploadedChunks,
|
||||||
|
"totalChunks", request.totalChunks()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/{roomCode}/file/upload/chunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public Map<String, Object> uploadChunk(
|
||||||
|
@PathVariable String roomCode,
|
||||||
|
@RequestParam("uploadId") String uploadId,
|
||||||
|
@RequestParam("chunkIndex") Integer chunkIndex,
|
||||||
|
@RequestParam("chunk") MultipartFile chunk) throws IOException {
|
||||||
|
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||||
|
throw new IllegalArgumentException("房间号无效");
|
||||||
|
}
|
||||||
|
if (chunkIndex == null || chunkIndex < 0) {
|
||||||
|
throw new IllegalArgumentException("chunkIndex 无效");
|
||||||
|
}
|
||||||
|
if (chunk == null || chunk.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("分片不能为空");
|
||||||
|
}
|
||||||
|
String safeUploadId = sanitizeUploadId(uploadId);
|
||||||
|
if (safeUploadId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("uploadId 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path uploadDir = resolveTmpUploadDir(roomCode, safeUploadId);
|
||||||
|
if (!Files.isDirectory(uploadDir)) {
|
||||||
|
throw new IllegalArgumentException("上传会话不存在,请重新初始化");
|
||||||
|
}
|
||||||
|
Path chunkPath = uploadDir.resolve(chunkIndex + ".part");
|
||||||
|
try (InputStream in = chunk.getInputStream()) {
|
||||||
|
Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of("ok", true, "chunkIndex", chunkIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(path = "/{roomCode}/file/upload/status")
|
||||||
|
public Map<String, Object> getUploadStatus(
|
||||||
|
@PathVariable String roomCode,
|
||||||
|
@RequestParam("uploadId") String uploadId) throws IOException {
|
||||||
|
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||||
|
return Map.of("uploadId", "", "uploadedChunks", List.of());
|
||||||
|
}
|
||||||
|
String safeUploadId = sanitizeUploadId(uploadId);
|
||||||
|
if (safeUploadId.isBlank()) {
|
||||||
|
return Map.of("uploadId", "", "uploadedChunks", List.of());
|
||||||
|
}
|
||||||
|
Path uploadDir = resolveTmpUploadDir(roomCode, safeUploadId);
|
||||||
|
if (!Files.isDirectory(uploadDir)) {
|
||||||
|
return Map.of("uploadId", safeUploadId, "uploadedChunks", List.of());
|
||||||
|
}
|
||||||
|
return Map.of(
|
||||||
|
"uploadId", safeUploadId,
|
||||||
|
"uploadedChunks", listUploadedChunks(uploadDir)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/{roomCode}/file/upload/complete", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public Map<String, Object> completeChunkUpload(
|
||||||
|
@PathVariable String roomCode,
|
||||||
|
@RequestBody CompleteUploadRequest request) throws IOException {
|
||||||
|
if (!isSafeRoomCode(roomCode) || roomCode.length() != 6) {
|
||||||
|
throw new IllegalArgumentException("房间号无效");
|
||||||
|
}
|
||||||
|
if (request == null || request.totalChunks() == null || request.totalChunks() <= 0) {
|
||||||
|
throw new IllegalArgumentException("分片参数无效");
|
||||||
|
}
|
||||||
|
String safeUploadId = sanitizeUploadId(request.uploadId());
|
||||||
|
if (safeUploadId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("uploadId 无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path uploadDir = resolveTmpUploadDir(roomCode, safeUploadId);
|
||||||
|
if (!Files.isDirectory(uploadDir)) {
|
||||||
|
throw new IllegalArgumentException("上传会话不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < request.totalChunks(); i++) {
|
||||||
|
Path chunkPath = uploadDir.resolve(i + ".part");
|
||||||
|
if (!Files.isRegularFile(chunkPath)) {
|
||||||
|
throw new IllegalArgumentException("缺少分片: " + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileId = "f_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||||
|
Path baseDir = resolveUploadRoot().resolve(roomCode);
|
||||||
|
Files.createDirectories(baseDir);
|
||||||
|
Path filePath = baseDir.resolve(fileId);
|
||||||
|
Path metaPath = baseDir.resolve(fileId + ".meta");
|
||||||
|
|
||||||
|
String sha256 = mergeChunksAndComputeSha256(uploadDir, request.totalChunks(), filePath);
|
||||||
|
String fileName = sanitizeFileName(request.fileName() != null ? request.fileName() : "download");
|
||||||
|
String mimeType = request.mimeType() != null && !request.mimeType().isBlank()
|
||||||
|
? request.mimeType()
|
||||||
|
: "application/octet-stream";
|
||||||
|
|
||||||
|
Map<String, String> meta = Map.of(
|
||||||
|
"fileName", fileName,
|
||||||
|
"mimeType", mimeType,
|
||||||
|
"sha256", sha256
|
||||||
|
);
|
||||||
|
Files.writeString(metaPath, objectMapper.writeValueAsString(meta));
|
||||||
|
|
||||||
|
deleteDirectoryQuietly(uploadDir);
|
||||||
|
|
||||||
|
long fileSize = Files.size(filePath);
|
||||||
|
return Map.<String, Object>of(
|
||||||
|
"fileId", fileId,
|
||||||
|
"fileName", fileName,
|
||||||
|
"fileSize", fileSize,
|
||||||
|
"mimeType", mimeType,
|
||||||
|
"sha256", sha256
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{roomCode}/file/{fileId}")
|
@GetMapping("/{roomCode}/file/{fileId}")
|
||||||
public ResponseEntity<Resource> download(
|
public ResponseEntity<Resource> download(
|
||||||
@PathVariable String roomCode,
|
@PathVariable String roomCode,
|
||||||
@PathVariable String fileId) throws IOException {
|
@PathVariable String fileId,
|
||||||
|
@RequestParam(value = "offset", required = false) Long offset) throws IOException {
|
||||||
if (!isSafeRoomCode(roomCode) || !isSafeFileId(fileId)) {
|
if (!isSafeRoomCode(roomCode) || !isSafeFileId(fileId)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -104,24 +272,54 @@ public class RoomFileController {
|
|||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long fileSize = Files.size(filePath);
|
||||||
|
long startOffset = (offset == null) ? 0L : Math.max(0L, offset);
|
||||||
|
if (startOffset >= fileSize) {
|
||||||
|
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();
|
||||||
|
}
|
||||||
|
|
||||||
String fileName = fileId;
|
String fileName = fileId;
|
||||||
String mimeType = "application/octet-stream";
|
String mimeType = "application/octet-stream";
|
||||||
|
String sha256 = null;
|
||||||
if (Files.isRegularFile(metaPath)) {
|
if (Files.isRegularFile(metaPath)) {
|
||||||
String json = Files.readString(metaPath);
|
String json = Files.readString(metaPath);
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, String> meta = objectMapper.readValue(json, Map.class);
|
Map<String, String> meta = objectMapper.readValue(json, Map.class);
|
||||||
fileName = meta.getOrDefault("fileName", fileId);
|
fileName = meta.getOrDefault("fileName", fileId);
|
||||||
mimeType = meta.getOrDefault("mimeType", mimeType);
|
mimeType = meta.getOrDefault("mimeType", mimeType);
|
||||||
|
sha256 = meta.get("sha256");
|
||||||
}
|
}
|
||||||
|
|
||||||
String contentDisposition = "attachment; filename=\"" + escapeForQuotedString(fileName) + "\"";
|
String contentDisposition = "attachment; filename=\"" + escapeForQuotedString(fileName) + "\"";
|
||||||
|
|
||||||
Resource resource = new FileSystemResource(filePath);
|
Resource resource;
|
||||||
|
long contentLength;
|
||||||
|
HttpStatus status;
|
||||||
|
if (startOffset > 0) {
|
||||||
|
InputStream in = Files.newInputStream(filePath);
|
||||||
|
in.skipNBytes(startOffset);
|
||||||
|
resource = new InputStreamResource(in);
|
||||||
|
contentLength = fileSize - startOffset;
|
||||||
|
status = HttpStatus.PARTIAL_CONTENT;
|
||||||
|
} else {
|
||||||
|
resource = new FileSystemResource(filePath);
|
||||||
|
contentLength = fileSize;
|
||||||
|
status = HttpStatus.OK;
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.status(status)
|
||||||
.contentType(MediaType.parseMediaType(mimeType))
|
.contentType(MediaType.parseMediaType(mimeType))
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||||
.body(resource);
|
.header(HttpHeaders.ACCEPT_RANGES, "bytes")
|
||||||
|
.contentLength(contentLength);
|
||||||
|
if (startOffset > 0) {
|
||||||
|
long end = fileSize - 1;
|
||||||
|
responseBuilder.header(HttpHeaders.CONTENT_RANGE, "bytes " + startOffset + "-" + end + "/" + fileSize);
|
||||||
|
}
|
||||||
|
if (sha256 != null && !sha256.isBlank()) {
|
||||||
|
responseBuilder.header("X-DataTool-SHA256", sha256);
|
||||||
|
}
|
||||||
|
return responseBuilder.body(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,4 +374,132 @@ public class RoomFileController {
|
|||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Path resolveTmpUploadDir(String roomCode, String uploadId) {
|
||||||
|
return resolveUploadRoot()
|
||||||
|
.resolve(UPLOAD_TMP_FOLDER)
|
||||||
|
.resolve(roomCode)
|
||||||
|
.resolve(uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeUploadId(String uploadId) {
|
||||||
|
if (uploadId == null) return "";
|
||||||
|
return uploadId.replaceAll("[^a-zA-Z0-9_-]", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> listUploadedChunks(Path uploadDir) throws IOException {
|
||||||
|
if (!Files.isDirectory(uploadDir)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Integer> list = new ArrayList<>();
|
||||||
|
try (Stream<Path> files = Files.list(uploadDir)) {
|
||||||
|
files.filter(Files::isRegularFile)
|
||||||
|
.map(path -> path.getFileName().toString())
|
||||||
|
.filter(name -> name.endsWith(".part"))
|
||||||
|
.map(name -> name.substring(0, name.length() - 5))
|
||||||
|
.forEach(name -> {
|
||||||
|
try {
|
||||||
|
list.add(Integer.parseInt(name));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// ignore invalid part name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
list.sort(Comparator.naturalOrder());
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String writeFileAndComputeSha256(InputStream inputStream, Path filePath) throws IOException {
|
||||||
|
MessageDigest digest;
|
||||||
|
try {
|
||||||
|
digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
try (OutputStream out = Files.newOutputStream(filePath)) {
|
||||||
|
int read;
|
||||||
|
while ((read = inputStream.read(buffer)) != -1) {
|
||||||
|
out.write(buffer, 0, read);
|
||||||
|
digest.update(buffer, 0, read);
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
} catch (IOException e) {
|
||||||
|
Files.deleteIfExists(filePath);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return bytesToHex(digest.digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String mergeChunksAndComputeSha256(Path uploadDir, int totalChunks, Path targetFilePath) throws IOException {
|
||||||
|
MessageDigest digest;
|
||||||
|
try {
|
||||||
|
digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
try (OutputStream out = Files.newOutputStream(targetFilePath)) {
|
||||||
|
for (int i = 0; i < totalChunks; i++) {
|
||||||
|
Path chunkPath = uploadDir.resolve(i + ".part");
|
||||||
|
try (InputStream in = Files.newInputStream(chunkPath)) {
|
||||||
|
int read;
|
||||||
|
while ((read = in.read(buffer)) != -1) {
|
||||||
|
out.write(buffer, 0, read);
|
||||||
|
digest.update(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
} catch (IOException e) {
|
||||||
|
Files.deleteIfExists(targetFilePath);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytesToHex(digest.digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteDirectoryQuietly(Path dir) {
|
||||||
|
if (dir == null || !Files.exists(dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (Stream<Path> walk = Files.walk(dir)) {
|
||||||
|
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String bytesToHex(byte[] bytes) {
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte value : bytes) {
|
||||||
|
sb.append(String.format("%02x", value));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InitUploadRequest(
|
||||||
|
@NotNull String uploadId,
|
||||||
|
@NotNull String fileName,
|
||||||
|
@NotNull Long fileSize,
|
||||||
|
String mimeType,
|
||||||
|
@NotNull Integer totalChunks
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CompleteUploadRequest(
|
||||||
|
@NotNull String uploadId,
|
||||||
|
String fileName,
|
||||||
|
String mimeType,
|
||||||
|
@NotNull Integer totalChunks
|
||||||
|
) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ public class MessagePayload {
|
|||||||
// 发送方昵称(可选,用于前端展示)
|
// 发送方昵称(可选,用于前端展示)
|
||||||
private String senderName;
|
private String senderName;
|
||||||
|
|
||||||
|
// 房间加入令牌(JOIN 时使用)
|
||||||
|
private String joinToken;
|
||||||
|
|
||||||
// 文本内容或图片 Base64 / URL(视前端实现而定)
|
// 文本内容或图片 Base64 / URL(视前端实现而定)
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ public class MessagePayload {
|
|||||||
private String fileName;
|
private String fileName;
|
||||||
private Long fileSize;
|
private Long fileSize;
|
||||||
private String mimeType;
|
private String mimeType;
|
||||||
|
private String sha256;
|
||||||
/** IMAGE 类型小图直发:base64 图片数据(doc07) */
|
/** IMAGE 类型小图直发:base64 图片数据(doc07) */
|
||||||
private String imageData;
|
private String imageData;
|
||||||
private Integer chunkIndex;
|
private Integer chunkIndex;
|
||||||
@@ -83,6 +87,14 @@ public class MessagePayload {
|
|||||||
this.senderName = senderName;
|
this.senderName = senderName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getJoinToken() {
|
||||||
|
return joinToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJoinToken(String joinToken) {
|
||||||
|
this.joinToken = joinToken;
|
||||||
|
}
|
||||||
|
|
||||||
public String getContent() {
|
public String getContent() {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
@@ -139,6 +151,14 @@ public class MessagePayload {
|
|||||||
this.mimeType = mimeType;
|
this.mimeType = mimeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSha256() {
|
||||||
|
return sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSha256(String sha256) {
|
||||||
|
this.sha256 = sha256;
|
||||||
|
}
|
||||||
|
|
||||||
public String getImageData() {
|
public String getImageData() {
|
||||||
return imageData;
|
return imageData;
|
||||||
}
|
}
|
||||||
@@ -195,4 +215,3 @@ public class MessagePayload {
|
|||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
public class Room {
|
public class Room {
|
||||||
|
|
||||||
private String roomCode;
|
private String roomCode;
|
||||||
|
private final String joinToken;
|
||||||
private final Map<String, SessionInfo> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SessionInfo> sessions = new ConcurrentHashMap<>();
|
||||||
private final Instant createdAt;
|
private final Instant createdAt;
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
public Room(String roomCode) {
|
public Room(String roomCode, String joinToken) {
|
||||||
this.roomCode = roomCode;
|
this.roomCode = roomCode;
|
||||||
|
this.joinToken = joinToken;
|
||||||
this.createdAt = Instant.now();
|
this.createdAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ public class Room {
|
|||||||
this.roomCode = roomCode;
|
this.roomCode = roomCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getJoinToken() {
|
||||||
|
return joinToken;
|
||||||
|
}
|
||||||
|
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
@@ -65,4 +71,3 @@ public class Room {
|
|||||||
return Collections.unmodifiableCollection(sessions.values());
|
return Collections.unmodifiableCollection(sessions.values());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import java.util.stream.Collectors;
|
|||||||
public class RoomService {
|
public class RoomService {
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private static final String TOKEN_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
private static final int TOKEN_LENGTH = 8;
|
||||||
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||||
private final SessionRegistry sessionRegistry;
|
private final SessionRegistry sessionRegistry;
|
||||||
private final UploadCleanupService uploadCleanupService;
|
private final UploadCleanupService uploadCleanupService;
|
||||||
@@ -36,13 +38,14 @@ public class RoomService {
|
|||||||
/**
|
/**
|
||||||
* 创建房间:生成唯一 6 位数字房间号并创建房间。
|
* 创建房间:生成唯一 6 位数字房间号并创建房间。
|
||||||
*/
|
*/
|
||||||
public String createRoom() {
|
public CreateRoomResult createRoom() {
|
||||||
String roomCode;
|
String roomCode;
|
||||||
do {
|
do {
|
||||||
roomCode = String.valueOf(100000 + RANDOM.nextInt(900000));
|
roomCode = String.valueOf(100000 + RANDOM.nextInt(900000));
|
||||||
} while (rooms.containsKey(roomCode));
|
} while (rooms.containsKey(roomCode));
|
||||||
rooms.put(roomCode, new Room(roomCode));
|
String joinToken = generateJoinToken();
|
||||||
return roomCode;
|
rooms.put(roomCode, new Room(roomCode, joinToken));
|
||||||
|
return new CreateRoomResult(roomCode, joinToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,8 +69,11 @@ public class RoomService {
|
|||||||
/**
|
/**
|
||||||
* 用户加入房间。
|
* 用户加入房间。
|
||||||
*/
|
*/
|
||||||
public Room joinRoom(String roomCode, String sessionId, String userId, String nickname) {
|
public Room joinRoom(String roomCode, String joinToken, String sessionId, String userId, String nickname) {
|
||||||
Room room = rooms.computeIfAbsent(roomCode, Room::new);
|
Room room = rooms.get(roomCode);
|
||||||
|
if (room == null || !verifyJoinToken(roomCode, joinToken)) {
|
||||||
|
throw new IllegalArgumentException("房间不存在或加入令牌错误");
|
||||||
|
}
|
||||||
String uniqueNickname = ensureUniqueNickname(roomCode, nickname);
|
String uniqueNickname = ensureUniqueNickname(roomCode, nickname);
|
||||||
SessionInfo sessionInfo = new SessionInfo(sessionId, userId, uniqueNickname, Instant.now());
|
SessionInfo sessionInfo = new SessionInfo(sessionId, userId, uniqueNickname, Instant.now());
|
||||||
room.addSession(sessionInfo);
|
room.addSession(sessionInfo);
|
||||||
@@ -102,6 +108,14 @@ public class RoomService {
|
|||||||
return roomCode != null && rooms.containsKey(roomCode);
|
return roomCode != null && rooms.containsKey(roomCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean verifyJoinToken(String roomCode, String joinToken) {
|
||||||
|
if (roomCode == null || roomCode.isBlank() || joinToken == null || joinToken.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Room room = rooms.get(roomCode);
|
||||||
|
return room != null && joinToken.equalsIgnoreCase(room.getJoinToken());
|
||||||
|
}
|
||||||
|
|
||||||
public Collection<SessionInfo> getUsers(String roomCode) {
|
public Collection<SessionInfo> getUsers(String roomCode) {
|
||||||
Room room = rooms.get(roomCode);
|
Room room = rooms.get(roomCode);
|
||||||
return room == null ? java.util.List.of() : room.getSessions();
|
return room == null ? java.util.List.of() : room.getSessions();
|
||||||
@@ -114,5 +128,16 @@ public class RoomService {
|
|||||||
Room room = rooms.get(roomCode);
|
Room room = rooms.get(roomCode);
|
||||||
return room == null ? null : room.getSession(sessionId);
|
return room == null ? null : room.getSession(sessionId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private static String generateJoinToken() {
|
||||||
|
StringBuilder sb = new StringBuilder(TOKEN_LENGTH);
|
||||||
|
for (int i = 0; i < TOKEN_LENGTH; i++) {
|
||||||
|
int idx = RANDOM.nextInt(TOKEN_CHARS.length());
|
||||||
|
sb.append(TOKEN_CHARS.charAt(idx));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateRoomResult(String roomCode, String joinToken) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import org.springframework.scheduling.annotation.Scheduled;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.FileStore;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,5 +70,90 @@ public class ScheduledUploadCleanup {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.warn("[ScheduledUploadCleanup] 列出上传目录失败 error={}", e.getMessage());
|
log.warn("[ScheduledUploadCleanup] 列出上传目录失败 error={}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanupByDiskWatermark(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupByDiskWatermark(Path root) {
|
||||||
|
int high = transferProperties.getDiskHighWatermarkPercent();
|
||||||
|
int target = transferProperties.getDiskTargetWatermarkPercent();
|
||||||
|
if (high <= 0 || target <= 0 || target >= high || high >= 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double used = getDiskUsedPercent(root);
|
||||||
|
if (used < 0 || used < high) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> inactiveRooms = listInactiveRoomsByOldest(root);
|
||||||
|
if (inactiveRooms.isEmpty()) {
|
||||||
|
log.warn("[ScheduledUploadCleanup] 磁盘使用率偏高({}%),但无可清理的离线房间目录", Math.round(used));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
for (String roomCode : inactiveRooms) {
|
||||||
|
if (used <= target) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (uploadCleanupService.deleteRoomFolder(roomCode)) {
|
||||||
|
deletedCount++;
|
||||||
|
used = getDiskUsedPercent(root);
|
||||||
|
if (used < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
if (used >= 0) {
|
||||||
|
log.info("[ScheduledUploadCleanup] 磁盘水位回收完成 deletedRooms={} usedPercent={} targetPercent={}",
|
||||||
|
deletedCount,
|
||||||
|
Math.round(used),
|
||||||
|
target);
|
||||||
|
} else {
|
||||||
|
log.info("[ScheduledUploadCleanup] 磁盘水位回收完成 deletedRooms={}", deletedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> listInactiveRoomsByOldest(Path root) {
|
||||||
|
try (Stream<Path> list = Files.list(root)) {
|
||||||
|
return list
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(path -> path.getFileName().toString().matches(ROOM_CODE_PATTERN))
|
||||||
|
.filter(path -> !roomService.hasRoom(path.getFileName().toString()))
|
||||||
|
.sorted(Comparator.comparingLong(this::safeLastModifiedMillis))
|
||||||
|
.map(path -> path.getFileName().toString())
|
||||||
|
.toList();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("[ScheduledUploadCleanup] 获取离线房间目录失败 error={}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long safeLastModifiedMillis(Path path) {
|
||||||
|
try {
|
||||||
|
return Files.getLastModifiedTime(path).toMillis();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Long.MAX_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double getDiskUsedPercent(Path root) {
|
||||||
|
try {
|
||||||
|
FileStore store = Files.getFileStore(root);
|
||||||
|
long total = store.getTotalSpace();
|
||||||
|
long usable = store.getUsableSpace();
|
||||||
|
if (total <= 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
long used = total - usable;
|
||||||
|
return (used * 100.0) / total;
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("[ScheduledUploadCleanup] 读取磁盘使用率失败 error={}", e.getMessage());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.datatool.message.MessageType;
|
|||||||
import com.datatool.room.RoomService;
|
import com.datatool.room.RoomService;
|
||||||
import com.datatool.room.SessionInfo;
|
import com.datatool.room.SessionInfo;
|
||||||
import com.datatool.room.SessionRegistry;
|
import com.datatool.room.SessionRegistry;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||||
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
|
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
|
||||||
@@ -30,6 +32,8 @@ import java.util.Map;
|
|||||||
@Controller
|
@Controller
|
||||||
public class RoomWsController {
|
public class RoomWsController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RoomWsController.class);
|
||||||
|
|
||||||
private final RoomService roomService;
|
private final RoomService roomService;
|
||||||
private final SessionRegistry sessionRegistry;
|
private final SessionRegistry sessionRegistry;
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
@@ -52,8 +56,14 @@ public class RoomWsController {
|
|||||||
String sessionId = headerAccessor.getSessionId();
|
String sessionId = headerAccessor.getSessionId();
|
||||||
String userId = payload.getSenderId();
|
String userId = payload.getSenderId();
|
||||||
String nickname = payload.getContent(); // 暂将 content 视为昵称占位,后续可单独扩展字段
|
String nickname = payload.getContent(); // 暂将 content 视为昵称占位,后续可单独扩展字段
|
||||||
|
String joinToken = payload.getJoinToken();
|
||||||
|
|
||||||
roomService.joinRoom(roomCode, sessionId, userId, nickname);
|
try {
|
||||||
|
roomService.joinRoom(roomCode, joinToken, sessionId, userId, nickname);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
log.warn("[WS] 加入房间失败: roomCode={}, sessionId={}, reason={}", roomCode, sessionId, ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String joinMessage = (nickname != null && !nickname.isEmpty()) ? nickname + " 加入房间" : "有用户加入房间";
|
String joinMessage = (nickname != null && !nickname.isEmpty()) ? nickname + " 加入房间" : "有用户加入房间";
|
||||||
Map<String, Object> joinData = new HashMap<>();
|
Map<String, Object> joinData = new HashMap<>();
|
||||||
@@ -123,4 +133,3 @@ public class RoomWsController {
|
|||||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload);
|
messagingTemplate.convertAndSend("/topic/room/" + roomCode, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ datatool:
|
|||||||
max-file-size: 2147483648 # 2GB
|
max-file-size: 2147483648 # 2GB
|
||||||
room-expire-hours: 24
|
room-expire-hours: 24
|
||||||
cleanup-interval-ms: 3600000 # 定时过期清理间隔(毫秒),默认 1 小时
|
cleanup-interval-ms: 3600000 # 定时过期清理间隔(毫秒),默认 1 小时
|
||||||
|
disk-high-watermark-percent: 80 # 磁盘使用率高于该值时,回收离线房间目录
|
||||||
|
disk-target-watermark-percent: 70 # 回收到低于该值即停止
|
||||||
|
|||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -920,7 +920,6 @@
|
|||||||
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
|
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1182,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1815,7 +1813,6 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -2063,7 +2060,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2556,7 +2552,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2590,7 +2585,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2660,7 +2654,6 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -2720,7 +2713,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.27",
|
"@vue/compiler-dom": "3.5.27",
|
||||||
"@vue/compiler-sfc": "3.5.27",
|
"@vue/compiler-sfc": "3.5.27",
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ export interface UploadFileResponse {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
sha256?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadProgressDetail {
|
||||||
|
mode: 'direct' | 'chunked';
|
||||||
|
chunkSizeBytes?: number;
|
||||||
|
totalChunks?: number;
|
||||||
|
uploadedChunks?: number;
|
||||||
|
resumedChunks?: number;
|
||||||
|
retriesUsed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitChunkUploadResponse {
|
||||||
|
uploadId: string;
|
||||||
|
uploadedChunks: number[];
|
||||||
|
totalChunks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoomResponse {
|
||||||
|
roomCode: string;
|
||||||
|
joinToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +39,21 @@ export async function uploadRoomFile(
|
|||||||
roomCode: string,
|
roomCode: string,
|
||||||
file: File,
|
file: File,
|
||||||
onProgress?: (percent: number) => void,
|
onProgress?: (percent: number) => void,
|
||||||
|
onDetail?: (detail: UploadProgressDetail) => void,
|
||||||
|
): Promise<UploadFileResponse> {
|
||||||
|
const LARGE_FILE_THRESHOLD = 8 * 1024 * 1024;
|
||||||
|
if (file.size <= LARGE_FILE_THRESHOLD) {
|
||||||
|
return uploadRoomFileDirect(roomCode, file, onProgress, onDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadRoomFileByChunks(roomCode, file, onProgress, onDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadRoomFileDirect(
|
||||||
|
roomCode: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
onDetail?: (detail: UploadProgressDetail) => void,
|
||||||
): Promise<UploadFileResponse> {
|
): Promise<UploadFileResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -38,6 +74,7 @@ export async function uploadRoomFile(
|
|||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(xhr.responseText) as UploadFileResponse;
|
const data = JSON.parse(xhr.responseText) as UploadFileResponse;
|
||||||
|
onDetail?.({ mode: 'direct' });
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch {
|
} catch {
|
||||||
reject(new Error('解析响应失败'));
|
reject(new Error('解析响应失败'));
|
||||||
@@ -61,6 +98,169 @@ export async function uploadRoomFile(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadRoomFileByChunks(
|
||||||
|
roomCode: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
onDetail?: (detail: UploadProgressDetail) => void,
|
||||||
|
): Promise<UploadFileResponse> {
|
||||||
|
const chunkSize = getAdaptiveUploadChunkSize(file.size);
|
||||||
|
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||||
|
const uploadId = buildUploadId(file, chunkSize);
|
||||||
|
|
||||||
|
const initRes = await fetch(`/api/room/${encodeURIComponent(roomCode)}/file/upload/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
uploadId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
totalChunks,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!initRes.ok) {
|
||||||
|
throw new Error(`初始化分块上传失败 ${initRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initData = (await initRes.json()) as InitChunkUploadResponse;
|
||||||
|
const uploaded = new Set<number>(initData.uploadedChunks ?? []);
|
||||||
|
onDetail?.({
|
||||||
|
mode: 'chunked',
|
||||||
|
chunkSizeBytes: chunkSize,
|
||||||
|
totalChunks,
|
||||||
|
uploadedChunks: uploaded.size,
|
||||||
|
resumedChunks: uploaded.size,
|
||||||
|
});
|
||||||
|
if (onProgress && totalChunks > 0) {
|
||||||
|
onProgress(Math.round((uploaded.size / totalChunks) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
if (uploaded.has(i)) continue;
|
||||||
|
const start = i * chunkSize;
|
||||||
|
const end = Math.min(file.size, start + chunkSize);
|
||||||
|
const chunkBlob = file.slice(start, end);
|
||||||
|
|
||||||
|
const retriesUsed = await uploadChunkWithRetry(roomCode, initData.uploadId, i, chunkBlob, 3);
|
||||||
|
uploaded.add(i);
|
||||||
|
onDetail?.({
|
||||||
|
mode: 'chunked',
|
||||||
|
chunkSizeBytes: chunkSize,
|
||||||
|
totalChunks,
|
||||||
|
uploadedChunks: uploaded.size,
|
||||||
|
retriesUsed,
|
||||||
|
});
|
||||||
|
if (onProgress && totalChunks > 0) {
|
||||||
|
onProgress(Math.round((uploaded.size / totalChunks) * 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeRes = await fetch(`/api/room/${encodeURIComponent(roomCode)}/file/upload/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
uploadId: initData.uploadId,
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type || 'application/octet-stream',
|
||||||
|
totalChunks,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!completeRes.ok) {
|
||||||
|
throw new Error(`分块合并失败 ${completeRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await completeRes.json()) as UploadFileResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadChunkWithRetry(
|
||||||
|
roomCode: string,
|
||||||
|
uploadId: string,
|
||||||
|
chunkIndex: number,
|
||||||
|
chunkBlob: Blob,
|
||||||
|
maxRetries: number,
|
||||||
|
): Promise<number> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('chunk', chunkBlob, `${chunkIndex}.part`);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/room/${encodeURIComponent(roomCode)}/file/upload/chunk?uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${chunkIndex}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`分片 ${chunkIndex} 上传失败 ${res.status}`);
|
||||||
|
}
|
||||||
|
return attempt;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
lastError = err;
|
||||||
|
} else {
|
||||||
|
lastError = new Error(`分片 ${chunkIndex} 上传失败(网络错误)`);
|
||||||
|
}
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
await sleep(300 * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error('分片上传失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUploadId(file: File, chunkSize: number): string {
|
||||||
|
const source = `${file.name}_${file.size}_${file.lastModified}_${chunkSize}`;
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < source.length; index++) {
|
||||||
|
hash ^= source.charCodeAt(index);
|
||||||
|
hash +=
|
||||||
|
(hash << 1) +
|
||||||
|
(hash << 4) +
|
||||||
|
(hash << 7) +
|
||||||
|
(hash << 8) +
|
||||||
|
(hash << 24);
|
||||||
|
}
|
||||||
|
return `up_${Math.abs(hash >>> 0).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdaptiveUploadChunkSize(fileSize: number): number {
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const connection = getNetworkConnection();
|
||||||
|
const effectiveType = connection?.effectiveType ?? '';
|
||||||
|
const saveData = connection?.saveData ?? false;
|
||||||
|
|
||||||
|
if (saveData || effectiveType === 'slow-2g' || effectiveType === '2g') {
|
||||||
|
return 512 * 1024;
|
||||||
|
}
|
||||||
|
if (effectiveType === '3g') {
|
||||||
|
return MB;
|
||||||
|
}
|
||||||
|
if (fileSize >= 1024 * MB) {
|
||||||
|
return 4 * MB;
|
||||||
|
}
|
||||||
|
if (fileSize >= 256 * MB) {
|
||||||
|
return 2 * MB;
|
||||||
|
}
|
||||||
|
return 1536 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetworkConnection():
|
||||||
|
| { effectiveType?: string; saveData?: boolean }
|
||||||
|
| undefined {
|
||||||
|
const nav = navigator as Navigator & {
|
||||||
|
connection?: { effectiveType?: string; saveData?: boolean };
|
||||||
|
mozConnection?: { effectiveType?: string; saveData?: boolean };
|
||||||
|
webkitConnection?: { effectiveType?: string; saveData?: boolean };
|
||||||
|
};
|
||||||
|
return nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前客户端的 IP(由服务端从请求中解析),用于作为默认昵称。
|
* 获取当前客户端的 IP(由服务端从请求中解析),用于作为默认昵称。
|
||||||
*/
|
*/
|
||||||
@@ -72,6 +272,24 @@ export async function getMyIp(apiBase = ''): Promise<string> {
|
|||||||
return data.ip ?? '';
|
return data.ip ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRoom(apiBase = ''): Promise<CreateRoomResponse> {
|
||||||
|
const url = `${apiBase}/api/room/create`.replace(/\/+/g, '/');
|
||||||
|
const res = await fetch(url, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error(`创建房间失败: ${res.status}`);
|
||||||
|
return (await res.json()) as CreateRoomResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyRoomAccess(
|
||||||
|
roomCode: string,
|
||||||
|
joinToken: string,
|
||||||
|
apiBase = '',
|
||||||
|
): Promise<boolean> {
|
||||||
|
const params = new URLSearchParams({ roomCode, joinToken });
|
||||||
|
const url = `${apiBase}/api/room/verify?${params.toString()}`.replace(/\/+/g, '/');
|
||||||
|
const res = await fetch(url);
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回房间内文件的下载 URL(相对路径,走当前 origin 的 /api 代理)。
|
* 返回房间内文件的下载 URL(相对路径,走当前 origin 的 /api 代理)。
|
||||||
*/
|
*/
|
||||||
@@ -86,26 +304,83 @@ export function downloadWithProgress(
|
|||||||
url: string,
|
url: string,
|
||||||
onProgress?: (percent: number) => void,
|
onProgress?: (percent: number) => void,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return downloadWithResume(url, onProgress, 2);
|
||||||
const xhr = new XMLHttpRequest();
|
}
|
||||||
xhr.responseType = 'blob';
|
|
||||||
|
async function downloadWithResume(
|
||||||
xhr.addEventListener('progress', (e) => {
|
url: string,
|
||||||
if (onProgress && e.lengthComputable && e.total > 0) {
|
onProgress?: (percent: number) => void,
|
||||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
maxRetries = 2,
|
||||||
}
|
): Promise<Blob> {
|
||||||
});
|
let downloaded = 0;
|
||||||
xhr.addEventListener('load', () => {
|
let totalSize: number | null = null;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
const chunks: Uint8Array[] = [];
|
||||||
resolve(xhr.response as Blob);
|
|
||||||
} else {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
reject(new Error(xhr.status === 404 ? '文件不存在' : `下载失败 ${xhr.status}`));
|
try {
|
||||||
}
|
const requestUrl = appendOffset(url, downloaded > 0 ? downloaded : null);
|
||||||
});
|
const response = await fetch(requestUrl);
|
||||||
xhr.addEventListener('error', () => reject(new Error('网络错误')));
|
|
||||||
xhr.addEventListener('abort', () => reject(new Error('已取消')));
|
if (!response.ok) {
|
||||||
|
throw new Error(response.status === 404 ? '文件不存在' : `下载失败 ${response.status}`);
|
||||||
xhr.open('GET', url);
|
}
|
||||||
xhr.send();
|
|
||||||
});
|
const contentRange = response.headers.get('Content-Range');
|
||||||
|
const contentLength = Number(response.headers.get('Content-Length') ?? '0');
|
||||||
|
|
||||||
|
if (downloaded > 0 && response.status === 200) {
|
||||||
|
downloaded = 0;
|
||||||
|
totalSize = null;
|
||||||
|
chunks.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize == null) {
|
||||||
|
if (contentRange) {
|
||||||
|
const match = contentRange.match(/\/([0-9]+)$/);
|
||||||
|
if (match) {
|
||||||
|
totalSize = Number(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalSize == null && contentLength > 0) {
|
||||||
|
totalSize = downloaded + contentLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('下载流不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (!value) continue;
|
||||||
|
chunks.push(value);
|
||||||
|
downloaded += value.length;
|
||||||
|
if (onProgress && totalSize && totalSize > 0) {
|
||||||
|
onProgress(Math.min(100, Math.round((downloaded / totalSize) * 100)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(chunks, {
|
||||||
|
type: response.headers.get('Content-Type') ?? 'application/octet-stream',
|
||||||
|
});
|
||||||
|
if (onProgress) onProgress(100);
|
||||||
|
return blob;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt >= maxRetries) {
|
||||||
|
if (err instanceof Error) throw err;
|
||||||
|
throw new Error('网络错误');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('下载失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOffset(url: string, offset: number | null): string {
|
||||||
|
if (offset == null || offset <= 0) return url;
|
||||||
|
const hasQuery = url.includes('?');
|
||||||
|
const sep = hasQuery ? '&' : '?';
|
||||||
|
return `${url}${sep}offset=${offset}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,15 @@
|
|||||||
multiple
|
multiple
|
||||||
@change="handleInputChange"
|
@change="handleInputChange"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref="folderInputRef"
|
||||||
|
type="file"
|
||||||
|
class="sr-only"
|
||||||
|
multiple
|
||||||
|
webkitdirectory
|
||||||
|
directory
|
||||||
|
@change="handleFolderInputChange"
|
||||||
|
/>
|
||||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
|
<span class="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -45,6 +54,14 @@
|
|||||||
|
|
||||||
<!-- 读取剪贴板按钮 -->
|
<!-- 读取剪贴板按钮 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-slate-50 hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
|
title="选择整个文件夹并批量发送"
|
||||||
|
@click="triggerFolderInput"
|
||||||
|
>
|
||||||
|
选择文件夹
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-slate-50 hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-slate-50 hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@@ -93,6 +110,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const zoneRef = ref<HTMLElement | null>(null);
|
const zoneRef = ref<HTMLElement | null>(null);
|
||||||
const inputRef = ref<HTMLInputElement | null>(null);
|
const inputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const folderInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const isDragOver = ref(false);
|
const isDragOver = ref(false);
|
||||||
|
|
||||||
// 剪贴板读取状态
|
// 剪贴板读取状态
|
||||||
@@ -130,6 +148,10 @@ function triggerInput() {
|
|||||||
inputRef.value?.click();
|
inputRef.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerFolderInput() {
|
||||||
|
folderInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
function emitFiles(files: FileList | File[]) {
|
function emitFiles(files: FileList | File[]) {
|
||||||
const list = Array.from(files).filter((f) => f && f.size >= 0);
|
const list = Array.from(files).filter((f) => f && f.size >= 0);
|
||||||
if (list.length) emit('fileSelected', list);
|
if (list.length) emit('fileSelected', list);
|
||||||
@@ -147,6 +169,15 @@ function handleInputChange(e: Event) {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFolderInputChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files?.length) {
|
||||||
|
emitFiles(input.files);
|
||||||
|
showClipboardMessage(`已选择 ${input.files.length} 个文件,正在排队发送`, 'success');
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理粘贴事件
|
* 处理粘贴事件
|
||||||
* - 优先处理文件(如截图、复制的文件)
|
* - 优先处理文件(如截图、复制的文件)
|
||||||
|
|||||||
@@ -74,9 +74,11 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="status === 'error'" class="mt-2">
|
<div v-else-if="status === 'error'" class="mt-2">
|
||||||
<p class="text-xs text-danger">传输失败</p>
|
<p class="text-xs text-danger">
|
||||||
|
传输失败<span v-if="chunkHint"> · {{ chunkHint }}</span>
|
||||||
|
</p>
|
||||||
<BaseButton size="sm" variant="ghost" class="mt-1" @click="$emit('retry')">
|
<BaseButton size="sm" variant="ghost" class="mt-1" @click="$emit('retry')">
|
||||||
重试
|
重试失败分片
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +98,7 @@ const props = withDefaults(
|
|||||||
isMe?: boolean;
|
isMe?: boolean;
|
||||||
status?: 'sending' | 'receiving' | 'done' | 'error';
|
status?: 'sending' | 'receiving' | 'done' | 'error';
|
||||||
progress?: number; // 0–100
|
progress?: number; // 0–100
|
||||||
|
chunkHint?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
fileSize: 0,
|
fileSize: 0,
|
||||||
@@ -130,8 +133,16 @@ const showProgress = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
const statusLabel = computed(() => {
|
||||||
if (props.status === 'sending') return `发送中 ${props.progress}%`;
|
if (props.status === 'sending') {
|
||||||
if (props.status === 'receiving') return `接收中 ${props.progress}%`;
|
return props.chunkHint
|
||||||
|
? `发送中 ${props.progress}% · ${props.chunkHint}`
|
||||||
|
: `发送中 ${props.progress}%`;
|
||||||
|
}
|
||||||
|
if (props.status === 'receiving') {
|
||||||
|
return props.chunkHint
|
||||||
|
? `接收中 ${props.progress}% · ${props.chunkHint}`
|
||||||
|
: `接收中 ${props.progress}%`;
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-primary" />
|
<div class="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-primary" />
|
||||||
<p class="mt-2 text-xs text-slate-500">
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
{{ status === 'sending' ? '发送中' : '接收中' }} {{ progress }}%
|
{{ status === 'sending' ? '发送中' : '接收中' }} {{ progress }}%
|
||||||
|
<span v-if="chunkHint"> · {{ chunkHint }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2 h-1.5 w-full max-w-[160px] overflow-hidden rounded-full bg-slate-200">
|
<div class="mt-2 h-1.5 w-full max-w-[160px] overflow-hidden rounded-full bg-slate-200">
|
||||||
<div
|
<div
|
||||||
@@ -55,6 +56,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="status === 'error'"
|
||||||
|
class="mt-2 rounded-md border border-red-200 bg-red-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-danger">
|
||||||
|
传输失败<span v-if="chunkHint"> · {{ chunkHint }}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-1 inline-flex items-center rounded border border-red-200 bg-white px-2 py-1 text-xs text-red-600 transition-colors hover:bg-red-50"
|
||||||
|
@click="handleRetry"
|
||||||
|
>
|
||||||
|
重试失败分片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 图片显示 -->
|
<!-- 图片显示 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="imageSrc"
|
v-else-if="imageSrc"
|
||||||
@@ -202,6 +219,8 @@ const props = withDefaults(
|
|||||||
status?: 'sending' | 'receiving' | 'done' | 'error';
|
status?: 'sending' | 'receiving' | 'done' | 'error';
|
||||||
/** 进度 0-100 */
|
/** 进度 0-100 */
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
/** 分片进度提示,例如 3/20 分片 */
|
||||||
|
chunkHint?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
isMe: false,
|
isMe: false,
|
||||||
@@ -212,6 +231,7 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
download: [];
|
download: [];
|
||||||
|
retry: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
@@ -255,6 +275,10 @@ function handleDownload() {
|
|||||||
emit('download');
|
emit('download');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
emit('retry');
|
||||||
|
}
|
||||||
|
|
||||||
// ESC 键关闭预览
|
// ESC 键关闭预览
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
>
|
>
|
||||||
{{ shortDisplayName }}
|
{{ shortDisplayName }}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[11px] rounded px-1.5 py-0.5 border transition-colors"
|
||||||
|
:class="
|
||||||
|
isMe
|
||||||
|
? 'border-white/25 text-white/80 hover:bg-white/10'
|
||||||
|
: 'border-slate-200 text-slate-500 hover:bg-slate-100'
|
||||||
|
"
|
||||||
|
:aria-label="copied ? '已复制消息' : '复制消息内容'"
|
||||||
|
@click="copyMessage"
|
||||||
|
>
|
||||||
|
{{ copied ? '已复制' : '复制' }}
|
||||||
|
</button>
|
||||||
<span
|
<span
|
||||||
v-if="timestamp"
|
v-if="timestamp"
|
||||||
class="text-[11px] ml-auto shrink-0 tabular-nums"
|
class="text-[11px] ml-auto shrink-0 tabular-nums"
|
||||||
@@ -47,8 +60,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||||
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
|
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
|
||||||
|
import { writeClipboardText } from '@/utils/clipboard';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -71,6 +85,26 @@ const displayName = computed(() => {
|
|||||||
const shortDisplayName = computed(() => getShortDisplayName(displayName.value));
|
const shortDisplayName = computed(() => getShortDisplayName(displayName.value));
|
||||||
const avatarLetter = computed(() => getAvatarLetter(displayName.value));
|
const avatarLetter = computed(() => getAvatarLetter(displayName.value));
|
||||||
const avatarColorClass = computed(() => getAvatarColorClass(displayName.value));
|
const avatarColorClass = computed(() => getAvatarColorClass(displayName.value));
|
||||||
|
const copied = ref(false);
|
||||||
|
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (copiedTimer) {
|
||||||
|
clearTimeout(copiedTimer);
|
||||||
|
copiedTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyMessage(): Promise<void> {
|
||||||
|
const result = await writeClipboardText(props.content ?? '');
|
||||||
|
if (!result.success) return;
|
||||||
|
copied.value = true;
|
||||||
|
if (copiedTimer) clearTimeout(copiedTimer);
|
||||||
|
copiedTimer = setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
copiedTimer = null;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
function formatTime(ts: number): string {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
|
|||||||
@@ -18,10 +18,21 @@
|
|||||||
复制
|
复制
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-600">
|
||||||
|
加入令牌 {{ joinToken || '--------' }}
|
||||||
|
</p>
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
<StatusDot :status="connectionStatusDot" />
|
<StatusDot :status="connectionStatusDot" />
|
||||||
<span class="text-xs text-slate-600">{{ connectionStatusLabel }}</span>
|
<span class="text-xs text-slate-600">{{ connectionStatusLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="copyHint"
|
||||||
|
class="mt-1 text-xs"
|
||||||
|
:class="copyOk ? 'text-success' : 'text-danger'"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ copyHint }}
|
||||||
|
</p>
|
||||||
<p class="mt-1 text-xs text-slate-500">临时房间 · 数据不落库</p>
|
<p class="mt-1 text-xs text-slate-500">临时房间 · 数据不落库</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +60,29 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span class="text-xs font-medium text-slate-700 block mb-2">
|
||||||
|
传输状态
|
||||||
|
</span>
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div class="rounded border border-slate-200 bg-slate-50 px-2 py-2">
|
||||||
|
<p class="text-[11px] text-slate-500">消息</p>
|
||||||
|
<p class="text-sm font-semibold text-slate-800">{{ transferStats.messageCount }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border border-slate-200 bg-slate-50 px-2 py-2">
|
||||||
|
<p class="text-[11px] text-slate-500">发送中</p>
|
||||||
|
<p class="text-sm font-semibold text-primary">{{ transferStats.sendingCount }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border border-slate-200 bg-slate-50 px-2 py-2">
|
||||||
|
<p class="text-[11px] text-slate-500">接收中</p>
|
||||||
|
<p class="text-sm font-semibold text-warning">{{ transferStats.receivingCount }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-[11px] text-slate-500">
|
||||||
|
队列 {{ transferStats.queueCount }} · 续传命中 {{ transferStats.resumedCount }} · 上传重试 {{ transferStats.retryCount }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 border-t border-slate-200">
|
<div class="p-4 border-t border-slate-200">
|
||||||
@@ -65,22 +99,40 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||||
import StatusDot from '@/components/ui/StatusDot.vue';
|
import StatusDot from '@/components/ui/StatusDot.vue';
|
||||||
import UserList from '@/components/UserList.vue';
|
import UserList from '@/components/UserList.vue';
|
||||||
import type { SessionInfo } from '@/types/room';
|
import type { SessionInfo } from '@/types/room';
|
||||||
import type { ConnectionStatus } from '@/ws/RoomWsClient';
|
import type { ConnectionStatus } from '@/ws/RoomWsClient';
|
||||||
|
import { writeClipboardText } from '@/utils/clipboard';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
roomCode: string;
|
roomCode: string;
|
||||||
|
joinToken: string;
|
||||||
userList: SessionInfo[];
|
userList: SessionInfo[];
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
myUserId: string;
|
myUserId: string;
|
||||||
|
transferStats: {
|
||||||
|
messageCount: number;
|
||||||
|
sendingCount: number;
|
||||||
|
receivingCount: number;
|
||||||
|
queueCount: number;
|
||||||
|
resumedCount: number;
|
||||||
|
retryCount: number;
|
||||||
|
};
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
userList: () => [],
|
userList: () => [],
|
||||||
|
transferStats: () => ({
|
||||||
|
messageCount: 0,
|
||||||
|
sendingCount: 0,
|
||||||
|
receivingCount: 0,
|
||||||
|
queueCount: 0,
|
||||||
|
resumedCount: 0,
|
||||||
|
retryCount: 0,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,6 +143,10 @@ defineEmits<{
|
|||||||
exportText: [];
|
exportText: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const copyHint = ref('');
|
||||||
|
const copyOk = ref(true);
|
||||||
|
let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const connectionStatusDot = computed(() => {
|
const connectionStatusDot = computed(() => {
|
||||||
switch (props.connectionStatus) {
|
switch (props.connectionStatus) {
|
||||||
case 'connecting':
|
case 'connecting':
|
||||||
@@ -119,13 +175,30 @@ const connectionStatusLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function copyRoomCode() {
|
onBeforeUnmount(() => {
|
||||||
if (!props.roomCode) return;
|
if (copyHintTimer) {
|
||||||
navigator.clipboard
|
clearTimeout(copyHintTimer);
|
||||||
?.writeText(props.roomCode)
|
copyHintTimer = null;
|
||||||
.then(() => {
|
}
|
||||||
// 可接入 Toast 提示“已复制”
|
});
|
||||||
})
|
|
||||||
.catch(() => {});
|
function showCopyHint(message: string, ok: boolean): void {
|
||||||
|
copyHint.value = message;
|
||||||
|
copyOk.value = ok;
|
||||||
|
if (copyHintTimer) clearTimeout(copyHintTimer);
|
||||||
|
copyHintTimer = setTimeout(() => {
|
||||||
|
copyHint.value = '';
|
||||||
|
copyHintTimer = null;
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyRoomCode() {
|
||||||
|
if (!props.roomCode || !props.joinToken) return;
|
||||||
|
const result = await writeClipboardText(`${props.roomCode}-${props.joinToken}`);
|
||||||
|
if (result.success) {
|
||||||
|
showCopyHint('房间邀请码已复制', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCopyHint(result.error ?? '复制失败,请手动复制', false);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,14 +15,23 @@ const MAX_HISTORY_ITEMS = 500;
|
|||||||
const MAX_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
const MAX_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
/** 超过此长度则分片发送(与 doc05 一致,如 32KB) */
|
/** 超过此长度则分片发送(与 doc05 一致,如 32KB) */
|
||||||
const CHUNK_THRESHOLD = 32 * 1024;
|
const CHUNK_THRESHOLD = 32 * 1024;
|
||||||
const CHUNK_SIZE = 32 * 1024;
|
const CHUNK_SIZE_SMALL = 32 * 1024;
|
||||||
|
const CHUNK_SIZE_MEDIUM = 64 * 1024;
|
||||||
|
const CHUNK_SIZE_LARGE = 96 * 1024;
|
||||||
/** 分片缓存超时(ms),超时未收齐则清理并可选提示 */
|
/** 分片缓存超时(ms),超时未收齐则清理并可选提示 */
|
||||||
const CHUNK_CACHE_TTL = 60 * 1000;
|
const CHUNK_CACHE_TTL = 60 * 1000;
|
||||||
/** 单文件大小上限(2GB),与后端 transfer.max-file-size 一致 */
|
/** 单文件大小上限(2GB),与后端 transfer.max-file-size 一致 */
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
|
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
|
||||||
|
/** 接收端背压:文件分片缓存的最大并发文件数 */
|
||||||
|
const MAX_ACTIVE_FILE_CHUNK_ENTRIES = 12;
|
||||||
|
/** 接收端背压:文本分片缓存的最大并发消息数 */
|
||||||
|
const MAX_ACTIVE_TEXT_CHUNK_ENTRIES = 200;
|
||||||
|
/** 发送端队列上限,避免一次性堆积过多任务 */
|
||||||
|
const MAX_SEND_QUEUE_ITEMS = 50;
|
||||||
|
|
||||||
interface JoinRoomPayload {
|
interface JoinRoomPayload {
|
||||||
roomCode: string;
|
roomCode: string;
|
||||||
|
joinToken?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
}
|
}
|
||||||
@@ -62,6 +71,19 @@ function saveHistory(roomCode: string, messages: RoomMessagePayload[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdaptiveChunkSize(contentLength: number, status: ConnectionStatus): number {
|
||||||
|
if (status === 'reconnecting') {
|
||||||
|
return CHUNK_SIZE_SMALL;
|
||||||
|
}
|
||||||
|
if (contentLength >= 1024 * 1024) {
|
||||||
|
return CHUNK_SIZE_LARGE;
|
||||||
|
}
|
||||||
|
if (contentLength >= 256 * 1024) {
|
||||||
|
return CHUNK_SIZE_MEDIUM;
|
||||||
|
}
|
||||||
|
return CHUNK_SIZE_SMALL;
|
||||||
|
}
|
||||||
|
|
||||||
export const useWsStore = defineStore('ws', () => {
|
export const useWsStore = defineStore('ws', () => {
|
||||||
const status = ref<ConnectionStatus>('disconnected');
|
const status = ref<ConnectionStatus>('disconnected');
|
||||||
const client = ref<RoomWsClient | null>(null);
|
const client = ref<RoomWsClient | null>(null);
|
||||||
@@ -76,6 +98,11 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
const downloadingFileIds = ref<Record<string, boolean>>({});
|
const downloadingFileIds = ref<Record<string, boolean>>({});
|
||||||
/** 文件发送进度 fileId -> 0-100 */
|
/** 文件发送进度 fileId -> 0-100 */
|
||||||
const sendingProgress = ref<Record<string, number>>({});
|
const sendingProgress = ref<Record<string, number>>({});
|
||||||
|
const uploadChunkStats = ref<Record<string, { uploaded: number; total: number }>>({});
|
||||||
|
const uploadRetryCount = ref(0);
|
||||||
|
const uploadResumeCount = ref(0);
|
||||||
|
const uploadQueueSize = ref(0);
|
||||||
|
const failedUploads = ref<Record<string, { roomCode: string; file: File; reason?: string; failedChunkIndex?: number }>>({});
|
||||||
/** 当前正在发送的 fileId,用于收到自己发的 FILE 时不重复追加 */
|
/** 当前正在发送的 fileId,用于收到自己发的 FILE 时不重复追加 */
|
||||||
const sendingFileIds = new Set<string>();
|
const sendingFileIds = new Set<string>();
|
||||||
/** 接收完成后 fileId -> object URL,用于下载 */
|
/** 接收完成后 fileId -> object URL,用于下载 */
|
||||||
@@ -110,6 +137,23 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
const fileSendQueue: Array<{
|
||||||
|
roomCode: string;
|
||||||
|
file: File;
|
||||||
|
resolve: (result: { ok: boolean; error?: string }) => void;
|
||||||
|
}> = [];
|
||||||
|
const sendingQueueBusy = ref(false);
|
||||||
|
|
||||||
|
function pushSystemTip(message: string): void {
|
||||||
|
if (!currentRoomCode.value) return;
|
||||||
|
roomMessages.value.push({
|
||||||
|
roomCode: currentRoomCode.value,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
senderId: 'system',
|
||||||
|
data: { message },
|
||||||
|
});
|
||||||
|
saveHistory(currentRoomCode.value, roomMessages.value);
|
||||||
|
}
|
||||||
|
|
||||||
function init(baseUrl: string, endpoint = '/ws') {
|
function init(baseUrl: string, endpoint = '/ws') {
|
||||||
if (!client.value) {
|
if (!client.value) {
|
||||||
@@ -135,6 +179,11 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
fileChunkCache.clear();
|
fileChunkCache.clear();
|
||||||
sendingFileIds.clear();
|
sendingFileIds.clear();
|
||||||
sendingProgress.value = {};
|
sendingProgress.value = {};
|
||||||
|
uploadChunkStats.value = {};
|
||||||
|
fileSendQueue.length = 0;
|
||||||
|
uploadQueueSize.value = 0;
|
||||||
|
failedUploads.value = {};
|
||||||
|
sendingQueueBusy.value = false;
|
||||||
client.value?.disconnect();
|
client.value?.disconnect();
|
||||||
status.value = 'disconnected';
|
status.value = 'disconnected';
|
||||||
currentRoomCode.value = null;
|
currentRoomCode.value = null;
|
||||||
@@ -176,6 +225,10 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
const messageId = payload.messageId;
|
const messageId = payload.messageId;
|
||||||
let entry = textChunkCache.get(messageId);
|
let entry = textChunkCache.get(messageId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
if (textChunkCache.size >= MAX_ACTIVE_TEXT_CHUNK_ENTRIES) {
|
||||||
|
pushSystemTip('接收文本分片过多,已启用限流,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
entry = {
|
entry = {
|
||||||
chunks: new Map(),
|
chunks: new Map(),
|
||||||
totalChunks: payload.totalChunks,
|
totalChunks: payload.totalChunks,
|
||||||
@@ -214,6 +267,10 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
payload.totalChunks != null &&
|
payload.totalChunks != null &&
|
||||||
payload.storage !== 'server'
|
payload.storage !== 'server'
|
||||||
) {
|
) {
|
||||||
|
if (!fileChunkCache.has(fileId) && fileChunkCache.size >= MAX_ACTIVE_FILE_CHUNK_ENTRIES) {
|
||||||
|
pushSystemTip('接收文件任务过多,已启用限流,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
fileChunkCache.set(fileId, {
|
fileChunkCache.set(fileId, {
|
||||||
meta: {
|
meta: {
|
||||||
fileName: payload.fileName ?? '未命名',
|
fileName: payload.fileName ?? '未命名',
|
||||||
@@ -353,6 +410,7 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
roomCode: payload.roomCode,
|
roomCode: payload.roomCode,
|
||||||
type: 'SYSTEM',
|
type: 'SYSTEM',
|
||||||
senderId,
|
senderId,
|
||||||
|
joinToken: payload.joinToken,
|
||||||
content: nickname,
|
content: nickname,
|
||||||
},
|
},
|
||||||
handleRoomMessage,
|
handleRoomMessage,
|
||||||
@@ -376,6 +434,11 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
fileChunkCache.clear();
|
fileChunkCache.clear();
|
||||||
sendingFileIds.clear();
|
sendingFileIds.clear();
|
||||||
sendingProgress.value = {};
|
sendingProgress.value = {};
|
||||||
|
uploadChunkStats.value = {};
|
||||||
|
fileSendQueue.length = 0;
|
||||||
|
uploadQueueSize.value = 0;
|
||||||
|
failedUploads.value = {};
|
||||||
|
sendingQueueBusy.value = false;
|
||||||
currentRoomCode.value = null;
|
currentRoomCode.value = null;
|
||||||
userList.value = [];
|
userList.value = [];
|
||||||
roomMessages.value = [];
|
roomMessages.value = [];
|
||||||
@@ -394,11 +457,13 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chunkSize = getAdaptiveChunkSize(content.length, status.value);
|
||||||
const messageId = `${myUserId.value}_${Date.now()}`;
|
const messageId = `${myUserId.value}_${Date.now()}`;
|
||||||
const totalChunks = Math.ceil(content.length / CHUNK_SIZE);
|
const totalChunks = Math.ceil(content.length / chunkSize);
|
||||||
for (let i = 0; i < totalChunks; i++) {
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
const start = i * CHUNK_SIZE;
|
const start = i * chunkSize;
|
||||||
const chunk = content.slice(start, start + CHUNK_SIZE);
|
const chunk = content.slice(start, start + chunkSize);
|
||||||
client.value.sendMessage(roomCode, {
|
client.value.sendMessage(roomCode, {
|
||||||
roomCode,
|
roomCode,
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
@@ -453,7 +518,40 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendFile(
|
async function processFileSendQueue(): Promise<void> {
|
||||||
|
if (sendingQueueBusy.value) return;
|
||||||
|
if (fileSendQueue.length === 0) return;
|
||||||
|
const task = fileSendQueue.shift();
|
||||||
|
uploadQueueSize.value = fileSendQueue.length;
|
||||||
|
if (!task) return;
|
||||||
|
sendingQueueBusy.value = true;
|
||||||
|
try {
|
||||||
|
const result = await sendFileInternal(task.roomCode, task.file);
|
||||||
|
task.resolve(result);
|
||||||
|
} finally {
|
||||||
|
sendingQueueBusy.value = false;
|
||||||
|
if (fileSendQueue.length > 0) {
|
||||||
|
void processFileSendQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(
|
||||||
|
roomCode: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (fileSendQueue.length >= MAX_SEND_QUEUE_ITEMS) {
|
||||||
|
resolve({ ok: false, error: '发送队列已满,请稍后再试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileSendQueue.push({ roomCode, file, resolve });
|
||||||
|
uploadQueueSize.value = fileSendQueue.length;
|
||||||
|
void processFileSendQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFileInternal(
|
||||||
roomCode: string,
|
roomCode: string,
|
||||||
file: File,
|
file: File,
|
||||||
): Promise<{ ok: boolean; error?: string }> {
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
@@ -493,15 +591,36 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let resumeReported = false;
|
||||||
const res = await uploadRoomFile(roomCode, file, (percent) => {
|
const res = await uploadRoomFile(roomCode, file, (percent) => {
|
||||||
sendingProgress.value[tempFileId] = percent;
|
sendingProgress.value[tempFileId] = percent;
|
||||||
sendingProgress.value = { ...sendingProgress.value };
|
sendingProgress.value = { ...sendingProgress.value };
|
||||||
|
}, (detail) => {
|
||||||
|
if (detail.mode === 'chunked' && detail.totalChunks && detail.uploadedChunks != null) {
|
||||||
|
uploadChunkStats.value[tempFileId] = {
|
||||||
|
uploaded: detail.uploadedChunks,
|
||||||
|
total: detail.totalChunks,
|
||||||
|
};
|
||||||
|
uploadChunkStats.value = { ...uploadChunkStats.value };
|
||||||
|
}
|
||||||
|
if (detail.retriesUsed && detail.retriesUsed > 0) {
|
||||||
|
uploadRetryCount.value += detail.retriesUsed;
|
||||||
|
}
|
||||||
|
if (!resumeReported && (detail.resumedChunks ?? 0) > 0) {
|
||||||
|
resumeReported = true;
|
||||||
|
uploadResumeCount.value += 1;
|
||||||
|
pushSystemTip(`文件「${file.name}」命中断点续传,已复用 ${detail.resumedChunks} 个分片`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const actualFileId = res.fileId;
|
const actualFileId = res.fileId;
|
||||||
sendingFileIds.delete(tempFileId);
|
sendingFileIds.delete(tempFileId);
|
||||||
sendingFileIds.add(actualFileId);
|
sendingFileIds.add(actualFileId);
|
||||||
sendingProgress.value[actualFileId] = 100;
|
sendingProgress.value[actualFileId] = 100;
|
||||||
delete sendingProgress.value[tempFileId];
|
delete sendingProgress.value[tempFileId];
|
||||||
|
delete uploadChunkStats.value[tempFileId];
|
||||||
|
uploadChunkStats.value = { ...uploadChunkStats.value };
|
||||||
|
delete failedUploads.value[tempFileId];
|
||||||
|
failedUploads.value = { ...failedUploads.value };
|
||||||
|
|
||||||
const payload: RoomMessagePayload = {
|
const payload: RoomMessagePayload = {
|
||||||
...optimistic,
|
...optimistic,
|
||||||
@@ -509,6 +628,7 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
fileName: res.fileName,
|
fileName: res.fileName,
|
||||||
fileSize: res.fileSize,
|
fileSize: res.fileSize,
|
||||||
mimeType: res.mimeType,
|
mimeType: res.mimeType,
|
||||||
|
sha256: res.sha256,
|
||||||
storage: 'server',
|
storage: 'server',
|
||||||
};
|
};
|
||||||
const idx = roomMessages.value.findIndex(
|
const idx = roomMessages.value.findIndex(
|
||||||
@@ -532,17 +652,16 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[sendFile] 上传失败:', e);
|
console.error('[sendFile] 上传失败:', e);
|
||||||
sendingFileIds.delete(tempFileId);
|
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 : '发送失败,请重试';
|
const errMsg = e instanceof Error ? e.message : '发送失败,请重试';
|
||||||
|
const failedChunkIndex = extractFailedChunkIndex(errMsg);
|
||||||
|
failedUploads.value[tempFileId] = {
|
||||||
|
roomCode,
|
||||||
|
file,
|
||||||
|
reason: errMsg,
|
||||||
|
failedChunkIndex,
|
||||||
|
};
|
||||||
|
failedUploads.value = { ...failedUploads.value };
|
||||||
|
pushSystemTip(`文件「${file.name}」上传失败,可点击重试继续上传`);
|
||||||
return { ok: false, error: errMsg };
|
return { ok: false, error: errMsg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,6 +672,58 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
return fileProgress.value[fileId] ?? 0;
|
return fileProgress.value[fileId] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUploadChunkHint(fileId: string): string | null {
|
||||||
|
const failed = failedUploads.value[fileId];
|
||||||
|
if (failed) {
|
||||||
|
if (failed.failedChunkIndex != null) {
|
||||||
|
return `失败分片 #${failed.failedChunkIndex}`;
|
||||||
|
}
|
||||||
|
return '上传失败';
|
||||||
|
}
|
||||||
|
const stat = uploadChunkStats.value[fileId];
|
||||||
|
if (!stat || stat.total <= 0) return null;
|
||||||
|
return `${stat.uploaded}/${stat.total} 分片`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFailedUpload(fileId: string): boolean {
|
||||||
|
return !!failedUploads.value[fileId];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryFailedUpload(fileId: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const failed = failedUploads.value[fileId];
|
||||||
|
if (!failed) {
|
||||||
|
return { ok: false, error: '未找到失败上传任务' };
|
||||||
|
}
|
||||||
|
const roomCode = failed.roomCode;
|
||||||
|
const file = failed.file;
|
||||||
|
const idx = roomMessages.value.findIndex(
|
||||||
|
(m) => m.type === 'FILE' && m.fileId === fileId && m.senderId === myUserId.value,
|
||||||
|
);
|
||||||
|
if (idx !== -1) {
|
||||||
|
roomMessages.value.splice(idx, 1);
|
||||||
|
}
|
||||||
|
delete failedUploads.value[fileId];
|
||||||
|
failedUploads.value = { ...failedUploads.value };
|
||||||
|
delete sendingProgress.value[fileId];
|
||||||
|
delete uploadChunkStats.value[fileId];
|
||||||
|
uploadChunkStats.value = { ...uploadChunkStats.value };
|
||||||
|
const url = imageBlobUrls.value[fileId];
|
||||||
|
if (url && url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
delete imageBlobUrls.value[fileId];
|
||||||
|
|
||||||
|
const result = await sendFile(roomCode, file);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFailedChunkIndex(errorMessage: string): number | undefined {
|
||||||
|
const match = errorMessage.match(/分片\s*(\d+)/);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const value = Number(match[1]);
|
||||||
|
return Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** 设置文件进度(用于下载时在 UI 显示 接收中 X%) */
|
/** 设置文件进度(用于下载时在 UI 显示 接收中 X%) */
|
||||||
function setFileProgress(fileId: string, percent: number): void {
|
function setFileProgress(fileId: string, percent: number): void {
|
||||||
fileProgress.value[fileId] = percent;
|
fileProgress.value[fileId] = percent;
|
||||||
@@ -674,6 +845,9 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
userList,
|
userList,
|
||||||
roomMessages,
|
roomMessages,
|
||||||
fileProgress,
|
fileProgress,
|
||||||
|
uploadRetryCount,
|
||||||
|
uploadResumeCount,
|
||||||
|
uploadQueueSize,
|
||||||
init,
|
init,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -685,6 +859,9 @@ export const useWsStore = defineStore('ws', () => {
|
|||||||
exportRoomHistory,
|
exportRoomHistory,
|
||||||
exportRoomHistoryAsText,
|
exportRoomHistoryAsText,
|
||||||
getFileProgress,
|
getFileProgress,
|
||||||
|
getUploadChunkHint,
|
||||||
|
isFailedUpload,
|
||||||
|
retryFailedUpload,
|
||||||
setFileProgress,
|
setFileProgress,
|
||||||
setDownloading,
|
setDownloading,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface RoomMessagePayload {
|
|||||||
roomCode?: string;
|
roomCode?: string;
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
|
joinToken?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
/** 文本内容(TEXT) */
|
/** 文本内容(TEXT) */
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -35,6 +36,7 @@ export interface RoomMessagePayload {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
sha256?: string;
|
||||||
chunkIndex?: number;
|
chunkIndex?: number;
|
||||||
/** 分片总数;无此字段或 storage===server 表示服务器存储,下载走 HTTP */
|
/** 分片总数;无此字段或 storage===server 表示服务器存储,下载走 HTTP */
|
||||||
totalChunks?: number;
|
totalChunks?: number;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export interface ClipboardReadResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClipboardWriteResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查剪贴板 API 是否可用
|
* 检查剪贴板 API 是否可用
|
||||||
*/
|
*/
|
||||||
@@ -20,6 +25,17 @@ export function isClipboardApiAvailable(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查剪贴板写入 API 是否可用
|
||||||
|
*/
|
||||||
|
export function isClipboardWriteAvailable(): boolean {
|
||||||
|
return (
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
typeof navigator.clipboard !== 'undefined' &&
|
||||||
|
typeof navigator.clipboard.writeText === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否在安全上下文中(HTTPS 或 localhost)
|
* 检查是否在安全上下文中(HTTPS 或 localhost)
|
||||||
*/
|
*/
|
||||||
@@ -105,6 +121,61 @@ export async function readClipboardText(): Promise<ClipboardReadResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入文本到剪贴板。
|
||||||
|
* 优先使用 Clipboard API,失败时降级到 execCommand('copy')。
|
||||||
|
*/
|
||||||
|
export async function writeClipboardText(text: string): Promise<ClipboardWriteResult> {
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '复制内容为空',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSecureContext() && isClipboardWriteAvailable()) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
// fallback to legacy way
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '当前环境不支持复制',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
if (ok) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '复制失败,请手动复制',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '复制失败,请手动复制',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从粘贴事件中提取文件列表
|
* 从粘贴事件中提取文件列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-xl font-semibold text-slate-900">加入房间</h2>
|
<h2 class="text-xl font-semibold text-slate-900">加入房间</h2>
|
||||||
<p class="text-sm text-slate-600">
|
<p class="text-sm text-slate-600">
|
||||||
输入房间号,与同一房间的其他终端进行数据同步传输。
|
输入房间号和加入令牌,与同一房间的其他终端进行数据同步传输。
|
||||||
</p>
|
</p>
|
||||||
<form class="space-y-3" @submit.prevent="handleJoin">
|
<form class="space-y-3" @submit.prevent="handleJoin">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -58,6 +58,20 @@
|
|||||||
{{ joinError }}
|
{{ joinError }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block text-xs font-medium text-slate-700">
|
||||||
|
加入令牌(8 位)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="joinToken"
|
||||||
|
type="text"
|
||||||
|
maxlength="8"
|
||||||
|
class="dt-input"
|
||||||
|
:class="{ 'border-danger focus:border-danger': joinError }"
|
||||||
|
placeholder="例如 AB12CD34"
|
||||||
|
@input="handleJoinTokenInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -76,8 +90,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import axios from 'axios';
|
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||||
|
import { createRoom, verifyRoomAccess } from '@/api/room';
|
||||||
|
|
||||||
// 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问
|
// 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||||
@@ -88,21 +102,23 @@ const createRoomCodeDisplay = ref('------');
|
|||||||
const createLoading = ref(false);
|
const createLoading = ref(false);
|
||||||
const createError = ref('');
|
const createError = ref('');
|
||||||
const joinRoomCode = ref('');
|
const joinRoomCode = ref('');
|
||||||
|
const joinToken = ref('');
|
||||||
const joinError = ref('');
|
const joinError = ref('');
|
||||||
|
|
||||||
const isJoinValid = computed(() => /^[0-9]{6}$/.test(joinRoomCode.value));
|
const isJoinValid = computed(() => {
|
||||||
|
return /^[0-9]{6}$/.test(joinRoomCode.value) && /^[A-Z0-9]{8}$/.test(joinToken.value);
|
||||||
|
});
|
||||||
|
|
||||||
async function handleCreateAndEnter() {
|
async function handleCreateAndEnter() {
|
||||||
createError.value = '';
|
createError.value = '';
|
||||||
createLoading.value = true;
|
createLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post<{ roomCode: string }>(
|
const data = await createRoom(API_BASE);
|
||||||
`${API_BASE}/api/room/create`,
|
|
||||||
);
|
|
||||||
const roomCode = data.roomCode;
|
const roomCode = data.roomCode;
|
||||||
|
const token = data.joinToken;
|
||||||
createRoomCodeDisplay.value = roomCode;
|
createRoomCodeDisplay.value = roomCode;
|
||||||
createLoading.value = false;
|
createLoading.value = false;
|
||||||
router.push({ name: 'room', params: { roomCode } });
|
router.push({ name: 'room', params: { roomCode }, query: { token } });
|
||||||
} catch {
|
} catch {
|
||||||
createError.value = '创建房间失败,请稍后重试。';
|
createError.value = '创建房间失败,请稍后重试。';
|
||||||
createRoomCodeDisplay.value = String(
|
createRoomCodeDisplay.value = String(
|
||||||
@@ -112,13 +128,26 @@ async function handleCreateAndEnter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleJoin() {
|
function handleJoinTokenInput() {
|
||||||
|
joinToken.value = joinToken.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
joinError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoin() {
|
||||||
joinError.value = '';
|
joinError.value = '';
|
||||||
if (!isJoinValid.value) {
|
if (!isJoinValid.value) {
|
||||||
joinError.value = '房间号必须是 6 位数字。';
|
joinError.value = '房间号为 6 位数字,加入令牌为 8 位字母数字。';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push({ name: 'room', params: { roomCode: joinRoomCode.value } });
|
const ok = await verifyRoomAccess(joinRoomCode.value, joinToken.value, API_BASE);
|
||||||
|
if (!ok) {
|
||||||
|
joinError.value = '房间号或加入令牌错误。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push({
|
||||||
|
name: 'room',
|
||||||
|
params: { roomCode: joinRoomCode.value },
|
||||||
|
query: { token: joinToken.value },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
<div class="hidden lg:flex lg:w-80 lg:shrink-0">
|
<div class="hidden lg:flex lg:w-80 lg:shrink-0">
|
||||||
<RoomPanel
|
<RoomPanel
|
||||||
:room-code="roomCode"
|
:room-code="roomCode"
|
||||||
|
:join-token="joinToken"
|
||||||
:user-list="wsStore.userList"
|
:user-list="wsStore.userList"
|
||||||
:connection-status="wsStore.status"
|
:connection-status="wsStore.status"
|
||||||
:my-user-id="wsStore.myUserId"
|
:my-user-id="wsStore.myUserId"
|
||||||
|
:transfer-stats="transferStats"
|
||||||
@leave="handleLeave"
|
@leave="handleLeave"
|
||||||
@clear-history="handleClearHistory"
|
@clear-history="handleClearHistory"
|
||||||
@export-json="handleExportJson"
|
@export-json="handleExportJson"
|
||||||
@@ -52,7 +54,9 @@
|
|||||||
:is-me="msg.senderId === wsStore.myUserId"
|
:is-me="msg.senderId === wsStore.myUserId"
|
||||||
:status="getFileStatus(msg)"
|
:status="getFileStatus(msg)"
|
||||||
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
|
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
|
||||||
|
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
|
||||||
@download="handleImageDownload(msg)"
|
@download="handleImageDownload(msg)"
|
||||||
|
@retry="handleRetryUpload(msg)"
|
||||||
/>
|
/>
|
||||||
<!-- 普通文件消息 -->
|
<!-- 普通文件消息 -->
|
||||||
<FileMessage
|
<FileMessage
|
||||||
@@ -63,7 +67,9 @@
|
|||||||
:is-me="msg.senderId === wsStore.myUserId"
|
:is-me="msg.senderId === wsStore.myUserId"
|
||||||
:status="getFileStatus(msg)"
|
:status="getFileStatus(msg)"
|
||||||
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
|
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
|
||||||
|
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
|
||||||
@download="handleFileDownload(msg)"
|
@download="handleFileDownload(msg)"
|
||||||
|
@retry="handleRetryUpload(msg)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +92,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from 'vue';
|
import { computed, ref, watch, onMounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||||
import RoomPanel from '@/components/RoomPanel.vue';
|
import RoomPanel from '@/components/RoomPanel.vue';
|
||||||
@@ -99,6 +105,7 @@ import { getFileDownloadUrl, downloadWithProgress } from '@/api/room';
|
|||||||
import { useWsStore } from '@/stores/wsStore';
|
import { useWsStore } from '@/stores/wsStore';
|
||||||
import type { RoomMessagePayload } from '@/types/room';
|
import type { RoomMessagePayload } from '@/types/room';
|
||||||
import { isImageMimeType } from '@/types/room';
|
import { isImageMimeType } from '@/types/room';
|
||||||
|
import { sha256HexOfBlob } from '@/utils/hash';
|
||||||
|
|
||||||
// 未设置 VITE_API_BASE 时使用同源:开发时走 Vite 代理,Docker/生产时与页面同 host:port,避免硬编码 localhost 导致部署后连不上
|
// 未设置 VITE_API_BASE 时使用同源:开发时走 Vite 代理,Docker/生产时与页面同 host:port,避免硬编码 localhost 导致部署后连不上
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||||
@@ -106,12 +113,41 @@ const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const roomCode = route.params.roomCode as string;
|
const roomCode = route.params.roomCode as string;
|
||||||
|
const joinToken = computed(() => {
|
||||||
|
const token = route.query.token;
|
||||||
|
return typeof token === 'string' ? token : '';
|
||||||
|
});
|
||||||
|
|
||||||
const messageListRef = ref<HTMLElement | null>(null);
|
const messageListRef = ref<HTMLElement | null>(null);
|
||||||
const wsStore = useWsStore();
|
const wsStore = useWsStore();
|
||||||
const downloadToast = ref('');
|
const downloadToast = ref('');
|
||||||
let downloadToastTimer: ReturnType<typeof setTimeout> | null = null;
|
let downloadToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const transferStats = computed(() => {
|
||||||
|
let sendingCount = 0;
|
||||||
|
let receivingCount = 0;
|
||||||
|
for (const msg of wsStore.roomMessages) {
|
||||||
|
if (msg.type !== 'FILE' && msg.type !== 'IMAGE') continue;
|
||||||
|
const fileId = msg.fileId ?? '';
|
||||||
|
if (!fileId) continue;
|
||||||
|
const progress = wsStore.getFileProgress(fileId);
|
||||||
|
if (progress < 0 || progress >= 100) continue;
|
||||||
|
if (msg.senderId === wsStore.myUserId) {
|
||||||
|
sendingCount += 1;
|
||||||
|
} else if (wsStore.isDownloading(fileId)) {
|
||||||
|
receivingCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageCount: wsStore.roomMessages.length,
|
||||||
|
sendingCount,
|
||||||
|
receivingCount,
|
||||||
|
queueCount: wsStore.uploadQueueSize,
|
||||||
|
resumedCount: wsStore.uploadResumeCount,
|
||||||
|
retryCount: wsStore.uploadRetryCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function showDownloadToast(message: string) {
|
function showDownloadToast(message: string) {
|
||||||
downloadToast.value = message;
|
downloadToast.value = message;
|
||||||
if (downloadToastTimer) clearTimeout(downloadToastTimer);
|
if (downloadToastTimer) clearTimeout(downloadToastTimer);
|
||||||
@@ -130,8 +166,12 @@ watch(
|
|||||||
() => wsStore.status,
|
() => wsStore.status,
|
||||||
(status) => {
|
(status) => {
|
||||||
if (status === 'connected' && roomCode) {
|
if (status === 'connected' && roomCode) {
|
||||||
|
if (!joinToken.value) {
|
||||||
|
showDownloadToast('缺少加入令牌,请返回首页重新加入房间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 使用 store 中已生成的随机中国人名
|
// 使用 store 中已生成的随机中国人名
|
||||||
wsStore.joinRoom({ roomCode, nickname: wsStore.myNickname });
|
wsStore.joinRoom({ roomCode, joinToken: joinToken.value, nickname: wsStore.myNickname });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -160,6 +200,9 @@ function resolveSenderName(senderId?: string): string {
|
|||||||
|
|
||||||
function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done' | 'error' {
|
function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done' | 'error' {
|
||||||
const fileId = msg.fileId ?? '';
|
const fileId = msg.fileId ?? '';
|
||||||
|
if (msg.senderId === wsStore.myUserId && wsStore.isFailedUpload(fileId)) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
const progress = wsStore.getFileProgress(fileId);
|
const progress = wsStore.getFileProgress(fileId);
|
||||||
const isMe = msg.senderId === wsStore.myUserId;
|
const isMe = msg.senderId === wsStore.myUserId;
|
||||||
// 发送方:0~99 显示「发送中」
|
// 发送方:0~99 显示「发送中」
|
||||||
@@ -169,6 +212,18 @@ function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done
|
|||||||
return 'done';
|
return 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRetryUpload(msg: RoomMessagePayload) {
|
||||||
|
const fileId = msg.fileId ?? '';
|
||||||
|
if (!fileId) {
|
||||||
|
showDownloadToast('重试失败:文件标识缺失');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await wsStore.retryFailedUpload(fileId);
|
||||||
|
if (!result.ok && result.error) {
|
||||||
|
showDownloadToast(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断消息是否为图片类型
|
* 判断消息是否为图片类型
|
||||||
* - FILE 类型且 mimeType 为 image/*(图片统一走 HTTP,与文件一致)
|
* - FILE 类型且 mimeType 为 image/*(图片统一走 HTTP,与文件一致)
|
||||||
@@ -203,6 +258,25 @@ function getImageSrc(msg: RoomMessagePayload): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyDownloadedBlob(
|
||||||
|
msg: RoomMessagePayload,
|
||||||
|
blob: Blob,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const expected = (msg.sha256 ?? '').trim().toLowerCase();
|
||||||
|
if (!expected) return true;
|
||||||
|
|
||||||
|
const actual = await sha256HexOfBlob(blob);
|
||||||
|
if (!actual) {
|
||||||
|
showDownloadToast('当前环境不支持 SHA-256 校验,已继续下载');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (actual !== expected) {
|
||||||
|
showDownloadToast('文件完整性校验失败,请重试或让发送方重传');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFileSelected(files: File[]) {
|
async function handleFileSelected(files: File[]) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const result = await wsStore.sendFile(roomCode, file);
|
const result = await wsStore.sendFile(roomCode, file);
|
||||||
@@ -226,6 +300,8 @@ async function handleImageDownload(msg: RoomMessagePayload) {
|
|||||||
const blob = await downloadWithProgress(url, (percent) => {
|
const blob = await downloadWithProgress(url, (percent) => {
|
||||||
wsStore.setFileProgress(fileId, percent);
|
wsStore.setFileProgress(fileId, percent);
|
||||||
});
|
});
|
||||||
|
const verified = await verifyDownloadedBlob(msg, blob);
|
||||||
|
if (!verified) return;
|
||||||
wsStore.setFileProgress(fileId, 100);
|
wsStore.setFileProgress(fileId, 100);
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -281,6 +357,8 @@ async function handleFileDownload(msg: RoomMessagePayload) {
|
|||||||
const blob = await downloadWithProgress(url, (percent) => {
|
const blob = await downloadWithProgress(url, (percent) => {
|
||||||
wsStore.setFileProgress(fileId, percent);
|
wsStore.setFileProgress(fileId, percent);
|
||||||
});
|
});
|
||||||
|
const verified = await verifyDownloadedBlob(msg, blob);
|
||||||
|
if (!verified) return;
|
||||||
wsStore.setFileProgress(fileId, 100);
|
wsStore.setFileProgress(fileId, 100);
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const backendTarget = env.VITE_DEV_BACKEND_TARGET || 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -21,12 +25,12 @@ export default defineConfig({
|
|||||||
// xfwd: true 会把真实客户端 IP 写入 X-Forwarded-For,后端 /api/room/my-ip 才能拿到各机器的 IP
|
// xfwd: true 会把真实客户端 IP 写入 X-Forwarded-For,后端 /api/room/my-ip 才能拿到各机器的 IP
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: backendTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
xfwd: true,
|
xfwd: true,
|
||||||
},
|
},
|
||||||
'/ws': { target: 'http://localhost:8080', ws: true },
|
'/ws': { target: backendTarget, ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user