- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌 - 新增房间加入令牌验证接口,加入时需提供房间号和令牌 - 前端HomeView新增加入令牌输入框及验证逻辑 - Clipboard工具增加写入API支持及复制按钮 - FileDropZone支持选择文件夹批量上传 - FileMessage和ImageMessage新增分片进度提示及失败重试功能 - API层新增分块上传及断点续传实现,支持大文件分片上传 - 文件上传存储时计算文件sha256,响应中返回该值 - 下载接口支持断点续传,优化大文件下载体验 - README新增加入令牌安全说明及压力测试使用示例 - 资源清理与配置优化,添加磁盘使用水位阈值控制
436 lines
15 KiB
Vue
436 lines
15 KiB
Vue
<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;
|
||
// 发送方:0~99 显示「发送中」
|
||
if (isMe && progress >= 0 && progress < 100) return 'sending';
|
||
// 接收方:仅当已点击下载(isDownloading)且 0~99 时显示「接收中」,否则显示「下载」按钮
|
||
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>
|