Initial commit: DataTool backend, frontend and Docker

This commit is contained in:
liu
2026-01-31 00:51:14 +08:00
commit 59bb8e16f5
69 changed files with 9449 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
<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>