Files
quick-share/frontend/src/views/RoomView.vue

333 lines
11 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"
:user-list="wsStore.userList"
:connection-status="wsStore.status"
:my-user-id="wsStore.myUserId"
@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 ?? '')"
@download="handleImageDownload(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 ?? '')"
@download="handleFileDownload(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 { 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, getMyIp } from '@/api/room';
import { useWsStore } from '@/stores/wsStore';
import type { RoomMessagePayload } from '@/types/room';
import { isImageMimeType } from '@/types/room';
// 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问
const API_BASE = import.meta.env.VITE_API_BASE ?? (import.meta.env.DEV ? '' : 'http://localhost:8080');
const route = useRoute();
const router = useRouter();
const roomCode = route.params.roomCode as string;
const messageListRef = ref<HTMLElement | null>(null);
const wsStore = useWsStore();
const downloadToast = ref('');
let downloadToastTimer: ReturnType<typeof setTimeout> | null = null;
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,
async (status) => {
if (status === 'connected' && roomCode) {
try {
const ip = await getMyIp(API_BASE);
wsStore.joinRoom({ roomCode, nickname: ip?.trim() || '访客' });
} catch {
wsStore.joinRoom({ roomCode, nickname: '访客' });
}
}
},
{ 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 ?? '';
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';
}
/**
* 判断消息是否为图片类型
* - IMAGE 类型(小图直发)
* - FILE 类型但 mimeType 为 image/*
*/
function isImageMessage(msg: RoomMessagePayload): boolean {
if (msg.type === 'IMAGE') return true;
if (msg.type === 'FILE' && isImageMimeType(msg.mimeType)) return true;
return false;
}
/**
* 获取图片消息的显示 URL
*/
function getImageSrc(msg: RoomMessagePayload): string | undefined {
const fileId = msg.fileId ?? '';
// 优先从 store 获取(包含接收完成的 blob URL 和小图直发的 data URL
const storeUrl = wsStore.getImageUrl(fileId);
if (storeUrl) return storeUrl;
// 如果是 IMAGE 类型且有 imageData直接构造 data URL
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 handleFileSelected(files: File[]) {
for (const file of files) {
const result = await wsStore.sendFile(roomCode, file);
if (!result.ok && result.error) {
showDownloadToast(result.error);
}
}
}
/**
* 下载图片
*/
function handleImageDownload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
const fileName = msg.fileName ?? 'image.png';
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);
});
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>