333 lines
11 KiB
Vue
333 lines
11 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"
|
||
: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;
|
||
// 发送方: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';
|
||
}
|
||
|
||
/**
|
||
* 判断消息是否为图片类型
|
||
* - 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>
|