feat(room): 增加加入令牌及分块传输支持
- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌 - 新增房间加入令牌验证接口,加入时需提供房间号和令牌 - 前端HomeView新增加入令牌输入框及验证逻辑 - Clipboard工具增加写入API支持及复制按钮 - FileDropZone支持选择文件夹批量上传 - FileMessage和ImageMessage新增分片进度提示及失败重试功能 - API层新增分块上传及断点续传实现,支持大文件分片上传 - 文件上传存储时计算文件sha256,响应中返回该值 - 下载接口支持断点续传,优化大文件下载体验 - README新增加入令牌安全说明及压力测试使用示例 - 资源清理与配置优化,添加磁盘使用水位阈值控制
This commit is contained in:
@@ -14,9 +14,11 @@
|
||||
<div class="hidden lg:flex lg:w-80 lg:shrink-0">
|
||||
<RoomPanel
|
||||
:room-code="roomCode"
|
||||
:join-token="joinToken"
|
||||
:user-list="wsStore.userList"
|
||||
:connection-status="wsStore.status"
|
||||
:my-user-id="wsStore.myUserId"
|
||||
:transfer-stats="transferStats"
|
||||
@leave="handleLeave"
|
||||
@clear-history="handleClearHistory"
|
||||
@export-json="handleExportJson"
|
||||
@@ -52,7 +54,9 @@
|
||||
:is-me="msg.senderId === wsStore.myUserId"
|
||||
:status="getFileStatus(msg)"
|
||||
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
|
||||
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
|
||||
@download="handleImageDownload(msg)"
|
||||
@retry="handleRetryUpload(msg)"
|
||||
/>
|
||||
<!-- 普通文件消息 -->
|
||||
<FileMessage
|
||||
@@ -63,7 +67,9 @@
|
||||
:is-me="msg.senderId === wsStore.myUserId"
|
||||
:status="getFileStatus(msg)"
|
||||
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
|
||||
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
|
||||
@download="handleFileDownload(msg)"
|
||||
@retry="handleRetryUpload(msg)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -86,7 +92,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||
import RoomPanel from '@/components/RoomPanel.vue';
|
||||
@@ -99,6 +105,7 @@ import { getFileDownloadUrl, downloadWithProgress } from '@/api/room';
|
||||
import { useWsStore } from '@/stores/wsStore';
|
||||
import type { RoomMessagePayload } from '@/types/room';
|
||||
import { isImageMimeType } from '@/types/room';
|
||||
import { sha256HexOfBlob } from '@/utils/hash';
|
||||
|
||||
// 未设置 VITE_API_BASE 时使用同源:开发时走 Vite 代理,Docker/生产时与页面同 host:port,避免硬编码 localhost 导致部署后连不上
|
||||
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 router = useRouter();
|
||||
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 wsStore = useWsStore();
|
||||
const downloadToast = ref('');
|
||||
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) {
|
||||
downloadToast.value = message;
|
||||
if (downloadToastTimer) clearTimeout(downloadToastTimer);
|
||||
@@ -130,8 +166,12 @@ watch(
|
||||
() => wsStore.status,
|
||||
(status) => {
|
||||
if (status === 'connected' && roomCode) {
|
||||
if (!joinToken.value) {
|
||||
showDownloadToast('缺少加入令牌,请返回首页重新加入房间');
|
||||
return;
|
||||
}
|
||||
// 使用 store 中已生成的随机中国人名
|
||||
wsStore.joinRoom({ roomCode, nickname: wsStore.myNickname });
|
||||
wsStore.joinRoom({ roomCode, joinToken: joinToken.value, nickname: wsStore.myNickname });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -160,6 +200,9 @@ function resolveSenderName(senderId?: string): string {
|
||||
|
||||
function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done' | 'error' {
|
||||
const fileId = msg.fileId ?? '';
|
||||
if (msg.senderId === wsStore.myUserId && wsStore.isFailedUpload(fileId)) {
|
||||
return 'error';
|
||||
}
|
||||
const progress = wsStore.getFileProgress(fileId);
|
||||
const isMe = msg.senderId === wsStore.myUserId;
|
||||
// 发送方:0~99 显示「发送中」
|
||||
@@ -169,6 +212,18 @@ function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | '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,与文件一致)
|
||||
@@ -203,6 +258,25 @@ function getImageSrc(msg: RoomMessagePayload): string | 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[]) {
|
||||
for (const file of files) {
|
||||
const result = await wsStore.sendFile(roomCode, file);
|
||||
@@ -226,6 +300,8 @@ async function handleImageDownload(msg: RoomMessagePayload) {
|
||||
const blob = await downloadWithProgress(url, (percent) => {
|
||||
wsStore.setFileProgress(fileId, percent);
|
||||
});
|
||||
const verified = await verifyDownloadedBlob(msg, blob);
|
||||
if (!verified) return;
|
||||
wsStore.setFileProgress(fileId, 100);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -281,6 +357,8 @@ async function handleFileDownload(msg: RoomMessagePayload) {
|
||||
const blob = await downloadWithProgress(url, (percent) => {
|
||||
wsStore.setFileProgress(fileId, percent);
|
||||
});
|
||||
const verified = await verifyDownloadedBlob(msg, blob);
|
||||
if (!verified) return;
|
||||
wsStore.setFileProgress(fileId, 100);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
Reference in New Issue
Block a user