Files
quick-share/frontend/src/views/RoomView.vue
mangmang 1c99136944 feat(room): 增加加入令牌及分块传输支持
- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌
- 新增房间加入令牌验证接口,加入时需提供房间号和令牌
- 前端HomeView新增加入令牌输入框及验证逻辑
- Clipboard工具增加写入API支持及复制按钮
- FileDropZone支持选择文件夹批量上传
- FileMessage和ImageMessage新增分片进度提示及失败重试功能
- API层新增分块上传及断点续传实现,支持大文件分片上传
- 文件上传存储时计算文件sha256,响应中返回该值
- 下载接口支持断点续传,优化大文件下载体验
- README新增加入令牌安全说明及压力测试使用示例
- 资源清理与配置优化,添加磁盘使用水位阈值控制
2026-03-06 03:15:18 +08:00

436 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-[calc(100vh-56px)] flex flex-col lg:flex-row">
<!-- 窄屏顶部栏显示房间号与退出 -->
<div
class="lg:hidden flex items-center justify-between px-4 py-2 border-b border-slate-200 bg-white shrink-0"
>
<span class="font-mono font-semibold text-slate-900">房间 {{ roomCode }}</span>
<BaseButton size="sm" variant="danger" @click="handleLeave">
退出
</BaseButton>
</div>
<!-- 左侧 RoomPanel房间号在线用户退出/清空/导出 -->
<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"
@export-text="handleExportText"
/>
</div>
<!-- 右侧消息与输入区域 -->
<section class="flex-1 flex flex-col bg-slate-100/80 min-w-0">
<div ref="messageListRef" class="flex-1 overflow-auto px-4 sm:px-6 py-4">
<div class="max-w-2xl mx-auto space-y-4">
<template v-for="(msg, index) in wsStore.roomMessages" :key="messageKey(msg, index)">
<SystemMessage
v-if="msg.type === 'SYSTEM' && getSystemMessageText(msg)"
:message="getSystemMessageText(msg)!"
/>
<MessageItem
v-else-if="msg.type === 'TEXT'"
:content="msg.content ?? ''"
:sender-name="msg.senderName ?? resolveSenderName(msg.senderId)"
:sender-id="msg.senderId"
:timestamp="msg.timestamp"
:is-me="msg.senderId === wsStore.myUserId"
/>
<!-- 图片消息IMAGE 类型或 FILE 类型但 mimeType 为图片 -->
<ImageMessage
v-else-if="isImageMessage(msg)"
:image-src="getImageSrc(msg)"
:file-name="msg.fileName"
:sender-name="msg.senderName ?? resolveSenderName(msg.senderId)"
:sender-id="msg.senderId"
:timestamp="msg.timestamp"
: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
v-else-if="msg.type === 'FILE'"
:file-name="msg.fileName ?? '未命名'"
:file-size="msg.fileSize ?? 0"
:sender-name="msg.senderName ?? resolveSenderName(msg.senderId)"
: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>
</div>
<MessageInput @send="handleSend" @file-selected="handleFileSelected" />
<!-- 下载提示 Toast URL 时说明原因 -->
<Transition name="fade">
<div
v-if="downloadToast"
class="fixed bottom-20 left-1/2 -translate-x-1/2 rounded-[var(--dt-radius-card)] border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 shadow-elevated"
role="status"
>
{{ downloadToast }}
</div>
</Transition>
</section>
</div>
</template>
<script setup lang="ts">
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';
import SystemMessage from '@/components/SystemMessage.vue';
import MessageItem from '@/components/MessageItem.vue';
import FileMessage from '@/components/FileMessage.vue';
import ImageMessage from '@/components/ImageMessage.vue';
import MessageInput from '@/components/MessageInput.vue';
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 ?? '';
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);
downloadToastTimer = setTimeout(() => {
downloadToast.value = '';
downloadToastTimer = null;
}, 3000);
}
onMounted(() => {
wsStore.init(API_BASE, '/ws');
wsStore.connect();
});
watch(
() => wsStore.status,
(status) => {
if (status === 'connected' && roomCode) {
if (!joinToken.value) {
showDownloadToast('缺少加入令牌,请返回首页重新加入房间');
return;
}
// 使用 store 中已生成的随机中国人名
wsStore.joinRoom({ roomCode, joinToken: joinToken.value, nickname: wsStore.myNickname });
}
},
{ immediate: true },
);
function getSystemMessageText(msg: RoomMessagePayload): string | undefined {
if (msg.type !== 'SYSTEM' || !msg.data) return undefined;
const data = msg.data as { message?: string };
return data.message;
}
function messageKey(msg: RoomMessagePayload, index: number): string {
if ((msg.type === 'FILE' || msg.type === 'IMAGE') && msg.fileId)
return `file-${msg.fileId}`;
if (msg.timestamp && msg.senderId)
return `${msg.type}-${msg.senderId}-${msg.timestamp}-${index}`;
return `msg-${index}`;
}
function resolveSenderName(senderId?: string): string {
if (!senderId) return '未知';
if (senderId === wsStore.myUserId) return wsStore.myNickname;
const user = wsStore.userList.find((u) => u.userId === senderId);
return user?.nickname ?? senderId.slice(-8);
}
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;
// 发送方099 显示「发送中」
if (isMe && progress >= 0 && progress < 100) return 'sending';
// 接收方仅当已点击下载isDownloading且 099 时显示「接收中」,否则显示「下载」按钮
if (!isMe && progress >= 0 && progress < 100 && wsStore.isDownloading(fileId)) return 'receiving';
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与文件一致
* - IMAGE 类型(历史兼容)
*/
function isImageMessage(msg: RoomMessagePayload): boolean {
if (msg.type === 'FILE' && isImageMimeType(msg.mimeType)) return true;
if (msg.type === 'IMAGE') return true;
return false;
}
/**
* 获取图片消息的显示 URL
* 图片统一走 HTTP 传输:服务器存储的图片直接使用下载 URL历史 IMAGE+imageData 仍支持。
*/
function getImageSrc(msg: RoomMessagePayload): string | undefined {
const fileId = msg.fileId ?? '';
// 优先从 store 获取(发送方本地预览 blob、接收方 CHUNK 合并后的 blob
const storeUrl = wsStore.getImageUrl(fileId);
if (storeUrl) return storeUrl;
// 服务器存储的图片:直接使用 HTTP 下载 URL 显示
if (fileId && wsStore.isServerFile(msg) && isImageMimeType(msg.mimeType)) {
return getFileDownloadUrl(roomCode, fileId);
}
// 历史消息兼容IMAGE 类型带 imageData 的旧数据
if (msg.type === 'IMAGE' && msg.imageData) {
const mimeType = msg.mimeType ?? 'image/png';
return msg.imageData.startsWith('data:')
? msg.imageData
: `data:${mimeType};base64,${msg.imageData}`;
}
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);
if (!result.ok && result.error) {
showDownloadToast(result.error);
}
}
}
/**
* 下载图片:服务器存储走 HTTP 下载(带进度),其余用当前显示的 URL 触发下载。
*/
async function handleImageDownload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
const fileName = msg.fileName ?? 'image.png';
if (wsStore.isServerFile(msg) && fileId) {
const url = getFileDownloadUrl(roomCode, fileId);
wsStore.setDownloading(fileId, true);
try {
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');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (e) {
showDownloadToast(e instanceof Error ? e.message : '下载失败');
} finally {
wsStore.setDownloading(fileId, false);
}
return;
}
const imageSrc = getImageSrc(msg);
if (!imageSrc) {
const isMe = msg.senderId === wsStore.myUserId;
showDownloadToast(
isMe
? '您发送的图片请从本机获取'
: '无法下载:图片未就绪或来自历史记录',
);
return;
}
const a = document.createElement('a');
a.href = imageSrc;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function handleFileDownload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
if (!fileId) {
showDownloadToast('无法下载:文件信息不完整');
return;
}
const fileName = msg.fileName ?? 'download';
// 服务器存储文件:走 HTTP 下载,带进度
if (wsStore.isServerFile(msg)) {
const url = getFileDownloadUrl(roomCode, fileId);
wsStore.setDownloading(fileId, true);
wsStore.setFileProgress(fileId, 0);
try {
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');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 500);
} catch {
wsStore.setFileProgress(fileId, 100);
showDownloadToast('下载失败,请重试');
} finally {
wsStore.setDownloading(fileId, false);
}
return;
}
// 兼容:历史或 CHUNK 流文件,从 blob URL 下载
const url = wsStore.getFileBlobUrl(fileId);
if (!url) {
const isMe = msg.senderId === wsStore.myUserId;
showDownloadToast(
isMe
? '您发送的文件请从本机获取,此处不提供下载'
: '无法下载:文件未就绪或来自历史记录(需在当前会话内接收完成)',
);
return;
}
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => wsStore.revokeFileBlobUrl(fileId), 500);
}
function handleSend(content: string) {
if (!content.trim()) return;
wsStore.sendRoomMessage(roomCode, content);
}
function handleLeave() {
wsStore.leaveRoom(roomCode);
wsStore.disconnect();
router.push({ name: 'home' });
}
function handleClearHistory() {
if (!confirm('确定清空当前房间的本地历史记录吗?')) return;
wsStore.clearRoomHistory(roomCode);
}
function handleExportJson() {
const url = wsStore.exportRoomHistory(roomCode);
const a = document.createElement('a');
a.href = url;
a.download = `DataTool-${roomCode}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleExportText() {
const url = wsStore.exportRoomHistoryAsText(roomCode);
const a = document.createElement('a');
a.href = url;
a.download = `DataTool-${roomCode}-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
}
</script>